From 86dd5d19b9c7f401f08095e7fd346505123bd592 Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Wed, 30 Aug 2023 16:48:04 -0600 Subject: [PATCH] Fix #25926 Reimplement the autocomplete component (#25935) * Reimplement the autocomplete component * dev (add styles autocomplete): enhance functionality * Fix filtering and styling. * Fix the filtering and language messages * Add tests for JsonClassesService * Test are not running * fix (add styles dialog): now tests are running * Fix tests * Update mocks --------- Co-authored-by: Jalinson Diaz --- .prettierrc | 1 + .../components/form/_autocomplete.scss | 10 +- .../add-style-classes-dialog.component.html | 74 ++-- .../add-style-classes-dialog.component.scss | 41 ++- ...add-style-classes-dialog.component.spec.ts | 335 +++++++++++++----- ...-style-classes-dialog.component.stories.ts | 4 +- .../add-style-classes-dialog.component.ts | 120 +++---- .../services/json-classes.service.spec.ts | 38 ++ .../services/json-classes.service.ts | 15 + .../add-style-classes-dialog.store.spec.ts | 164 --------- .../store/add-style-classes-dialog.store.ts | 233 ------------ .../template-builder-row.component.spec.ts | 4 +- .../template-builder/models/models.ts | 23 -- .../template-builder.component.spec.ts | 2 - .../template-builder.component.stories.ts | 4 +- .../template-builder/utils/mocks.ts | 7 +- .../src/lib/template-builder.module.ts | 8 +- .../WEB-INF/messages/Language.properties | 3 + 18 files changed, 437 insertions(+), 649 deletions(-) create mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/services/json-classes.service.spec.ts create mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/services/json-classes.service.ts delete mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/store/add-style-classes-dialog.store.spec.ts delete mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/store/add-style-classes-dialog.store.ts diff --git a/.prettierrc b/.prettierrc index a7fecf74b1ff..50f70c5eab84 100644 --- a/.prettierrc +++ b/.prettierrc @@ -6,6 +6,7 @@ "trailingComma": "none", "jsxBracketSameLine": false, "arrowParens": "always", + "bracketSameLine": true, "overrides": [ { "files": "*.scss", diff --git a/core-web/libs/dotcms-scss/angular/dotcms-theme/components/form/_autocomplete.scss b/core-web/libs/dotcms-scss/angular/dotcms-theme/components/form/_autocomplete.scss index f99d3c8cc79c..98a3a8893978 100644 --- a/core-web/libs/dotcms-scss/angular/dotcms-theme/components/form/_autocomplete.scss +++ b/core-web/libs/dotcms-scss/angular/dotcms-theme/components/form/_autocomplete.scss @@ -3,7 +3,6 @@ .p-autocomplete { @extend #form-field-extend; - min-height: $field-height-md; height: auto; padding-right: 0; @@ -38,6 +37,8 @@ align-items: center; max-height: 13rem; // 208px overflow: auto; // Make it scrollable + gap: $spacing-0; + padding: 7px 0 6px; // Specific padding for the tokens in multiple lines &:hover, &:active, @@ -45,23 +46,18 @@ border: none; } - &:has(> .p-autocomplete-token) { - padding: $spacing-1 0; - } - .p-autocomplete-token { @extend #field-chip; } .p-autocomplete-input-token { - padding: 0.375rem 0; - input { font-family: $font-default; font-size: $font-size-md; color: $black; padding: 0; margin: 0; + height: 1.5rem; } } } diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/add-style-classes-dialog.component.html b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/add-style-classes-dialog.component.html index 007c68c281e5..ad3b6d2e2645 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/add-style-classes-dialog.component.html +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/add-style-classes-dialog.component.html @@ -1,30 +1,44 @@ - -
-
- - -
-
-
- - {{ 'dot.template.builder.classes.dialog.update.button' | dm }} -
-
+
+ + + +
    +
  • + + {{ 'dot.template.builder.autocomplete.has.suggestions' | dm }} +
  • +
+
+ + +
    +
  • + + {{ 'dot.template.builder.autocomplete.no.suggestions' | dm }} +
  • +
  • + + +
  • +
