From a563dc2f54f30de8daa9b193f5b0548c39419472 Mon Sep 17 00:00:00 2001 From: MichalKinas <113341662+MichalKinas@users.noreply.github.com> Date: Thu, 20 Apr 2023 18:49:45 +0200 Subject: [PATCH] [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 --- .../categories-management.component.md | 44 ++ .../services/category.service.md | 19 + .../categories-management-mode.ts | 21 + .../categories-management.component.html | 85 +++ .../categories-management.component.scss | 79 +++ .../categories-management.component.spec.ts | 487 ++++++++++++++++++ .../categories-management.component.ts | 332 ++++++++++++ .../src/lib/category/category.module.ts | 41 ++ .../src/lib/category/public-api.ts | 3 + .../services/category.service.spec.ts | 25 + .../lib/category/services/category.service.ts | 33 ++ .../content-metadata-card.component.html | 3 +- .../content-metadata-card.component.spec.ts | 4 + .../content-metadata.component.html | 29 ++ .../content-metadata.component.scss | 22 + .../content-metadata.component.spec.ts | 259 +++++++++- .../content-metadata.component.ts | 200 ++++--- .../content-metadata.module.ts | 4 +- .../src/lib/content.module.ts | 7 +- lib/content-services/src/lib/i18n/en.json | 6 +- .../services/search-facet-filters.service.ts | 2 +- .../tags-creator/tags-creator.component.html | 6 +- .../tags-creator/tags-creator.component.scss | 13 - .../tags-creator.component.spec.ts | 24 +- .../tags-creator/tags-creator.component.ts | 6 +- 25 files changed, 1625 insertions(+), 129 deletions(-) create mode 100644 docs/content-services/components/categories-management.component.md create mode 100644 lib/content-services/src/lib/category/categories-management/categories-management-mode.ts create mode 100644 lib/content-services/src/lib/category/categories-management/categories-management.component.html create mode 100644 lib/content-services/src/lib/category/categories-management/categories-management.component.scss create mode 100644 lib/content-services/src/lib/category/categories-management/categories-management.component.spec.ts create mode 100644 lib/content-services/src/lib/category/categories-management/categories-management.component.ts create mode 100644 lib/content-services/src/lib/category/category.module.ts diff --git a/docs/content-services/components/categories-management.component.md b/docs/content-services/components/categories-management.component.md new file mode 100644 index 0000000000..dc2f8c985b --- /dev/null +++ b/docs/content-services/components/categories-management.component.md @@ -0,0 +1,44 @@ +--- +Title: Categories management component +Added: v6.0.0-A.3 +Status: Active +Last reviewed: 2023-04-07 +--- + +# [Categories management component](../../../lib/content-services/src/lib/category/categories-management/categories-management.component.ts "Defined in categories-management.component.ts") + +Component allows to both assign/unassign categories to content and create multiple categories depending on selected mode. In assign mode component is composed of: list of categories that will be assigned to a file, input to search for existing categories that user can select and second list under the input containing existing categories that node can be assigned to. In crud mode component is composed of: list of categories that will be created, input to type a name of category that will be created and a list of categories existing under a given parent, in this mode existing categories are not selectable. + +## Basic Usage + +```html + + +``` + +## Class members + +### Properties + +| Name | Type | Default value | Description | +| ---- | ---- | ------------- | ----------- | +| categories | [`Category`](https://github.com/Alfresco/alfresco-js-api/blob/develop/src/api/content-rest-api/docs/Category.md)[] | [] | List of categories to assign/create. | +| categoryNameControlVisible | `boolean` | false | Determines if category name control is visible. | +| classifiableChanged | [`Observable`](https://rxjs.dev/guide/observable) | | (optional) Observable emitting when `classifiable` aspect changes for a given node. | +| disableRemoval | `boolean` | false | Determines if categories assigned/created can be unassigned/removed from the list. | +| managementMode | `CategoriesManagementMode` | | Management mode determines if component works in assign/unassign mode or create mode. | +| parentId | `string` | | (optional) ID of a parent category that new categories will be created under. | + +### Events + +| Name | Type | Description | +| ---- | ---- | ----------- | +| categoriesChange | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<`[`Category`](https://github.com/Alfresco/alfresco-js-api/blob/develop/src/api/content-rest-api/docs/Category.md)`>` | Emitted when categories list changes. | +| categoryNameControlVisibleChange | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`` | Emitted when category name control visibility changes. | diff --git a/docs/content-services/services/category.service.md b/docs/content-services/services/category.service.md index 402dcf6657..4eb14b8b9e 100644 --- a/docs/content-services/services/category.service.md +++ b/docs/content-services/services/category.service.md @@ -43,6 +43,25 @@ Manages categories in Content Services. - _skipCount:_ `number` - Specify how many first results should be skipped. Default 0. - _maxItems:_ `number` - (Optional) Specify max number of returned categories. Default is specified by [UserPreferencesService](../../core/services/user-preferences.service.md). - **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`ResultSetPaging`](https://github.com/Alfresco/alfresco-js-api/blob/develop/src/api/search-rest-api/docs/ResultSetPaging.md)`>` - [`Observable`](http://reactivex.io/documentation/observable.html)<ResultSetPaging> Found categories which name contains searched name. +- **updateCategory**(categoryId: `string`, payload: `CategoryBody`): [`Observable`](http://reactivex.io/documentation/observable.html)`` + Updates category + - _categoryId:_ `string` - The identifier of a category. + - _payload:_ `CategoryBody` - Updated category body + - **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`` - [`Observable`](http://reactivex.io/documentation/observable.html)<CategoryEntry> +- **getCategoryLinksForNode**(nodeId: `string`): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`CategoryPaging`](https://github.com/Alfresco/alfresco-js-api/blob/master/src/api/content-rest-api/docs/CategoryPaging.md)`>` + Provides list of categories that node is linked to. + - _nodeId:_ `string` - Id of a node that is linked to categories + - **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`CategoryPaging`](https://github.com/Alfresco/alfresco-js-api/blob/master/src/api/content-rest-api/docs/CategoryPaging.md)`>` - Found categories that node is linked to. +- **unlinkNodeFromCategory**(nodeId: `string`, categoryId: `string`): [`Observable`](http://reactivex.io/documentation/observable.html)`` + Unlinks category from a node. + - _nodeId:_ `string` - The identifier of a node. + - _categoryId:_ `string` - The identifier of a category. + - **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`` +- **linkNodeToCategory**(nodeId: `string`, categoryLinkBodyCreate: `CategoryLinkBody[]`): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`CategoryPaging`]((https://github.com/Alfresco/alfresco-js-api/blob/master/src/api/content-rest-api/docs/CategoryPaging.md))` | `[`CategoryEntry`](https://github.com/Alfresco/alfresco-js-api/blob/master/src/api/content-rest-api/docs/CategoryEntry.md)`>` + Links node to a category. + - _nodeId:_ `string` - The identifier of a node. + - _categoryLinkBodyCreate:_ [`CategoryLinkBody[]`](https://github.com/Alfresco/alfresco-js-api/blob/master/src/api/content-rest-api/docs/CategoryLinkBody.md) - Categories that node will be linked to. + - **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`CategoryPaging`]((https://github.com/Alfresco/alfresco-js-api/blob/master/src/api/content-rest-api/docs/CategoryPaging.md))` | `[`CategoryEntry`](https://github.com/Alfresco/alfresco-js-api/blob/master/src/api/content-rest-api/docs/CategoryEntry.md)`>` - Categories that node has been linked to. ## Details diff --git a/lib/content-services/src/lib/category/categories-management/categories-management-mode.ts b/lib/content-services/src/lib/category/categories-management/categories-management-mode.ts new file mode 100644 index 0000000000..ca6f4aa4eb --- /dev/null +++ b/lib/content-services/src/lib/category/categories-management/categories-management-mode.ts @@ -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 +} diff --git a/lib/content-services/src/lib/category/categories-management/categories-management.component.html b/lib/content-services/src/lib/category/categories-management/categories-management.component.html new file mode 100644 index 0000000000..cef42a3877 --- /dev/null +++ b/lib/content-services/src/lib/category/categories-management/categories-management.component.html @@ -0,0 +1,85 @@ + + + {{ isCRUDMode ? 'CATEGORIES_MANAGEMENT.NO_CATEGORIES_CREATED' : 'CATEGORIES_MANAGEMENT.NO_CATEGORIES_ASSIGNED' | translate }} + + + + {{ category.name }} + + remove + + + + + + search + + {{ 'CATEGORIES_MANAGEMENT.NAME' | translate }} + + + {{ categoryNameErrorMessageKey | translate }} + + + remove + + + + + + + {{ 'CATEGORIES_MANAGEMENT.GENERIC_CREATE' | translate : { name: categoryNameControl.value } }} + + + + + + {{ isCRUDMode ? 'CATEGORIES_MANAGEMENT.EXISTING_CATEGORIES' : 'CATEGORIES_MANAGEMENT.SELECT_EXISTING_CATEGORY' | translate }} + + + + {{ category.name }} + + + {{ 'CATEGORIES_MANAGEMENT.NO_EXISTING_CATEGORIES' | translate }} + + + + + + + diff --git a/lib/content-services/src/lib/category/categories-management/categories-management.component.scss b/lib/content-services/src/lib/category/categories-management/categories-management.component.scss new file mode 100644 index 0000000000..31911029be --- /dev/null +++ b/lib/content-services/src/lib/category/categories-management/categories-management.component.scss @@ -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; + } +} diff --git a/lib/content-services/src/lib/category/categories-management/categories-management.component.spec.ts b/lib/content-services/src/lib/category/categories-management/categories-management.component.spec.ts new file mode 100644 index 0000000000..40b4332f5b --- /dev/null +++ b/lib/content-services/src/lib/category/categories-management/categories-management.component.spec.ts @@ -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; + let categoryService: CategoryService; + const classifiableChangedSubject = new Subject(); + 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(); + })); + }); + }); +}); diff --git a/lib/content-services/src/lib/category/categories-management/categories-management.component.ts b/lib/content-services/src/lib/category/categories-management/categories-management.component.ts new file mode 100644 index 0000000000..4d70d4819d --- /dev/null +++ b/lib/content-services/src/lib/category/categories-management/categories-management.component.ts @@ -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([ + ['duplicatedExistingCategory', 'ALREADY_EXISTS'], + ['duplicatedCategory', 'DUPLICATED_CATEGORY'], + ['emptyCategory', 'EMPTY_CATEGORY'], + ['required', 'REQUIRED'] + ]); + + private existingCategoryLoaded$ = new Subject(); + private cancelExistingCategoriesLoading$ = new Subject(); + private onDestroy$ = new Subject(); + private _categoryNameControl = new FormControl( + '', + [ + 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; + + /** 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(); + + /** Emits when categoryNameControl visibility changes */ + @Output() + categoryNameControlVisibleChange = new EventEmitter(); + + @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 { + 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): CategoryNameControlErrors | null { + return this.categories?.some((category) => this.compareCategories(category, nameControl.value)) && this.isCRUDMode + ? { duplicatedCategory: true } + : null; + } + + private validateIfNotAlreadyCreated(nameControl: FormControl): Observable { + return this.existingCategoryLoaded$.pipe( + map(() => 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): 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)); + } +} diff --git a/lib/content-services/src/lib/category/category.module.ts b/lib/content-services/src/lib/category/category.module.ts new file mode 100644 index 0000000000..45d7bbe74f --- /dev/null +++ b/lib/content-services/src/lib/category/category.module.ts @@ -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 {} diff --git a/lib/content-services/src/lib/category/public-api.ts b/lib/content-services/src/lib/category/public-api.ts index ab9ef141fc..0e2501b0f7 100644 --- a/lib/content-services/src/lib/category/public-api.ts +++ b/lib/content-services/src/lib/category/public-api.ts @@ -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'; diff --git a/lib/content-services/src/lib/category/services/category.service.spec.ts b/lib/content-services/src/lib/category/services/category.service.spec.ts index 73cd1425fb..f769164865 100644 --- a/lib/content-services/src/lib/category/services/category.service.spec.ts +++ b/lib/content-services/src/lib/category/services/category.service.spec.ts @@ -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); + }); + })); }); diff --git a/lib/content-services/src/lib/category/services/category.service.ts b/lib/content-services/src/lib/category/services/category.service.ts index 3512511b01..f5e6da5eb5 100644 --- a/lib/content-services/src/lib/category/services/category.service.ts +++ b/lib/content-services/src/lib/category/services/category.service.ts @@ -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 Categories that node is assigned to + */ + getCategoryLinksForNode(nodeId: string): Observable { + 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 + */ + unlinkNodeFromCategory(nodeId: string, categoryId: string): Observable { + 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 + */ + linkNodeToCategory(nodeId: string, categoryLinkBodyCreate: CategoryLinkBody[]): Observable { + return from(this.categoriesApi.linkNodeToCategory(nodeId, categoryLinkBodyCreate)); + } } diff --git a/lib/content-services/src/lib/content-metadata/components/content-metadata-card/content-metadata-card.component.html b/lib/content-services/src/lib/content-metadata/components/content-metadata-card/content-metadata-card.component.html index 66529e27c1..d99f5651e7 100644 --- a/lib/content-services/src/lib/content-metadata/components/content-metadata-card/content-metadata-card.component.html +++ b/lib/content-services/src/lib/content-metadata/components/content-metadata-card/content-metadata-card.component.html @@ -9,7 +9,8 @@ [multi]="multi" [displayAspect]="displayAspect" [preset]="preset" - [displayTags]="true"> + [displayTags]="true" + [displayCategories]="true"> diff --git a/lib/content-services/src/lib/content-metadata/components/content-metadata-card/content-metadata-card.component.spec.ts b/lib/content-services/src/lib/content-metadata/components/content-metadata-card/content-metadata-card.component.spec.ts index f0d6eda959..883444c8c0 100644 --- a/lib/content-services/src/lib/content-metadata/components/content-metadata-card/content-metadata-card.component.spec.ts +++ b/lib/content-services/src/lib/content-metadata/components/content-metadata-card/content-metadata-card.component.spec.ts @@ -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; diff --git a/lib/content-services/src/lib/content-metadata/components/content-metadata/content-metadata.component.html b/lib/content-services/src/lib/content-metadata/components/content-metadata/content-metadata.component.html index 867a4f01b7..4b1b3beba4 100644 --- a/lib/content-services/src/lib/content-metadata/components/content-metadata/content-metadata.component.html +++ b/lib/content-services/src/lib/content-metadata/components/content-metadata/content-metadata.component.html @@ -49,6 +49,35 @@ + + + + {{ 'CATEGORIES_MANAGEMENT.CATEGORIES_TITLE' | translate }} + + {{ category.name }} + + + + {{ 'CATEGORIES_MANAGEMENT.CATEGORIES_TITLE' | translate }} + + add + + + + + + { 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); + }); + }); }); }); diff --git a/lib/content-services/src/lib/content-metadata/components/content-metadata/content-metadata.component.ts b/lib/content-services/src/lib/content-metadata/components/content-metadata/content-metadata.component.ts index 3e71bcb69d..77e95350de 100644 --- a/lib/content-services/src/lib/content-metadata/components/content-metadata/content-metadata.component.ts +++ b/lib/content-services/src/lib/content-metadata/components/content-metadata/content-metadata.component.ts @@ -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(); + private _saving = false; + multiValueSeparator: string; basicProperties$: Observable; groupedProperties$: Observable; @@ -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('content-metadata.copy-to-clipboard-action'); this.multiValueSeparator = this.appConfig.get('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 } { + const observables: { [key: string]: Observable } = {}; + 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; diff --git a/lib/content-services/src/lib/content-metadata/content-metadata.module.ts b/lib/content-services/src/lib/content-metadata/content-metadata.module.ts index 527e42682f..b8d7406dae 100644 --- a/lib/content-services/src/lib/content-metadata/content-metadata.module.ts +++ b/lib/content-services/src/lib/content-metadata/content-metadata.module.ts @@ -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, diff --git a/lib/content-services/src/lib/content.module.ts b/lib/content-services/src/lib/content.module.ts index a472ce8e13..6d6e39367d 100644 --- a/lib/content-services/src/lib/content.module.ts +++ b/lib/content-services/src/lib/content.module.ts @@ -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 { diff --git a/lib/content-services/src/lib/i18n/en.json b/lib/content-services/src/lib/i18n/en.json index c8cdc5ece5..bbcb2075dc 100644 --- a/lib/content-services/src/lib/i18n/en.json +++ b/lib/content-services/src/lib/i18n/en.json @@ -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" } diff --git a/lib/content-services/src/lib/search/services/search-facet-filters.service.ts b/lib/content-services/src/lib/search/services/search-facet-filters.service.ts index 0b1e0fea86..6edb9a010d 100644 --- a/lib/content-services/src/lib/search/services/search-facet-filters.service.ts +++ b/lib/content-services/src/lib/search/services/search-facet-filters.service.ts @@ -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; diff --git a/lib/content-services/src/lib/tag/tags-creator/tags-creator.component.html b/lib/content-services/src/lib/tag/tags-creator/tags-creator.component.html index 7a6adc8a3d..fa2e68dbf6 100644 --- a/lib/content-services/src/lib/tag/tags-creator/tags-creator.component.html +++ b/lib/content-services/src/lib/tag/tags-creator/tags-creator.component.html @@ -6,7 +6,7 @@ + [disabled]="disabledTagsRemoving"> remove @@ -67,7 +67,7 @@ { 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(); diff --git a/lib/content-services/src/lib/tag/tags-creator/tags-creator.component.ts b/lib/content-services/src/lib/tag/tags-creator/tags-creator.component.ts index ed378c4e4f..b338720381 100644 --- a/lib/content-services/src/lib/tag/tags-creator/tags-creator.component.ts +++ b/lib/content-services/src/lib/tag/tags-creator/tags-creator.component.ts @@ -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; } }
+ {{ isCRUDMode ? 'CATEGORIES_MANAGEMENT.NO_CATEGORIES_CREATED' : 'CATEGORIES_MANAGEMENT.NO_CATEGORIES_ASSIGNED' | translate }} +
+ {{ isCRUDMode ? 'CATEGORIES_MANAGEMENT.EXISTING_CATEGORIES' : 'CATEGORIES_MANAGEMENT.SELECT_EXISTING_CATEGORY' | translate }} +
+ {{ 'CATEGORIES_MANAGEMENT.NO_EXISTING_CATEGORIES' | translate }} +
{{ category.name }}
{{ 'CATEGORIES_MANAGEMENT.CATEGORIES_TITLE' | translate }}
+ [disabled]="disabledTagsRemoving"> remove