[ACS-4523] Assigning content to categories (#8451)

* [ACS-4523] Add categories management mode component, manage mode completed

* [ACS-4523] Add categories management fixes and unit tests

* [ACS-4523] Add unit tests to content metadata

* ACS-4523] CR fixes

* [ACS-4523] Tag and category name controls decoupled

* [ACS-4523] CR fixes

* [ACS-4523] CR fixes

* [ACS-4523] CR fixes

* [ACS-4523] Display path for already linked categories

* [ACS-4523] Add new license header

* [ACS-4523] Fix category service import
This commit is contained in:
MichalKinas
2023-04-20 18:49:45 +02:00
committed by GitHub
parent e9b8d99b13
commit a563dc2f54
25 changed files with 1625 additions and 129 deletions

View File

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

View File

@@ -0,0 +1,85 @@
<div class="adf-categories-management">
<p *ngIf="!categories.length && !categoryNameControlVisible"
class="adf-no-categories-message">
{{ isCRUDMode ? 'CATEGORIES_MANAGEMENT.NO_CATEGORIES_CREATED' : 'CATEGORIES_MANAGEMENT.NO_CATEGORIES_ASSIGNED' | translate }}
</p>
<div class="adf-categories-list"
[class.adf-categories-list-fixed]="!categoryNameControlVisible">
<span
*ngFor="let category of categories"
[class.adf-categories-padded]="!isCRUDMode"
class="adf-assigned-categories">
{{ category.name }}
<button
data-automation-id="categories-remove-category-button"
mat-icon-button
[class.adf-btn-padded]="!isCRUDMode"
(click)="removeCategory(category)"
[attr.title]="isCRUDMode ? 'CATEGORIES_MANAGEMENT.DELETE_CATEGORY' : 'CATEGORIES_MANAGEMENT.UNASSIGN_CATEGORY' | translate"
[disabled]="disableRemoval">
<mat-icon>remove</mat-icon>
</button>
</span>
</div>
<div *ngIf="((!categoryNameControlVisible && categories.length)) || categoryNameControlVisible"
[hidden]="!categoryNameControlVisible"
class="adf-category-name-field">
<mat-form-field>
<mat-icon matPrefix>search</mat-icon>
<mat-label id="adf-category-name-input-label">
{{ 'CATEGORIES_MANAGEMENT.NAME' | translate }}
</mat-label>
<input
#categoryNameInput
matInput
autocomplete="off"
[formControl]="categoryNameControl"
(keyup.enter)="addCategory()"
aria-labelledby="adf-category-name-input-label"
adf-auto-focus
/>
<mat-error [hidden]="!categoryNameControl.invalid">{{ categoryNameErrorMessageKey | translate }}</mat-error>
</mat-form-field>
<button
mat-icon-button
[class.adf-btn-padded]="!isCRUDMode"
(click)="hideNameInput()"
[attr.title]="'CATEGORIES_MANAGEMENT.HIDE_INPUT' | translate">
<mat-icon>remove</mat-icon>
</button>
</div>
</div>
<div class="adf-existing-categories-panel" *ngIf="existingCategoriesPanelVisible">
<ng-container *ngIf="isCRUDMode && (!existingCategoriesLoading || existingCategories)">
<span class="adf-create-category-label"
(click)="addCategory()"
[hidden]="categoryNameControl.invalid || typing">
{{ 'CATEGORIES_MANAGEMENT.GENERIC_CREATE' | translate : { name: categoryNameControl.value } }}
</span>
</ng-container>
<div *ngIf="categoryNameControlVisible" class="adf-categories-list">
<ng-container *ngIf="!existingCategoriesLoading && existingCategories">
<p class="adf-existing-categories-label">
{{ isCRUDMode ? 'CATEGORIES_MANAGEMENT.EXISTING_CATEGORIES' : 'CATEGORIES_MANAGEMENT.SELECT_EXISTING_CATEGORY' | translate }}
</p>
<mat-selection-list
[disabled]="isCRUDMode"
(selectionChange)="addCategoryToAssign($event)">
<mat-list-option
*ngFor="let category of existingCategories"
class="adf-category"
[value]="category">
{{ category.name }}
</mat-list-option>
<p *ngIf="!existingCategories?.length && !existingCategoriesLoading">
{{ 'CATEGORIES_MANAGEMENT.NO_EXISTING_CATEGORIES' | translate }}
</p>
</mat-selection-list>
</ng-container>
<mat-spinner
*ngIf="existingCategoriesLoading"
[diameter]="50"
[attr.aria-label]="'CATEGORIES_MANAGEMENT.LOADING' | translate">
</mat-spinner>
</div>
</div>

View File

@@ -0,0 +1,79 @@
.adf-categories-management {
.adf-category-name-field {
display: flex;
justify-content: space-between;
width: 100%;
mat-form-field {
width: 100%;
}
.adf-btn-padded {
margin-right: -14px;
}
}
.adf-assigned-categories {
display: flex;
justify-content: space-between;
align-items: center;
word-break: break-word;
.adf-btn-padded {
margin-right: -14px;
}
}
.adf-categories-padded {
padding: 5px 0;
}
[hidden] {
visibility: hidden;
}
}
.adf-categories-list {
padding-bottom: 10px;
.mat-list-base .mat-list-item,
.mat-list-base .mat-list-option {
display: flex;
height: 100%;
overflow-wrap: anywhere;
padding: 5px 0;
font-size: 14px;
&.mat-list-item-disabled {
background-color: inherit;
color: inherit;
}
.mat-list-item-content,
.mat-list-item-content-reverse {
padding: 0;
.mat-pseudo-checkbox {
display: none;
}
}
}
.adf-existing-categories-label {
font-size: 10px;
color: var(--theme-secondary-text-color);
margin-bottom: 2px;
}
mat-spinner {
margin: auto;
}
}
.adf-existing-categories-panel {
.adf-create-category-label {
color: var(--theme-primary-color);
cursor: pointer;
overflow-wrap: anywhere;
}
}

View File

