[ACS-4126] viewer allow assigning tags to content basic (#8417)

* ACS-4126 Moved component for tags creation from applications to ADF, putting that component into Viewer

* ACS-4126 Assigning tags to node and removed reference to modal from component for tags creating

* ACS-4126 Started working on assigning existing tags to node

* ACS-4126 Added newest changes for tags creation component

* ACS-4126 Usage of MatSelectionList, possibility for selecting existing tag

* ACS-4126 Allow to pass already assigned tags to tags creator as default top list

* ACS-4126 Fixed some css issues like aligning of icons, hiden add input icon when input is already visible

* ACS-4126 Allow to unlink tags from content

* ACS-4126 Allow for discard changes and fixed some validation issues

* ACS-4126 Integrate with changes for removing pagination

* ACS-4126 Autoscroll to input, remove scrollbar from search list

* ACS-4126 Code formatting

* ACS-4126 Renamed prefix for tags and style classes

* ACS-4126 Refreshing assigned tags after linking them, disable some operations during saving

* ACS-4126 Remove scrollbar from create label for long text, corrected some validations, use p tag instead of card view for displaying already assigned tags

* ACS-4126 Removed redundant code from tags creator component

* ACS-4126 Corrected translations

* ACS-4126 Hide input during saving

* ACS-4126 Unit tests for ContentMetadataComponent

* ACS-4126 Unit tests for TagService

* ACS-4126 Fixed unit tests for TagsCreator

* ACS-4126 Added documentation

* ACS-4126 Added additional unit tests

* ACS-4126 Fixed lint issues

* ACS-4126 Remove tags from files and folders list when taggable is unchecked

* ACS-4126 Small correction in styles

* ACS-4126 Corrected type for assigning single tag

* ACS-4126 Updated docs

* ACS-4126 Updated docs

* ACS-4126 Fixed some unit tests

* ACS-4126 Fixed lint issues

* ACS-4126 Updated jsdoc

* ACS-4126 Reverted one unwanted line

* ACS-4126 Removed space which caused lint issue

* ACS-4126 Fixed unit tests

* ACS-4126 Restored change

* ACS-4126 Fixed unit tests

* ACS-4126 Small correction
This commit is contained in:
AleksanderSklorz
2023-03-30 08:47:09 +02:00
committed by GitHub
parent d761966a59
commit 3e2683e06b
23 changed files with 2067 additions and 35 deletions

View File

@@ -25,6 +25,7 @@ import { ContentTestingModule } from '../../testing/content.testing.module';
import { NodeAspectService } from './node-aspect.service';
import { DialogAspectListService } from './dialog-aspect-list.service';
import { CardViewContentUpdateService } from '../../common/services/card-view-content-update.service';
import { TagService } from '@alfresco/adf-content-services';
describe('NodeAspectService', () => {
@@ -99,4 +100,13 @@ describe('NodeAspectService', () => {
nodeAspectService.updateNodeAspects('fake-node-id');
});
it('should call emit on refresh from TagService', () => {
const tagService = TestBed.inject(TagService);
spyOn(dialogAspectListService, 'openAspectListDialog').and.returnValue(of([]));
const node = new MinimalNode({ id: 'fake-node-id', aspectNames: ['a', 'b', 'c'] });
spyOn(nodeApiService, 'updateNode').and.returnValue(of(node));
spyOn(tagService.refresh, 'emit');
nodeAspectService.updateNodeAspects('some node id', 'some-selector');
expect(tagService.refresh.emit).toHaveBeenCalled();
});
});

View File

@@ -19,6 +19,7 @@ import { Injectable } from '@angular/core';
import { DialogAspectListService } from './dialog-aspect-list.service';
import { CardViewContentUpdateService } from '../../common/services/card-view-content-update.service';
import { NodesApiService } from '../../common/services/nodes-api.service';
import { TagService } from '../../tag/services/tag.service';
@Injectable({
providedIn: 'root'
@@ -27,7 +28,8 @@ export class NodeAspectService {
constructor(private nodesApiService: NodesApiService,
private dialogAspectListService: DialogAspectListService,
private cardViewContentUpdateService: CardViewContentUpdateService) {
private cardViewContentUpdateService: CardViewContentUpdateService,
private tagService: TagService) {
}
updateNodeAspects(nodeId: string, selectorAutoFocusedOnClose?: string) {
@@ -35,6 +37,7 @@ export class NodeAspectService {
this.nodesApiService.updateNode(nodeId, { aspectNames: [...aspectList] }).subscribe((updatedNode) => {
this.nodesApiService.nodeUpdated.next(updatedNode);
this.cardViewContentUpdateService.updateNodeAspect(updatedNode);
this.tagService.refresh.emit();
});
});
}

View File

@@ -8,7 +8,8 @@
[editable]="editable"
[multi]="multi"
[displayAspect]="displayAspect"
[preset]="preset">
[preset]="preset"
[displayTags]="true">
</adf-content-metadata>
</mat-card-content>
<mat-card-footer class="adf-content-metadata-card-footer" fxLayout="row" fxLayoutAlign="space-between stretch">

View File

@@ -55,7 +55,8 @@ describe('ContentMetadataCardComponent', () => {
content: {},
properties: {},
createdByUser: {},
modifiedByUser: {}
modifiedByUser: {},
id: 'some-id'
} as Node;
component.node = node;
@@ -91,6 +92,10 @@ describe('ContentMetadataCardComponent', () => {
expect(contentMetadataComponent.node).toBe(node);
});
it('should assign true to displayTags for ContentMetadataComponent', () => {
expect(fixture.debugElement.query(By.directive(ContentMetadataComponent)).componentInstance.displayTags).toBeTrue();
});
it('should pass through the preset to the underlying component', () => {
const contentMetadataComponent = fixture.debugElement.query(By.directive(ContentMetadataComponent)).componentInstance;

View File

@@ -19,7 +19,36 @@
[multiValueSeparator]="multiValueSeparator">
</adf-card-view>
</mat-expansion-panel>
<ng-container *ngIf="displayTags">
<mat-expansion-panel *ngIf="!editable">
<mat-expansion-panel-header>
<mat-panel-title>{{ 'METADATA.BASIC.TAGS' | translate }}</mat-panel-title>
</mat-expansion-panel-header>
<p *ngFor="let tag of tags" class="adf-metadata-properties-tag">{{ tag }}</p>
</mat-expansion-panel>
<div
*ngIf="editable"
class="adf-metadata-properties-tags">
<div class="adf-metadata-properties-tags-title">
<p>{{ 'METADATA.BASIC.TAGS' | translate }}</p>
<button
data-automation-id="showing-tag-input-button"
mat-icon-button
[attr.title]="'METADATA.BASIC.ADD_TAG_TOOLTIP' | translate"
(click)="tagNameControlVisible = true"
[hidden]="tagNameControlVisible || saving">
<mat-icon>add</mat-icon>
</button>
</div>
<adf-tags-creator
[(tagNameControlVisible)]="tagNameControlVisible"
(tagsChange)="storeTagsToAssign($event)"
[mode]="tagsCreatorMode"
[tags]="assignedTags"
[disabledTagsRemoving]="saving">
</adf-tags-creator>
</div>
</ng-container>
<ng-container *ngIf="expanded">
<ng-container *ngIf="groupedProperties$ | async; else loading; let groupedProperties">
<div *ngFor="let group of groupedProperties; let first = first;"

View File

@@ -17,6 +17,45 @@
.mat-expansion-panel:not([class*='mat-elevation-z']) {
box-shadow: none;
}
.adf-metadata-properties-tag {
height: 40px;
display: flex;
align-items: center;
margin-top: -14px;
margin-bottom: 1em;
}
&-tags {
padding: 0 10px 0 26px;
&-title {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 15px;
height: 68px;
}
adf-tags-creator {
.adf-tags-creation {
padding-right: 0;
padding-left: 12px;
.adf-tag {
margin-top: -14px;
}
}
&.adf-creator-with-existing-tags-panel {
background: var(--adf-theme-background-dialog-color);
}
}
[hidden] {
visibility: hidden;
}
}
}
&-metadata-action-buttons {

View File

@@ -16,23 +16,24 @@
*/
import { ComponentFixture, TestBed, tick, fakeAsync } from '@angular/core/testing';
import { SimpleChange } from '@angular/core';
import { DebugElement, SimpleChange } from '@angular/core';
import { By } from '@angular/platform-browser';
import { ClassesApi, MinimalNode, Node } from '@alfresco/js-api';
import { ClassesApi, MinimalNode, Node, Tag, TagBody, TagEntry, TagPaging, TagPagingList } from '@alfresco/js-api';
import { ContentMetadataComponent } from './content-metadata.component';
import { ContentMetadataService } from '../../services/content-metadata.service';
import {
CardViewBaseItemModel, CardViewComponent,
LogService, setupTestBed, AppConfigService
LogService, setupTestBed, AppConfigService, UpdateNotification
} from '@alfresco/adf-core';
import { NodesApiService } from '../../../common/services/nodes-api.service';
import { throwError, of } from 'rxjs';
import { throwError, of, EMPTY } from 'rxjs';
import { ContentTestingModule } from '../../../testing/content.testing.module';
import { mockGroupProperties } from './mock-data';
import { TranslateModule } from '@ngx-translate/core';
import { CardViewContentUpdateService } from '../../../common/services/card-view-content-update.service';
import { PropertyGroup } from '../../interfaces/property-group.interface';
import { PropertyDescriptorsService } from '../../services/property-descriptors.service';
import { TagsCreatorComponent, TagsCreatorMode, TagService } from '@alfresco/adf-content-services';
describe('ContentMetadataComponent', () => {
let component: ContentMetadataComponent;
@@ -42,14 +43,64 @@ describe('ContentMetadataComponent', () => {
let nodesApiService: NodesApiService;
let node: Node;
let folderNode: Node;
let tagService: TagService;
const preset = 'custom-preset';
const mockTagPaging = (): TagPaging => {
const tagPaging = new TagPaging();
tagPaging.list = new TagPagingList();
const tagEntry1 = new TagEntry();
tagEntry1.entry = new Tag();
tagEntry1.entry.tag = 'Tag 1';
tagEntry1.entry.id = 'some id 1';
const tagEntry2 = new TagEntry();
tagEntry2.entry = new Tag();
tagEntry2.entry.tag = 'Tag 2';
tagEntry2.entry.id = 'some id 2';
tagPaging.list.entries = [tagEntry1, tagEntry2];
return tagPaging;
};
const findTagElements = (): DebugElement[] => fixture.debugElement.queryAll(By.css('.adf-metadata-properties-tag'));
const findCancelButton = (): HTMLButtonElement => fixture.debugElement.query(By.css('[data-automation-id=reset-metadata]')).nativeElement;
const clickOnCancel = () => {
findCancelButton().click();
fixture.detectChanges();
};
const findSaveButton = (): HTMLButtonElement => fixture.debugElement.query(By.css('[data-automation-id=save-metadata]')).nativeElement;
const clickOnSave = () => {
findSaveButton().click();
fixture.detectChanges();
};
const findTagsCreator = (): TagsCreatorComponent => fixture.debugElement.query(By.directive(TagsCreatorComponent))?.componentInstance;
const findShowingTagInputButton = (): HTMLButtonElement =>
fixture.debugElement.query(By.css('[data-automation-id=showing-tag-input-button]')).nativeElement;
setupTestBed({
imports: [
TranslateModule.forRoot(),
ContentTestingModule
],
providers: [{ provide: LogService, useValue: { error: jasmine.createSpy('error') } }]
providers: [{
provide: LogService,
useValue: {
error: jasmine.createSpy('error')
}
}, {
provide: TagService,
useValue: {
getTagsByNodeId: () => EMPTY,
removeTag: () => EMPTY,
assignTagsToNode: () => EMPTY
}
}]
});
beforeEach(() => {
@@ -58,6 +109,7 @@ describe('ContentMetadataComponent', () => {
contentMetadataService = TestBed.inject(ContentMetadataService);
updateService = TestBed.inject(CardViewContentUpdateService);
nodesApiService = TestBed.inject(NodesApiService);
tagService = TestBed.inject(TagService);
node = {
id: 'node-id',
@@ -151,14 +203,42 @@ describe('ContentMetadataComponent', () => {
fixture.detectChanges();
await fixture.whenStable();
const saveButton = fixture.debugElement.query(By.css('[data-automation-id="save-metadata"]'));
saveButton.nativeElement.click();
clickOnSave();
await fixture.whenStable();
expect(component.node).toEqual(expectedNode);
expect(nodesApiService.updateNode).toHaveBeenCalled();
}));
it('should call removeTag and assignTagsToNode on TagService on save click', fakeAsync( () => {
component.editable = true;
component.displayTags = true;
const property = { key: 'properties.property-key', value: 'original-value' } as CardViewBaseItemModel;
const expectedNode = { ...node, name: 'some-modified-value' };
spyOn(nodesApiService, 'updateNode').and.returnValue(of(expectedNode));
const tagPaging = mockTagPaging();
spyOn(tagService, 'getTagsByNodeId').and.returnValue(of(tagPaging));
component.ngOnInit();
spyOn(tagService, 'removeTag').and.returnValue(EMPTY);
spyOn(tagService, 'assignTagsToNode').and.returnValue(EMPTY);
const tagName1 = tagPaging.list.entries[0].entry.tag;
const tagName2 = 'New tag 3';
updateService.update(property, 'updated-value');
tick(600);
fixture.detectChanges();
findTagsCreator().tagsChange.emit([tagName1, tagName2]);
clickOnSave();
const tag1 = new TagBody();
tag1.tag = tagName1;
const tag2 = new TagBody();
tag2.tag = tagName2;
expect(tagService.removeTag).toHaveBeenCalledWith(node.id, tagPaging.list.entries[1].entry.id);
expect(tagService.assignTagsToNode).toHaveBeenCalledWith(node.id, [tag1, tag2]);
}));
it('should throw error on unsuccessful save', fakeAsync((done) => {
const logService: LogService = TestBed.inject(LogService);
component.editable = true;
@@ -177,11 +257,7 @@ describe('ContentMetadataComponent', () => {
spyOn(nodesApiService, 'updateNode').and.returnValue(throwError(new Error('My bad')));
fixture.detectChanges();
fixture.whenStable().then(() => {
const saveButton = fixture.debugElement.query(By.css('[data-automation-id="save-metadata"]'));
saveButton.nativeElement.click();
fixture.detectChanges();
});
fixture.whenStable().then(() => clickOnSave());
}));
it('should open the confirm dialog when content type is changed', fakeAsync(() => {
@@ -196,8 +272,7 @@ describe('ContentMetadataComponent', () => {
fixture.detectChanges();
tick(100);
const saveButton = fixture.debugElement.query(By.css('[data-automation-id="save-metadata"]'));
saveButton.nativeElement.click();
clickOnSave();
tick(100);
expect(component.node).toEqual(expectedNode);
@@ -205,6 +280,39 @@ describe('ContentMetadataComponent', () => {
expect(nodesApiService.updateNode).toHaveBeenCalled();
}));
it('should call removeTag and assignTagsToNode on TagService after confirming confirmation dialog when content type is changed', fakeAsync(() => {
component.editable = true;
component.displayTags = true;
const property = { key: 'nodeType', value: 'ft:sbiruli' } as CardViewBaseItemModel;
const expectedNode = { ...node, nodeType: 'ft:sbiruli' };
spyOn(contentMetadataService, 'openConfirmDialog').and.returnValue(of(true));
spyOn(nodesApiService, 'updateNode').and.returnValue(of(expectedNode));
const tagPaging = mockTagPaging();
spyOn(tagService, 'getTagsByNodeId').and.returnValue(of(tagPaging));
component.ngOnInit();
spyOn(tagService, 'removeTag').and.returnValue(EMPTY);
spyOn(tagService, 'assignTagsToNode').and.returnValue(EMPTY);
const tagName1 = tagPaging.list.entries[0].entry.tag;
const tagName2 = 'New tag 3';
updateService.update(property, 'ft:poppoli');
tick(600);
fixture.detectChanges();
findTagsCreator().tagsChange.emit([tagName1, tagName2]);
tick(100);
fixture.detectChanges();
clickOnSave();
tick(100);
const tag1 = new TagBody();
tag1.tag = tagName1;
const tag2 = new TagBody();
tag2.tag = tagName2;
expect(tagService.removeTag).toHaveBeenCalledWith(node.id, tagPaging.list.entries[1].entry.id);
expect(tagService.assignTagsToNode).toHaveBeenCalledWith(node.id, [tag1, tag2]);
}));
it('should retrigger the load of the properties when the content type has changed', fakeAsync(() => {
component.editable = true;
const property = { key: 'nodeType', value: 'ft:sbiruli' } as CardViewBaseItemModel;
@@ -218,8 +326,7 @@ describe('ContentMetadataComponent', () => {
fixture.detectChanges();
tick(100);
const saveButton = fixture.debugElement.query(By.css('[data-automation-id="save-metadata"]'));
saveButton.nativeElement.click();
clickOnSave();
tick(100);
expect(component.node).toEqual(expectedNode);
@@ -775,6 +882,244 @@ describe('ContentMetadataComponent', () => {
expect(event.stopPropagation).not.toHaveBeenCalled();
});
});
describe('Tags list', () => {
let tagPaging: TagPaging;
beforeEach(() => {
tagPaging = mockTagPaging();
component.displayTags = true;
});
it('should render tags after loading tags in ngOnInit', () => {
spyOn(tagService, 'getTagsByNodeId').and.returnValue(of(tagPaging));
component.ngOnInit();
fixture.detectChanges();
const tagElements = findTagElements();
expect(tagElements).toHaveSize(2);
expect(tagElements[0].nativeElement.textContent).toBe(tagPaging.list.entries[0].entry.tag);
expect(tagElements[1].nativeElement.textContent).toBe(tagPaging.list.entries[1].entry.tag);
expect(tagService.getTagsByNodeId).toHaveBeenCalledWith(node.id);
});
it('should not render tags after loading tags in ngOnInit if displayTags is false', () => {
component.displayTags = false;
spyOn(tagService, 'getTagsByNodeId').and.returnValue(of(tagPaging));
component.ngOnInit();
fixture.detectChanges();
const tagElements = findTagElements();
expect(tagElements).toHaveSize(0);
expect(tagService.getTagsByNodeId).not.toHaveBeenCalled();
});
it('should render tags after loading tags in ngOnChanges', () => {
spyOn(tagService, 'getTagsByNodeId').and.returnValue(of(tagPaging));
component.ngOnChanges({
node: new SimpleChange(undefined, node, false)
});
fixture.detectChanges();
const tagElements = findTagElements();
expect(tagElements).toHaveSize(2);
expect(tagElements[0].nativeElement.textContent).toBe(tagPaging.list.entries[0].entry.tag);
expect(tagElements[1].nativeElement.textContent).toBe(tagPaging.list.entries[1].entry.tag);
expect(tagService.getTagsByNodeId).toHaveBeenCalledWith(node.id);
});
it('should not render tags after loading tags in ngOnChanges if displayTags is false', () => {
component.displayTags = false;
spyOn(tagService, 'getTagsByNodeId').and.returnValue(of(tagPaging));
component.ngOnChanges({
node: new SimpleChange(undefined, node, false)
});
fixture.detectChanges();
const tagElements = findTagElements();
expect(tagElements).toHaveSize(0);
expect(tagService.getTagsByNodeId).not.toHaveBeenCalled();
});
it('should not render tags after loading tags in ngOnChanges if node is not changed', () => {
spyOn(tagService, 'getTagsByNodeId').and.returnValue(of(tagPaging));
component.ngOnChanges({});
fixture.detectChanges();
const tagElements = findTagElements();
expect(tagElements).toHaveSize(0);
expect(tagService.getTagsByNodeId).not.toHaveBeenCalled();
});
it('should not render tags after loading tags in ngOnChanges if node is changed first time', () => {
spyOn(tagService, 'getTagsByNodeId').and.returnValue(of(tagPaging));
component.ngOnChanges({
node: new SimpleChange(undefined, node, true)
});
fixture.detectChanges();
const tagElements = findTagElements();
expect(tagElements).toHaveSize(0);
expect(tagService.getTagsByNodeId).not.toHaveBeenCalled();
});
it('should render tags after loading tags after clicking on Cancel button', fakeAsync(() => {
component.editable = true;
fixture.detectChanges();
TestBed.inject(CardViewContentUpdateService).itemUpdated$.next({
changed: {}
} as UpdateNotification);
tick(500);
fixture.detectChanges();
spyOn(tagService, 'getTagsByNodeId').and.returnValue(of(tagPaging));
clickOnCancel();
component.editable = false;
fixture.detectChanges();
const tagElements = findTagElements();
expect(tagElements).toHaveSize(2);
expect(tagElements[0].nativeElement.textContent).toBe(tagPaging.list.entries[0].entry.tag);
expect(tagElements[1].nativeElement.textContent).toBe(tagPaging.list.entries[1].entry.tag);
expect(tagService.getTagsByNodeId).toHaveBeenCalledOnceWith(node.id);
}));
it('should be hidden when editable is true', () => {
spyOn(tagService, 'getTagsByNodeId').and.returnValue(of(tagPaging));
component.ngOnInit();
fixture.detectChanges();
component.editable = true;
fixture.detectChanges();
expect(findTagElements()).toHaveSize(0);
});
});
describe('Tags creator', () => {
let tagsCreator: TagsCreatorComponent;
beforeEach(() => {
component.editable = true;
component.displayTags = true;
fixture.detectChanges();
tagsCreator = findTagsCreator();
});
it('should have assigned false to tagNameControlVisible initially', () => {
expect(tagsCreator.tagNameControlVisible).toBeFalse();
});
it('should hide showing tag input button after emitting tagNameControlVisibleChange event with true', () => {
tagsCreator.tagNameControlVisibleChange.emit(true);
fixture.detectChanges();
expect(findShowingTagInputButton().hasAttribute('hidden')).toBeTrue();
});
it('should show showing tag input button after emitting tagNameControlVisibleChange event with false', fakeAsync(() => {
tagsCreator.tagNameControlVisibleChange.emit(true);
fixture.detectChanges();
tick();
tagsCreator.tagNameControlVisibleChange.emit(false);
fixture.detectChanges();
tick(100);
expect(findShowingTagInputButton().hasAttribute('hidden')).toBeFalse();
}));
it('should have assigned correct mode', () => {
expect(tagsCreator.mode).toBe(TagsCreatorMode.CREATE_AND_ASSIGN);
});
it('should enable cancel button after emitting tagsChange event', () => {
tagsCreator.tagsChange.emit(['New tag 1', 'New tag 2', 'New tag 3']);
fixture.detectChanges();
expect(findCancelButton().disabled).toBeFalse();
});
it('should enable save button after emitting tagsChange event', () => {
tagsCreator.tagsChange.emit(['New tag 1', 'New tag 2', 'New tag 3']);
fixture.detectChanges();
expect(findSaveButton().disabled).toBeFalse();
});
it('should have assigned false to disabledTagsRemoving', () => {
expect(tagsCreator.disabledTagsRemoving).toBeFalse();
});
it('should have assigned true to disabledTagsRemoving after clicking on update button', () => {
tagsCreator.tagsChange.emit([]);
fixture.detectChanges();
clickOnSave();
expect(tagsCreator.disabledTagsRemoving).toBeTrue();
});
it('should have assigned false to disabledTagsRemoving if forkJoin fails', fakeAsync( () => {
const property = { key: 'properties.property-key', value: 'original-value' } as CardViewBaseItemModel;
const expectedNode = { ...node, name: 'some-modified-value' };
spyOn(nodesApiService, 'updateNode').and.returnValue(of(expectedNode));
const tagPaging = mockTagPaging();
spyOn(tagService, 'getTagsByNodeId').and.returnValue(of(tagPaging));
component.ngOnInit();
spyOn(tagService, 'removeTag').and.returnValue(throwError({}));
spyOn(tagService, 'assignTagsToNode').and.returnValue(EMPTY);
const tagName1 = tagPaging.list.entries[0].entry.tag;
const tagName2 = 'New tag 3';
updateService.update(property, 'updated-value');
tick(600);
fixture.detectChanges();
tagsCreator.tagsChange.emit([tagName1, tagName2]);
clickOnSave();
expect(tagsCreator.disabledTagsRemoving).toBeFalse();
}));
it('should have assigned false to tagNameControlVisible after clicking on update button', () => {
tagsCreator.tagNameControlVisibleChange.emit(true);
tagsCreator.tagsChange.emit([]);
fixture.detectChanges();
clickOnSave();
expect(tagsCreator.tagNameControlVisible).toBeFalse();
});
describe('Setting tags', () => {
let tagPaging: TagPaging;
beforeEach(() => {
tagPaging = mockTagPaging();
spyOn(tagService, 'getTagsByNodeId').and.returnValue(of(tagPaging));
});
it('should assign correct tags after ngOnInit', () => {
component.ngOnInit();
fixture.detectChanges();
expect(tagsCreator.tags).toEqual([tagPaging.list.entries[0].entry.tag, tagPaging.list.entries[1].entry.tag]);
expect(tagService.getTagsByNodeId).toHaveBeenCalledWith(node.id);
});
it('should assign correct tags after ngOnChanges', () => {
component.ngOnInit();
fixture.detectChanges();
expect(tagsCreator.tags).toEqual([tagPaging.list.entries[0].entry.tag, tagPaging.list.entries[1].entry.tag]);
expect(tagService.getTagsByNodeId).toHaveBeenCalledWith(node.id);
});
});
});
it('should show tags creator if editable is true and displayTags is true', () => {
component.editable = true;
component.displayTags = true;
fixture.detectChanges();
expect(findTagsCreator()).toBeDefined();
});
it('should not show tags creator if editable is true and displayTags is false', () => {
component.editable = true;
component.displayTags = false;
fixture.detectChanges();
expect(findTagsCreator()).toBeUndefined();
});
});
const queryDom = (fixture: ComponentFixture<ContentMetadataComponent>, properties: string = 'properties') =>

View File

@@ -16,8 +16,8 @@
*/
import { Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewEncapsulation } from '@angular/core';
import { Node } from '@alfresco/js-api';
import { Observable, Subject, of, zip } from 'rxjs';
import { Node, TagBody, TagEntry, TagPaging } from '@alfresco/js-api';
import { Observable, Subject, of, zip, forkJoin } from 'rxjs';
import {
CardViewItem,
LogService,
@@ -31,6 +31,8 @@ import { CardViewGroup, PresetConfig } from '../../interfaces/content-metadata.i
import { takeUntil, debounceTime, catchError, map } from 'rxjs/operators';
import { CardViewContentUpdateService } from '../../../common/services/card-view-content-update.service';
import { NodesApiService } from '../../../common/services/nodes-api.service';
import { TagsCreatorMode } from '../../../tag/tags-creator/tags-creator-mode';
import { TagService } from '../../../tag/services/tag.service';
const DEFAULT_SEPARATOR = ', ';
@@ -50,7 +52,14 @@ export class ContentMetadataComponent implements OnChanges, OnInit, OnDestroy {
/** Toggles whether the edit button should be shown */
@Input()
editable: boolean = false;
set editable(editable: boolean) {
this._editable = editable;
this._assignedTags = [...this.tags];
}
get editable(): boolean {
return this._editable;
}
/** Toggles whether to display empty values in the card view */
@Input()
@@ -86,13 +95,25 @@ export class ContentMetadataComponent implements OnChanges, OnInit, OnDestroy {
@Input()
useChipsForMultiValueProperty: boolean = true;
/** True if tags should be displayed, false otherwise */
@Input()
displayTags = false;
multiValueSeparator: string;
basicProperties$: Observable<CardViewItem[]>;
groupedProperties$: Observable<CardViewGroup[]>;
changedProperties = {};
hasMetadataChanged = false;
tagNameControlVisible = false;
private _assignedTags: string[] = [];
private assignedTagsEntries: TagEntry[] = [];
private _editable = false;
private _tagsCreatorMode = TagsCreatorMode.CREATE_AND_ASSIGN;
private _tags: string[] = [];
private targetProperty: CardViewBaseItemModel;
private _saving = false;
constructor(
private contentMetadataService: ContentMetadataService,
@@ -100,7 +121,8 @@ export class ContentMetadataComponent implements OnChanges, OnInit, OnDestroy {
private nodesApiService: NodesApiService,
private logService: LogService,
private translationService: TranslationService,
private appConfig: AppConfigService
private appConfig: AppConfigService,
private tagService: TagService
) {
this.copyToClipboardAction = this.appConfig.get<boolean>('content-metadata.copy-to-clipboard-action');
this.multiValueSeparator = this.appConfig.get<string>('content-metadata.multi-value-pipe-separator') || DEFAULT_SEPARATOR;
@@ -128,6 +150,22 @@ export class ContentMetadataComponent implements OnChanges, OnInit, OnDestroy {
this.loadProperties(this.node);
}
get assignedTags(): string[] {
return this._assignedTags;
}
get tags(): string[] {
return this._tags;
}
get tagsCreatorMode(): TagsCreatorMode {
return this._tagsCreatorMode;
}
get saving(): boolean {
return this._saving;
}
protected handleUpdateError(error: Error) {
this.logService.error(error);
@@ -160,6 +198,9 @@ export class ContentMetadataComponent implements OnChanges, OnInit, OnDestroy {
if (node) {
this.basicProperties$ = this.getProperties(node);
this.groupedProperties$ = this.contentMetadataService.getGroupedProperties(node, this.preset);
if (this.displayTags) {
this.loadTagsForNode(node.id);
}
}
}
@@ -186,7 +227,12 @@ export class ContentMetadataComponent implements OnChanges, OnInit, OnDestroy {
});
}
/**
* Called after clicking save button. It confirms all changes done for metadata. Before clicking on that button they are not saved.
*/
saveChanges() {
this._saving = true;
this.tagNameControlVisible = false;
if (this.hasContentTypeChanged(this.changedProperties)) {
this.contentMetadataService.openConfirmDialog(this.changedProperties).subscribe(() => {
this.updateNode();
@@ -196,22 +242,36 @@ export class ContentMetadataComponent implements OnChanges, OnInit, OnDestroy {
}
}
/**
* Register all tags which should be assigned to node. Please note that they are just in "register" state and are not yet saved
* until button for saving data is clicked. Calling that function causes that save button is enabled.
* @param tags array of tags to register, they are not saved yet until we click save button.
*/
storeTagsToAssign(tags: string[]) {
this._tags = tags;
this.hasMetadataChanged = true;
}
private updateNode() {
this.nodesApiService.updateNode(this.node.id, this.changedProperties).pipe(
forkJoin({
updatedNode: this.nodesApiService.updateNode(this.node.id, this.changedProperties),
...(this.displayTags ? this.saveTags() : {})
}).pipe(
catchError((err) => {
this.cardViewContentUpdateService.updateElement(this.targetProperty);
this.handleUpdateError(err);
return of(null);
}))
.subscribe((updatedNode) => {
if (updatedNode) {
.subscribe((result) => {
if (result) {
if (this.hasContentTypeChanged(this.changedProperties)) {
this.cardViewContentUpdateService.updateNodeAspect(this.node);
}
this.revertChanges();
Object.assign(this.node, updatedNode);
Object.assign(this.node, result.updatedNode);
this.nodesApiService.nodeUpdated.next(this.node);
}
this._saving = false;
});
}
@@ -257,4 +317,31 @@ export class ContentMetadataComponent implements OnChanges, OnInit, OnDestroy {
private isEmpty(value: any): boolean {
return value === undefined || value === null || value === '';
}
private loadTagsForNode(id: string) {
this.tagService.getTagsByNodeId(id).subscribe((tagPaging) => {
this.assignedTagsEntries = tagPaging.list.entries;
this._tags = tagPaging.list.entries.map((tagEntry) => tagEntry.entry.tag);
this._assignedTags = [...this._tags];
});
}
private saveTags(): { [key: string]: Observable<TagPaging | TagEntry | void> } {
const observables: { [key: string]: Observable<TagPaging | TagEntry | void> } = {};
if (this.tags) {
this.assignedTagsEntries.forEach((tagEntry) => {
if (!this.tags.some((tag) => tagEntry.entry.tag === tag)) {
observables[`${tagEntry.entry.id}Removing`] = this.tagService.removeTag(this.node.id, tagEntry.entry.id);
}
});
if (this.tags.length) {
observables.tagsAssigning = this.tagService.assignTagsToNode(this.node.id, this.tags.map((tag) => {
const tagBody = new TagBody();
tagBody.tag = tag;
return tagBody;
}));
}
}
return observables;
}
}

View File

@@ -22,13 +22,15 @@ import { MaterialModule } from '../material.module';
import { CoreModule } from '@alfresco/adf-core';
import { ContentMetadataComponent } from './components/content-metadata/content-metadata.component';
import { ContentMetadataCardComponent } from './components/content-metadata-card/content-metadata-card.component';
import { TagModule } from '../tag/tag.module';
@NgModule({
imports: [
CommonModule,
MaterialModule,
FlexLayoutModule,
CoreModule
CoreModule,
TagModule
],
exports: [
ContentMetadataComponent,

View File

@@ -132,6 +132,29 @@
},
"BUTTON": {
"ADD": "Add Tag"
},
"TAGS_CREATOR": {
"EXISTING_TAGS": "Existing tags:",
"EXISTING_TAGS_SELECTION": "Select an existing tag:",
"NO_TAGS_CREATED": "No Tags Created",
"NO_EXISTING_TAGS": "No Existing Tags",
"TITLE": "Create Tags",
"CREATE_TAG": "Create: {{tag}}",
"NAME": "Name",
"ERRORS": {
"EXISTING_TAG": "Tag already exists",
"ALREADY_ADDED_TAG": "Tag is already added",
"EMPTY_TAG": "Tag name can't contain only spaces",
"REQUIRED": "Tag name is required",
"FETCH_TAGS": "Error while fetching the tags",
"CREATE_TAGS": "Error while creating the tags"
},
"TOOLTIPS": {
"DELETE_TAG": "Delete tag",
"HIDE_INPUT": "Hide input"
},
"TAGS_LOADING": "Tags loading",
"CREATE_TAGS_SUCCESS": "Tags created successfully"
}
},
"ADF_FILE_UPLOAD": {
@@ -359,7 +382,9 @@
"CREATED_DATE": "Created Date",
"MODIFIER": "Modifier",
"MODIFIED_DATE": "Modified Date",
"CONTENT_TYPE": "Content Type"
"CONTENT_TYPE": "Content Type",
"TAGS": "Tags",
"ADD_TAG_TOOLTIP": "Add tag"
},
"CONTENT_TYPE": {
"DIALOG" :{

View File

@@ -22,3 +22,7 @@ export * from './tag-node-list.component';
export * from './services/tag.service';
export * from './tag.module';
export * from './tags-creator/tags-creator-mode';
export * from './tags-creator/tags-creator.component';

View File

@@ -345,5 +345,50 @@ describe('TagService', () => {
tick();
}));
});
describe('assignTagsToNode', () => {
let singleResult: TagEntry;
let tags: TagBody[];
const nodeId = 'some node id';
beforeEach(() => {
singleResult = new TagEntry();
singleResult.entry = new Tag();
singleResult.entry.tag = 'some name';
const tag = new TagBody();
tag.tag = 'some name';
tags = [tag];
});
it('should call assignTagsToNode on TagsApi with correct parameters', () => {
spyOn(service.tagsApi, 'assignTagsToNode').and.returnValue(Promise.resolve(singleResult));
service.assignTagsToNode(nodeId, tags);
expect(service.tagsApi.assignTagsToNode).toHaveBeenCalledWith(nodeId, tags);
});
it('should return observable which emits paging object for tags', fakeAsync(() => {
const pagingResult = mockTagPaging();
const tag2 = new TagBody();
tag2.tag = 'some other tag';
tags.push(tag2);
spyOn(service.tagsApi, 'assignTagsToNode').and.returnValue(Promise.resolve(pagingResult));
service.assignTagsToNode(nodeId, tags).subscribe((tagsResult) => {
expect(tagsResult).toEqual(pagingResult);
});
tick();
}));
it('should return observable which emits single tag', fakeAsync(() => {
spyOn(service.tagsApi, 'assignTagsToNode').and.returnValue(Promise.resolve(singleResult));
service.assignTagsToNode(nodeId, tags).subscribe((tagsResult) => {
expect(tagsResult).toEqual(singleResult);
});
tick();
}));
});
});
});

View File

@@ -96,7 +96,7 @@ export class TagService {
* @param tag Name of the tag to remove
* @returns Null object when the operation completes
*/
removeTag(nodeId: string, tag: string): Observable<any> {
removeTag(nodeId: string, tag: string): Observable<void> {
const observableRemove = from(this.tagsApi.deleteTagFromNode(nodeId, tag));
observableRemove.subscribe((data) => {
@@ -191,6 +191,20 @@ export class TagService {
);
}
/**
* Assign tags to node. If tag is new then tag is also created additionally, if tag already exists then it is just assigned.
*
* @param nodeId Id of node to which tags should be assigned.
* @param tags List of tags to create and assign or just assign if they already exist.
*
* @return Just linked tags to node or single tag if linked only one tag.
*/
assignTagsToNode(nodeId: string, tags: TagBody[]): Observable<TagPaging | TagEntry> {
return from(this.tagsApi.assignTagsToNode(nodeId, tags)).pipe(
tap((data) => this.refresh.emit(data))
);
}
private handleError(error: any) {
this.logService.error(error);
return throwError(error || 'Server error');

View File

@@ -90,7 +90,7 @@ describe('TagActionsComponent', () => {
it('Tag list click on delete button should delete the tag', async () => {
component.nodeId = 'fake-node-id';
spyOn(tagService, 'removeTag').and.returnValue(of(true));
spyOn(tagService, 'removeTag').and.returnValue(of(undefined));
component.ngOnChanges();
fixture.detectChanges();

View File

@@ -98,7 +98,7 @@ describe('TagNodeList', () => {
});
it('Tag list click on delete button should delete the tag', async () => {
spyOn(tagService, 'removeTag').and.returnValue(of(true));
spyOn(tagService, 'removeTag').and.returnValue(of(undefined));
component.ngOnChanges();
fixture.detectChanges();

View File

@@ -24,10 +24,13 @@ import { TagActionsComponent } from './tag-actions.component';
import { TagListComponent } from './tag-list.component';
import { TagNodeListComponent } from './tag-node-list.component';
import { CoreModule } from '@alfresco/adf-core';
import { TagsCreatorComponent } from './tags-creator/tags-creator.component';
import { ContentDirectiveModule } from '../directives/content-directive.module';
@NgModule({
imports: [
CommonModule,
ContentDirectiveModule,
MaterialModule,
FormsModule,
ReactiveFormsModule,
@@ -36,12 +39,14 @@ import { CoreModule } from '@alfresco/adf-core';
exports: [
TagActionsComponent,
TagListComponent,
TagNodeListComponent
TagNodeListComponent,
TagsCreatorComponent
],
declarations: [
TagActionsComponent,
TagListComponent,
TagNodeListComponent
TagNodeListComponent,
TagsCreatorComponent
]
})
export class TagModule {}

View File

@@ -0,0 +1,26 @@
/*!
* @license
* Copyright 2019 Alfresco Software, Ltd.
*
* 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.
*/
/**
* Available modes for tags creator.
* Create mode allows only for creating completely new tags.
* Create and Assign mode allows for both - creation of new tags and selection of existing tags.
*/
export enum TagsCreatorMode {
CREATE,
CREATE_AND_ASSIGN
}

View File

@@ -0,0 +1,87 @@
<div class="adf-tags-creation">
<p
class="adf-no-tags-message"
*ngIf="!tags.length && !tagNameControlVisible">
{{ 'TAG.TAGS_CREATOR.NO_TAGS_CREATED' | translate }}
</p>
<div
class="adf-tags-list"
[class.adf-tags-list-with-scrollbar]="tagsListScrollbarVisible"
#tagsList>
<p
*ngFor="let tag of tags"
class="adf-tag adf-label-with-icon-button">
{{ tag }}
<button
data-automation-id="remove-tag-button"
mat-icon-button
(click)="removeTag(tag)"
[attr.title]="'TAG.TAGS_CREATOR.TOOLTIPS.DELETE_TAG' | translate"
[hidden]="disabledTagsRemoving">
<mat-icon>remove</mat-icon>
</button>
</p>
</div>
<div
class="adf-tag-name-field"
*ngIf="(!tagNameControlVisible && tags.length) || tagNameControlVisible"
[hidden]="!tagNameControlVisible">
<mat-form-field *ngIf="tagNameControlVisible">
<mat-icon matPrefix>search</mat-icon>
<mat-label id="adf-tag-name-input-label">
{{ 'TAG.TAGS_CREATOR.NAME' | translate }}
</mat-label>
<input
#tagNameInput
matInput
autocomplete="off"
[formControl]="tagNameControl"
(keyup.enter)="addTag()"
aria-labelledby="adf-tag-name-input-label"
adf-auto-focus
/>
<mat-error [hidden]="!tagNameControl.invalid">{{ tagNameErrorMessageKey | translate }}</mat-error>
</mat-form-field>
<button
data-automation-id="hide-tag-name-input-button"
mat-icon-button
(click)="hideNameInput()"
[attr.title]="'TAG.TAGS_CREATOR.TOOLTIPS.HIDE_INPUT' | translate">
<mat-icon>remove</mat-icon>
</button>
</div>
</div>
<div
class="adf-existing-tags-panel"
*ngIf="existingTagsPanelVisible">
<ng-container *ngIf="!spinnerVisible || existingTags">
<span
class="adf-create-tag-label"
(click)="addTag()"
[hidden]="tagNameControl.invalid || typing">
{{ 'TAG.TAGS_CREATOR.CREATE_TAG' | translate : { tag: tagNameControl.value } }}
</span>
<p class="adf-existing-tags-label">
{{ existingTagsLabelKey | translate }}
</p>
</ng-container>
<div class="adf-tags-list">
<mat-selection-list
*ngIf="!spinnerVisible || existingTags"
(selectionChange)="addExistingTagToTagsToAssign($event)"
[disabled]="isOnlyCreateMode()">
<mat-list-option
*ngFor="let tagRow of existingTags"
class="adf-tag"
[value]="tagRow">
{{ tagRow.entry.tag }}
</mat-list-option>
<p *ngIf="!existingTags?.length">{{ 'TAG.TAGS_CREATOR.NO_EXISTING_TAGS' | translate }}</p>
</mat-selection-list>
<mat-spinner
*ngIf="spinnerVisible"
[diameter]="50"
[attr.aria-label]="'TAG.TAGS_CREATOR.TAGS_LOADING' | translate">
</mat-spinner>
</div>
</div>

View File

@@ -0,0 +1,144 @@
adf-tags-creator {
display: block;
margin-left: -24px;
&.adf-creator-with-existing-tags-panel {
background-color: var(--theme-background-color);
}
.adf-label-with-icon-button {
display: flex;
justify-content: space-between;
align-items: center;
}
.adf-no-tags-message {
margin-left: 9px;
margin-top: 28.5px;
margin-bottom: 0;
height: 30px;
}
.adf-tag-name-field,
.adf-tag-name-field[hidden] {
display: flex;
align-items: center;
min-height: 57px;
mat-form-field {
width: 100%;
box-sizing: border-box;
padding-right: 3px;
font-size: 14px;
}
}
.adf-create-tag-label {
color: var(--theme-primary-color);
cursor: pointer;
margin-top: -1px;
padding-left: 27px;
overflow-wrap: anywhere;
display: inline-block;
padding-right: 12px;
overflow: auto;
max-height: 7vh;
}
.adf-tags-list {
padding-left: 10px;
padding-right: 0;
overflow: auto;
&.adf-tags-list-with-scrollbar {
padding-right: 7px;
margin-right: -22px;
}
}
.adf-tag {
margin-top: 0;
overflow-wrap: anywhere;
& + .adf-tag {
margin-top: -14px;
}
}
.adf-tags-creation {
padding-left: 27px;
padding-right: 22px;
}
.adf-existing-tags-panel {
background-color: var(--theme-card-background-color);
border-top-left-radius: 6px;
border-top-right-radius: 6px;
padding-top: 12px;
position: relative;
width: 100%;
z-index: 100;
.adf-existing-tags-label {
font-size: 10px;
color: var(--theme-secondary-text-color);
padding-left: 12px;
margin-bottom: 2px;
}
.adf-tags-list {
padding-left: 18px;
margin-top: -2px;
padding-right: 0;
display: flex;
flex-direction: column;
min-height: 75px;
.adf-tag {
margin-bottom: 18px;
margin-top: 0;
padding-right: 12px;
font-size: 14px;
height: auto;
width: unset;
& + .adf-tag {
margin-top: -10px;
}
.mat-list-item-content-reverse {
padding: 0 6px;
.mat-pseudo-checkbox {
display: none;
}
}
}
mat-spinner {
margin: auto;
overflow: unset;
min-height: 25px;
}
.mat-list-item-disabled {
background-color: inherit;
color: inherit;
}
}
}
mat-dialog-actions {
margin-right: -24px;
background-color: inherit;
.adf-save-button {
margin-right: 24px;
}
}
[hidden] {
visibility: hidden;
display: unset;
}
}

View File

@@ -0,0 +1,689 @@
/*!
* @license
* Copyright 2019 Alfresco Software, Ltd.
*
* 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, discardPeriodicTasks, fakeAsync, flush, TestBed, tick } from '@angular/core/testing';
import { TagsCreatorComponent } from './tags-creator.component';
import { NotificationService } from '@alfresco/adf-core';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import { MatIconModule } from '@angular/material/icon';
import { MatError, MatFormField, MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { ContentDirectiveModule, TagsCreatorMode, TagService } from '@alfresco/adf-content-services';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { EMPTY, of, throwError } from 'rxjs';
import { DebugElement } from '@angular/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatListModule, MatSelectionList, MatSelectionListChange } from '@angular/material/list';
describe('TagsCreatorComponent', () => {
let fixture: ComponentFixture<TagsCreatorComponent>;
let component: TagsCreatorComponent;
let tagService: TagService;
let notificationService: NotificationService;
const tagNameFieldSelector = '.adf-tag-name-field';
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [TagsCreatorComponent],
imports: [
ContentDirectiveModule,
MatButtonModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
MatListModule,
NoopAnimationsModule,
ReactiveFormsModule,
TranslateModule.forRoot()
],
providers: [
{
provide: TagService,
useValue: {
findTagByName: () => of(null),
searchTags: () =>
of({
list: {
entries: []
}
})
}
},
{
provide: NotificationService,
useValue: {
showError: () => ({})
}
}
]
});
fixture = TestBed.createComponent(TagsCreatorComponent);
component = fixture.componentInstance;
tagService = TestBed.inject(TagService);
notificationService = TestBed.inject(NotificationService);
fixture.detectChanges();
});
function getNameInput(): HTMLInputElement {
return fixture.debugElement.query(By.css(`.adf-tag-name-field input`))?.nativeElement;
}
function getCreateTagLabel(): HTMLSpanElement {
return fixture.debugElement.query(By.css('.adf-create-tag-label'))?.nativeElement;
}
function getRemoveTagButtons(): HTMLButtonElement[] {
const elements = fixture.debugElement.queryAll(By.css(`[data-automation-id="remove-tag-button"]`));
return elements.map(el => el.nativeElement);
}
function clickAtHideNameInputButton() {
fixture.debugElement.query(By.css(`[data-automation-id="hide-tag-name-input-button"]`)).nativeElement.click();
fixture.detectChanges();
}
function getAddedTags(): string[] {
const tagElements = fixture.debugElement.queryAll(By.css(`.adf-tags-creation .adf-tag`));
return tagElements.map(el => el.nativeElement.firstChild.nodeValue.trim());
}
function addTagToAddedList(tagName: string, addUsingEnter?: boolean, typingTimeout = 300): void {
typeTag(tagName, typingTimeout);
if (addUsingEnter) {
getNameInput().dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
} else {
getCreateTagLabel().click();
}
tick(300);
fixture.detectChanges();
}
function typeTag(tagName: string, timeout = 300): void {
component.tagNameControlVisible = true;
fixture.detectChanges();
const tagNameInput = getNameInput();
tagNameInput.value = tagName;
tagNameInput.dispatchEvent(new InputEvent('input'));
tick(timeout);
fixture.detectChanges();
}
function findSelectionList(): MatSelectionList {
return fixture.debugElement.query(By.directive(MatSelectionList)).componentInstance;
}
describe('Created tags list', () => {
it('should display no tags created message after initialization', () => {
const message = fixture.debugElement.query(By.css('.adf-no-tags-message')).nativeElement.textContent.trim();
expect(message).toBe('TAG.TAGS_CREATOR.NO_TAGS_CREATED');
});
it('should display all tags which have been typed in input and accepted using enter', fakeAsync(() => {
const tag1 = 'Tag 1';
const tag2 = 'Tag 2';
addTagToAddedList(tag1, true);
addTagToAddedList(tag2, true);
const tagElements = getAddedTags();
expect(tagElements.length).toBe(2);
expect(tagElements[0]).toBe(tag1);
expect(tagElements[1]).toBe(tag2);
}));
it('should display all tags which have been typed in input and accepted by clicking at create label', fakeAsync(() => {
const tag1 = 'Tag 1';
const tag2 = 'Tag 2';
addTagToAddedList(tag1);
addTagToAddedList(tag2);
const tagElements = getAddedTags();
expect(tagElements).toEqual([tag1, tag2]);
}));
it('should not add tag if contains only spaces', fakeAsync(() => {
addTagToAddedList(' ', true);
expect(getAddedTags().length).toBe(0);
}));
it('should not add tag if field is empty', fakeAsync(() => {
addTagToAddedList('', true);
expect(getAddedTags().length).toBe(0);
}));
it('should not duplicate already added tag', fakeAsync(() => {
const tag = 'Some tag';
addTagToAddedList(tag, true);
addTagToAddedList(tag, true);
expect(getAddedTags().length).toBe(1);
}));
it('should not duplicate already existing tag', fakeAsync(() => {
const tag = 'Tag';
spyOn(tagService, 'findTagByName').and.returnValue(of({
entry: {
tag,
id: 'tag-1'
}
}));
addTagToAddedList(tag, true);
expect(getAddedTags().length).toBe(0);
}));
it('should not add tag if hit enter during tags loading', fakeAsync(() => {
addTagToAddedList('Tag', true, 0);
expect(getAddedTags().length).toBe(0);
}));
it('should remove specific tag after clicking at remove icon', fakeAsync(() => {
const tag1 = 'Tag 1';
const tag2 = 'Tag 2';
addTagToAddedList(tag1);
addTagToAddedList(tag2);
getRemoveTagButtons()[0].click();
tick();
fixture.detectChanges();
const tagElements = getAddedTags();
expect(tagElements).toEqual([tag2]);
}));
it('should hide button for removing tag if disabledTagsRemoving is true', fakeAsync(() => {
const tag1 = 'Tag 1';
component.disabledTagsRemoving = true;
addTagToAddedList(tag1);
tick();
expect(getRemoveTagButtons()[0].hasAttribute('hidden')).toBeTrue();
}));
it('should show button for removing tag if disabledTagsRemoving is false', fakeAsync(() => {
const tag1 = 'Tag 1';
component.disabledTagsRemoving = false;
addTagToAddedList(tag1);
tick();
expect(getRemoveTagButtons()[0].hasAttribute('hidden')).toBeFalse();
}));
it('should display tags passed by tags input', () => {
component.tags = ['Passed tag 1', 'Passed tag 2'];
fixture.detectChanges();
expect(getAddedTags()).toEqual(component.tags);
});
});
describe('Tag name field', () => {
it('should be visible if tagNameControlVisible is true', () => {
component.tagNameControlVisible = true;
fixture.detectChanges();
const tagNameField = fixture.debugElement.query(By.css(tagNameFieldSelector));
expect(tagNameField).toBeTruthy();
expect(tagNameField.nativeElement.hasAttribute('hidden')).toBeFalsy();
expect(tagNameField.query(By.directive(MatFormField))).toBeTruthy();
});
it('should be hidden after clicking button for hiding input', fakeAsync(() => {
component.tagNameControlVisible = true;
fixture.detectChanges();
tick(100);
clickAtHideNameInputButton();
const tagNameField = fixture.debugElement.query(By.css(tagNameFieldSelector));
expect(tagNameField).toBeFalsy();
}));
it('should input be autofocused', fakeAsync(() => {
component.tagNameControlVisible = true;
fixture.detectChanges();
tick(100);
expect(getNameInput()).toBe(document.activeElement as HTMLInputElement);
}));
it('should input be autofocused after showing input second time', fakeAsync(() => {
component.tagNameControlVisible = true;
fixture.detectChanges();
tick(100);
clickAtHideNameInputButton();
component.tagNameControlVisible = true;
fixture.detectChanges();
tick(100);
expect(getNameInput()).toBe(document.activeElement as HTMLInputElement);
}));
describe('Errors', () => {
function getFirstError(): string {
const error = fixture.debugElement.query(By.directive(MatError));
return error.nativeElement.textContent;
}
it('should show error for only spaces', fakeAsync(() => {
typeTag(' ');
const error = getFirstError();
expect(error).toBe('TAG.TAGS_CREATOR.ERRORS.EMPTY_TAG');
}));
it('should show error for required', fakeAsync(() => {
typeTag('');
const error = getFirstError();
expect(error).toBe('TAG.TAGS_CREATOR.ERRORS.REQUIRED');
}));
it('should show error when duplicated already added tag', fakeAsync(() => {
const tag = 'Some tag';
addTagToAddedList(tag);
typeTag(tag);
const error = getFirstError();
expect(error).toBe('TAG.TAGS_CREATOR.ERRORS.ALREADY_ADDED_TAG');
}));
it('should show error when duplicated already existing tag', fakeAsync(() => {
const tag = 'Some tag';
spyOn(tagService, 'findTagByName').and.returnValue(of({
entry: {
tag,
id: 'tag-1'
}
}));
typeTag(tag);
const error = getFirstError();
expect(error).toBe('TAG.TAGS_CREATOR.ERRORS.EXISTING_TAG');
}));
it('should error for required when not typed anything and blur input', fakeAsync(() => {
component.tagNameControlVisible = true;
fixture.detectChanges();
tick(100);
getNameInput().blur();
fixture.detectChanges();
const error = getFirstError();
expect(error).toBe('TAG.TAGS_CREATOR.ERRORS.REQUIRED');
flush();
}));
});
});
describe('Existing tags panel', () => {
function getPanel(): DebugElement {
return fixture.debugElement.query(By.css(`.adf-existing-tags-panel`));
}
it('should be visible when input is visible and something is typed in input', fakeAsync(() => {
typeTag('some tag');
expect(getPanel()).toBeTruthy();
}));
it('should not be visible initially when input is hidden and nothing was typed', () => {
expect(getPanel()).toBeFalsy();
});
it('should not be visible when input is visible and empty string is typed in input', fakeAsync(() => {
typeTag(' ');
expect(getPanel()).toBeFalsy();
}));
it('should not be visible when input is visible and nothing has been typed', () => {
component.tagNameControlVisible = true;
fixture.detectChanges();
expect(getPanel()).toBeFalsy();
});
it('should not be visible when something has been typed and input has been hidden', fakeAsync(() => {
typeTag('some tag');
clickAtHideNameInputButton();
expect(getPanel()).toBeFalsy();
}));
describe('Label for tag creation', () => {
it('should be visible', fakeAsync(() => {
typeTag('some tag');
expect(getCreateTagLabel()).toBeTruthy();
}));
it('should not be visible if typed only spaces', fakeAsync(() => {
typeTag(' ');
expect(getCreateTagLabel()).toBeFalsy();
}));
it('should not be visible if required error occurs', fakeAsync(() => {
typeTag('');
expect(getCreateTagLabel()).toBeFalsy();
}));
it('should not be visible when trying to duplicate already added tag', fakeAsync(() => {
const tag = 'Some tag';
addTagToAddedList(tag);
typeTag(tag);
expect(getCreateTagLabel().hasAttribute('hidden')).toBeTruthy();
}));
it('should not be visible when trying to duplicate already existing tag', fakeAsync(() => {
const tag = 'Tag';
spyOn(tagService, 'findTagByName').and.returnValue(of({
entry: {
tag,
id: 'tag-1'
}
}));
typeTag(tag);
expect(getCreateTagLabel().hasAttribute('hidden')).toBeTruthy();
}));
it('should not be visible if typed nothing', () => {
component.tagNameControlVisible = true;
fixture.detectChanges();
expect(getCreateTagLabel()).toBeFalsy();
});
it('should not be visible during typing', fakeAsync(() => {
typeTag('some tag', 0);
expect(getCreateTagLabel()).toBeFalsy();
discardPeriodicTasks();
flush();
}));
});
describe('Existing tags', () => {
function getExistingTags(): string[] {
const tagElements = fixture.debugElement.queryAll(By.css(`.adf-existing-tags-panel .adf-tag .mat-list-text`));
return tagElements.map(el => el.nativeElement.firstChild.nodeValue.trim());
}
it('should call findTagByName on tagService using name set in input', fakeAsync(() => {
spyOn(tagService, 'findTagByName').and.returnValue(EMPTY);
const name = 'Tag';
typeTag(name);
expect(tagService.findTagByName).toHaveBeenCalledWith(name);
}));
it('should call searchTags on tagService using name set in input and correct params', fakeAsync(() => {
spyOn(tagService, 'searchTags').and.returnValue(EMPTY);
const name = 'Tag';
typeTag(name);
expect(tagService.searchTags).toHaveBeenCalledWith(name, { orderBy: 'tag', direction: 'asc' },
false, 0, 15 );
}));
it('should display loaded existing tags', fakeAsync(() => {
const tag1 = 'Tag 1';
const tag2 = 'Tag 2';
spyOn(tagService, 'searchTags').and.returnValue(
of({
list: {
entries: [
{ entry: { tag: tag1 } as any },
{ entry: { tag: tag2 } as any }
],
pagination: {}
}
})
);
typeTag('Tag');
const tagElements = getExistingTags();
expect(tagElements).toEqual([tag1, tag2]);
}));
it('should exclude tags passed through tags input from loaded existing tags', fakeAsync(() => {
const tag1 = 'Tag 1';
const tag2 = 'Tag 2';
component.tags = [tag1];
spyOn(tagService, 'searchTags').and.returnValue(
of({
list: {
entries: [
{ entry: { tag: tag1 } as any },
{ entry: { tag: tag2 } as any }
],
pagination: {}
}
})
);
typeTag('Tag');
expect(getExistingTags()).toEqual([tag2]);
}));
it('should not display existing tags if searching fails', fakeAsync(() => {
spyOn(notificationService, 'showError');
spyOn(tagService, 'searchTags').and.returnValue(throwError({}));
typeTag('Tag');
expect(getExistingTags()).toEqual([]);
expect(notificationService.showError).toHaveBeenCalledWith('TAG.TAGS_CREATOR.ERRORS.FETCH_TAGS');
}));
it('should display exact tag', fakeAsync(() => {
const tag = 'Tag';
spyOn(tagService, 'findTagByName').and.returnValue(of({
entry: {
tag,
id: 'tag-1'
}
}));
typeTag(tag);
const tagElements = getExistingTags();
expect(tagElements).toEqual([tag]);
}));
it('should not display exact tag if that tag was passed through tags input', fakeAsync(() => {
const tag = 'Tag';
component.tags = [tag];
spyOn(tagService, 'findTagByName').and.returnValue(of({
entry: {
tag,
id: 'tag-1'
}
}));
typeTag(tag);
expect(getExistingTags()).toEqual([]);
}));
it('should not display exact tag if exact tag loading fails', fakeAsync(() => {
spyOn(notificationService, 'showError');
spyOn(tagService, 'findTagByName').and.returnValue(throwError({}));
typeTag('Tag');
expect(getExistingTags()).toEqual([]);
expect(notificationService.showError).toHaveBeenCalledWith('TAG.TAGS_CREATOR.ERRORS.FETCH_TAGS');
}));
it('should exact tag be above others existing tags when there are some different existing tags than exact tag', fakeAsync(() => {
const tag = 'Tag';
const tag1 = 'Tag 1';
const tag2 = 'Tag 2';
spyOn(tagService, 'findTagByName').and.returnValue(of({
entry: {
tag,
id: 'tag-1'
}
}));
spyOn(tagService, 'searchTags').and.returnValue(
of({
list: {
entries: [
{ entry: { tag: tag1 } as any },
{ entry: { tag: tag2 } as any }
],
pagination: {}
}
})
);
typeTag(tag);
const tagElements = getExistingTags();
expect(tagElements).toEqual([tag, tag1, tag2]);
}));
it('should selection be disabled if mode is Create', fakeAsync(() => {
component.mode = TagsCreatorMode.CREATE;
spyOn(tagService, 'searchTags').and.returnValue(
of({
list: {
entries: [
{ entry: { tag: 'tag' } as any }
],
pagination: {}
}
})
);
typeTag('Tag');
expect(findSelectionList().disabled).toBeTrue();
}));
it('should selection be enabled if mode is Create And Assign', fakeAsync(() => {
component.mode = TagsCreatorMode.CREATE_AND_ASSIGN;
spyOn(tagService, 'searchTags').and.returnValue(
of({
list: {
entries: [
{ entry: { tag: 'tag' } as any }
],
pagination: {}
}
})
);
typeTag('Tag');
expect(findSelectionList().disabled).toBeFalse();
}));
it('should select existing tag when selectionChange event emits', fakeAsync(() => {
const selectedTag = { entry: { tag: 'tag1' } as any };
const leftTag = 'tag2';
component.mode = TagsCreatorMode.CREATE_AND_ASSIGN;
spyOn(tagService, 'searchTags').and.returnValue(
of({
list: {
entries: [
selectedTag,
{ entry: { tag: leftTag } as any }
],
pagination: {}
}
})
);
typeTag('Tag');
findSelectionList().selectionChange.emit({
options: [{
value: selectedTag
}]
} as MatSelectionListChange);
fixture.detectChanges();
expect(getAddedTags()).toEqual([selectedTag.entry.tag]);
expect(getExistingTags()).toEqual([leftTag]);
}));
});
describe('Spinner', () => {
function getSpinner(): DebugElement {
return fixture.debugElement.query(By.css(`.mat-progress-spinner`));
}
it('should be displayed when existing tags are loading', fakeAsync(() => {
typeTag('tag', 0);
const spinner = getSpinner();
expect(spinner).toBeTruthy();
discardPeriodicTasks();
flush();
}));
it('should not be displayed when existing tags stopped loading', fakeAsync(() => {
typeTag('tag');
const spinner = getSpinner();
expect(spinner).toBeFalsy();
}));
it('should have correct diameter', fakeAsync(() => {
typeTag('tag', 0);
const spinner = getSpinner();
expect(spinner.componentInstance.diameter).toBe(50);
discardPeriodicTasks();
flush();
}));
});
});
});

View File

@@ -0,0 +1,427 @@
/*!
* @license
* Copyright 2019 Alfresco Software, Ltd.
*
* 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 { TagEntry, TagPaging } from '@alfresco/js-api';
import {
Component,
ElementRef,
EventEmitter,
HostBinding,
Input,
OnDestroy,
OnInit,
Output,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import { FormControl, Validators } from '@angular/forms';
import { debounce, distinctUntilChanged, finalize, first, map, takeUntil, tap } from 'rxjs/operators';
import { EMPTY, forkJoin, Observable, Subject, timer } from 'rxjs';
import { NotificationService } from '@alfresco/adf-core';
import { TagsCreatorMode } from './tags-creator-mode';
import { MatSelectionListChange } from '@angular/material/list';
import { TagService } from '../services/tag.service';
interface TagNameControlErrors {
duplicatedExistingTag?: boolean;
duplicatedAddedTag?: boolean;
emptyTag?: boolean;
required?: boolean;
}
const DEFAULT_TAGS_SORTING = {
orderBy: 'tag',
direction: 'asc'
};
/**
* Allows to create multiple tags. That component contains input and two lists. Top list is all created tags, bottom list is searched tags based on input's value.
*/
@Component({
selector: 'adf-tags-creator',
templateUrl: './tags-creator.component.html',
styleUrls: ['./tags-creator.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class TagsCreatorComponent implements OnInit, OnDestroy {
/**
* Mode for component.
* In Create mode we can't select existing tags, we can only create them.
* In Create and Assign mode we can both - create tags and select existing tags.
*/
@Input()
mode: TagsCreatorMode;
/**
* False if tags can be removed from top list, true otherwise.
*/
@Input()
disabledTagsRemoving = false;
/**
* Default top list.
* @param tags tags which should be displayed as default tags for top list.
*/
@Input()
set tags(tags: string[]) {
this._tags = [...tags];
this._spinnerVisible = true;
this._initialExistingTags = null;
this._existingTags = null;
this.loadTags(this.tagNameControl.value);
this.tagNameControl.updateValueAndValidity();
}
get tags(): string[] {
return this._tags;
}
/**
* Decides if input for tags creation/searching should be visible. When input is hidden then panel of existing tags is hidden as well.
* @param tagNameControlVisible true if input should be visible, false otherwise.
*/
@Input()
set tagNameControlVisible(tagNameControlVisible: boolean) {
this._tagNameControlVisible = tagNameControlVisible;
if (tagNameControlVisible) {
this._existingTagsPanelVisible = !!this.tagNameControl.value.trim();
setTimeout(() => {
this.tagNameInputElement.nativeElement.scrollIntoView();
});
} else {
this._existingTagsPanelVisible = false;
}
this.existingTagsPanelVisibilityChange.emit(this.existingTagsPanelVisible);
}
get tagNameControlVisible(): boolean {
return this._tagNameControlVisible;
}
/**
* Emitted when bottom list is showing or hiding.
*/
@Output()
existingTagsPanelVisibilityChange = new EventEmitter<boolean>();
@Output()
/**
* Emitted when tags in top list are changed.
*/
tagsChange = new EventEmitter<string[]>();
/**
* Emitted when input is showing or hiding.
*/
@Output()
tagNameControlVisibleChange = new EventEmitter<boolean>();
readonly nameErrorMessagesByErrors = new Map<keyof TagNameControlErrors, string>([
['duplicatedExistingTag', 'EXISTING_TAG'],
['duplicatedAddedTag', 'ALREADY_ADDED_TAG'],
['emptyTag', 'EMPTY_TAG'],
['required', 'REQUIRED']
]);
private readonly existingTagsListLimit = 15;
private exactTagSet$ = new Subject<void>();
private _tags: string[] = [];
private _tagNameControl = new FormControl<string>(
'',
[
this.validateIfNotAlreadyAdded.bind(this),
Validators.required,
this.validateEmptyTag
],
this.validateIfNotExistingTag.bind(this)
);
private _tagNameControlVisible = false;
private _existingTags: TagEntry[];
private _initialExistingTags: TagEntry[];
private onDestroy$ = new Subject<void>();
private _tagNameErrorMessageKey = '';
private _spinnerVisible = false;
private _typing = false;
private _tagsListScrollbarVisible = false;
private cancelExistingTagsLoading$ = new Subject<void>();
private existingExactTag: TagEntry;
private _existingTagsPanelVisible: boolean;
private _existingTagsLabelKey: string;
@ViewChild('tagsList')
private tagsListElement: ElementRef;
@ViewChild('tagNameInput')
private tagNameInputElement: ElementRef;
constructor(
private tagService: TagService,
private notificationService: NotificationService
) {}
ngOnInit(): void {
this.tagNameControl.valueChanges
.pipe(
map((name: string) => name.trim()),
distinctUntilChanged(),
tap((name: string) => {
this._typing = true;
if (name) {
this._spinnerVisible = true;
this._existingTagsPanelVisible = true;
} else {
this._existingTagsPanelVisible = false;
}
this.existingTagsPanelVisibilityChange.emit(this.existingTagsPanelVisible);
this.cancelExistingTagsLoading$.next();
this._initialExistingTags = null;
this._existingTags = null;
}),
debounce((name: string) => (name ? timer(300) : EMPTY)),
takeUntil(this.onDestroy$)
)
.subscribe((name: string) => this.onTagNameControlValueChange(name));
this.tagNameControl.statusChanges
.pipe(takeUntil(this.onDestroy$))
.subscribe(() => this.setTagNameControlErrorMessageKey());
this.setTagNameControlErrorMessageKey();
}
ngOnDestroy(): void {
this.onDestroy$.next();
this.onDestroy$.complete();
this.cancelExistingTagsLoading$.next();
this.cancelExistingTagsLoading$.complete();
}
@HostBinding('class.adf-creator-with-existing-tags-panel')
get hostClass(): boolean {
return this.existingTagsPanelVisible;
}
get tagNameControl(): FormControl<string> {
return this._tagNameControl;
}
get existingTags(): TagEntry[] {
return this._existingTags;
}
get tagNameErrorMessageKey(): string {
return this._tagNameErrorMessageKey;
}
get spinnerVisible(): boolean {
return this._spinnerVisible;
}
get typing(): boolean {
return this._typing;
}
get tagsListScrollbarVisible(): boolean {
return this._tagsListScrollbarVisible;
}
get existingTagsPanelVisible(): boolean {
return this._existingTagsPanelVisible;
}
get existingTagsLabelKey(): string {
return this._existingTagsLabelKey;
}
/**
* Hide input for typing name for new tag or for searching. When input is hidden then panel of existing tags is hidden as well.
*/
hideNameInput(): void {
this.tagNameControlVisible = false;
this._existingTagsPanelVisible = false;
this.existingTagsPanelVisibilityChange.emit(this.existingTagsPanelVisible);
this.tagNameControlVisibleChange.emit(this.tagNameControlVisible);
}
/**
* Add tags to top list using value which is set in input. Adding tag is not allowed when value in input is invalid
* or if user is still typing what means that validation for input is not called yet.
*/
addTag(): void {
if (!this._typing && !this.tagNameControl.invalid) {
this.tags.push(this.tagNameControl.value.trim());
this.hideNameInput();
this.tagNameControl.setValue('');
this.checkScrollbarVisibility();
this.tagNameControl.markAsUntouched();
this.tagsChange.emit(this.tags);
}
}
/**
* Remove tag from top list. In case that tag was part of search result then that tag is moved to bottom list
* (list of existing tags) after removing so user can reselect it again later.
* @param tag tag's name which should be removed from top list.
*/
removeTag(tag: string): void {
this.removeTagFromArray(this.tags, tag);
this.tagNameControl.updateValueAndValidity();
this.updateExistingTagsListOnRemoveFromTagsToConfirm(tag);
this.checkScrollbarVisibility();
this.tagsChange.emit(this.tags);
}
/**
* Called when user selects any tag from list of existing tags. It moves tag from existing tags list to top list.
* @param change
*/
addExistingTagToTagsToAssign(change: MatSelectionListChange): void {
const selectedTag: TagEntry = change.options[0].value;
this.tags.push(selectedTag.entry.tag);
this.removeTagFromArray(this.existingTags, selectedTag);
this.tagNameControl.updateValueAndValidity();
this.exactTagSet$.next();
this.tagsChange.emit(this.tags);
}
/**
* Checks if component is in Create mode.
* @return true if Create mode, false otherwise.
*/
isOnlyCreateMode(): boolean {
return this.mode === TagsCreatorMode.CREATE;
}
private onTagNameControlValueChange(name: string): void {
this.tagNameControl.markAsTouched();
this.loadTags(name);
}
private loadTags(name: string) {
if (name) {
forkJoin({
exactResult: this.tagService.findTagByName(name),
searchedResult: this.tagService.searchTags(name, DEFAULT_TAGS_SORTING, false, 0, this.existingTagsListLimit)
}).pipe(
takeUntil(this.cancelExistingTagsLoading$),
finalize(() => (this._typing = false))
).subscribe(({ exactResult, searchedResult }: {
exactResult: TagEntry;
searchedResult: TagPaging;
}) => {
if (exactResult) {
this.existingExactTag = exactResult;
this.removeExactTagFromSearchedResult(searchedResult);
searchedResult.list.entries.unshift(exactResult);
} else {
this.existingExactTag = null;
}
this._initialExistingTags = searchedResult.list.entries;
this.excludeAlreadyAddedTags(this._initialExistingTags);
this.exactTagSet$.next();
this._spinnerVisible = false;
}, () => {
this.notificationService.showError('TAG.TAGS_CREATOR.ERRORS.FETCH_TAGS');
this._spinnerVisible = false;
});
} else {
this.existingExactTag = null;
}
}
private removeExactTagFromSearchedResult(searchedResult: TagPaging): void {
const exactTagIndex = searchedResult.list.entries.findIndex(
(row) => this.compareTags(row.entry.tag, this.existingExactTag.entry.tag)
);
if (exactTagIndex > -1) {
searchedResult.list.entries.splice(exactTagIndex, 1);
}
}
private validateIfNotExistingTag(tagNameControl: FormControl<string>): Observable<TagNameControlErrors | null> {
return this.exactTagSet$.pipe(
map<void, TagNameControlErrors | null>(() =>
this.compareTags(tagNameControl.value, this.existingExactTag?.entry?.tag) ? { duplicatedExistingTag: true }
: null
), first()
);
}
private validateIfNotAlreadyAdded(tagNameControl: FormControl<string>): TagNameControlErrors | null {
return this.tags.some((tag) => this.compareTags(tag, tagNameControl.value))
? { duplicatedAddedTag: true }
: null;
}
private compareTags(tagName1?: string, tagName2?: string): boolean {
return tagName1?.trim().toUpperCase() === tagName2?.trim().toUpperCase();
}
private validateEmptyTag(tagNameControl: FormControl<string>): TagNameControlErrors | null {
return tagNameControl.value.length && !tagNameControl.value.trim()
? { emptyTag: true }
: null;
}
private setTagNameControlErrorMessageKey(): void {
this._tagNameErrorMessageKey = this.tagNameControl.invalid
? `TAG.TAGS_CREATOR.ERRORS.${this.nameErrorMessagesByErrors.get(
Object.keys(this.tagNameControl.errors)[0] as keyof TagNameControlErrors
)}`
: '';
}
private checkScrollbarVisibility(): void {
setTimeout(() => {
this._tagsListScrollbarVisible =
this.tagsListElement.nativeElement.scrollHeight >
this.tagsListElement.nativeElement.clientHeight;
});
}
private removeTagFromArray<T>(tags: T[], tag: T) {
tags.splice(tags.indexOf(tag), 1);
}
private updateExistingTagsListOnRemoveFromTagsToConfirm(tag: string) {
const entryForTagAddedToExistingTags = this._initialExistingTags?.find(
(tagEntry) => tagEntry.entry.tag === tag
);
if (entryForTagAddedToExistingTags) {
this.existingTags.unshift(entryForTagAddedToExistingTags);
if (this.existingExactTag) {
if (tag !== this.existingExactTag.entry.tag) {
this.removeTagFromArray(this.existingTags, this.existingExactTag);
this.sortExistingTags();
this.existingTags.unshift(this.existingExactTag);
}
} else {
this.sortExistingTags();
}
this.exactTagSet$.next();
}
}
private sortExistingTags() {
this.existingTags.sort((tagEntry1, tagEntry2) =>
tagEntry1.entry.tag.localeCompare(tagEntry2.entry.tag));
}
private excludeAlreadyAddedTags(tags: TagEntry[]) {
this._existingTags = tags.filter((tag) => !this.tags.includes(tag.entry.tag));
}
}