mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2025-07-24 17:32:15 +00:00
[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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@@ -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">
|
||||
|
@@ -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;
|
||||
|
||||
|
@@ -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;"
|
||||
|
@@ -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 {
|
||||
|
@@ -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') =>
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
@@ -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,
|
||||
|
@@ -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" :{
|
||||
|
@@ -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';
|
||||
|
||||
|
@@ -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();
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -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');
|
||||
|
@@ -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();
|
||||
|
@@ -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();
|
||||
|
@@ -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 {}
|
||||
|
@@ -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
|
||||
}
|
@@ -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>
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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();
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
@@ -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));
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user