@@ -0,0 +1,487 @@
/*!
* @license
* Copyright © 2005-2023 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Category, CategoryPaging, ResultNode, ResultSetPaging } from '@alfresco/js-api';
import { DebugElement } from '@angular/core';
import { ComponentFixture, discardPeriodicTasks, fakeAsync, flush, TestBed, tick } from '@angular/core/testing';
import { Validators } from '@angular/forms';
import { MatError } from '@angular/material/form-field';
import { MatListOption, MatSelectionList } from '@angular/material/list';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import { of, Subject } from 'rxjs';
import { ContentTestingModule } from '../../testing/content.testing.module';
import { CategoriesManagementMode } from './categories-management-mode';
import { CategoryService } from '../services/category.service';
import { CategoriesManagementComponent } from './categories-management.component';
describe('CategoriesManagementComponent', () => {
let component: CategoriesManagementComponent;
let fixture: ComponentFixture<CategoriesManagementComponent>;
let categoryService: CategoryService;
const classifiableChangedSubject = new Subject<void>();
const category1 = new Category({ id: 'test', name: 'testCat' });
const category2 = new Category({ id: 'test2', name: 'testCat2' });
const category3 = new Category({ id: 'test3', name: 'testCat3' });
const category4 = new Category({ id: 'test4', name: 'testCat4' });
const resultCat1 = new ResultNode({ id: 'test', name: 'testCat', path: { name: 'general/categories' }});
const resultCat2 = new ResultNode({ id: 'test2', name: 'testCat2', path: { name: 'general/categories' }});
const categoryPagingResponse: CategoryPaging = { list: { pagination: {}, entries: [ { entry: category1 }, { entry: category2 }]}};
const categorySearchResponse: ResultSetPaging = { list: { pagination: {}, entries: [ { entry: resultCat1 }, { entry: resultCat2 }]}};
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [CategoriesManagementComponent],
imports: [
TranslateModule.forRoot(),
ContentTestingModule
],
providers: [
{
provide: CategoryService,
useValue: {
getSubcategories: () => of(categoryPagingResponse),
searchCategories: () => of(categorySearchResponse)
}
}
],
teardown: { destroyAfterEach: true }
});
fixture = TestBed.createComponent(CategoriesManagementComponent);
component = fixture.componentInstance;
categoryService = TestBed.inject(CategoryService);
});
function getNoCategoriesMessage(): string {
return fixture.debugElement.query(By.css(`.adf-no-categories-message`))?.nativeElement.textContent.trim();
}
function getAssignedCategoriesList(): HTMLSpanElement[] {
return fixture.debugElement.queryAll(By.css('.adf-assigned-categories'))?.map((debugElem) => debugElem.nativeElement);
}
function getExistingCategoriesList(): MatListOption[] {
return fixture.debugElement.queryAll(By.directive(MatListOption))?.map((debugElem) => debugElem.componentInstance);
}
function createCategory(name: string, addUsingEnter?: boolean, typingTimeout = 300): void {
typeCategory(name, typingTimeout);
if (addUsingEnter) {
getCategoryControlInput().dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
} else {
getCreateCategoryLabel().click();
}
tick(300);
fixture.detectChanges();
}
function getFirstError(): string {
return fixture.debugElement.query(By.directive(MatError)).nativeElement.textContent;
}
function getSelectionList(): MatSelectionList {
return fixture.debugElement.query(By.directive(MatSelectionList)).componentInstance;
}
function getRemoveCategoryButtons(): HTMLButtonElement[] {
return fixture.debugElement.queryAll(By.css(`[data-automation-id="categories-remove-category-button"]`)).map((debugElem) => debugElem.nativeElement);
}
function getCategoryControlInput(): HTMLInputElement {
return fixture.debugElement.query(By.css('.adf-category-name-field input'))?.nativeElement;
}
function getCreateCategoryLabel(): HTMLSpanElement {
return fixture.debugElement.query(By.css('.adf-existing-categories-panel span'))?.nativeElement;
}
function typeCategory(name: string, timeout = 300): void {
component.categoryNameControlVisible = true;
fixture.detectChanges();
const categoryControlInput = getCategoryControlInput();
categoryControlInput.value = name;
categoryControlInput.dispatchEvent(new InputEvent('input'));
tick(timeout);
fixture.detectChanges();
}
describe('Shared tests', () => {
beforeEach(() => {
component.managementMode = CategoriesManagementMode.CRUD;
component.categories = [category3, category4];
fixture.detectChanges();
});
describe('Assigned categories list', () => {
it('should display all categories assigned/created', () => {
const assignedCategories = getAssignedCategoriesList();
expect(assignedCategories.length).toBe(2);
expect(assignedCategories[0].textContent.trim()).toContain(category3.name);
expect(assignedCategories[1].textContent.trim()).toContain(category4.name);
});
it('should unassign/remove specific category after clicking at remove icon and emit categories change', () => {
const removeBtns = getRemoveCategoryButtons();
const categoriesChangeSpy = spyOn(component.categoriesChange, 'emit');
removeBtns[0].click();
fixture.detectChanges();
const assignedCategories = getAssignedCategoriesList();
expect(component.categories.length).toBe(1);
expect(assignedCategories.length).toBe(1);
expect(assignedCategories[0].textContent.trim()).toContain(category4.name);
expect(categoriesChangeSpy).toHaveBeenCalledWith(component.categories);
});
it('should disable unassigning/removing categories when disableRemoval is set', () => {
component.disableRemoval = true;
fixture.detectChanges();
const removeBtns = getRemoveCategoryButtons();
const allBtnsDisabled = removeBtns.every((removeBtn) => removeBtn.disabled);
expect(allBtnsDisabled).toBeTrue();
});
});
describe('Category name control', () => {
beforeEach(() => {
component.categoryNameControlVisible = true;
fixture.detectChanges();
});
it('should be hidden initially', () => {
component.categoryNameControlVisible = false;
fixture.detectChanges();
const categoryControl: HTMLDivElement = fixture.debugElement.query(By.css('.adf-category-name-field')).nativeElement;
expect(categoryControl.hidden).toBeTrue();
});
it('should be visible when categoryNameControlVisible is true', () => {
const categoryControl = fixture.debugElement.query(By.css('.adf-category-name-field'));
expect(categoryControl).toBeTruthy();
});
it('should have correct label and hide button', () => {
const categoryControlLabel = fixture.debugElement.query(By.css('#adf-category-name-input-label')).nativeElement;
const categoryControlHideBtn: HTMLButtonElement = fixture.debugElement.query(By.css('.adf-category-name-field button')).nativeElement;
expect(categoryControlHideBtn).toBeTruthy();
expect(categoryControlHideBtn.attributes.getNamedItem('title').textContent.trim()).toBe('CATEGORIES_MANAGEMENT.HIDE_INPUT');
expect(categoryControlLabel.textContent.trim()).toBe('CATEGORIES_MANAGEMENT.NAME');
});
it('should hide category control and existing categories panel on clicking hide button', () => {
const categoryControlHideBtn: HTMLButtonElement = fixture.debugElement.query(By.css('.adf-category-name-field button')).nativeElement;
const controlVisibilityChangeSpy = spyOn(component.categoryNameControlVisibleChange, 'emit').and.callThrough();
categoryControlHideBtn.click();
fixture.detectChanges();
const categoryControl: HTMLDivElement = fixture.debugElement.query(By.css('.adf-category-name-field')).nativeElement;
expect(categoryControl.hidden).toBeTrue();
expect(component.categoryNameControlVisible).toBeFalse();
expect(component.existingCategoriesPanelVisible).toBeFalse();
expect(controlVisibilityChangeSpy).toHaveBeenCalledOnceWith(false);
});
});
describe('Spinner', () => {
function getSpinner(): DebugElement {
return fixture.debugElement.query(By.css(`.mat-progress-spinner`));
}
it('should be displayed with correct diameter when existing categories are loading', fakeAsync(() => {
typeCategory('Category 1', 0);
const spinner = getSpinner();
expect(spinner).toBeTruthy();
expect(spinner.componentInstance.diameter).toBe(50);
discardPeriodicTasks();
flush();
}));
it('should not be displayed when existing categories stopped loading', fakeAsync(() => {
typeCategory('Category 1');
const spinner = getSpinner();
expect(spinner).toBeFalsy();
}));
});
it('should display correct message when there are no existing categories', fakeAsync(() => {
spyOn(categoryService, 'getSubcategories').and.returnValue(of({list: { pagination: {}, entries: []}}));
typeCategory('test');
const noExistingCategoriesMsg = fixture.debugElement.query(By.css('mat-selection-list p'))?.nativeElement.textContent.trim();
expect(noExistingCategoriesMsg).toBe('CATEGORIES_MANAGEMENT.NO_EXISTING_CATEGORIES');
}));
});
describe('Assign mode', () => {
beforeEach(() => {
component.managementMode = CategoriesManagementMode.ASSIGN;
component.categories = [category3, category4];
component.classifiableChanged = classifiableChangedSubject.asObservable();
fixture.detectChanges();
});
it('should return false for is CRUD mode check', () => {
expect(component.isCRUDMode).toBeFalse();
});
it('should display correct no categories message', () => {
component.categories = [];
fixture.detectChanges();
expect(getNoCategoriesMessage()).toBe('CATEGORIES_MANAGEMENT.NO_CATEGORIES_ASSIGNED');
});
it('should store initially assigned categories', () => {
expect(component.initialCategories.length).toBe(2);
});
it('should pad assigned categories', () => {
const assignedCategories = getAssignedCategoriesList();
const allCategoriesPadded = assignedCategories.every((categoryElem) => categoryElem.classList.contains('adf-categories-padded'));
expect(allCategoriesPadded).toBeTrue();
});
it('should have correct remove category title', () => {
const removeBtns = getRemoveCategoryButtons();
const isTitleCorrect = removeBtns.every((removeBtn) => removeBtn.attributes.getNamedItem('title').textContent === 'CATEGORIES_MANAGEMENT.UNASSIGN_CATEGORY');
expect(isTitleCorrect).toBeTrue();
});
it('should have no required validator set for category control', () => {
expect(component.categoryNameControl.hasValidator(Validators.required)).toBeFalse();
});
it('should display validation error when searching for empty category', fakeAsync(() => {
typeCategory(' ');
expect(getFirstError()).toBe('CATEGORIES_MANAGEMENT.ERRORS.EMPTY_CATEGORY');
}));
it('should clear categories and hide category control when classifiable aspect is removed', () => {
const controlVisibilityChangeSpy = spyOn(component.categoryNameControlVisibleChange, 'emit').and.callThrough();
classifiableChangedSubject.next();
fixture.detectChanges();
expect(controlVisibilityChangeSpy).toHaveBeenCalledOnceWith(false);
expect(component.categoryNameControlVisible).toBeFalse();
expect(component.categories).toEqual([]);
});
it('should not display create category label', fakeAsync(() => {
typeCategory('test');
expect(getCreateCategoryLabel()).toBeUndefined();
}));
it('should not disable existing categories', fakeAsync(() => {
typeCategory('test');
expect(getSelectionList().disabled).toBeFalse();
}));
it('should add selected category to categories list and remove from existing categories', fakeAsync(() => {
const categoriesChangeSpy = spyOn(component.categoriesChange, 'emit').and.callThrough();
typeCategory('test');
const options = getExistingCategoriesList();
// eslint-disable-next-line no-underscore-dangle
options[0]._handleClick();
expect(component.categories.length).toBe(3);
expect(component.categories[2].name).toBe('testCat');
expect(component.existingCategories.length).toBe(1);
expect(categoriesChangeSpy).toHaveBeenCalledOnceWith(component.categories);
discardPeriodicTasks();
flush();
}));
it('should remove selected category from categories list and add it back to existing categories', fakeAsync(() => {
typeCategory('test');
const options = getExistingCategoriesList();
// eslint-disable-next-line no-underscore-dangle
options[0]._handleClick();
fixture.detectChanges();
const categoriesChangeSpy = spyOn(component.categoriesChange, 'emit').and.callThrough();
const removeCategoryBtns = getRemoveCategoryButtons();
removeCategoryBtns[2].click();
fixture.detectChanges();
expect(component.categories.length).toBe(2);
expect(component.categories[1].name).toBe('testCat4');
expect(categoriesChangeSpy).toHaveBeenCalledOnceWith(component.categories);
expect(component.existingCategories.length).toBe(2);
discardPeriodicTasks();
flush();
}));
it('should not add back to existing categories when category was assigned initially', fakeAsync(() => {
typeCategory('test');
expect(component.existingCategories.length).toBe(2);
const categoriesChangeSpy = spyOn(component.categoriesChange, 'emit').and.callThrough();
const removeCategoryBtns = getRemoveCategoryButtons();
removeCategoryBtns[0].click();
fixture.detectChanges();
expect(component.categories.length).toBe(1);
expect(component.categories[0].name).toBe('testCat4');
expect(categoriesChangeSpy).toHaveBeenCalledOnceWith(component.categories);
expect(component.existingCategories.length).toBe(2);
discardPeriodicTasks();
flush();
}));
});
describe('CRUD mode', () => {
beforeEach(() => {
component.managementMode = CategoriesManagementMode.CRUD;
component.categories = [category3, category4];
fixture.detectChanges();
});
it('should return true for is CRUD mode check', () => {
expect(component.isCRUDMode).toBeTrue();
});
it('should display correct no categories message', () => {
component.categories = [];
fixture.detectChanges();
expect(getNoCategoriesMessage()).toBe('CATEGORIES_MANAGEMENT.NO_CATEGORIES_CREATED');
});
it('should have correct remove category title', () => {
const removeBtns = getRemoveCategoryButtons();
const isTitleCorrect = removeBtns.every((removeBtn) => removeBtn.attributes.getNamedItem('title').textContent === 'CATEGORIES_MANAGEMENT.DELETE_CATEGORY');
expect(isTitleCorrect).toBeTrue();
});
it('should display create category label', fakeAsync(() => {
typeCategory('test');
expect(getCreateCategoryLabel().textContent.trim()).toBe('CATEGORIES_MANAGEMENT.GENERIC_CREATE');
}));
it('should hide create category label when typing', fakeAsync(() => {
typeCategory('test', 0);
expect(getCreateCategoryLabel()).toBeUndefined();
discardPeriodicTasks();
flush();
}));
it('should disable existing categories', fakeAsync(() => {
typeCategory('test');
expect(getSelectionList().disabled).toBeTrue();
}));
it('should be able to add typed category when create label is clicked', fakeAsync(() => {
const categoriesChangeSpy = spyOn(component.categoriesChange, 'emit');
createCategory('test');
expect(component.categories.length).toBe(3);
expect(component.categories[2].name).toBe('test');
expect(categoriesChangeSpy).toHaveBeenCalledOnceWith(component.categories);
}));
it('should be able to add typed category when enter is hit', fakeAsync(() => {
const categoriesChangeSpy = spyOn(component.categoriesChange, 'emit');
createCategory('test', true);
expect(component.categories.length).toBe(3);
expect(component.categories[2].name).toBe('test');
expect(categoriesChangeSpy).toHaveBeenCalledOnceWith(component.categories);
}));
it('should clear and hide input after category is created', fakeAsync(() => {
const controlVisibilityChangeSpy = spyOn(component.categoryNameControlVisibleChange, 'emit');
createCategory('test');
const categoryControl: HTMLDivElement = fixture.debugElement.query(By.css('.adf-category-name-field')).nativeElement;
expect(categoryControl.hidden).toBeTrue();
expect(controlVisibilityChangeSpy).toHaveBeenCalledOnceWith(false);
expect(getExistingCategoriesList()).toEqual([]);
expect(component.categoryNameControl.value).toBe('');
expect(component.categoryNameControl.untouched).toBeTrue();
}));
it('should be able to remove added category', fakeAsync(() => {
createCategory('test');
const categoriesChangeSpy = spyOn(component.categoriesChange, 'emit');
const removeCategoryBtns = getRemoveCategoryButtons();
removeCategoryBtns[2].click();
fixture.detectChanges();
expect(component.categories.length).toBe(2);
expect(component.categories[1].name).toBe('testCat4');
expect(categoriesChangeSpy).toHaveBeenCalledOnceWith(component.categories);
}));
describe('Errors', () => {
it('should display validation error when searching for empty category', fakeAsync(() => {
typeCategory(' ');
expect(getFirstError()).toBe('CATEGORIES_MANAGEMENT.ERRORS.EMPTY_CATEGORY');
}));
it('should show error for required', fakeAsync(() => {
typeCategory('');
expect(getFirstError()).toBe('CATEGORIES_MANAGEMENT.ERRORS.REQUIRED');
}));
it('should show error when duplicated already added category', fakeAsync(() => {
const category = 'new Cat';
createCategory(category);
typeCategory(category);
expect(getFirstError()).toBe('CATEGORIES_MANAGEMENT.ERRORS.DUPLICATED_CATEGORY');
}));
it('should show error when duplicated already existing category', fakeAsync(() => {
const category = 'testCat2';
typeCategory(category);
expect(getFirstError()).toBe('CATEGORIES_MANAGEMENT.ERRORS.ALREADY_EXISTS');
}));
it('should show error for required when not typed anything and blur input', fakeAsync(() => {
typeCategory('');
getCategoryControlInput().blur();
fixture.detectChanges();
expect(getFirstError()).toBe('CATEGORIES_MANAGEMENT.ERRORS.REQUIRED');
flush();
}));
it('should not display create category label when control has error', fakeAsync(() => {
typeCategory(' ');
expect(getCreateCategoryLabel().hidden).toBeTrue();
}));
});
});
});

View File

@@ -0,0 +1,332 @@
/*!
* @license
* Copyright © 2005-2023 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Category } from '@alfresco/js-api';
import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild, ViewEncapsulation } from '@angular/core';
import { FormControl, Validators } from '@angular/forms';
import { MatSelectionListChange } from '@angular/material/list';
import { EMPTY, Observable, Subject, timer } from 'rxjs';
import { debounce, first, map, takeUntil, tap } from 'rxjs/operators';
import { CategoriesManagementMode } from './categories-management-mode';
import { CategoryService } from '../services/category.service';
interface CategoryNameControlErrors {
duplicatedExistingCategory?: boolean;
duplicatedCategory?: boolean;
emptyCategory?: boolean;
required?: boolean;
}
@Component({
selector: 'adf-categories-management',
templateUrl: './categories-management.component.html',
styleUrls: ['./categories-management.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class CategoriesManagementComponent implements OnInit, OnDestroy {
readonly nameErrorMessagesByErrors = new Map<keyof CategoryNameControlErrors, string>([
['duplicatedExistingCategory', 'ALREADY_EXISTS'],
['duplicatedCategory', 'DUPLICATED_CATEGORY'],
['emptyCategory', 'EMPTY_CATEGORY'],
['required', 'REQUIRED']
]);
private existingCategoryLoaded$ = new Subject<void>();
private cancelExistingCategoriesLoading$ = new Subject<void>();
private onDestroy$ = new Subject<void>();
private _categoryNameControl = new FormControl<string>(
'',
[
this.validateIfNotAlreadyAdded.bind(this),
this.validateEmptyCategory,
Validators.required
],
this.validateIfNotAlreadyCreated.bind(this)
);
private _existingCategories: Category[];
private _categoryNameErrorMessageKey = '';
private _existingCategoriesLoading = false;
private _typing = false;
private _existingCategoriesPanelVisible: boolean;
private _categoryNameControlVisible = false;
private readonly existingCategoriesListLimit = 15;
initialCategories: Category[] = [];
/** Categories to display initially */
@Input()
categories: Category[] = [];
/**
* Decides if categoryNameControl should be visible. Sets also existing categories panel visibility
* and scrolls control into view when visible.
*
* @param categoryNameControlVisible control visibility.
*/
@Input()
set categoryNameControlVisible(categoryNameControlVisible: boolean) {
this._categoryNameControlVisible = categoryNameControlVisible;
if (categoryNameControlVisible) {
setTimeout(() => {
this.categoryNameInputElement.nativeElement.scrollIntoView();
});
this._existingCategoriesPanelVisible = true;
} else {
this._existingCategoriesPanelVisible = false;
}
}
get categoryNameControlVisible(): boolean {
return this._categoryNameControlVisible;
}
/** Emits when classifiable aspect changes */
@Input()
classifiableChanged: Observable<void>;
/** Disables remove button in upper categories list */
@Input()
disableRemoval = false;
/**
* Component mode.
* In ASSIGN mode we can only assign/unassign categories from existing list.
* In CRUD mode we can create categories.
*/
@Input()
managementMode: CategoriesManagementMode;
/** ID of a parent category. New categories will be created under this parent */
@Input()
parentId: string;
/** Emits when state of upper categories list changes */
@Output()
categoriesChange = new EventEmitter<Category[]>();
/** Emits when categoryNameControl visibility changes */
@Output()
categoryNameControlVisibleChange = new EventEmitter<boolean>();
@ViewChild('categoryNameInput')
private categoryNameInputElement: ElementRef;
constructor(private categoryService: CategoryService) {}
ngOnInit() {
this.categoryNameControl.valueChanges
.pipe(
map((name: string) => name.trim()),
tap((name: string) => {
this._typing = true;
if (name) {
this._existingCategoriesLoading = true;
this._existingCategoriesPanelVisible = true;
}
this.cancelExistingCategoriesLoading$.next();
}),
debounce((name: string) => (name ? timer(300) : EMPTY)),
takeUntil(this.onDestroy$)
)
.subscribe((name: string) => this.onNameControlValueChange(name));
this.categoryNameControl.statusChanges
.pipe(takeUntil(this.onDestroy$))
.subscribe(() => this.setCategoryNameControlErrorMessageKey());
this.setCategoryNameControlErrorMessageKey();
if (!this.isCRUDMode) {
this._categoryNameControl.removeValidators(Validators.required);
this.categories.forEach((category) => this.initialCategories.push(category));
this.classifiableChanged
.pipe(takeUntil(this.onDestroy$))
.subscribe(() => {
this.categories = [];
this.categoryNameControlVisible = false;
this.categoryNameControlVisibleChange.emit(false);
});
}
}
ngOnDestroy() {
this.onDestroy$.next();
this.onDestroy$.complete();
this.cancelExistingCategoriesLoading$.next();
this.cancelExistingCategoriesLoading$.complete();
}
get categoryNameControl(): FormControl<string> {
return this._categoryNameControl;
}
get existingCategories(): Category[] {
return this._existingCategories;
}
get categoryNameErrorMessageKey(): string {
return this._categoryNameErrorMessageKey;
}
get existingCategoriesLoading(): boolean {
return this._existingCategoriesLoading;
}
get typing(): boolean {
return this._typing;
}
get existingCategoriesPanelVisible(): boolean {
return this._existingCategoriesPanelVisible;
}
get isCRUDMode(): boolean {
return this.managementMode === CategoriesManagementMode.CRUD;
}
/**
* Hides and emits categoryNameControl and hides existing categories panel.
*/
hideNameInput() {
this.categoryNameControlVisible = false;
this.categoryNameControlVisibleChange.emit(false);
this._existingCategoriesPanelVisible = false;
}
/**
* Adds category that has been typed to a categoryNameControl and hides it afterwards.
*/
addCategory() {
if (this.isCRUDMode && !this._typing && !this.categoryNameControl.invalid) {
const newCatName = this.categoryNameControl.value.trim();
const newCat = new Category({ id: newCatName, name: newCatName });
this.categories.push(newCat);
this.hideNameInput();
this.categoryNameControl.setValue('');
this.categoryNameControl.markAsUntouched();
this._existingCategories = null;
this.categoriesChange.emit(this.categories);
}
}
/**
* Adds existing category to categories list and removes it from existing categories list.
*
* @param change - selection list change containing selected category
*/
addCategoryToAssign(change: MatSelectionListChange) {
const selectedCategory: Category = change.options[0].value;
this.categories.push(selectedCategory);
this._existingCategories.splice(this._existingCategories.indexOf(selectedCategory), 1);
this.categoryNameControl.updateValueAndValidity();
this.categoriesChange.emit(this.categories);
}
/**
* Removes the category from categories list and adds it to existing categories list in ASSIGN mode.
*
* @param category - category to remove
*/
removeCategory(category: Category) {
this.categories.splice(this.categories.indexOf(category), 1);
if (!this.isCRUDMode && !!this._existingCategories && !this.initialCategories.some((cat) => cat.id === category.id)) {
this._existingCategories.push(category);
this.sortCategoriesList(this._existingCategories);
}
this.categoryNameControl.updateValueAndValidity({
emitEvent: false
});
this.categoriesChange.emit(this.categories);
}
private onNameControlValueChange(name: string) {
this.categoryNameControl.markAsTouched();
if (name) {
if (this.isCRUDMode) {
this.getChildrenCategories(name);
} else {
this.searchForExistingCategories(name);
}
} else {
this._existingCategories = null;
}
}
private searchForExistingCategories(searchTerm: string) {
this.categoryService.searchCategories(searchTerm, 0 , this.existingCategoriesListLimit).subscribe((existingCategoriesResult) => {
this._existingCategories = existingCategoriesResult.list.entries.map((rowEntry) => {
const existingCat = new Category();
existingCat.id = rowEntry.entry.id;
const path = rowEntry.entry.path.name.split('/').splice(3).join('/');
existingCat.name = path ? `${path}/${rowEntry.entry.name}` : rowEntry.entry.name;
return existingCat;
});
this._existingCategories = this._existingCategories.filter((existingCat) => this.categories.find((category) => existingCat.id === category.id) === undefined);
this.sortCategoriesList(this._existingCategories);
this._existingCategoriesLoading = false;
this._typing = false;
this.existingCategoryLoaded$.next();
});
}
private getChildrenCategories(searchTerm: string) {
this.categoryService.getSubcategories(this.parentId).subscribe((childrenCategories) => {
this._existingCategories = childrenCategories.list.entries.map((categoryEntry) => categoryEntry.entry);
this._existingCategories = this._existingCategories.filter((existingCat) => existingCat.name.toLowerCase().includes(searchTerm.toLowerCase()));
this.sortCategoriesList(this._existingCategories);
this._existingCategoriesLoading = false;
this._typing = false;
this.existingCategoryLoaded$.next();
});
}
private validateIfNotAlreadyAdded(nameControl: FormControl<string>): CategoryNameControlErrors | null {
return this.categories?.some((category) => this.compareCategories(category, nameControl.value)) && this.isCRUDMode
? { duplicatedCategory: true }
: null;
}
private validateIfNotAlreadyCreated(nameControl: FormControl<string>): Observable<CategoryNameControlErrors | null> {
return this.existingCategoryLoaded$.pipe(
map<void, CategoryNameControlErrors | null>(() => this.existingCategories.some((category) => this.compareCategories(category, nameControl.value)) && this.isCRUDMode
? { duplicatedExistingCategory: true }
: null),
first()
);
}
private compareCategories(category1?: Category, cat2Name?: string): boolean {
return category1?.name.trim().toUpperCase() === cat2Name?.trim().toUpperCase();
}
private validateEmptyCategory(categoryNameControl: FormControl<string>): CategoryNameControlErrors | null {
return categoryNameControl.value.length && !categoryNameControl.value.trim()
? { emptyCategory: true }
: null;
}
private setCategoryNameControlErrorMessageKey() {
this._categoryNameErrorMessageKey = this.categoryNameControl.invalid
? `CATEGORIES_MANAGEMENT.ERRORS.${this.nameErrorMessagesByErrors.get(
Object.keys(this.categoryNameControl.errors)[0] as keyof CategoryNameControlErrors
)}`
: '';
}
private sortCategoriesList(categoriesList: Category[]) {
categoriesList.sort((category1, category2) => category1.name.localeCompare(category2.name));
}
}

