diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-search-list/dot-category-field-search-list.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-search-list/dot-category-field-search-list.component.html index 1964eba92838..e6d14efa7282 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-search-list/dot-category-field-search-list.component.html +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-search-list/dot-category-field-search-list.component.html @@ -32,7 +32,11 @@ {{ category.value }} - {{ category.path }} + + + {{ category.path }} + + diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-search-list/dot-category-field-search-list.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-search-list/dot-category-field-search-list.component.ts index 59ff0880501c..90d40227f74f 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-search-list/dot-category-field-search-list.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-search-list/dot-category-field-search-list.component.ts @@ -18,6 +18,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { SkeletonModule } from 'primeng/skeleton'; import { TableModule } from 'primeng/table'; +import { TooltipModule } from 'primeng/tooltip'; import { debounceTime } from 'rxjs/operators'; @@ -33,7 +34,14 @@ import { DotTableSkeletonComponent } from '../dot-table-skeleton/dot-table-skele @Component({ selector: 'dot-category-field-search-list', standalone: true, - imports: [CommonModule, TableModule, SkeletonModule, DotTableSkeletonComponent, DotMessagePipe], + imports: [ + CommonModule, + TableModule, + SkeletonModule, + DotTableSkeletonComponent, + DotMessagePipe, + TooltipModule + ], templateUrl: './dot-category-field-search-list.component.html', styleUrl: './dot-category-field-search-list.component.scss', changeDetection: ChangeDetectionStrategy.OnPush diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-search/dot-category-field-search.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-search/dot-category-field-search.component.html index 2ff2d3c63adb..25ac9728b9fd 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-search/dot-category-field-search.component.html +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-search/dot-category-field-search.component.html @@ -6,7 +6,7 @@ data-testId="search-input" pInputText type="text" /> - @if (searchControl.value && !$isLoading()) { + @if (!searchControl.pristine && !$isLoading()) { value.length >= MINIMUM_CHARACTERS) + switchMap((value: string) => { + if (value.length >= MINIMUM_CHARACTERS) { + return of(value); + } else if (value.length === 0) { + this.clearInput(); + + return of(''); + } + + return EMPTY; + }) ) .subscribe((value: string) => { - this.term.emit(value); + if (value.length >= MINIMUM_CHARACTERS) { + this.term.emit(value); + } }); } diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-selected/dot-category-field-selected.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-selected/dot-category-field-selected.component.html new file mode 100644 index 000000000000..fd52f76f2ace --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-selected/dot-category-field-selected.component.html @@ -0,0 +1,29 @@ + diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-selected/dot-category-field-selected.component.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-selected/dot-category-field-selected.component.scss new file mode 100644 index 000000000000..0f64e2d71b37 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-selected/dot-category-field-selected.component.scss @@ -0,0 +1,57 @@ +@use "variables" as *; + +.category-list { + list-style: none; + padding: $spacing-3; + margin: 0; +} + +.category-list__item { + display: grid; + grid-template-columns: 1fr 40px; + align-items: center; + padding: $spacing-1 0; + border-bottom: 1px solid $color-palette-gray-300; +} + +.category-list__item-content { + display: flex; + justify-content: space-between; + width: 100%; + overflow: hidden; + gap: $spacing-1; + flex-direction: column; +} + +.category-list__title { + color: $black; + font-size: $font-size-smd; + font-weight: $font-weight-medium-bold; + flex-shrink: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.category-list__path { + flex-grow: 1; + overflow: hidden; +} + +.category-list__path-content { + font-size: $font-size-smd; + color: $color-palette-gray-700; + font-weight: $font-weight-regular-bold; + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + direction: rtl; + text-align: left; +} + +.category-list__remove { + display: flex; + align-items: center; + justify-content: center; +} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-selected/dot-category-field-selected.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-selected/dot-category-field-selected.component.ts new file mode 100644 index 000000000000..5ca6589990fb --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-selected/dot-category-field-selected.component.ts @@ -0,0 +1,58 @@ +import { animate, state, style, transition, trigger } from '@angular/animations'; +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, EventEmitter, input, Output } from '@angular/core'; + +import { ButtonModule } from 'primeng/button'; +import { ChipModule } from 'primeng/chip'; +import { TooltipModule } from 'primeng/tooltip'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { DotCategoryFieldKeyValueObj } from '../../models/dot-category-field.models'; +import { DotCategoryFieldSearchListComponent } from '../dot-category-field-search-list/dot-category-field-search-list.component'; + +@Component({ + selector: 'dot-category-field-selected', + standalone: true, + imports: [ + CommonModule, + ButtonModule, + DotMessagePipe, + DotCategoryFieldSearchListComponent, + ChipModule, + TooltipModule + ], + templateUrl: './dot-category-field-selected.component.html', + styleUrl: './dot-category-field-selected.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + trigger('fadeAnimation', [ + state( + 'void', + style({ + opacity: 0 + }) + ), + transition(':enter, :leave', [animate('50ms ease-in-out')]) + ]) + ] +}) +export class DotCategoryFieldSelectedComponent { + /** + * Represents the array of selected categories. + */ + $categories = input([], { + alias: 'categories' + }); + + @Output() + removeItem = new EventEmitter(); + + private convertPathToArray(path: string): string[] { + if (!path) { + return []; + } + + return path.split(' / '); + } +} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/dot-edit-content-category-field.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/dot-edit-content-category-field.component.ts index 1413be622d92..df457f16cb96 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/dot-edit-content-category-field.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/dot-edit-content-category-field.component.ts @@ -126,7 +126,10 @@ export class DotEditContentCategoryFieldComponent implements OnInit { } ngOnInit(): void { - this.store.load(this.field(), this.contentlet()); + this.store.load({ + field: this.field(), + contentlet: this.contentlet() + }); } private setSidebarListener() { diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/models/dot-category-field.models.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/models/dot-category-field.models.ts index c6c0df9b6ef0..6fee136ce687 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/models/dot-category-field.models.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/models/dot-category-field.models.ts @@ -1,3 +1,10 @@ +import { DotCategory, DotCMSContentlet, DotCMSContentTypeField } from '@dotcms/dotcms-models'; + +export interface DotCategoryField { + field: DotCMSContentTypeField; + contentlet: DotCMSContentlet; +} + /** * Object representing a key-value pair. * @interface @@ -11,6 +18,13 @@ export interface DotCategoryFieldKeyValueObj { hasChildren?: boolean; } +/** + * The HierarchyParent type represents a parent object in a hierarchical structure. + * It is defined as a subset of the DotCategory type, which includes the categoryName, + * inode, and parentList properties. + */ +export type HierarchyParent = Pick; + /** * Represents an clicked item in a DotCategoryField. */ diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/services/categories.service.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/services/categories.service.ts index c10d22254928..7d763d49bd78 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/services/categories.service.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/services/categories.service.ts @@ -8,6 +8,8 @@ import { pluck } from 'rxjs/operators'; import { DotCMSResponse } from '@dotcms/dotcms-js'; import { DotCategory } from '@dotcms/dotcms-models'; +import { HierarchyParent } from '../models/dot-category-field.models'; + export const API_URL = '/api/v1/categories'; export const ITEMS_PER_PAGE = 7000; @@ -55,6 +57,19 @@ export class CategoriesService { .pipe(pluck('entity')); } + /** + * Retrieves the complete hierarchy for the given selected keys. + * + * + * @return {Observable} - An Observable that emits the complete hierarchy as an array of DotCategory objects. + * @param keys + */ + getSelectedHierarchy(keys: string[]): Observable { + return this.#http + .post>(`${API_URL}/hierarchy`, { keys }) + .pipe(pluck('entity')); + } + /** * Merges default parameters with provided parameters. * diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/store/content-category-field.store.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/store/content-category-field.store.ts index cbca89e46de7..34e65a43ffe5 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/store/content-category-field.store.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/store/content-category-field.store.ts @@ -3,19 +3,17 @@ import { patchState, signalStore, withComputed, withMethods, withState } from '@ import { rxMethod } from '@ngrx/signals/rxjs-interop'; import { pipe } from 'rxjs'; +import { HttpErrorResponse } from '@angular/common/http'; import { computed, inject } from '@angular/core'; import { filter, switchMap, tap } from 'rxjs/operators'; -import { - ComponentStatus, - DotCategory, - DotCMSContentlet, - DotCMSContentTypeField -} from '@dotcms/dotcms-models'; +import { DotHttpErrorManagerService } from '@dotcms/data-access'; +import { ComponentStatus, DotCategory, DotCMSContentTypeField } from '@dotcms/dotcms-models'; import { CategoryFieldViewMode, + DotCategoryField, DotCategoryFieldItem, DotCategoryFieldKeyValueObj } from '../models/dot-category-field.models'; @@ -103,164 +101,213 @@ export const CategoryFieldStore = signalStore( .map((column) => transformCategories(column, store.keyParentPath())) ) })), - withMethods((store, categoryService = inject(CategoriesService)) => ({ - /** - * Sets a given iNode as the main parent and loads selected categories into the store. - */ - load(field: DotCMSContentTypeField, contentlet: DotCMSContentlet): void { - const selected = transformSelectedCategories(field, contentlet); - patchState(store, { - field, - selected - }); - }, + withMethods( + ( + store, + categoryService = inject(CategoriesService), + dotHttpErrorManagerService = inject(DotHttpErrorManagerService) + ) => ({ + /** + * The method gets `DotCategoryField` as an input, then it performs the following operations: + * - Initially sets the state in the store to LOADING. + * - Transforms and collects selected categories from given `DotCategoryField`. + * - Constructs parents hierarchy for selected categories using service, and loads it into the store. + */ + load: rxMethod( + pipe( + tap(() => patchState(store, { state: ComponentStatus.LOADING })), + switchMap((categoryField) => { + const { field, contentlet } = categoryField; + const selected = transformSelectedCategories(field, contentlet); - setMode(mode: 'list' | 'search'): void { - patchState(store, { - mode, - searchCategories: [], - filter: '' - }); - }, + const selectedKeys = selected.map((item) => item.key); - /** - * Updates the selected items based on the provided item. - */ - updateSelected(selected: string[], item: DotCategoryFieldKeyValueObj): void { - const currentChecked: DotCategoryFieldKeyValueObj[] = updateChecked( - store.selected(), - selected, - item - ); + // TODO: Wait for the final working endpoint + return categoryService.getSelectedHierarchy(selectedKeys).pipe( + tapResponse({ + next: () => { + patchState(store, { + field, + selected, + state: ComponentStatus.IDLE + }); + }, + error: (error: HttpErrorResponse) => { + patchState(store, { + field, + selected, + state: ComponentStatus.IDLE + }); - patchState(store, { - selected: currentChecked - }); - }, + dotHttpErrorManagerService.handle(error); + } + }) + ); + }) + ) + ), - /** - * Adds the selected item(s) to the store's selected state. - * - * @param {DotCategoryFieldKeyValueObj | DotCategoryFieldKeyValueObj[]} selectedItem - The item(s) to be added. - * @returns {void} - */ - addSelected( - selectedItem: DotCategoryFieldKeyValueObj | DotCategoryFieldKeyValueObj[] - ): void { - const updatedSelected = addSelected(store.selected(), selectedItem); - patchState(store, { - selected: updatedSelected - }); - }, + setMode(mode: CategoryFieldViewMode): void { + patchState(store, { + mode, + searchCategories: [], + filter: '' + }); + }, - /** - * Removes the selected items with the given key(s). - * - * @param {string | string[]} key - The key(s) of the item(s) to be removed. - * @return {void} - */ - removeSelected(key: string | string[]): void { - const newSelected = removeItemByKey(store.selected(), key); + /** + * Updates the selected items based on the provided item. + */ + updateSelected(selected: string[], item: DotCategoryFieldKeyValueObj): void { + const currentChecked: DotCategoryFieldKeyValueObj[] = updateChecked( + store.selected(), + selected, + item + ); - patchState(store, { - selected: newSelected - }); - }, + patchState(store, { + selected: currentChecked + }); + }, - /** - * Clears all categories from the store, effectively resetting state related to categories and their parent paths. - */ - clean() { - patchState(store, { - categories: [], - keyParentPath: [], - mode: 'list', - filter: '', - searchCategories: [] - }); - }, + /** + * Adds the selected item(s) to the store's selected state. + * + * @param {DotCategoryFieldKeyValueObj | DotCategoryFieldKeyValueObj[]} selectedItem - The item(s) to be added. + * @returns {void} + */ + addSelected( + selectedItem: DotCategoryFieldKeyValueObj | DotCategoryFieldKeyValueObj[] + ): void { + const updatedSelected = addSelected(store.selected(), selectedItem); + // TODO: MAKE A REQUEST TO GET THE parentPath + patchState(store, { + selected: updatedSelected + }); + }, - /** - * Fetches categories from a given iNode category parent. - * This method accepts either void to get the parent, or an index and item returned after clicking an item with children. - */ - getCategories: rxMethod( - pipe( - tap((event) => { - const index = event ? event.index : 0; - const currentCategories = store.categories(); + /** + * Removes the selected items with the given key(s). + * + * @param {string | string[]} key - The key(s) of the item(s) to be removed. + * @return {void} + */ + removeSelected(key: string | string[]): void { + const newSelected = removeItemByKey(store.selected(), key); - if (event) { - if (!checkIfClickedIsLastItem(index, currentCategories)) { - patchState(store, { - categories: [ - ...clearCategoriesAfterIndex(currentCategories, index) - ], - keyParentPath: [ - ...clearParentPathAfterIndex(store.keyParentPath(), index) - ] - }); - } - } - }), - // Only pass if you click a item with children - filter( - (event) => - !event || - (event.item.hasChildren && !store.keyParentPath().includes(event.item.key)) - ), - tap(() => patchState(store, { state: ComponentStatus.LOADING })), - switchMap((event) => { - const rootCategoryInode: string = event - ? event.item.inode - : store.rootCategoryInode(); + patchState(store, { + selected: newSelected + }); + }, - return categoryService.getChildren(rootCategoryInode).pipe( - tapResponse({ - next: (newCategories) => { - if (event) { - patchState(store, { - categories: [...store.categories(), newCategories], - state: ComponentStatus.LOADED, - keyParentPath: [...store.keyParentPath(), event.item.key] - }); - } else { - patchState(store, { - categories: [...store.categories(), newCategories], - state: ComponentStatus.LOADED - }); - } - }, - error: () => { - // TODO: Add Error Handler - patchState(store, { state: ComponentStatus.IDLE }); - } - }) - ); - }) - ) - ), + /** + * Clears all categories from the store, effectively resetting state related to categories and their parent paths. + */ + clean() { + patchState(store, { + categories: [], + keyParentPath: [], + mode: 'list', + filter: '', + searchCategories: [] + }); + }, - search: rxMethod( - pipe( - tap(() => patchState(store, { mode: 'search', state: ComponentStatus.LOADING })), - switchMap((filter) => { - return categoryService.getChildren(store.rootCategoryInode(), { filter }).pipe( - tapResponse({ - next: (categories) => { + /** + * Fetches categories from a given iNode category parent. + * This method accepts either void to get the parent, or an index and item returned after clicking an item with children. + */ + getCategories: rxMethod( + pipe( + tap((event) => { + const index = event ? event.index : 0; + const currentCategories = store.categories(); + + if (event) { + if (!checkIfClickedIsLastItem(index, currentCategories)) { patchState(store, { - searchCategories: [...categories], - state: ComponentStatus.LOADED + categories: [ + ...clearCategoriesAfterIndex(currentCategories, index) + ], + keyParentPath: [ + ...clearParentPathAfterIndex(store.keyParentPath(), index) + ] }); - }, - error: () => { - // TODO: Add Error Handler - patchState(store, { state: ComponentStatus.IDLE }); } - }) - ); - }) + } + }), + // Only pass if you click a item with children + filter( + (event) => + !event || + (event.item.hasChildren && + !store.keyParentPath().includes(event.item.key)) + ), + tap(() => patchState(store, { state: ComponentStatus.LOADING })), + switchMap((event) => { + const categoryInode: string = event + ? event.item.inode + : store.rootCategoryInode(); + + return categoryService.getChildren(categoryInode).pipe( + tapResponse({ + next: (newCategories) => { + if (event) { + patchState(store, { + categories: [...store.categories(), newCategories], + state: ComponentStatus.LOADED, + keyParentPath: [ + ...store.keyParentPath(), + event.item.key + ] + }); + } else { + patchState(store, { + categories: [...store.categories(), newCategories], + state: ComponentStatus.LOADED + }); + } + }, + error: () => { + // TODO: Add Error Handler + patchState(store, { state: ComponentStatus.IDLE }); + } + }) + ); + }) + ) + ), + + /** + * Searches for children categories based on the specified filter. + * + * @param {string} filter - The filter to apply when searching for children categories. + */ + search: rxMethod( + pipe( + tap(() => + patchState(store, { mode: 'search', state: ComponentStatus.LOADING }) + ), + switchMap((filter) => { + return categoryService + .getChildren(store.rootCategoryInode(), { filter }) + .pipe( + tapResponse({ + next: (categories) => { + patchState(store, { + searchCategories: [...categories], + state: ComponentStatus.LOADED + }); + }, + error: () => { + // TODO: Add Error Handler + patchState(store, { state: ComponentStatus.IDLE }); + } + }) + ); + }) + ) ) - ) - })) + }) + ) ); diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index f77ab8ba75ce..3f81961fec9c 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -5751,6 +5751,6 @@ edit.content.category-field.list.show.more={0} More edit.content.category-field.list.show.less=Less edit.content.category-field.search.name=Name -edit.content.category-field.search.assignee=Assignee +edit.content.category-field.search.assignee=Category Path edit.content.category-field.search.input.placeholder=Search