+
+
+
+ + {{ 'dot.template.builder.classes.dialog.update.button' | dm }} +
diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/add-style-classes-dialog.component.scss b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/add-style-classes-dialog.component.scss index 063265b1ecb3..362a035f264a 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/add-style-classes-dialog.component.scss +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/add-style-classes-dialog.component.scss @@ -1,30 +1,37 @@ @use "variables" as *; + :host { width: 33.875rem; display: block; } -.dialog__auto-complete-container { - .auto-complete-container__label { - margin: 0; - color: $black; - } -} - .dialog__actions-container { display: flex; justify-content: end; - padding: 1.25rem $spacing-5; } -::ng-deep { - .p-autocomplete.p-autocomplete-multiple - .p-autocomplete-multiple-container - .p-autocomplete-token { - margin-right: 0; - } +p-autoComplete { + margin-bottom: $spacing-3; + display: block; +} + +ul { + color: $color-palette-gray-700; + display: flex; + flex-direction: column; + font-size: $font-size-sm; + gap: $spacing-1; + list-style: none; + margin: 0; + padding: 0; +} + +li { + display: flex; + align-items: center; + gap: $spacing-0; +} - .p-autocomplete-multiple-container { - gap: 0.25rem; - } +.pi { + color: $color-palette-gray; } diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/add-style-classes-dialog.component.spec.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/add-style-classes-dialog.component.spec.ts index 332d1a36f5fa..d024ae5e1dbc 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/add-style-classes-dialog.component.spec.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/add-style-classes-dialog.component.spec.ts @@ -1,147 +1,298 @@ -import { byTestId, createHostFactory, SpectatorHost } from '@ngneat/spectator'; -import { of } from 'rxjs'; +import { expect, it } from '@jest/globals'; +import { Spectator, byTestId, createComponentFactory } from '@ngneat/spectator/jest'; +import { of, throwError } from 'rxjs'; -import { AsyncPipe, NgIf } from '@angular/common'; -import { HttpClient, HttpClientModule } from '@angular/common/http'; +import { NgIf, AsyncPipe } from '@angular/common'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; import { FormsModule } from '@angular/forms'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { AutoCompleteModule } from 'primeng/autocomplete'; +import { AutoComplete, AutoCompleteModule } from 'primeng/autocomplete'; import { ButtonModule } from 'primeng/button'; -import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { DynamicDialogConfig, DynamicDialogRef, DynamicDialogModule } from 'primeng/dynamicdialog'; import { DotMessageService } from '@dotcms/data-access'; import { DotMessagePipe } from '@dotcms/ui'; +import { MockDotMessageService } from '@dotcms/utils-testing'; import { AddStyleClassesDialogComponent } from './add-style-classes-dialog.component'; -import { DotAddStyleClassesDialogStore } from './store/add-style-classes-dialog.store'; - -import { - CLASS_NAME_MOCK, - DOT_MESSAGE_SERVICE_TB_MOCK, - MOCK_SELECTED_STYLE_CLASSES, - MOCK_STYLE_CLASSES_FILE, - mockMatchMedia -} from '../../utils/mocks'; +import { JsonClassesService } from './services/json-classes.service'; + +const DOT_MESSAGES = { + 'dot.template.builder.autocomplete.has.suggestions': 'has suggestions', + 'dot.template.builder.autocomplete.no.suggestions': 'no suggestions', + 'dot.template.builder.autocomplete.setup.suggestions': 'setup suggestions', + 'dot.template.builder.classes.dialog.update.button': 'update button' +}; + +const providers = [ + { + provide: DotMessageService, + useValue: new MockDotMessageService(DOT_MESSAGES) + }, + { + provide: DynamicDialogRef, + useValue: { + close: jest.fn() + } + } +]; describe('AddStyleClassesDialogComponent', () => { - let spectator: SpectatorHost; - let input: HTMLInputElement; - let ref: DynamicDialogRef; - let store: DotAddStyleClassesDialogStore; + let spectator: Spectator; + let service: JsonClassesService; + let dialogRef: DynamicDialogRef; + let autocomplete: AutoComplete; - const createHost = createHostFactory({ - component: AddStyleClassesDialogComponent, + const createComponent = createComponentFactory({ imports: [ AutoCompleteModule, + HttpClientTestingModule, + DynamicDialogModule, FormsModule, ButtonModule, DotMessagePipe, NgIf, - AsyncPipe, - HttpClientModule, - NoopAnimationsModule + AsyncPipe ], - providers: [ - { - provide: DynamicDialogConfig, - useValue: { - data: { - selectedClasses: MOCK_SELECTED_STYLE_CLASSES - } - } - }, - { - provide: HttpClient, - useValue: { - get: () => of(MOCK_STYLE_CLASSES_FILE) - } - }, - { - provide: DotMessageService, - useValue: DOT_MESSAGE_SERVICE_TB_MOCK - }, - DotAddStyleClassesDialogStore, - DynamicDialogRef - ] + component: AddStyleClassesDialogComponent, + providers: [JsonClassesService, DynamicDialogRef, DynamicDialogConfig, DotMessageService], + detectChanges: false }); - beforeEach(() => { - spectator = createHost( - '' - ); + describe('with classes', () => { + beforeEach(() => { + spectator = createComponent({ + providers: [ + ...providers, + { + provide: DynamicDialogConfig, + useValue: { + data: { + selectedClasses: ['backend-class'] + } + } + }, + { + provide: JsonClassesService, + useValue: { + getClasses() { + return of({ classes: ['class1', 'class2'] }); + } + } + } + ] + }); + + service = spectator.inject(JsonClassesService); + dialogRef = spectator.inject(DynamicDialogRef); + autocomplete = spectator.query(AutoComplete); + }); - ref = spectator.inject(DynamicDialogRef); - store = spectator.inject(DotAddStyleClassesDialogStore); + it('should set attributes to autocomplete', () => { + spectator.detectChanges(); + expect(autocomplete.unique).toBe(true); + expect(autocomplete.autofocus).toBe(true); + expect(autocomplete.multiple).toBe(true); + expect(autocomplete.size).toBe(446); + expect(autocomplete.inputId).toBe('auto-complete-input'); + expect(autocomplete.appendTo).toBe('body'); + expect(autocomplete.dropdown).toBe(true); + expect(autocomplete.el.nativeElement.className).toContain('p-fluid'); + expect(autocomplete.suggestions).toBe(null); + }); - spectator.detectChanges(); + it('should call jsonClassesService.getClasses on init', () => { + const getClassesMock = jest.spyOn(service, 'getClasses'); + spectator.detectChanges(); - input = spectator.query('#auto-complete-input'); + expect(getClassesMock).toHaveBeenCalledTimes(1); + }); - mockMatchMedia(); - }); + it('should set classes property on init', () => { + spectator.detectChanges(); - it('should have an update button', () => { - expect(spectator.query(byTestId('update-btn'))).toBeTruthy(); - }); + expect(spectator.component.classes).toEqual(['class1', 'class2']); + }); - it('should trigger filterClasses when focusing on the input', () => { - const filterMock = jest.spyOn(store, 'filterClasses'); - const query = CLASS_NAME_MOCK; + it('should initialize selectedClasses from DynamicDialogConfig data', () => { + spectator.detectChanges(); - input.value = query; + expect(spectator.component.selectedClasses).toEqual(['backend-class']); + }); - spectator.click(input); + it('should filter suggestions and pass to autocomplete on completeMethod', () => { + spectator.detectChanges(); + spectator.triggerEventHandler(AutoComplete, 'completeMethod', { query: 'class1' }); - spectator.detectChanges(); + expect(autocomplete.suggestions).toEqual(['class1']); + }); - expect(filterMock).toHaveBeenCalledWith(query); - }); + it('should add class on keyup.enter', () => { + const selectItemSpy = jest.spyOn(autocomplete, 'selectItem'); + spectator.detectChanges(); + + const input = document.createElement('input'); + input.value = 'class1'; - it('should trigger addClass when autocomplete emits onSelect', () => { - const autoComplete = spectator.query('p-autocomplete'); + spectator.triggerEventHandler(AutoComplete, 'onKeyUp', { key: 'Enter', target: input }); - const addClassMock = jest.spyOn(store, 'addClass'); + expect(selectItemSpy).toBeCalledWith('class1'); + }); - spectator.dispatchFakeEvent(autoComplete, 'onSelect'); + it('should save selected classes and close the dialog', () => { + spectator.component.selectedClasses = ['class1']; + spectator.component.save(); + spectator.detectChanges(); - spectator.detectChanges(); + expect(dialogRef.close).toHaveBeenCalledWith(['class1']); + }); - expect(addClassMock).toHaveBeenCalled(); + it('should have help message', () => { + spectator.detectChanges(); + const list = spectator.query(byTestId('list')); + + expect(list.textContent).toContain('has suggestions'); + }); }); - it('should trigger removeClass when autocomplete emits onUnselect', () => { - const autoComplete = spectator.query('p-autocomplete'); + describe('no classes', () => { + beforeEach(() => { + spectator = createComponent({ + providers: [ + ...providers, + { + provide: DynamicDialogConfig, + useValue: { + data: { + selectedClasses: [] + } + } + }, + { + provide: JsonClassesService, + useValue: { + getClasses() { + return of({ classes: [] }); + } + } + } + ] + }); + + service = spectator.inject(JsonClassesService); + dialogRef = spectator.inject(DynamicDialogRef); + autocomplete = spectator.query(AutoComplete); + }); - const removeClass = jest.spyOn(store, 'removeClass'); + it('should set dropdown to false in autocomplete', () => { + spectator.detectChanges(); + expect(autocomplete.dropdown).toBe(false); + }); - spectator.dispatchFakeEvent(autoComplete, 'onUnselect'); + it('should set component.classes empty', () => { + spectator.detectChanges(); - spectator.detectChanges(); + expect(spectator.component.classes).toEqual([]); + }); - expect(removeClass).toHaveBeenCalled(); + it('should have multiples help message', () => { + spectator.detectChanges(); + const list = spectator.query(byTestId('list')); + + expect(list.textContent).toContain('no suggestions setup suggestions'); + }); }); - it('should trigger saveClass when clicking on update-btn', () => { - const closeMock = jest.spyOn(ref, 'close'); + describe('bad format json', () => { + beforeEach(() => { + spectator = createComponent({ + providers: [ + ...providers, + { + provide: DynamicDialogConfig, + useValue: { + data: { + selectedClasses: [] + } + } + }, + { + provide: JsonClassesService, + useValue: { + getClasses() { + return of({ badFormat: ['class1'] }); + } + } + } + ] + }); + + service = spectator.inject(JsonClassesService); + dialogRef = spectator.inject(DynamicDialogRef); + autocomplete = spectator.query(AutoComplete); + }); + + it('should set dropdown to false in autocomplete', () => { + spectator.detectChanges(); + expect(autocomplete.dropdown).toBe(false); + }); - const updateBtn = spectator.query(byTestId('update-btn')); + it('should set component.classes empty', () => { + spectator.detectChanges(); - spectator.dispatchFakeEvent(updateBtn, 'onClick'); + expect(spectator.component.classes).toEqual([]); + }); - spectator.detectChanges(); + it('should have multiples help message', () => { + spectator.detectChanges(); + const list = spectator.query(byTestId('list')); - expect(closeMock).toHaveBeenCalled(); + expect(list.textContent).toContain('no suggestions setup suggestions'); + }); }); - it('should trigger addClass when enter is pressed', () => { - const addClassMock = jest.spyOn(store, 'addClass'); + describe('error', () => { + beforeEach(() => { + spectator = createComponent({ + providers: [ + ...providers, + { + provide: DynamicDialogConfig, + useValue: { + data: { + selectedClasses: [] + } + } + }, + { + provide: JsonClassesService, + useValue: { + getClasses() { + return throwError( + new Error('An error occurred while fetching classes') + ); + } + } + } + ] + }); - spectator.typeInElement(CLASS_NAME_MOCK, input); - spectator.keyboard.pressEnter(input); + service = spectator.inject(JsonClassesService); + dialogRef = spectator.inject(DynamicDialogRef); + autocomplete = spectator.query(AutoComplete); + }); - spectator.detectChanges(); + it('should set dropdown to false in autocomplete', () => { + spectator.detectChanges(); + expect(autocomplete.dropdown).toBe(false); + }); - expect(addClassMock).toHaveBeenCalledWith({ cssClass: CLASS_NAME_MOCK }); + it('should set component.classes empty', () => { + spectator.detectChanges(); + + expect(spectator.component.classes).toEqual([]); + }); }); + + // More tests can be added as needed... }); diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/add-style-classes-dialog.component.stories.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/add-style-classes-dialog.component.stories.ts index 83d4c4c18a6a..18e43013428d 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/add-style-classes-dialog.component.stories.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/add-style-classes-dialog.component.stories.ts @@ -14,7 +14,7 @@ import { DotMessageService } from '@dotcms/data-access'; import { DotMessagePipe } from '@dotcms/ui'; import { AddStyleClassesDialogComponent } from './add-style-classes-dialog.component'; -import { DotAddStyleClassesDialogStore } from './store/add-style-classes-dialog.store'; +import { JsonClassesService } from './services/json-classes.service'; import { DOT_MESSAGE_SERVICE_TB_MOCK, @@ -57,7 +57,7 @@ export default { useValue: DOT_MESSAGE_SERVICE_TB_MOCK }, DynamicDialogRef, - DotAddStyleClassesDialogStore + JsonClassesService ] }) ] diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/add-style-classes-dialog.component.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/add-style-classes-dialog.component.ts index f06ae5b19ad4..47bd706b52c4 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/add-style-classes-dialog.component.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/add-style-classes-dialog.component.ts @@ -1,25 +1,18 @@ -import { Subject } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { AsyncPipe, NgIf } from '@angular/common'; -import { - AfterViewInit, - ChangeDetectionStrategy, - Component, - OnDestroy, - OnInit, - ViewChild -} from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit, ViewChild } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { AutoComplete, AutoCompleteModule } from 'primeng/autocomplete'; import { ButtonModule } from 'primeng/button'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { DotMessagePipe } from '@dotcms/ui'; +import { catchError, map, shareReplay, tap } from 'rxjs/operators'; -import { DotAddStyleClassesDialogStore } from './store/add-style-classes-dialog.store'; +import { DotMessagePipe } from '@dotcms/ui'; -import { StyleClassModel } from '../../models/models'; +import { JsonClassesService } from './services/json-classes.service'; @Component({ selector: 'dotcms-add-style-classes-dialog', @@ -27,92 +20,87 @@ import { StyleClassModel } from '../../models/models'; imports: [AutoCompleteModule, FormsModule, ButtonModule, DotMessagePipe, NgIf, AsyncPipe], templateUrl: './add-style-classes-dialog.component.html', styleUrls: ['./add-style-classes-dialog.component.scss'], + providers: [JsonClassesService], changeDetection: ChangeDetectionStrategy.OnPush }) -export class AddStyleClassesDialogComponent implements OnInit, AfterViewInit, OnDestroy { - public vm$ = this.store.vm$; - @ViewChild(AutoComplete) - autoComplete: AutoComplete; - private autoCompleteInput: HTMLInputElement; - private destroy$: Subject = new Subject(); +export class AddStyleClassesDialogComponent implements OnInit { + @ViewChild(AutoComplete) autoComplete: AutoComplete; + filteredSuggestions = null; + selectedClasses: string[] = []; + + isJsonClasses$: Observable; + classes: string[]; constructor( - private ref: DynamicDialogRef, - private store: DotAddStyleClassesDialogStore, + private jsonClassesService: JsonClassesService, public dynamicDialogConfig: DynamicDialogConfig<{ selectedClasses: string[]; - }> + }>, + private ref: DynamicDialogRef ) {} ngOnInit() { const { selectedClasses } = this.dynamicDialogConfig.data; - - this.store.init({ selectedClasses }); - - this.store.fetchStyleClasses(); - } - - ngAfterViewInit() { - this.autoCompleteInput = document.getElementById('auto-complete-input') as HTMLInputElement; - } - - ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); + this.selectedClasses = selectedClasses; + + this.isJsonClasses$ = this.jsonClassesService.getClasses().pipe( + tap(({ classes }) => { + if (classes?.length) { + this.classes = classes; + } else { + this.classes = []; + } + }), + map(({ classes }) => { + return !!classes?.length; + }), + catchError(() => { + this.classes = []; + + return of(false); + }), + shareReplay(1) + ); } /** - * @description Filter the classes based on the query + * Filter the suggestions based on the query * * @param {{ query: string }} { query } + * @return {*} * @memberof AddStyleClassesDialogComponent */ - filterClasses({ query }: { query: string }) { - this.store.filterClasses(query); - - // To reset the input when the user types a comma or space - if (query.includes(',') || query.includes(' ')) { - this.autoCompleteInput.value = ''; - } - } + filterClasses({ query }: { query: string }): void { + /* + https://github.com/primefaces/primeng/blob/master/src/app/components/autocomplete/autocomplete.ts#L739 - /** - * @description Closes the dialog and returns the selected classes - * - * @memberof AddStyleClassesDialogComponent - */ - saveClass(selectedClasses: StyleClassModel[]): void { - this.ref.close(selectedClasses.map((styleClass) => styleClass.cssClass)); - } + Sadly we need to pass suggestions all the time, even if they are empty because on the set is where the primeng remove the loading icon + */ - /** - * @description Selects a class and adds it to the selected classes - * - * @param {StyleClassModel} newClass - * @memberof AddStyleClassesDialogComponent - */ - onSelect(newClass: StyleClassModel): void { - this.store.addClass(newClass); + // PrimeNG autocomplete doesn't support async pipe in the suggestions + this.filteredSuggestions = this.classes.filter((item) => item.includes(query)); } /** - * @description Removes the last class from the selected classes + * Save the selected classes * * @memberof AddStyleClassesDialogComponent */ - onUnselect(deletedClass: StyleClassModel): void { - this.store.removeClass(deletedClass); + save() { + this.ref.close(this.selectedClasses); } /** - * @description Used to listen for enter presses + * Remove a class from the selected classes * * @param {KeyboardEvent} event * @memberof AddStyleClassesDialogComponent */ - onKeyUp(event: KeyboardEvent): void { - if (event.key === 'Enter' && this.autoCompleteInput.value) { - this.autoComplete.selectItem({ cssClass: this.autoCompleteInput.value }); + onKeyUp(event: KeyboardEvent) { + const target: HTMLInputElement = event.target as unknown as HTMLInputElement; + + if (event.key === 'Enter' && !!target.value) { + this.autoComplete.selectItem(target.value); } } } diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/services/json-classes.service.spec.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/services/json-classes.service.spec.ts new file mode 100644 index 000000000000..7f9b685d157f --- /dev/null +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/services/json-classes.service.spec.ts @@ -0,0 +1,38 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed, getTestBed } from '@angular/core/testing'; + +import { JsonClassesService, STYLE_CLASSES_FILE_URL } from './json-classes.service'; + +describe('JsonClassesService', () => { + let injector: TestBed; + let service: JsonClassesService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [JsonClassesService] + }); + + injector = getTestBed(); + service = injector.inject(JsonClassesService); + httpMock = injector.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should return an Observable with classes', () => { + const expectedClasses = { classes: ['class1', 'class2', 'class3'] }; + // httpClientSpy.get.and.returnValue(of(expectedClasses)); + + service.getClasses().subscribe((classes) => { + expect(classes).toEqual(expectedClasses); + }); + + const req = httpMock.expectOne(STYLE_CLASSES_FILE_URL); + expect(req.request.method).toBe('GET'); + req.flush(expectedClasses); + }); +}); diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/services/json-classes.service.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/services/json-classes.service.ts new file mode 100644 index 000000000000..eb0323d271f5 --- /dev/null +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/services/json-classes.service.ts @@ -0,0 +1,15 @@ +import { Observable } from 'rxjs'; + +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +export const STYLE_CLASSES_FILE_URL = '/application/templates/classes.json'; + +@Injectable() +export class JsonClassesService { + constructor(private http: HttpClient) {} + + getClasses(): Observable<{ classes: string[] }> { + return this.http.get<{ classes: string[] }>(STYLE_CLASSES_FILE_URL); + } +} diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/store/add-style-classes-dialog.store.spec.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/store/add-style-classes-dialog.store.spec.ts deleted file mode 100644 index 1b79198adcd0..000000000000 --- a/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/store/add-style-classes-dialog.store.spec.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { expect, describe } from '@jest/globals'; - -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; -import { TestBed } from '@angular/core/testing'; - -import { DotAddStyleClassesDialogStore } from './add-style-classes-dialog.store'; - -import { MOCK_STYLE_CLASSES_FILE } from '../../../utils/mocks'; - -jest.mock('uuid', () => ({ - v4: () => 'test-id' -})); - -describe('DotAddStyleClassesDialogStore', () => { - let service: DotAddStyleClassesDialogStore; - let httpTestingController: HttpTestingController; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - providers: [DotAddStyleClassesDialogStore] - }); - service = TestBed.inject(DotAddStyleClassesDialogStore); - httpTestingController = TestBed.inject(HttpTestingController); - }); - - afterEach(() => httpTestingController.verify()); - - it('should have selected style classes on init when passed classes', (done) => { - service.init({ selectedClasses: ['class1', 'class2'] }); - service.state$.subscribe((state) => { - expect(state.selectedClasses).toEqual([ - { cssClass: 'class1', id: 'test-id' }, - { cssClass: 'class2', id: 'test-id' } - ]); - done(); - }); - }); - - it('should not have selected style classes on init', (done) => { - service.state$.subscribe((state) => { - expect(state.selectedClasses.length).toBe(0); - done(); - }); - }); - - it('should add a class to selected ', (done) => { - service.init({ selectedClasses: [] }); - - service.addClass({ cssClass: 'class1', id: 'test-id' }); - - service.state$.subscribe((state) => { - expect(state.selectedClasses.length).toBe(1); - done(); - }); - }); - - it('should remove a class from selected that have the same id', (done) => { - service.init({ selectedClasses: ['class1'] }); - - service.removeClass({ - cssClass: 'class1', - id: 'test-id' - }); - - service.state$.subscribe((state) => { - expect(state.selectedClasses).toEqual([]); - done(); - }); - }); - - it('should fetch style classes file', (done) => { - service.fetchStyleClasses(); - const req = httpTestingController.expectOne('/application/templates/classes.json'); - expect(req.request.method).toEqual('GET'); - req.flush(MOCK_STYLE_CLASSES_FILE); - service.state$.subscribe((state) => { - expect(state.styleClasses).toEqual( - MOCK_STYLE_CLASSES_FILE.classes.map((cssClass) => ({ cssClass, id: 'test-id' })) - ); - done(); - }); - }); - - it('should set styleClasses to empty array if fetch style classes fails', (done) => { - service.fetchStyleClasses(); - const req = httpTestingController.expectOne('/application/templates/classes.json'); - expect(req.request.method).toEqual('GET'); - req.flush("This file doesn't exist", { status: 404, statusText: 'Not Found' }); - service.state$.subscribe((state) => { - expect(state.styleClasses).toEqual([]); - done(); - }); - }); - - it('should filter style classes by a query', (done) => { - const query = 'align'; - - service.fetchStyleClasses(); - const req = httpTestingController.expectOne('/application/templates/classes.json'); - req.flush(MOCK_STYLE_CLASSES_FILE); - - service.filterClasses(query); - - service.state$.subscribe((state) => { - expect( - state.filteredClasses.every(({ cssClass }) => cssClass.startsWith(query)) - ).toBeTruthy(); - done(); - }); - }); - - it('should add class to selectedClasses when found a comma on query', (done) => { - const query = 'align,'; - - service.filterClasses(query); - - service.state$.subscribe((state) => { - expect(state.selectedClasses).toEqual([{ cssClass: 'align', id: 'test-id' }]); - done(); - }); - }); - - it('should add class to selectedClasses when found a comma on query', (done) => { - const query = 'align '; - - service.filterClasses(query); - - service.state$.subscribe((state) => { - expect(state.selectedClasses).toEqual([{ cssClass: 'align', id: 'test-id' }]); - done(); - }); - }); - - it('should show query on filtered is nothing is found', (done) => { - const query = 'align-custom-class'; - - service.filterClasses(query); - - service.state$.subscribe((state) => { - expect(state.filteredClasses).toEqual([{ cssClass: query, id: 'test-id' }]); - done(); - }); - }); - - it('should filter selected classes from filteredClass', (done) => { - const query = 'd-'; - const selectedClasses = ['d-flex']; - service.init({ selectedClasses }); - - service.fetchStyleClasses(); - const req = httpTestingController.expectOne('/application/templates/classes.json'); - req.flush(MOCK_STYLE_CLASSES_FILE); - - service.filterClasses(query); - - service.state$.subscribe((state) => { - expect( - state.filteredClasses.find(({ cssClass }) => cssClass == selectedClasses[0]) - ).toBeFalsy(); - done(); - }); - }); -}); diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/store/add-style-classes-dialog.store.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/store/add-style-classes-dialog.store.ts deleted file mode 100644 index 66330b88774f..000000000000 --- a/core-web/libs/template-builder/src/lib/components/template-builder/components/add-style-classes-dialog/store/add-style-classes-dialog.store.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { ComponentStore, tapResponse } from '@ngrx/component-store'; -import { Observable } from 'rxjs'; -import { v4 as uuid } from 'uuid'; - -import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; - -import { switchMap } from 'rxjs/operators'; - -import { DotAddStyleClassesDialogState, StyleClassModel } from '../../../models/models'; -export const STYLE_CLASSES_FILE_URL = '/application/templates/classes.json'; - -const COMMA_SPACES_REGEX = /(,|\s)(.*)/; - -/** - * - * - * @export - * @class DotAddStyleClassesDialogStore - * @extends {ComponentStore} - */ -@Injectable() -export class DotAddStyleClassesDialogStore extends ComponentStore { - public vm$ = this.select((state) => ({ - filteredClasses: state.filteredClasses, - selectedClasses: state.selectedClasses, - styleClasses: state.styleClasses - })); - - constructor(private http: HttpClient) { - super({ styleClasses: [], selectedClasses: [], filteredClasses: [] }); - } - - readonly init = this.updater((state, { selectedClasses }: { selectedClasses: string[] }) => { - return { - ...state, - selectedClasses: selectedClasses.map((cssClass) => this.buildStyleClass(cssClass)) - }; - }); - - // Effects - /** - * @description This effect fetchs the style classes from the file only once - * - * @memberof DotAddStyleClassesDialogStore - */ - readonly fetchStyleClasses = this.effect((trigger$) => { - return trigger$.pipe( - switchMap(() => - this.getStyleClassesFromFile().pipe( - // This operator is used to handle the error - tapResponse( - // 200 response - ({ classes = [] }: { classes: string[] }) => { - this.patchState({ - styleClasses: classes.map((cssClass) => - this.buildStyleClass(cssClass) - ) - }); - }, - // Here is the error, if it fails for any reason I just fill the state with an empty array - (_) => { - this.patchState({ styleClasses: [] }); - } - ) - ) - ) - ); - }); - - // Updaters - - /** - * @description Filters the classes based on the query - * - * @param { query: string } { query } - * @return {*} - * @memberof DotAddStyleClassesDialogStore - */ - readonly filterClasses = this.updater((state, query: string) => { - const { styleClasses, selectedClasses } = state; - const queryIsNotEmpty = query.trim().length > 0; - - // To select the text if it has "," or space - const queryContainsDelimiter = query.includes(',') || query.includes(' '); - - if (queryIsNotEmpty && queryContainsDelimiter) - return { - ...state, - filteredClasses: [], // I need to reset the filter, because I'm doing the selection manually - selectedClasses: [ - ...state.selectedClasses, - this.buildStyleClass(query.replace(COMMA_SPACES_REGEX, '')) - ] - }; - - return { - ...state, - filteredClasses: this.getFilteredClasses({ - query, - queryIsNotEmpty, - styleClasses, - selectedClasses - }) - }; - }); - - /** - * @description This method removes the last class from the selected classes - * - * @memberof DotAddStyleClassesDialogStore - */ - readonly removeClass = this.updater((state, { id }: StyleClassModel) => { - return { - ...state, - selectedClasses: state.selectedClasses.filter((classObj) => classObj.id !== id) - }; - }); - - /** - * @description This method adds a class to the selected classes - * - * @memberof DotAddStyleClassesDialogStore - */ - readonly addClass = this.updater((state, classToAdd: StyleClassModel) => { - return { - ...state, - selectedClasses: [...state.selectedClasses, classToAdd] - }; - }); - - // Util methods - - /** - * @description This method fetchs the style classes from "/application/templates/classes.json" - * - * @return {*} {Observable} - * @memberof DotAddStyleClassesDialogStore - */ - private getStyleClassesFromFile(): Observable { - return this.http.get(STYLE_CLASSES_FILE_URL); - } - - /** - * @description This method filters the classes based on the query and if the class is already selected - * - * @private - * @param {{ - * query: string; - * queryIsNotEmpty: boolean; - * styleClasses: StyleClassModel[]; - * selectedClasses: StyleClassModel[]; - * }} { - * query, - * queryIsNotEmpty, - * styleClasses, - * selectedClasses - * } - * @return {*} {StyleClassModel[]} - * @memberof DotAddStyleClassesDialogStore - */ - private getFilteredClasses({ - query, - queryIsNotEmpty, - styleClasses, - selectedClasses - }: { - query: string; - queryIsNotEmpty: boolean; - styleClasses: StyleClassModel[]; - selectedClasses: StyleClassModel[]; - }): StyleClassModel[] { - const filtered: StyleClassModel[] = []; - - styleClasses.forEach((classObj) => { - if ( - this.classMatchesQuery(query, classObj) && - !this.classAlreadySelected(classObj, selectedClasses) - ) { - filtered.push(classObj); - } - }); - - // If no classes were found and query is not empty, create a new class based on the query - if (queryIsNotEmpty && filtered.length === 0) { - filtered.push(this.buildStyleClass(query.trim())); - } - - return filtered; - } - - /** - * Checks if a class matches the query - * - * @param {string} query - * @param {StyleClassModel} classObj - * @return {boolean} - */ - private classMatchesQuery(query: string, classObj: StyleClassModel): boolean { - const queryLowerCased = query.toLowerCase(); - const cssClassLowerCased = classObj.cssClass.toLowerCase(); - - return cssClassLowerCased.startsWith(queryLowerCased); - } - - /** - * Checks if a class is already selected - * - * @param {StyleClassModel} classObj - * @return {boolean} - */ - private classAlreadySelected( - classObj: StyleClassModel, - selectedClasses: StyleClassModel[] - ): boolean { - return selectedClasses.some(({ cssClass }) => cssClass === classObj.cssClass); - } - - /** - * @description This method builds a StyleClassModel - * - * @private - * @param {string} cssClass - * @return {*} {StyleClassModel} - * @memberof DotAddStyleClassesDialogStore - */ - private buildStyleClass(cssClass: string): StyleClassModel { - return { - cssClass, - id: uuid() - }; - } -} diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.spec.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.spec.ts index 405b94d4ab05..fd964828dc0b 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.spec.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.spec.ts @@ -17,7 +17,6 @@ import { TemplateBuilderRowComponent } from './template-builder-row.component'; import { DotTemplateBuilderStore } from '../../store/template-builder.store'; import { DOT_MESSAGE_SERVICE_TB_MOCK } from '../../utils/mocks'; -import { DotAddStyleClassesDialogStore } from '../add-style-classes-dialog/store/add-style-classes-dialog.store'; import { RemoveConfirmDialogComponent } from '../remove-confirm-dialog/remove-confirm-dialog.component'; import { TemplateBuilderBackgroundColumnsComponent } from '../template-builder-background-columns/template-builder-background-columns.component'; @@ -59,8 +58,7 @@ describe('TemplateBuilderRowComponent', () => { useValue: DOT_MESSAGE_SERVICE_TB_MOCK }, DotTemplateBuilderStore, - DialogService, - DotAddStyleClassesDialogStore + DialogService ], teardown: { destroyAfterEach: false } }).compileComponents(); diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/models/models.ts b/core-web/libs/template-builder/src/lib/components/template-builder/models/models.ts index e21c83827127..dee56e5641bf 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/models/models.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/models/models.ts @@ -71,18 +71,6 @@ export interface DotTemplateBuilderState { resizingRowID: string; } -/** - * @description This is the model for the DotAddStyleClassesDialogStore - * - * @export - * @interface DotAddStyleClassesDialogState - */ -export interface DotAddStyleClassesDialogState { - styleClasses: StyleClassModel[]; - selectedClasses: StyleClassModel[]; - filteredClasses: StyleClassModel[]; -} - export type WidgetType = 'col' | 'row'; /** @@ -109,17 +97,6 @@ export interface DotTemplateLayoutProperties { sidebar: DotLayoutSideBar; } -/** - * @description This it the model for Autocomplete StyleClasses - * - * @export - * @interface StyleClassModel - */ -export interface StyleClassModel { - cssClass: string; - id: string; -} - /** * @description This it the model for the Scroll Direction of the GridStack * diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.spec.ts b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.spec.ts index f13687b13964..b9f07a6c2568 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.spec.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.spec.ts @@ -15,7 +15,6 @@ import { DotContainersService, DotMessageService } from '@dotcms/data-access'; import { DotMessagePipe } from '@dotcms/ui'; import { containersMock, DotContainersServiceMock } from '@dotcms/utils-testing'; -import { DotAddStyleClassesDialogStore } from './components/add-style-classes-dialog/store/add-style-classes-dialog.store'; import { TemplateBuilderComponentsModule } from './components/template-builder-components.module'; import { DotGridStackWidget, SCROLL_DIRECTION } from './models/models'; import { DotTemplateBuilderStore } from './store/template-builder.store'; @@ -71,7 +70,6 @@ describe('TemplateBuilderComponent', () => { DotTemplateBuilderStore, DialogService, DynamicDialogRef, - DotAddStyleClassesDialogStore, { provide: DotMessageService, useValue: DOT_MESSAGE_SERVICE_TB_MOCK diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.stories.ts b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.stories.ts index 63204f077512..977820d5883b 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.stories.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.stories.ts @@ -26,7 +26,7 @@ import { SiteServiceMock } from '@dotcms/utils-testing'; -import { DotAddStyleClassesDialogStore } from './components/add-style-classes-dialog/store/add-style-classes-dialog.store'; +import { JsonClassesService } from './components/add-style-classes-dialog/services/json-classes.service'; import { TemplateBuilderComponentsModule } from './components/template-builder-components.module'; import { DotTemplateBuilderStore } from './store/template-builder.store'; import { TemplateBuilderComponent } from './template-builder.component'; @@ -62,7 +62,7 @@ export default { DotTemplateBuilderStore, DialogService, DynamicDialogRef, - DotAddStyleClassesDialogStore, + JsonClassesService, { provide: DotMessageService, useValue: DOT_MESSAGE_SERVICE_TB_MOCK diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/utils/mocks.ts b/core-web/libs/template-builder/src/lib/components/template-builder/utils/mocks.ts index b75aec95f823..4887225ad1cf 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/utils/mocks.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/utils/mocks.ts @@ -288,7 +288,12 @@ export const MESSAGES_MOCK = { 'editpage.layout.theme.search': 'Search', 'dot.template.builder.classes.dialog.update.button': 'Update', 'dot.template.builder.sidebar.header.title': 'Sidebar', - 'dot.template.builder.row.box.wont.fit': 'Minimum 1 column needed for box drop.' + 'dot.template.builder.row.box.wont.fit': 'Minimum 1 column needed for box drop.', + 'dot.template.builder.autocomplete.has.suggestions': + 'Type and hit enter or select from suggestions to add a class', + 'dot.template.builder.autocomplete.no.suggestions': 'Type and hit enter to add a class', + 'dot.template.builder.autocomplete.setup.suggestions': + 'You can set up predefined class suggestions. Get the setup guide' }; export const DOT_MESSAGE_SERVICE_TB_MOCK = new MockDotMessageService(MESSAGES_MOCK); diff --git a/core-web/libs/template-builder/src/lib/template-builder.module.ts b/core-web/libs/template-builder/src/lib/template-builder.module.ts index ad5b40e65770..d9a28db37755 100644 --- a/core-web/libs/template-builder/src/lib/template-builder.module.ts +++ b/core-web/libs/template-builder/src/lib/template-builder.module.ts @@ -8,7 +8,6 @@ import { ToolbarModule } from 'primeng/toolbar'; import { DotContainersService } from '@dotcms/data-access'; import { DotMessagePipe } from '@dotcms/ui'; -import { DotAddStyleClassesDialogStore } from './components/template-builder/components/add-style-classes-dialog/store/add-style-classes-dialog.store'; import { DotLayoutPropertiesComponent } from './components/template-builder/components/dot-layout-properties/dot-layout-properties.component'; import { TemplateBuilderComponentsModule } from './components/template-builder/components/template-builder-components.module'; import { TemplateBuilderComponent } from './components/template-builder/template-builder.component'; @@ -27,12 +26,7 @@ import { TemplateBuilderComponent } from './components/template-builder/template TemplateBuilderComponentsModule ], declarations: [TemplateBuilderComponent], - providers: [ - DialogService, - DynamicDialogRef, - DotAddStyleClassesDialogStore, - DotContainersService - ], + providers: [DialogService, DynamicDialogRef, DotContainersService], exports: [TemplateBuilderComponent, DotLayoutPropertiesComponent] }) export class TemplateBuilderModule {} diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 6631dd8267b0..5a21c1777320 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -1193,6 +1193,9 @@ dot.template.builder.toolbar.button.layout.label=Layout dot.template.builder.toolbar.button.theme.label=Theme dot.template.builder.sidebar.header.title=Sidebar dot.template.builder.row.box.wont.fit=Minimum 1 column needed for box drop. +dot.template.builder.autocomplete.has.suggestions=Type and hit enter or select from suggestions to add a class +dot.template.builder.autocomplete.no.suggestions=Type and hit enter to add a class +dot.template.builder.autocomplete.setup.suggestions=You can set up predefined class suggestions. Get the setup guide dotCMS-Enterprise-comes-with-an-advanced-Image-Editor-tool=Advanced Image Tools is a dotCMS Enterprise only feature. dotCMS-Image-Clipboard=dotCMS Image Clipboard dotCMS-is-dedicated-to-quality-assurance=dotCMS is dedicated to delivering high quality and extremely reliable enterprise software, and we make extensive efforts to perform thorough quality assurance and resolve all issues before release. However software is complex, and if you feel you have found a problem with dotCMS, we would very much like you to report it so we can resolve it in a future release; please click the link below to report it.