View File

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

View File

@@ -18,3 +18,6 @@
export * from './services/category.service';
export * from './services/category-tree-datasource.service';
export * from './models/category-node.interface';
export * from './category.module';
export * from './categories-management/categories-management.component';
export * from './categories-management/categories-management-mode';

View File

@@ -19,6 +19,7 @@ import { CoreTestingModule, UserPreferencesService } from '@alfresco/adf-core';
import {
CategoryBody,
CategoryEntry,
CategoryLinkBody,
CategoryPaging, PathInfo,
RequestQuery, ResultNode,
ResultSetPaging,
@@ -32,9 +33,12 @@ describe('CategoryService', () => {
let userPreferencesService: UserPreferencesService;
const fakeParentCategoryId = 'testParentId';
const fakeCategoryId = 'fakeId';
const fakeNodeId = 'fakeNodeId';
const fakeCategoriesResponse: CategoryPaging = { list: { pagination: {}, entries: [] }};
const fakeCategoryEntry: CategoryEntry = { entry: { id: 'testId', name: 'testName' }};
const fakeCategoryBody: CategoryBody = { name: 'updatedName' };
const fakeCategoriesLinkBodies: CategoryLinkBody[] = [{ categoryId: fakeCategoryId }];
beforeEach(() => {
TestBed.configureTestingModule({
@@ -150,4 +154,25 @@ describe('CategoryService', () => {
});
});
});
it('should fetch categories linked to node with nodeId with path included', fakeAsync(() => {
const getLinkedCategoriesSpy = spyOn(categoryService.categoriesApi, 'getCategoryLinksForNode').and.returnValue(Promise.resolve(fakeCategoriesResponse));
categoryService.getCategoryLinksForNode(fakeNodeId).subscribe(() => {
expect(getLinkedCategoriesSpy).toHaveBeenCalledOnceWith(fakeNodeId, {include: ['path']});
});
}));
it('should unlink categories from node', fakeAsync(() => {
const unlinkCategoriesSpy = spyOn(categoryService.categoriesApi, 'unlinkNodeFromCategory').and.returnValue(Promise.resolve());
categoryService.unlinkNodeFromCategory(fakeNodeId, fakeCategoryId).subscribe(() => {
expect(unlinkCategoriesSpy).toHaveBeenCalledOnceWith(fakeNodeId, fakeCategoryId);
});
}));
it('should link categories to a node with nodeId', fakeAsync(() => {
const linkCategoriesSpy = spyOn(categoryService.categoriesApi, 'linkNodeToCategory').and.returnValue(Promise.resolve(fakeCategoryEntry));
categoryService.linkNodeToCategory(fakeNodeId, fakeCategoriesLinkBodies).subscribe(() => {
expect(linkCategoriesSpy).toHaveBeenCalledOnceWith(fakeNodeId, fakeCategoriesLinkBodies);
});
}));
});

View File

@@ -21,6 +21,7 @@ import {
CategoriesApi,
CategoryBody,
CategoryEntry,
CategoryLinkBody,
CategoryPaging,
RequestQuery,
ResultSetPaging,
@@ -121,4 +122,36 @@ export class CategoryService {
include: ['path']
}));
}
/**
* List of categories that node is assigned to
*
* @param nodeId The identifier of a node.
* @return Observable<CategoryPaging> Categories that node is assigned to
*/
getCategoryLinksForNode(nodeId: string): Observable<CategoryPaging> {
return from(this.categoriesApi.getCategoryLinksForNode(nodeId, {include: ['path']}));
}
/**
* Unlink category from a node
*
* @param nodeId The identifier of a node.
* @param categoryId The identifier of a category.
* @return Observable<void>
*/
unlinkNodeFromCategory(nodeId: string, categoryId: string): Observable<void> {
return from(this.categoriesApi.unlinkNodeFromCategory(nodeId, categoryId));
}
/**
* Link node to a category
*
* @param nodeId The identifier of a node.
* @param categoryLinkBodyCreate Array of a categories that node will be linked to.
* @return Observable<CategoryEntry>
*/
linkNodeToCategory(nodeId: string, categoryLinkBodyCreate: CategoryLinkBody[]): Observable<CategoryPaging | CategoryEntry> {
return from(this.categoriesApi.linkNodeToCategory(nodeId, categoryLinkBodyCreate));
}
}

