diff --git a/.nx/cache/18.3.5-nx.darwin-arm64.node b/.nx/cache/18.3.5-nx.darwin-arm64.node deleted file mode 100644 index 9f025e997503..000000000000 Binary files a/.nx/cache/18.3.5-nx.darwin-arm64.node and /dev/null differ diff --git a/core-web/libs/dotcms-models/src/lib/shared-models.ts b/core-web/libs/dotcms-models/src/lib/shared-models.ts index 1f9784a5d791..665e02e46561 100644 --- a/core-web/libs/dotcms-models/src/lib/shared-models.ts +++ b/core-web/libs/dotcms-models/src/lib/shared-models.ts @@ -6,13 +6,15 @@ * |-> IDLE = Finished Loading or Saving * SAVING = Status of an action of the component loaded (delete, saving, editing) * |-> IDLE = Finished delete, saving, editing + * ERROR = Error state for the component **/ export enum ComponentStatus { INIT = 'INIT', LOADING = 'LOADING', LOADED = 'LOADED', SAVING = 'SAVING', - IDLE = 'IDLE' + IDLE = 'IDLE', + ERROR = 'ERROR' } export const enum FeaturedFlags { 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 e63e1d7aeafe..6bcd3de48109 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 @@ -1,5 +1,5 @@
- @if (!isLoading()) { + @if (!$isLoading()) { + styleClass="dotTable"> @@ -42,6 +42,16 @@ + + + + + + + + } @else { .p-datatable-wrapper { - border-radius: 0; - border: none; +:host { + ::ng-deep { + .dotTable.p-datatable > .p-datatable-wrapper { + border-radius: 0; + border: none; + } + } + + &.category-field__search-list--empty ::ng-deep { + p-table, + .dotTable.p-datatable, + .p-datatable-wrapper, + table { + height: 100%; + + tr:not(.p-highlight):hover { + background: $white; + cursor: auto; + } + } + } } 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.spec.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.spec.ts index ea01e28d77e6..a1005bdc9ab1 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.spec.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.spec.ts @@ -1,25 +1,34 @@ -import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { byTestId, createComponentFactory, Spectator } from '@ngneat/spectator/jest'; import { DotMessageService } from '@dotcms/data-access'; +import { ComponentStatus } from '@dotcms/dotcms-models'; +import { DotEmptyContainerComponent } from '@dotcms/ui'; import { DotCategoryFieldSearchListComponent } from './dot-category-field-search-list.component'; +import { CATEGORY_FIELD_EMPTY_MESSAGES } from '../../../../models/dot-edit-content-field.constant'; import { MockResizeObserver } from '../../../../utils/mocks'; import { CATEGORY_MOCK_TRANSFORMED } from '../../mocks/category-field.mocks'; +const mockMessageService = { + get: jest.fn((key: string) => `${key}`) +}; + describe('DotCategoryFieldSearchListComponent', () => { let spectator: Spectator; + const createComponent = createComponentFactory({ component: DotCategoryFieldSearchListComponent, - providers: [mockProvider(DotMessageService)] + providers: [{ provide: DotMessageService, useValue: mockMessageService }] }); beforeEach(() => { spectator = createComponent({ + detectChanges: false, props: { categories: CATEGORY_MOCK_TRANSFORMED, selected: CATEGORY_MOCK_TRANSFORMED, - isLoading: false + status: ComponentStatus.LOADED } }); @@ -30,19 +39,15 @@ describe('DotCategoryFieldSearchListComponent', () => { global.ResizeObserver = MockResizeObserver; }); - afterEach(() => { - jest.resetAllMocks(); - }); - it('should show the skeleton if the component is loading', () => { - spectator.setInput('isLoading', true); + spectator.setInput('status', ComponentStatus.LOADING); spectator.detectChanges(); expect(spectator.query(byTestId('categories-skeleton'))).not.toBeNull(); expect(spectator.query(byTestId('categories-table'))).toBeNull(); }); it('should show the table if the component is not loading', () => { - spectator.setInput('isLoading', false); + spectator.setInput('status', ComponentStatus.LOADED); spectator.detectChanges(); expect(spectator.query(byTestId('categories-table'))).not.toBeNull(); expect(spectator.query(byTestId('categories-skeleton'))).toBeNull(); @@ -63,4 +68,24 @@ describe('DotCategoryFieldSearchListComponent', () => { const rows = spectator.queryAll(byTestId('table-row')); expect(rows.length).toBe(CATEGORY_MOCK_TRANSFORMED.length); }); + + it('should render `dot-empty-container` with `empty` configuration ', () => { + const expectedConfig = CATEGORY_FIELD_EMPTY_MESSAGES.empty; + spectator.setInput('status', ComponentStatus.LOADED); + spectator.setInput('categories', []); + spectator.detectChanges(); + + expect(spectator.query(DotEmptyContainerComponent)).not.toBeNull(); + expect(spectator.component.$emptyOrErrorMessage()).toEqual(expectedConfig); + }); + + it('should render `dot-empty-container` with `error` configuration ', () => { + const expectedConfig = CATEGORY_FIELD_EMPTY_MESSAGES[ComponentStatus.ERROR]; + spectator.setInput('status', ComponentStatus.ERROR); + spectator.setInput('categories', []); + + spectator.detectChanges(); + expect(spectator.query(DotEmptyContainerComponent)).not.toBeNull(); + expect(spectator.component.$emptyOrErrorMessage()).toEqual(expectedConfig); + }); }); 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 90d40227f74f..ac17553d7129 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 @@ -5,12 +5,13 @@ import { AfterViewInit, ChangeDetectionStrategy, Component, + computed, effect, ElementRef, - EventEmitter, + inject, input, OnDestroy, - Output, + output, signal, ViewChild } from '@angular/core'; @@ -22,8 +23,11 @@ import { TooltipModule } from 'primeng/tooltip'; import { debounceTime } from 'rxjs/operators'; -import { DotMessagePipe } from '@dotcms/ui'; +import { DotMessageService } from '@dotcms/data-access'; +import { ComponentStatus } from '@dotcms/dotcms-models'; +import { DotEmptyContainerComponent, DotMessagePipe, PrincipalConfiguration } from '@dotcms/ui'; +import { CATEGORY_FIELD_EMPTY_MESSAGES } from '../../../../models/dot-edit-content-field.constant'; import { DotCategoryFieldKeyValueObj, DotTableHeaderCheckboxSelectEvent, @@ -31,6 +35,9 @@ import { } from '../../models/dot-category-field.models'; import { DotTableSkeletonComponent } from '../dot-table-skeleton/dot-table-skeleton.component'; +/** + * Represents a search list component for category field. + */ @Component({ selector: 'dot-category-field-search-list', standalone: true, @@ -40,10 +47,14 @@ import { DotTableSkeletonComponent } from '../dot-table-skeleton/dot-table-skele SkeletonModule, DotTableSkeletonComponent, DotMessagePipe, - TooltipModule + TooltipModule, + DotEmptyContainerComponent ], templateUrl: './dot-category-field-search-list.component.html', styleUrl: './dot-category-field-search-list.component.scss', + host: { + '[class.category-field__search-list--empty]': '$tableIsEmpty()' + }, changeDetection: ChangeDetectionStrategy.OnPush }) export class DotCategoryFieldSearchListComponent implements AfterViewInit, OnDestroy { @@ -52,11 +63,13 @@ export class DotCategoryFieldSearchListComponent implements AfterViewInit, OnDes * viewport to use in the virtual scroll */ @ViewChild('tableContainer', { static: false }) tableContainer!: ElementRef; + /** * The scrollHeight variable represents a signal with a default value of '0px'. * It can be used to track and manipulate the height of a scrollable element. */ $scrollHeight = signal('0px'); + /** * Represents the categories found with the filter */ @@ -66,35 +79,55 @@ export class DotCategoryFieldSearchListComponent implements AfterViewInit, OnDes * Represent the selected items in the store */ selected = input.required(); + /** - * EventEmitter for emit the selected category(ies). + * Represents the current state of the component. */ - @Output() itemChecked = new EventEmitter< - DotCategoryFieldKeyValueObj | DotCategoryFieldKeyValueObj[] - >(); + status = input.required(); + /** - * EventEmitter that emits events to remove a selected item(s). + * Output for emit the selected category(ies). */ - @Output() removeItem = new EventEmitter(); + $itemChecked = output({ + alias: 'itemChecked' + }); + /** - * Represents a variable indicating if the component is in loading state. + * Output that emits events to remove a selected item(s). */ - isLoading = input.required(); + $removeItem = output({ alias: 'removeItem' }); /** * Model of the items selected */ itemsSelected: DotCategoryFieldKeyValueObj[]; + /** * Represents an array of temporary selected items. */ temporarySelectedAll: string[] = []; + /** + * Flag indicating whether the table is empty. + */ + $tableIsEmpty = computed(() => !this.$isLoading() && this.categories().length === 0); + + /** + * A computed variable that represents the loading status of a component. + */ + $isLoading = computed(() => this.status() === ComponentStatus.LOADING); + + /** + * Gets the computed value of $emptyOrErrorMessage. + */ + $emptyOrErrorMessage = computed(() => this.getMessageConfig()); + + #messageService = inject(DotMessageService); + readonly #effectRef = effect(() => { // Todo: find a better way to update this this.itemsSelected = this.selected(); }); - readonly #resize$ = new Subject(); readonly #resizeObserver = new ResizeObserver((entries) => this.#resize$.next(entries[0])); @@ -111,7 +144,7 @@ export class DotCategoryFieldSearchListComponent implements AfterViewInit, OnDes * @return {void} */ onSelectItem({ data }: DotTableRowSelectEvent): void { - this.itemChecked.emit(data); + this.$itemChecked.emit(data); } /** @@ -121,7 +154,7 @@ export class DotCategoryFieldSearchListComponent implements AfterViewInit, OnDes * @return {void} */ onRemoveItem({ data: { key } }: DotTableRowSelectEvent): void { - this.removeItem.emit(key); + this.$removeItem.emit(key); } /** @@ -134,10 +167,10 @@ export class DotCategoryFieldSearchListComponent implements AfterViewInit, OnDes onHeaderCheckboxToggle({ checked }: DotTableHeaderCheckboxSelectEvent): void { if (checked) { const values = this.categories().map((item) => item.key); - this.itemChecked.emit(this.categories()); + this.$itemChecked.emit(this.categories()); this.temporarySelectedAll = [...values]; } else { - this.removeItem.emit(this.temporarySelectedAll); + this.$removeItem.emit(this.temporarySelectedAll); this.temporarySelectedAll = []; } } @@ -162,4 +195,21 @@ export class DotCategoryFieldSearchListComponent implements AfterViewInit, OnDes this.$scrollHeight.set(`${containerHeight}px`); } } + + /** + * Retrieves the message configuration based on the current component status. + * + * @private + * @returns {PrincipalConfiguration | null} Returns the message configuration, or null if no configuration is found. + */ + private getMessageConfig(): PrincipalConfiguration | null { + const configKey = this.status() === ComponentStatus.ERROR ? ComponentStatus.ERROR : 'empty'; + const { title, icon, subtitle } = CATEGORY_FIELD_EMPTY_MESSAGES[configKey]; + + return { + title: this.#messageService.get(title), + icon: icon, + subtitle: this.#messageService.get(subtitle) + }; + } } diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-sidebar/dot-category-field-sidebar.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-sidebar/dot-category-field-sidebar.component.html index 0cbcc04b4672..42d51421d336 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-sidebar/dot-category-field-sidebar.component.html +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-sidebar/dot-category-field-sidebar.component.html @@ -44,7 +44,7 @@ @fadeAnimation (itemChecked)="store.addSelected($event)" (removeItem)="store.removeSelected($event)" - [isLoading]="store.isSearchLoading()" + [status]="store.searchStatus()" [categories]="store.searchCategoryList()" [selected]="store.selected()" /> } diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-sidebar/dot-category-field-sidebar.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-sidebar/dot-category-field-sidebar.component.ts index 5ef4d25ca400..4ae13c2edb79 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-sidebar/dot-category-field-sidebar.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-sidebar/dot-category-field-sidebar.component.ts @@ -68,16 +68,19 @@ export class DotCategoryFieldSidebarComponent implements OnInit, OnDestroy { * @memberof DotCategoryFieldSidebarComponent */ @Input() visible = false; + /** * Output that emit if the sidebar is closed */ @Output() closedSidebar = new EventEmitter(); + /** * Store based on the `CategoryFieldStore`. * * @memberof DotCategoryFieldSidebarComponent */ readonly store = inject(CategoryFieldStore); + /** * Computed property for retrieving all category keys. */ 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 f22691092006..96df742a848e 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 @@ -101,7 +101,10 @@ export class DotEditContentCategoryFieldComponent implements OnInit { effect( () => { const categoryValues = this.store.selectedCategoriesValues(); - this.categoryFieldControl.setValue(categoryValues); + + if (this.categoryFieldControl) { + this.categoryFieldControl.setValue(categoryValues); + } }, { injector: this.#injector @@ -117,7 +120,7 @@ export class DotEditContentCategoryFieldComponent implements OnInit { this.$showCategoriesSidebar.set(true); } /** - * Close the categories sidebar. + * Close the categories' sidebar. * * @memberof DotEditContentCategoryFieldComponent */ 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 025a5b076bc3..9bd655453b5b 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 @@ -102,6 +102,13 @@ export const CategoryFieldStore = signalStore( () => store.mode() === 'search' && store.state() === ComponentStatus.LOADING ), + /** + * Status of the Search Component + */ + searchStatus: computed(() => + store.mode() === 'search' ? store.state() : ComponentStatus.INIT + ), + /** * Categories for render with added properties */ @@ -141,13 +148,13 @@ export const CategoryFieldStore = signalStore( patchState(store, { field, selected, - state: ComponentStatus.IDLE + state: ComponentStatus.LOADED }); }, error: (error: HttpErrorResponse) => { patchState(store, { field, - state: ComponentStatus.IDLE + state: ComponentStatus.ERROR }); dotHttpErrorManagerService.handle(error); @@ -285,7 +292,7 @@ export const CategoryFieldStore = signalStore( }, error: () => { // TODO: Add Error Handler - patchState(store, { state: ComponentStatus.IDLE }); + patchState(store, { state: ComponentStatus.ERROR }); } }) ); @@ -315,8 +322,10 @@ export const CategoryFieldStore = signalStore( }); }, error: () => { - // TODO: Add Error Handler - patchState(store, { state: ComponentStatus.IDLE }); + patchState(store, { + state: ComponentStatus.ERROR, + searchCategories: [] + }); } }) ); diff --git a/core-web/libs/edit-content/src/lib/models/dot-edit-content-field.constant.ts b/core-web/libs/edit-content/src/lib/models/dot-edit-content-field.constant.ts index c35a07107569..45bdb2213f31 100644 --- a/core-web/libs/edit-content/src/lib/models/dot-edit-content-field.constant.ts +++ b/core-web/libs/edit-content/src/lib/models/dot-edit-content-field.constant.ts @@ -1,5 +1,8 @@ import { MonacoEditorConstructionOptions } from '@materia-ui/ngx-monaco-editor'; +import { ComponentStatus } from '@dotcms/dotcms-models'; +import { PrincipalConfiguration } from '@dotcms/ui'; + import { FIELD_TYPES } from './dot-edit-content-field.enum'; export const CALENDAR_FIELD_TYPES = [FIELD_TYPES.DATE, FIELD_TYPES.DATE_AND_TIME, FIELD_TYPES.TIME]; @@ -29,3 +32,22 @@ export const DEFAULT_MONACO_CONFIG: MonacoEditorConstructionOptions = { language: 'text', fontSize: 14 }; + +/** + * Represent the able messages to use in the component DotEmptyContainerComponent + */ +export const CATEGORY_FIELD_EMPTY_MESSAGES: Record< + ComponentStatus.ERROR | 'empty', + PrincipalConfiguration +> = { + empty: { + title: 'edit.content.category-field.search.not-found.title', + icon: 'pi-exclamation-circle', + subtitle: 'edit.content.category-field.search.not-found.legend' + }, + [ComponentStatus.ERROR]: { + title: 'edit.content.category-field.search.error.title', + icon: 'pi-exclamation-triangle', + subtitle: 'edit.content.category-field.search.error.legend' + } +}; diff --git a/core-web/libs/ui/src/lib/components/dot-empty-container/dot-empty-container.component.html b/core-web/libs/ui/src/lib/components/dot-empty-container/dot-empty-container.component.html index 0c9eec942bc1..85dc57f16066 100644 --- a/core-web/libs/ui/src/lib/components/dot-empty-container/dot-empty-container.component.html +++ b/core-web/libs/ui/src/lib/components/dot-empty-container/dot-empty-container.component.html @@ -1,17 +1,14 @@
- @if (configuration?.icon) { - + data-testid="message-principal"> + @if (configuration.icon) { + } -

{{ configuration?.title }}

- @if (configuration?.subtitle) { -

- {{ configuration?.subtitle }} +

{{ configuration.title }}

+ @if (configuration.subtitle) { +

+ {{ configuration.subtitle }}

}
@@ -19,25 +16,26 @@

@if (!hideContactUsLink || buttonLabel) {
+ data-testid="message-extra"> @if (buttonLabel) { + (click)="buttonAction.emit()" + data-testid="message-button"> } + @if (!hideContactUsLink) { - @if (!hideContactUsLink && buttonLabel) { - {{ 'dot.common.or.text' | dm }} + @if (buttonLabel) { + {{ 'dot.common.or.text' }} } - {{ 'Contact-Us-for-more-Information' | dm }} + {{ 'Contact-Us-for-more-Information' }} }
diff --git a/core-web/libs/ui/src/lib/components/dot-empty-container/dot-empty-container.component.ts b/core-web/libs/ui/src/lib/components/dot-empty-container/dot-empty-container.component.ts index 08a8b844b589..8aa422cc4cde 100644 --- a/core-web/libs/ui/src/lib/components/dot-empty-container/dot-empty-container.component.ts +++ b/core-web/libs/ui/src/lib/components/dot-empty-container/dot-empty-container.component.ts @@ -24,7 +24,7 @@ export interface PrincipalConfiguration { changeDetection: ChangeDetectionStrategy.OnPush }) export class DotEmptyContainerComponent { - //Todo: make required when Angular 16 updated + //Todo: change to input signal when ui migrated to jest /** * Principal configuration of the component */ diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 8c7471c794d0..2dc7cbd24121 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -5754,5 +5754,10 @@ edit.content.category-field.list.show.less=Less edit.content.category-field.search.name=Name edit.content.category-field.search.assignee=Category Path edit.content.category-field.search.input.placeholder=Search +edit.content.category-field.search.not-found.title=No categories found +edit.content.category-field.search.not-found.legend=Your search does not match any of the categories, please try again. +edit.content.category-field.search.error.title=Something went wrong +edit.content.category-field.search.error.legend=Oops! Something went wrong. Please try again later. edit.content.category-field.category.root-name=Root +