View File

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

View File

@@ -96,6 +96,10 @@ describe('ContentMetadataCardComponent', () => {
expect(fixture.debugElement.query(By.directive(ContentMetadataComponent)).componentInstance.displayTags).toBeTrue();
});
it('should assign true to displayCategories for ContentMetadataComponent', () => {
expect(fixture.debugElement.query(By.directive(ContentMetadataComponent)).componentInstance.displayCategories).toBeTrue();
});
it('should pass through the preset to the underlying component', () => {
const contentMetadataComponent = fixture.debugElement.query(By.directive(ContentMetadataComponent)).componentInstance;

View File

@@ -49,6 +49,35 @@
</adf-tags-creator>
</div>
</ng-container>
<ng-container *ngIf="displayCategories">
<mat-expansion-panel *ngIf="!editable">
<mat-expansion-panel-header>
<mat-panel-title>{{ 'CATEGORIES_MANAGEMENT.CATEGORIES_TITLE' | translate }}</mat-panel-title>
</mat-expansion-panel-header>
<p *ngFor="let category of categories" class="adf-metadata-categories">{{ category.name }}</p>
</mat-expansion-panel>
<div *ngIf="editable"
class="adf-metadata-categories-header">
<div class="adf-metadata-categories-title">
<p>{{ 'CATEGORIES_MANAGEMENT.CATEGORIES_TITLE' | translate }}</p>
<button
mat-icon-button
[attr.title]="'CATEGORIES_MANAGEMENT.ASSIGN_CATEGORIES' | translate"
[hidden]="categoryControlVisible || saving"
(click)="categoryControlVisible = true">
<mat-icon>add</mat-icon>
</button>
</div>
<adf-categories-management
[(categoryNameControlVisible)]="categoryControlVisible"
[disableRemoval]="saving"
[categories]="categories"
[managementMode]="categoriesManagementMode"
[classifiableChanged]="classifiableChanged"
(categoriesChange)="storeCategoriesToAssign($event)">
</adf-categories-management>
</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

@@ -63,4 +63,26 @@
justify-content: space-evenly;
margin: 10px;
}
&-metadata-categories-header {
display: flex;
flex-direction: column;
padding: 0 24px;
.adf-metadata-categories-title {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 15px;
height: 64px;
button {
margin-right: -14px;
}
[hidden] {
visibility: hidden;
}
}
}
}

View File

@@ -18,7 +18,7 @@
import { ComponentFixture, TestBed, tick, fakeAsync } from '@angular/core/testing';
import { DebugElement, SimpleChange } from '@angular/core';
import { By } from '@angular/platform-browser';
import { ClassesApi, MinimalNode, Node, Tag, TagBody, TagEntry, TagPaging, TagPagingList } from '@alfresco/js-api';
import { Category, CategoryPaging, 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 {
@@ -33,7 +33,7 @@ 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';
import { CategoriesManagementComponent, CategoriesManagementMode, CategoryService, TagsCreatorComponent, TagsCreatorMode, TagService } from '@alfresco/adf-content-services';
describe('ContentMetadataComponent', () => {
let component: ContentMetadataComponent;
@@ -44,6 +44,7 @@ describe('ContentMetadataComponent', () => {
let node: Node;
let folderNode: Node;
let tagService: TagService;
let categoryService: CategoryService;
const preset = 'custom-preset';
@@ -62,6 +63,10 @@ describe('ContentMetadataComponent', () => {
return tagPaging;
};
const category1 = new Category({ id: 'test', name: 'testCat' });
const category2 = new Category({ id: 'test2', name: 'testCat2' });
const categoryPagingResponse: CategoryPaging = { list: { pagination: {}, entries: [ { entry: category1 }, { entry: category2 }]}};
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;
@@ -83,24 +88,46 @@ describe('ContentMetadataComponent', () => {
const findShowingTagInputButton = (): HTMLButtonElement =>
fixture.debugElement.query(By.css('[data-automation-id=showing-tag-input-button]')).nativeElement;
function getCategories(): HTMLParagraphElement[] {
return fixture.debugElement.queryAll(By.css('.adf-metadata-categories'))?.map((debugElem) => debugElem.nativeElement);
}
function getCategoriesManagementComponent(): CategoriesManagementComponent {
return fixture.debugElement.query(By.directive(CategoriesManagementComponent))?.componentInstance;
}
function getAssignCategoriesBtn(): HTMLButtonElement {
return fixture.debugElement.query(By.css('.adf-metadata-categories-title button')).nativeElement;
}
setupTestBed({
imports: [
TranslateModule.forRoot(),
ContentTestingModule
],
providers: [{
provide: LogService,
useValue: {
error: jasmine.createSpy('error')
}
}, {
provide: TagService,
useValue: {
getTagsByNodeId: () => EMPTY,
removeTag: () => EMPTY,
assignTagsToNode: () => EMPTY
}
}]
providers: [
{
provide: LogService,
useValue: {
error: jasmine.createSpy('error')
}
},
{
provide: TagService,
useValue: {
getTagsByNodeId: () => EMPTY,
removeTag: () => EMPTY,
assignTagsToNode: () => EMPTY
}
},
{
provide: CategoryService,
useValue: {
getCategoryLinksForNode: () => EMPTY,
linkNodeToCategory: () => EMPTY,
unlinkNodeFromCategory: () => EMPTY
}
}]
});
beforeEach(() => {
@@ -110,6 +137,7 @@ describe('ContentMetadataComponent', () => {
updateService = TestBed.inject(CardViewContentUpdateService);
nodesApiService = TestBed.inject(NodesApiService);
tagService = TestBed.inject(TagService);
categoryService = TestBed.inject(CategoryService);
node = {
id: 'node-id',
@@ -1137,11 +1165,202 @@ describe('ContentMetadataComponent', () => {
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();
describe('Categories list', () => {
beforeEach(() => {
component.displayCategories = true;
component.node.aspectNames.push('generalclassifiable');
spyOn(categoryService, 'getCategoryLinksForNode').and.returnValue(of(categoryPagingResponse));
});
it('should render categories node is assigned to', () => {
component.ngOnInit();
fixture.detectChanges();
const categories = getCategories();
expect(categories.length).toBe(2);
expect(categories[0].textContent).toBe(category1.name);
expect(categories[1].textContent).toBe(category2.name);
expect(categoryService.getCategoryLinksForNode).toHaveBeenCalledWith(node.id);
});
it('should not render categories after loading categories in ngOnInit if displayCategories is false', () => {
component.displayCategories = false;
component.ngOnInit();
fixture.detectChanges();
const categories = getCategories();
expect(categories).toHaveSize(0);
expect(categoryService.getCategoryLinksForNode).not.toHaveBeenCalled();
});
it('should render categories when ngOnChanges', () => {
component.ngOnChanges({ node: new SimpleChange(undefined, node, false)});
fixture.detectChanges();
const categories = getCategories();
expect(categories.length).toBe(2);
expect(categories[0].textContent).toBe(category1.name);
expect(categories[1].textContent).toBe(category2.name);
expect(categoryService.getCategoryLinksForNode).toHaveBeenCalledWith(node.id);
});
it('should not render categories after loading categories in ngOnChanges if displayCategories is false', () => {
component.displayCategories = false;
component.ngOnChanges({
node: new SimpleChange(undefined, node, false)
});
fixture.detectChanges();
const categories = getCategories();
expect(categories).toHaveSize(0);
expect(categoryService.getCategoryLinksForNode).not.toHaveBeenCalled();
});
it('should not reload categories in ngOnChanges if node is not changed', () => {
component.ngOnChanges({});
fixture.detectChanges();
expect(categoryService.getCategoryLinksForNode).not.toHaveBeenCalled();
});
it('should render categories after discard changes button is clicked', fakeAsync(() => {
component.editable = true;
fixture.detectChanges();
TestBed.inject(CardViewContentUpdateService).itemUpdated$.next({
changed: {}
} as UpdateNotification);
tick(500);
fixture.detectChanges();
clickOnCancel();
component.editable = false;
fixture.detectChanges();
const categories = getCategories();
expect(categories.length).toBe(2);
expect(categories[0].textContent).toBe(category1.name);
expect(categories[1].textContent).toBe(category2.name);
expect(categoryService.getCategoryLinksForNode).toHaveBeenCalledWith(node.id);
}));
it('should be hidden when editable is true', () => {
component.editable = true;
fixture.detectChanges();
expect(getCategories().length).toBe(0);
});
});
describe('Categories management', () => {
let categoriesManagementComponent: CategoriesManagementComponent;
beforeEach(() => {
component.editable = true;
component.displayCategories = true;
component.node.aspectNames.push('generalclassifiable');
spyOn(categoryService, 'getCategoryLinksForNode').and.returnValue(of(categoryPagingResponse));
fixture.detectChanges();
categoriesManagementComponent = getCategoriesManagementComponent();
});
it('should set categoryNameControlVisible to false initially', () => {
expect(categoriesManagementComponent.categoryNameControlVisible).toBeFalse();
});
it('should hide assign categories button when categoryNameControlVisible changes to true', () => {
categoriesManagementComponent.categoryNameControlVisibleChange.emit(true);
fixture.detectChanges();
expect(getAssignCategoriesBtn().hasAttribute('hidden')).toBeTrue();
});
it('should show assign categories button when categoryNameControlVisible changes to false', fakeAsync(() => {
categoriesManagementComponent.categoryNameControlVisibleChange.emit(true);
fixture.detectChanges();
tick();
categoriesManagementComponent.categoryNameControlVisibleChange.emit(false);
fixture.detectChanges();
tick(100);
expect(getAssignCategoriesBtn().hasAttribute('hidden')).toBeFalse();
}));
it('should have correct mode', () => {
expect(categoriesManagementComponent.managementMode).toBe(CategoriesManagementMode.ASSIGN);
});
it('should clear categories and emit event when classifiable changes', (done) => {
component.node.aspectNames = [];
component.ngOnChanges({ node: new SimpleChange(undefined, node, false)});
component.classifiableChanged.subscribe(() => {
expect(component.categories).toEqual([]);
done();
});
component.ngOnChanges({ node: new SimpleChange(undefined, node, false)});
});
it('should enable discard and save buttons after emitting categories change event', () => {
categoriesManagementComponent.categoriesChange.emit([category1, category2]);
fixture.detectChanges();
expect(findCancelButton().disabled).toBeFalse();
expect(findSaveButton().disabled).toBeFalse();
});
it('should not disable removal initially', () => {
expect(categoriesManagementComponent.disableRemoval).toBeFalse();
});
it('should disable removal on saving', () => {
categoriesManagementComponent.categoriesChange.emit([]);
fixture.detectChanges();
clickOnSave();
expect(categoriesManagementComponent.disableRemoval).toBeTrue();
});
it('should not disable removal 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));
component.ngOnInit();
spyOn(tagService, 'removeTag').and.returnValue(EMPTY);
spyOn(tagService, 'assignTagsToNode').and.returnValue(EMPTY);
spyOn(categoryService, 'unlinkNodeFromCategory').and.returnValue(EMPTY);
spyOn(categoryService, 'linkNodeToCategory').and.returnValue(throwError({}));
updateService.update(property, 'updated-value');
tick(600);
fixture.detectChanges();
categoriesManagementComponent.categoriesChange.emit([category1, category2]);
clickOnSave();
expect(categoriesManagementComponent.disableRemoval).toBeFalse();
}));
it('should set categoryNameControlVisible to false after saving', () => {
categoriesManagementComponent.categoryNameControlVisibleChange.emit(true);
categoriesManagementComponent.categoriesChange.emit([]);
fixture.detectChanges();
clickOnSave();
expect(categoriesManagementComponent.categoryNameControlVisible).toBeFalse();
});
describe('Setting categories', () => {
it('should set correct categories after ngOnInit', () => {
component.ngOnInit();
fixture.detectChanges();
expect(categoriesManagementComponent.categories).toEqual([ category1, category2 ]);
expect(categoryService.getCategoryLinksForNode).toHaveBeenCalledWith(node.id);
});
it('should set correct tags after ngOnChanges', () => {
component.ngOnChanges({ node: new SimpleChange(undefined, node, false)});
fixture.detectChanges();
expect(categoriesManagementComponent.categories).toEqual([ category1, category2 ]);
expect(categoryService.getCategoryLinksForNode).toHaveBeenCalledWith(node.id);
});
});
});
});

View File

@@ -16,7 +16,7 @@
*/
import { Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewEncapsulation } from '@angular/core';
import { Node, TagBody, TagEntry, TagPaging } from '@alfresco/js-api';
import { Category, CategoryEntry, CategoryLinkBody, CategoryPaging, Node, TagBody, TagEntry, TagPaging } from '@alfresco/js-api';
import { Observable, Subject, of, zip, forkJoin } from 'rxjs';
import {
CardViewItem,
@@ -33,6 +33,8 @@ import { CardViewContentUpdateService } from '../../../common/services/card-view
import { NodesApiService } from '../../../common/services/nodes-api.service';
import { TagsCreatorMode } from '../../../tag/tags-creator/tags-creator-mode';
import { TagService } from '../../../tag/services/tag.service';
import { CategoryService } from '../../../category/services/category.service';
import { CategoriesManagementMode } from '../../../category/categories-management/categories-management-mode';
const DEFAULT_SEPARATOR = ', ';
@@ -99,6 +101,19 @@ export class ContentMetadataComponent implements OnChanges, OnInit, OnDestroy {
@Input()
displayTags = false;
/** True if categories should be displayed, false otherwise */
@Input()
displayCategories = false;
private _assignedTags: string[] = [];
private assignedTagsEntries: TagEntry[];
private _editable = false;
private _tagsCreatorMode = TagsCreatorMode.CREATE_AND_ASSIGN;
private _tags: string[] = [];
private targetProperty: CardViewBaseItemModel;
private classifiableChangedSubject = new Subject<void>();
private _saving = false;
multiValueSeparator: string;
basicProperties$: Observable<CardViewItem[]>;
groupedProperties$: Observable<CardViewGroup[]>;
@@ -106,14 +121,11 @@ export class ContentMetadataComponent implements OnChanges, OnInit, OnDestroy {
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;
assignedCategories: Category[] = [];
categories: Category[] = [];
categoriesManagementMode = CategoriesManagementMode.ASSIGN;
categoryControlVisible = false;
classifiableChanged = this.classifiableChangedSubject.asObservable();
constructor(
private contentMetadataService: ContentMetadataService,
@@ -122,7 +134,8 @@ export class ContentMetadataComponent implements OnChanges, OnInit, OnDestroy {
private logService: LogService,
private translationService: TranslationService,
private appConfig: AppConfigService,
private tagService: TagService
private tagService: TagService,
private categoryService: CategoryService
) {
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;
@@ -194,24 +207,9 @@ export class ContentMetadataComponent implements OnChanges, OnInit, OnDestroy {
}
}
private loadProperties(node: Node) {
if (node) {
this.basicProperties$ = this.getProperties(node);
this.groupedProperties$ = this.contentMetadataService.getGroupedProperties(node, this.preset);
if (this.displayTags) {
this.loadTagsForNode(node.id);
}
}
}
private getProperties(node: Node) {
const properties$ = this.contentMetadataService.getBasicProperties(node);
const contentTypeProperty$ = this.contentMetadataService.getContentTypeProperty(node);
return zip(properties$, contentTypeProperty$)
.pipe(map(([properties, contentTypeProperty]) => {
const filteredProperties = contentTypeProperty.filter((property) => properties.findIndex((baseProperty) => baseProperty.key === property.key) === -1);
return [...properties, ...filteredProperties];
}));
ngOnDestroy() {
this.onDestroy$.next(true);
this.onDestroy$.complete();
}
updateChanges(updatedNodeChanges) {
@@ -228,11 +226,13 @@ 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.
* Called after clicking save button. It confirms all changes done for metadata and hides both category and tag name controls.
* Before clicking on that button they are not saved.
*/
saveChanges() {
this._saving = true;
this.tagNameControlVisible = false;
this.categoryControlVisible = false;
if (this.hasContentTypeChanged(this.changedProperties)) {
this.contentMetadataService.openConfirmDialog(this.changedProperties).subscribe(() => {
this.updateNode();
@@ -253,34 +253,15 @@ export class ContentMetadataComponent implements OnChanges, OnInit, OnDestroy {
this.hasMetadataChanged = true;
}
private updateNode() {
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((result) => {
if (result) {
if (this.hasContentTypeChanged(this.changedProperties)) {
this.cardViewContentUpdateService.updateNodeAspect(this.node);
}
this.revertChanges();
Object.assign(this.node, result.updatedNode);
this.nodesApiService.nodeUpdated.next(this.node);
if (Object.keys(result).length > 1 && this.displayTags) {
this.loadTagsForNode(this.node.id);
}
}
this._saving = false;
});
}
private hasContentTypeChanged(changedProperties): boolean {
return !!changedProperties?.nodeType;
/**
* Store all categories that node should be assigned to. Please note that they are just in "stored" state and are not yet saved
* until button for saving data is clicked. Calling that function causes that save button is enabled.
*
* @param categoriesToAssign array of categories to store.
*/
storeCategoriesToAssign(categoriesToAssign: Category[]) {
this.categories = categoriesToAssign;
this.hasMetadataChanged = true;
}
revertChanges() {
@@ -299,16 +280,11 @@ export class ContentMetadataComponent implements OnChanges, OnInit, OnDestroy {
return properties.length > 0;
}
ngOnDestroy() {
this.onDestroy$.next(true);
this.onDestroy$.complete();
}
public canExpandTheCard(group: CardViewGroup): boolean {
canExpandTheCard(group: CardViewGroup): boolean {
return group.title === this.displayAspect;
}
public canExpandProperties(): boolean {
canExpandProperties(): boolean {
return !this.expanded || this.displayAspect === 'Properties';
}
@@ -318,10 +294,106 @@ export class ContentMetadataComponent implements OnChanges, OnInit, OnDestroy {
}
}
private updateNode() {
forkJoin({
updatedNode: this.nodesApiService.updateNode(this.node.id, this.changedProperties),
...(this.displayTags ? this.saveTags() : {}),
...(this.displayCategories ? this.saveCategories() : {})
}).pipe(
catchError((err) => {
this.cardViewContentUpdateService.updateElement(this.targetProperty);
this.handleUpdateError(err);
this._saving = false;
return of(null);
}))
.subscribe((result) => {
if (result) {
if (this.hasContentTypeChanged(this.changedProperties)) {
this.cardViewContentUpdateService.updateNodeAspect(this.node);
}
this.revertChanges();
Object.assign(this.node, result.updatedNode);
this.nodesApiService.nodeUpdated.next(this.node);
if (Object.keys(result).length > 1 && this.displayTags) {
this.loadTagsForNode(this.node.id);
}
if (this.displayCategories && !!result.LinkingCategories) {
this.assignedCategories = !!result.LinkingCategories.list ?
result.LinkingCategories.list.entries.map((entry: CategoryEntry) => entry.entry) :
[result.LinkingCategories.entry];
}
}
this._saving = false;
});
}
private hasContentTypeChanged(changedProperties): boolean {
return !!changedProperties?.nodeType;
}
private loadProperties(node: Node) {
if (node) {
this.basicProperties$ = this.getProperties(node);
this.groupedProperties$ = this.contentMetadataService.getGroupedProperties(node, this.preset);
if (this.displayTags) {
this.loadTagsForNode(node.id);
}
if (this.displayCategories) {
this.loadCategoriesForNode(node.id);
if (!this.node.aspectNames.includes('generalclassifiable')) {
this.categories = [];
this.classifiableChangedSubject.next();
}
}
}
}
private getProperties(node: Node) {
const properties$ = this.contentMetadataService.getBasicProperties(node);
const contentTypeProperty$ = this.contentMetadataService.getContentTypeProperty(node);
return zip(properties$, contentTypeProperty$)
.pipe(map(([properties, contentTypeProperty]) => {
const filteredProperties = contentTypeProperty.filter((property) => properties.findIndex((baseProperty) => baseProperty.key === property.key) === -1);
return [...properties, ...filteredProperties];
}));
}
private isEmpty(value: any): boolean {
return value === undefined || value === null || value === '';
}
private loadCategoriesForNode(nodeId: string) {
this.assignedCategories = [];
this.categoryService.getCategoryLinksForNode(nodeId).subscribe((categoryPaging) => {
this.categories = categoryPaging.list.entries.map((categoryEntry) => {
const path = categoryEntry.entry.path ? categoryEntry.entry.path.split('/').splice(3).join('/') : null;
categoryEntry.entry.name = path ? `${path}/${categoryEntry.entry.name}` : categoryEntry.entry.name;
return categoryEntry.entry;
});
this.assignedCategories = [...this.categories];
});
}
private saveCategories(): { [key: string]: Observable<CategoryPaging | CategoryEntry | void> } {
const observables: { [key: string]: Observable<CategoryPaging | CategoryEntry | void> } = {};
if (this.categories) {
this.assignedCategories.forEach((assignedCategory) => {
if (this.categories.every((category) => category.name !== assignedCategory.name)) {
observables[`Removing ${assignedCategory.id}`] = this.categoryService.unlinkNodeFromCategory(this.node.id, assignedCategory.id);
}
});
const categoryLinkBodies = this.categories.map((category) => {
const categoryLinkBody = new CategoryLinkBody();
categoryLinkBody.categoryId = category.id;
return categoryLinkBody;
});
if (categoryLinkBodies.length > 0) {
observables['LinkingCategories'] = this.categoryService.linkNodeToCategory(this.node.id, categoryLinkBodies);
}
}
return observables;
}
private loadTagsForNode(id: string) {
this.tagService.getTagsByNodeId(id).subscribe((tagPaging) => {
this.assignedTagsEntries = tagPaging.list.entries;

View File

@@ -23,6 +23,7 @@ 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';
import { CategoriesModule } from '../category/category.module';
@NgModule({
imports: [
@@ -30,7 +31,8 @@ import { TagModule } from '../tag/tag.module';
MaterialModule,
FlexLayoutModule,
CoreModule,
TagModule
TagModule,
CategoriesModule
],
exports: [
ContentMetadataComponent,

View File

@@ -50,6 +50,7 @@ import { TreeModule } from './tree/tree.module';
import { AlfrescoViewerModule } from './viewer/alfresco-viewer.module';
import { ContentUserInfoModule } from './content-user-info/content-user-info.module';
import { SecurityControlsServiceModule } from './security/services/security-controls-service.module';
import { CategoriesModule } from './category/category.module';
@NgModule({
imports: [
@@ -84,7 +85,8 @@ import { SecurityControlsServiceModule } from './security/services/security-cont
TreeModule,
SearchTextModule,
AlfrescoViewerModule,
SecurityControlsServiceModule
SecurityControlsServiceModule,
CategoriesModule
],
providers: [
{
@@ -123,7 +125,8 @@ import { SecurityControlsServiceModule } from './security/services/security-cont
TreeModule,
SearchTextModule,
AlfrescoViewerModule,
SecurityControlsServiceModule
SecurityControlsServiceModule,
CategoriesModule
]
})
export class ContentModule {

View File

@@ -172,7 +172,9 @@
"ASSIGN_CATEGORIES": "Assign Categories",
"UNASSIGN_CATEGORY": "Unassign Category",
"DELETE_CATEGORY": "Delete Category",
"EXISTING_CATEGORIES": "Existing Categories",
"EXISTING_CATEGORIES": "Existing Categories:",
"SELECT_EXISTING_CATEGORY": "Select a existing Category:",
"NO_EXISTING_CATEGORIES": "No existing Categories",
"GENERIC_CREATE": "Create: {{name}}",
"NAME": "Category name",
"LOADING": "Loading",
@@ -182,7 +184,7 @@
"REQUIRED": "Category name is required",
"EMPTY_CATEGORY": "Category name can't contain only spaces",
"ALREADY_EXISTS": "Category already exists",
"ALREADY_ADDED_CATEGORY": "Category is already added",
"DUPLICATED_CATEGORY": "Category is already added",
"CREATE_CATEGORIES": "Error while creating categories",
"EXISTING_CATEGORIES": "Some categories already exist"
}

View File

@@ -26,7 +26,7 @@ import { catchError, concatMap, takeUntil } from 'rxjs/operators';
import { GenericBucket, GenericFacetResponse, ResultSetContext, ResultSetPaging } from '@alfresco/js-api';
import { SearchFilterList } from '../models/search-filter-list.model';
import { FacetFieldBucket } from '../models/facet-field-bucket.interface';
import { CategoryService } from '../../category';
import { CategoryService } from '../../category/services/category.service';
export interface SelectedBucket {
field: FacetField;

View File

@@ -6,7 +6,7 @@
</p>
<div
class="adf-tags-list"
[class.adf-tags-list-with-scrollbar]="tagsListScrollbarVisible"
[class.adf-tags-list-fixed]="!tagNameControlVisible"
#tagsList>
<p
*ngFor="let tag of tags"
@@ -17,7 +17,7 @@
mat-icon-button
(click)="removeTag(tag)"
[attr.title]="'TAG.TAGS_CREATOR.TOOLTIPS.DELETE_TAG' | translate"
[hidden]="disabledTagsRemoving">
[disabled]="disabledTagsRemoving">
<mat-icon>remove</mat-icon>
</button>
</p>
@@ -67,7 +67,7 @@
</ng-container>
<div class="adf-tags-list">
<mat-selection-list
*ngIf="!spinnerVisible || existingTags"
*ngIf="!spinnerVisible && existingTags"
(selectionChange)="addExistingTagToTagsToAssign($event)"
[disabled]="isOnlyCreateMode()">
<mat-list-option

View File

@@ -2,10 +2,6 @@ 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;
@@ -49,11 +45,6 @@ adf-tags-creator {
padding-left: 10px;
padding-right: 0;
overflow: auto;
&.adf-tags-list-with-scrollbar {
padding-right: 7px;
margin-right: -22px;
}
}
.adf-tag {
@@ -71,13 +62,10 @@ adf-tags-creator {
}
.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;
@@ -92,7 +80,6 @@ adf-tags-creator {
padding-right: 0;
display: flex;
flex-direction: column;
min-height: 75px;
.adf-tag {
margin-bottom: 18px;

View File

@@ -228,14 +228,14 @@ describe('TagsCreatorComponent', () => {
expect(tagElements).toEqual([tag2]);
}));
it('should hide button for removing tag if disabledTagsRemoving is true', fakeAsync(() => {
it('should disable 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();
expect(getRemoveTagButtons()[0].hasAttribute('disabled')).toBeTrue();
}));
it('should show button for removing tag if disabledTagsRemoving is false', fakeAsync(() => {
@@ -371,17 +371,11 @@ describe('TagsCreatorComponent', () => {
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', () => {
it('should be visible when input is visible and nothing has been typed to reserve required space', () => {
component.tagNameControlVisible = true;
fixture.detectChanges();
expect(getPanel()).toBeFalsy();
expect(getPanel()).toBeTruthy();
});
it('should not be visible when something has been typed and input has been hidden', fakeAsync(() => {
@@ -416,12 +410,12 @@ describe('TagsCreatorComponent', () => {
it('should not be visible if typed only spaces', fakeAsync(() => {
typeTag(' ');
expect(getCreateTagLabel()).toBeFalsy();
expect(getCreateTagLabel().hidden).toBeTrue();
}));
it('should not be visible if required error occurs', fakeAsync(() => {
typeTag('');
expect(getCreateTagLabel()).toBeFalsy();
expect(getCreateTagLabel().hidden).toBeTrue();
}));
it('should not be visible when trying to duplicate already added tag', fakeAsync(() => {
@@ -445,12 +439,6 @@ describe('TagsCreatorComponent', () => {
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();

View File

@@ -80,7 +80,6 @@ export class TagsCreatorComponent implements OnInit, OnDestroy {
@Input()
set tags(tags: string[]) {
this._tags = [...tags];
this._spinnerVisible = true;
this._initialExistingTags = null;
this._existingTags = null;
this.loadTags(this.tagNameControl.value);
@@ -100,7 +99,7 @@ export class TagsCreatorComponent implements OnInit, OnDestroy {
set tagNameControlVisible(tagNameControlVisible: boolean) {
this._tagNameControlVisible = tagNameControlVisible;
if (tagNameControlVisible) {
this._existingTagsPanelVisible = !!this.tagNameControl.value.trim();
this._existingTagsPanelVisible = true;
setTimeout(() => {
this.tagNameInputElement.nativeElement.scrollIntoView();
});
@@ -182,8 +181,6 @@ export class TagsCreatorComponent implements OnInit, OnDestroy {
if (name) {
this._spinnerVisible = true;
this._existingTagsPanelVisible = true;
} else {
this._existingTagsPanelVisible = false;
}
this.existingTagsPanelVisibilityChange.emit(this.existingTagsPanelVisible);
this.cancelExistingTagsLoading$.next();
@@ -339,6 +336,7 @@ export class TagsCreatorComponent implements OnInit, OnDestroy {
});
} else {
this.existingExactTag = null;
this._spinnerVisible = false;
}
}