diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.html b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.html index a4a2559da91b..89dd45cdd782 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.html +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.html @@ -6,13 +6,23 @@ >{{ field.name }} + + + + + [attr.data-testId]="'field-' + field.variable" /> + [attr.data-testId]="'field-' + field.variable" /> {{ field.hint }} diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.ts index 98ce71c0fe2d..cc9dfe7da610 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.ts @@ -8,6 +8,8 @@ import { DotFieldRequiredDirective } from '@dotcms/ui'; import { FIELD_TYPES } from './utils'; import { DotEditContentFieldsModule } from '../../fields/dot-edit-content-fields.module'; +import { DotEditContentRadioFieldComponent } from '../../fields/dot-edit-content-radio-field/dot-edit-content-radio-field.component'; +import { DotEditContentSelectFieldComponent } from '../../fields/dot-edit-content-select-field/dot-edit-content-select-field.component'; @Component({ selector: 'dot-edit-content-field', @@ -18,7 +20,9 @@ import { DotEditContentFieldsModule } from '../../fields/dot-edit-content-fields NgIf, ReactiveFormsModule, DotEditContentFieldsModule, - DotFieldRequiredDirective + DotFieldRequiredDirective, + DotEditContentSelectFieldComponent, + DotEditContentRadioFieldComponent ], templateUrl: './dot-edit-content-field.component.html', styleUrls: ['./dot-edit-content-field.component.scss'], diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/utils.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/utils.ts index b913b01741e3..88ca5dfc2b0e 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/utils.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/utils.ts @@ -1,17 +1,23 @@ import { Type } from '@angular/core'; +import { DotEditContentRadioFieldComponent } from '../../fields/dot-edit-content-radio-field/dot-edit-content-radio-field.component'; +import { DotEditContentSelectFieldComponent } from '../../fields/dot-edit-content-select-field/dot-edit-content-select-field.component'; import { DotEditContentTextAreaComponent } from '../../fields/dot-edit-content-text-area/dot-edit-content-text-area.component'; import { DotEditContentTextFieldComponent } from '../../fields/dot-edit-content-text-field/dot-edit-content-text-field.component'; // Map to match the field type to component selector export enum FIELD_TYPES { TEXT = 'Text', - TEXTAREA = 'Textarea' + TEXTAREA = 'Textarea', + SELECT = 'Select', + RADIO = 'Radio' } // This holds the mapping between the field type and the component that should be used to render it. export const FIELD_TYPES_COMPONENTS: Record> = { // We had to use unknown because components have different types. [FIELD_TYPES.TEXT]: DotEditContentTextFieldComponent, - [FIELD_TYPES.TEXTAREA]: DotEditContentTextAreaComponent + [FIELD_TYPES.TEXTAREA]: DotEditContentTextAreaComponent, + [FIELD_TYPES.SELECT]: DotEditContentSelectFieldComponent, + [FIELD_TYPES.RADIO]: DotEditContentRadioFieldComponent }; diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.html b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.html index 424615f1f9b2..3c0338ea4d01 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.html +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.html @@ -1,10 +1,11 @@ -
- -
-
+ + +
+
+ [field]="field" + data-testId="field" />
@@ -12,5 +13,5 @@ diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.scss b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.scss index 1ea595120f12..030a2f09fd02 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.scss +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.scss @@ -26,3 +26,9 @@ flex-direction: column; gap: $spacing-3; } + +code { + // Added temporaly. Only to show the form value. + display: block; + overflow: scroll; +} diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts index eaf08ad665f6..c30231e6ac8c 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts @@ -1,30 +1,29 @@ -import { Spectator, byTestId, createComponentFactory } from '@ngneat/spectator'; +import { Spectator, byTestId, createComponentFactory } from '@ngneat/spectator/jest'; -import { CommonModule } from '@angular/common'; -import { ReactiveFormsModule } from '@angular/forms'; - -import { ButtonModule } from 'primeng/button'; +import { Validators } from '@angular/forms'; import { DotMessageService } from '@dotcms/data-access'; -import { DotMessagePipe } from '@dotcms/ui'; import { MockDotMessageService } from '@dotcms/utils-testing'; import { DotEditContentFormComponent } from './dot-edit-content-form.component'; -import { FIELDS_MOCK, FIELD_MOCK, LAYOUT_MOCK } from '../../utils/mocks'; +import { EditContentFormData } from '../../models/dot-edit-content-form.interface'; +import { JUST_FIELDS_MOCKS, LAYOUT_MOCK } from '../../utils/mocks'; import { DotEditContentFieldComponent } from '../dot-edit-content-field/dot-edit-content-field.component'; +export const VALUES_MOCK = { + name1: 'Name1', + text2: 'Text2' +}; + +export const CONTENT_FORM_DATA_MOCK: EditContentFormData = { + layout: LAYOUT_MOCK +}; + describe('DotFormComponent', () => { let spectator: Spectator; const createComponent = createComponentFactory({ component: DotEditContentFormComponent, - imports: [ - DotEditContentFieldComponent, - CommonModule, - ReactiveFormsModule, - ButtonModule, - DotMessagePipe - ], providers: [ { provide: DotMessageService, @@ -35,50 +34,75 @@ describe('DotFormComponent', () => { ] }); - beforeEach(() => { - spectator = createComponent({ - props: { - formData: LAYOUT_MOCK - } + describe('with data', () => { + beforeEach(() => { + spectator = createComponent({ + props: { + formData: CONTENT_FORM_DATA_MOCK + } + }); }); - }); - describe('initilizeForm', () => { - it('should initialize the form group with form controls for each field in the `formData` array', () => { - const component = spectator.component; - component.formData = LAYOUT_MOCK; - spectator.detectChanges(); + it('should initialize the form controls', () => { + expect(spectator.component.form.value).toEqual({ + name1: 'Placeholder', + text2: null, + text3: null + }); + }); - expect(component.form.controls['name1']).toBeDefined(); - expect(component.form.controls['text2']).toBeDefined(); + it('should initialize the form validators', () => { + expect( + spectator.component.form.controls['name1'].hasValidator(Validators.required) + ).toBe(true); + expect( + spectator.component.form.controls['text2'].hasValidator(Validators.required) + ).toBe(true); + expect( + spectator.component.form.controls['text3'].hasValidator(Validators.required) + ).toBe(false); }); - }); - describe('initializeFormControl', () => { - it('should initialize a form control for a given DotCMSContentTypeField', () => { - const formControl = spectator.component.initializeFormControl(FIELD_MOCK); + it('should validate regex', () => { + expect(spectator.component.form.controls['text2'].valid).toBeFalsy(); - expect(formControl).toBeDefined(); - expect(formControl.validator).toBeDefined(); + spectator.component.form.controls['text2'].setValue('dot@gmail.com'); + expect(spectator.component.form.controls['text2'].valid).toBeTruthy(); }); - it('should have a default value if is defined', () => { - const formControl = spectator.component.initializeFormControl(FIELDS_MOCK[1]); - expect(formControl.value).toEqual(FIELDS_MOCK[1].defaultValue); + it('should have 1 row, 2 columns and 3 fields', () => { + expect(spectator.queryAll(byTestId('row'))).toHaveLength(1); + expect(spectator.queryAll(byTestId('column'))).toHaveLength(2); + expect(spectator.queryAll(byTestId('field'))).toHaveLength(3); }); - }); - describe('saveContent', () => { - it('should emit the form value through the `formSubmit` event', () => { - const component = spectator.component; - component.formData = LAYOUT_MOCK; - component.initilizeForm(); + it('should pass field to attr to dot-edit-content-field', () => { + const fields = spectator.queryAll(DotEditContentFieldComponent); + JUST_FIELDS_MOCKS.forEach((field, index) => { + expect(fields[index].field).toEqual(field); + }); + }); - jest.spyOn(component.formSubmit, 'emit'); + it('should emit the form value through the `formSubmit` event', () => { + jest.spyOn(spectator.component.formSubmit, 'emit'); const button = spectator.query(byTestId('button-save')); spectator.click(button); - expect(component.formSubmit.emit).toHaveBeenCalledWith(component.form.value); + expect(spectator.component.formSubmit.emit).toHaveBeenCalledWith( + spectator.component.form.value + ); + }); + }); + + describe('no data', () => { + beforeEach(() => { + spectator = createComponent({}); + }); + + it('should have form undefined', () => { + jest.spyOn(spectator.component, 'initilizeForm'); + expect(spectator.component.form).toEqual(undefined); + expect(spectator.component.initilizeForm).not.toHaveBeenCalled(); }); }); }); diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.ts index 85f631628f47..acaa6688af0a 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.ts @@ -12,9 +12,11 @@ import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angula import { ButtonModule } from 'primeng/button'; -import { DotCMSContentTypeField, DotCMSContentTypeLayoutRow } from '@dotcms/dotcms-models'; +import { DotCMSContentTypeField } from '@dotcms/dotcms-models'; import { DotMessagePipe } from '@dotcms/ui'; +import { EditContentFormData } from '../../models/dot-edit-content-form.interface'; +import { castSingleSelectableValue } from '../../utils/functions.util'; import { DotEditContentFieldComponent } from '../dot-edit-content-field/dot-edit-content-field.component'; @Component({ selector: 'dot-edit-content-form', @@ -31,7 +33,7 @@ import { DotEditContentFieldComponent } from '../dot-edit-content-field/dot-edit changeDetection: ChangeDetectionStrategy.OnPush }) export class DotEditContentFormComponent implements OnInit { - @Input() formData: DotCMSContentTypeLayoutRow[] = []; + @Input() formData!: EditContentFormData; @Output() formSubmit = new EventEmitter(); private fb = inject(FormBuilder); @@ -45,11 +47,12 @@ export class DotEditContentFormComponent implements OnInit { /** * Initializes the form group with form controls for each field in the `formData` array. - * @returns void + * + * @memberof DotEditContentFormComponent */ initilizeForm() { this.form = this.fb.group({}); - this.formData.forEach(({ columns }) => { + this.formData.layout.forEach(({ columns }) => { columns?.forEach((column) => { column.fields.forEach((field) => { const fieldControl = this.initializeFormControl(field); @@ -61,10 +64,13 @@ export class DotEditContentFormComponent implements OnInit { /** * Initializes a form control for a given DotCMSContentTypeField. - * @param field - The DotCMSContentTypeField to initialize the form control for. - * @returns The initialized form control. + * + * @private + * @param {DotCMSContentTypeField} field + * @return {*} + * @memberof DotEditContentFormComponent */ - initializeFormControl(field: DotCMSContentTypeField) { + private initializeFormControl(field: DotCMSContentTypeField) { const validators = []; if (field.required) validators.push(Validators.required); if (field.regexCheck) { @@ -76,9 +82,13 @@ export class DotEditContentFormComponent implements OnInit { } } + const value = + castSingleSelectableValue(this.formData.contentlet?.[field.variable], field.dataType) ?? + castSingleSelectableValue(field.defaultValue, field.dataType); + return this.fb.control( { - value: field.defaultValue || '', + value: value ?? null, disabled: field.readOnly }, { validators } diff --git a/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.html b/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.html index 62da2afede3f..1ffbe2f14b0b 100644 --- a/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.html +++ b/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.html @@ -1,5 +1,5 @@ - - + +

Content saved!

diff --git a/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.ts b/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.ts index b26c02e33b7c..1465f1c75dea 100644 --- a/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.ts +++ b/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.ts @@ -1,13 +1,15 @@ -import { EMPTY } from 'rxjs'; +import { EMPTY, Observable } from 'rxjs'; import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { switchMap } from 'rxjs/operators'; +import { map, switchMap } from 'rxjs/operators'; import { DotEditContentFormComponent } from '../../components/dot-edit-content-form/dot-edit-content-form.component'; +import { EditContentFormData } from '../../models/dot-edit-content-form.interface'; import { DotEditContentService } from '../../services/dot-edit-content.service'; + @Component({ selector: 'dot-edit-content-form-layout', standalone: true, @@ -25,19 +27,24 @@ export class EditContentLayoutComponent { private readonly dotEditContentService = inject(DotEditContentService); isContentSaved = false; - formData$ = this.identifier + + formData$: Observable = this.identifier ? this.dotEditContentService.getContentById(this.identifier).pipe( - switchMap(({ contentType }) => { + switchMap(({ contentType, ...contentData }) => { if (contentType) { this.contentType = contentType; - return this.dotEditContentService.getContentTypeFormData(contentType); + return this.dotEditContentService + .getContentTypeFormData(contentType) + .pipe(map((res) => ({ contentlet: { ...contentData }, layout: res }))); } else { return EMPTY; } }) ) - : this.dotEditContentService.getContentTypeFormData(this.contentType); + : this.dotEditContentService + .getContentTypeFormData(this.contentType) + .pipe(map((res) => ({ layout: res }))); /** * Saves the contentlet with the given values. @@ -45,7 +52,11 @@ export class EditContentLayoutComponent { */ saveContent(value: { [key: string]: string }) { this.dotEditContentService - .saveContentlet({ ...value, inode: this.identifier, contentType: this.contentType }) + .saveContentlet({ + ...value, + inode: this.identifier, + contentType: this.contentType + }) .subscribe({ next: () => { this.isContentSaved = true; diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-radio-field/dot-edit-content-radio-field.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-radio-field/dot-edit-content-radio-field.component.html new file mode 100644 index 000000000000..327b2eef3e81 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-radio-field/dot-edit-content-radio-field.component.html @@ -0,0 +1,9 @@ + + diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-radio-field/dot-edit-content-radio-field.component.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-radio-field/dot-edit-content-radio-field.component.scss new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-radio-field/dot-edit-content-radio-field.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-radio-field/dot-edit-content-radio-field.component.spec.ts new file mode 100644 index 000000000000..a6e5c8e8744d --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-radio-field/dot-edit-content-radio-field.component.spec.ts @@ -0,0 +1,268 @@ +import { Spectator, createComponentFactory } from '@ngneat/spectator'; + +import { ControlContainer, FormControl, FormGroup, FormGroupDirective } from '@angular/forms'; + +import { RadioButton } from 'primeng/radiobutton'; + +import { DotEditContentRadioFieldComponent } from './dot-edit-content-radio-field.component'; + +import { + RADIO_FIELD_BOOLEAN_MOCK, + RADIO_FIELD_FLOAT_MOCK, + RADIO_FIELD_INTEGER_MOCK, + RADIO_FIELD_TEXT_MOCK, + createFormGroupDirectiveMock +} from '../../utils/mocks'; + +describe('DotEditContentRadioFieldComponent', () => { + describe('test with value', () => { + let spectator: Spectator; + + const FAKE_FORM_GROUP = new FormGroup({ + radio: new FormControl('one') + }); + + const createComponent = createComponentFactory({ + component: DotEditContentRadioFieldComponent, + componentViewProviders: [ + { + provide: ControlContainer, + useValue: createFormGroupDirectiveMock(FAKE_FORM_GROUP) + } + ], + providers: [FormGroupDirective], + detectChanges: false + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('should render radio selected if the form have value', () => { + spectator.setInput('field', RADIO_FIELD_TEXT_MOCK); + spectator.detectComponentChanges(); + + const inputChecked = spectator.queryAll(RadioButton).filter((radio) => radio.checked); + expect(inputChecked.length).toBe(1); + }); + }); + + describe('test without value', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: DotEditContentRadioFieldComponent, + componentViewProviders: [ + { provide: ControlContainer, useValue: createFormGroupDirectiveMock() } + ], + providers: [FormGroupDirective], + detectChanges: false + }); + + beforeEach(() => { + spectator = createComponent({}); + }); + + it('should dont have any value if the form value or defaultValue is null', () => { + spectator.setInput('field', RADIO_FIELD_TEXT_MOCK); + spectator.detectComponentChanges(); + + const inputChecked = spectator.queryAll(RadioButton).filter((radio) => radio.checked); + expect(inputChecked.length).toBe(0); + }); + + it('should set the key/value the same when bad formatting options passed', () => { + const RADIO_FIELD_TEXT_MOCK_WITHOUT_VALUE_AND_LABEL = { + ...RADIO_FIELD_TEXT_MOCK, + values: 'one\r\ntwo' + }; + spectator.setInput('field', RADIO_FIELD_TEXT_MOCK_WITHOUT_VALUE_AND_LABEL); + spectator.detectComponentChanges(); + + expect(spectator.queryAll(RadioButton).map((radio) => radio.value)).toEqual([ + 'one', + 'two' + ]); + }); + + it('should render radio options', () => { + spectator.setInput('field', RADIO_FIELD_TEXT_MOCK); + spectator.detectComponentChanges(); + }); + + it('should have label with for attribute and text equal to radio options', () => { + spectator.setInput('field', RADIO_FIELD_TEXT_MOCK); + spectator.detectComponentChanges(); + spectator.queryAll(RadioButton).forEach((radio) => { + expect(spectator.query(`label[for="${radio.inputId}"]`)).toBeTruthy(); + expect(spectator.query(`label[for="${radio.inputId}"]`).textContent).toEqual( + radio.label + ); + }); + }); + + it('should set the key/value the same when bad formatting options passed', () => { + const RADIO_FIELD_FLOAT_MOCK_WITHOUT_VALUE_AND_LABEL = { + ...RADIO_FIELD_FLOAT_MOCK, + values: '100.5' + }; + spectator.setInput('field', RADIO_FIELD_FLOAT_MOCK_WITHOUT_VALUE_AND_LABEL); + spectator.detectComponentChanges(); + + const expectedList = [ + { + label: '100.5', + value: 100.5 + } + ]; + expect( + spectator + .queryAll(RadioButton) + .every((radioOption) => typeof radioOption.value === 'number') + ).toBeTruthy(); + + expectedList.forEach((option) => { + expect( + spectator + .queryAll(RadioButton) + .find((radioOption) => radioOption.value === option.value) + ).toBeTruthy(); + }); + }); + }); + + describe('test DataType', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: DotEditContentRadioFieldComponent, + componentViewProviders: [ + { provide: ControlContainer, useValue: createFormGroupDirectiveMock() } + ], + providers: [FormGroupDirective], + detectChanges: false + }); + + beforeEach(() => { + spectator = createComponent({}); + }); + it('should have a options array as radio with Text dataType', () => { + const expectedList = [ + { + label: 'One', + value: 'one' + }, + { + label: 'Two', + value: 'two' + } + ]; + spectator.setInput('field', RADIO_FIELD_TEXT_MOCK); + spectator.detectComponentChanges(); + + expect( + spectator + .queryAll(RadioButton) + .every((radioOption) => typeof radioOption.value === 'string') + ).toBeTruthy(); + + expectedList.forEach((option) => { + expect( + spectator + .queryAll(RadioButton) + .find((radioOption) => radioOption.value === option.value) + ).toBeTruthy(); + }); + }); + + it('should have a options array as radio with Boolean dataType', () => { + const expectedList = [ + { + label: 'Falsy', + value: false + }, + { + label: 'Truthy', + value: true + } + ]; + spectator.setInput('field', RADIO_FIELD_BOOLEAN_MOCK); + spectator.detectComponentChanges(); + + expect( + spectator + .queryAll(RadioButton) + .every((radioOption) => typeof radioOption.value === 'boolean') + ).toBeTruthy(); + + expectedList.forEach((option) => { + expect( + spectator + .queryAll(RadioButton) + .find((radioOption) => radioOption.value === option.value) + ).toBeTruthy(); + }); + }); + it('should have a options array as radio with Integer dataType', () => { + const expectedList = [ + { + label: 'Twelve', + value: 12 + }, + { + label: 'Twenty', + value: 20 + }, + { + label: 'Thirty', + value: 30 + } + ]; + spectator.setInput('field', RADIO_FIELD_INTEGER_MOCK); + spectator.detectComponentChanges(); + + expect( + spectator + .queryAll(RadioButton) + .every((radioOption) => typeof radioOption.value === 'number') + ).toBeTruthy(); + + expectedList.forEach((option) => { + expect( + spectator + .queryAll(RadioButton) + .find((radioOption) => radioOption.value === option.value) + ).toBeTruthy(); + }); + }); + + it('should have a options array as radio with Float dataType', () => { + const expectedList = [ + { + label: 'Five point two', + value: 5.2 + }, + { + label: 'Nine point three', + value: 9.3 + } + ]; + spectator.setInput('field', RADIO_FIELD_FLOAT_MOCK); + spectator.detectComponentChanges(); + + expect( + spectator + .queryAll(RadioButton) + .every((radioOption) => typeof radioOption.value === 'number') + ).toBeTruthy(); + + expectedList.forEach((option) => { + expect( + spectator + .queryAll(RadioButton) + .find((radioOption) => radioOption.value === option.value) + ).toBeTruthy(); + }); + }); + }); +}); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-radio-field/dot-edit-content-radio-field.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-radio-field/dot-edit-content-radio-field.component.ts new file mode 100644 index 000000000000..3a970a9f9c6a --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-radio-field/dot-edit-content-radio-field.component.ts @@ -0,0 +1,36 @@ +import { NgFor } from '@angular/common'; +import { ChangeDetectionStrategy, Component, Input, OnInit, inject } from '@angular/core'; +import { ControlContainer, ReactiveFormsModule } from '@angular/forms'; + +import { RadioButtonModule } from 'primeng/radiobutton'; + +import { DotCMSContentTypeField } from '@dotcms/dotcms-models'; +import { DotFieldRequiredDirective } from '@dotcms/ui'; + +import { getSingleSelectableFieldOptions } from '../../utils/functions.util'; + +@Component({ + selector: 'dot-edit-content-radio-field', + standalone: true, + imports: [NgFor, RadioButtonModule, ReactiveFormsModule, DotFieldRequiredDirective], + templateUrl: './dot-edit-content-radio-field.component.html', + styleUrls: ['./dot-edit-content-radio-field.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + viewProviders: [ + { + provide: ControlContainer, + useFactory: () => inject(ControlContainer, { skipSelf: true }) + } + ] +}) +export class DotEditContentRadioFieldComponent implements OnInit { + @Input() field!: DotCMSContentTypeField; + options = []; + + ngOnInit() { + this.options = getSingleSelectableFieldOptions( + this.field.values || '', + this.field.dataType + ); + } +} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-select-field/dot-edit-content-select-field.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-select-field/dot-edit-content-select-field.component.html new file mode 100644 index 000000000000..cd3ff565fbfe --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-select-field/dot-edit-content-select-field.component.html @@ -0,0 +1,6 @@ + diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-select-field/dot-edit-content-select-field.component.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-select-field/dot-edit-content-select-field.component.scss new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-select-field/dot-edit-content-select-field.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-select-field/dot-edit-content-select-field.component.spec.ts new file mode 100644 index 000000000000..1d0e1f2f0fef --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-select-field/dot-edit-content-select-field.component.spec.ts @@ -0,0 +1,152 @@ +import { Spectator, createComponentFactory } from '@ngneat/spectator'; + +import { ControlContainer, FormGroupDirective } from '@angular/forms'; + +import { Dropdown } from 'primeng/dropdown'; + +import { DotEditContentSelectFieldComponent } from './dot-edit-content-select-field.component'; + +import { + SELECT_FIELD_BOOLEAN_MOCK, + SELECT_FIELD_TEXT_MOCK, + createFormGroupDirectiveMock, + SELECT_FIELD_INTEGER_MOCK, + SELECT_FIELD_FLOAT_MOCK +} from '../../utils/mocks'; + +describe('DotEditContentSelectFieldComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: DotEditContentSelectFieldComponent, + componentViewProviders: [ + { provide: ControlContainer, useValue: createFormGroupDirectiveMock() } + ], + providers: [FormGroupDirective], + detectChanges: false + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('should set the first value to the control if no value or defaultValue', () => { + spectator.setInput('field', SELECT_FIELD_TEXT_MOCK); + spectator.component.formControl.setValue(null); + spectator.detectChanges(); + expect(spectator.component.formControl.value).toEqual('Test,1'); + + const spanElement = spectator.query('span.p-dropdown-label'); + expect(spanElement).toBeTruthy(); + expect(spanElement.textContent).toEqual('Option 1'); + }); + + it('should set the value from control to dropdown', () => { + spectator.setInput('field', SELECT_FIELD_TEXT_MOCK); + spectator.component.formControl.setValue('2'); + spectator.detectChanges(); + + const spanElement = spectator.query('span.p-dropdown-label'); + expect(spanElement).toBeTruthy(); + expect(spanElement.textContent).toEqual('Option 2'); + }); + + it('should set the key/value the same when bad formatting options passed', () => { + const SELECT_FIELD_INTEGER_MOCK_WITHOUT_VALUE_AND_LABEL = { + ...SELECT_FIELD_INTEGER_MOCK, + values: '1000' + }; + spectator.setInput('field', SELECT_FIELD_INTEGER_MOCK_WITHOUT_VALUE_AND_LABEL); + spectator.detectComponentChanges(); + + const expectedList = [ + { + label: '1000', + value: 1000 + } + ]; + expect(spectator.query(Dropdown).options).toEqual(expectedList); + }); + + describe('test DataType', () => { + it('should have options array as select with Text', () => { + const expectedList = [ + { + label: 'Option 1', + value: 'Test,1' + }, + { + label: 'Option 2', + value: '2' + }, + { + label: 'Option 3', + value: '3' + }, + { + label: '123-ad', + value: '123-ad' + }, + { + label: 'rules and weird code', + value: 'rules and weird code' + } + ]; + spectator.setInput('field', SELECT_FIELD_TEXT_MOCK); + spectator.detectComponentChanges(); + expect(spectator.query(Dropdown).options).toEqual(expectedList); + }); + + it('should have options array as select with Bool', () => { + const expectedList = [ + { + label: 'Truthy', + value: true + }, + { + label: 'Falsy', + value: false + } + ]; + spectator.setInput('field', SELECT_FIELD_BOOLEAN_MOCK); + spectator.detectComponentChanges(); + expect(spectator.query(Dropdown).options).toEqual(expectedList); + }); + + it('should have options array as select with Float', () => { + const expectedList = [ + { + label: 'One hundred point five', + value: 100.5 + }, + { + label: 'Three point five', + value: 10.3 + } + ]; + spectator.setInput('field', SELECT_FIELD_FLOAT_MOCK); + spectator.detectComponentChanges(); + expect(spectator.query(Dropdown).options).toEqual(expectedList); + }); + + it('should have options array as select with Integer', () => { + const expectedList = [ + { + label: 'One hundred', + value: 100 + }, + { + label: 'One thousand', + value: 1000 + }, + { + label: 'Ten thousand', + value: 10000 + } + ]; + spectator.setInput('field', SELECT_FIELD_INTEGER_MOCK); + spectator.detectComponentChanges(); + expect(spectator.query(Dropdown).options).toEqual(expectedList); + }); + }); +}); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-select-field/dot-edit-content-select-field.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-select-field/dot-edit-content-select-field.component.ts new file mode 100644 index 000000000000..e94b1b73dcbe --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-select-field/dot-edit-content-select-field.component.ts @@ -0,0 +1,51 @@ +import { ChangeDetectionStrategy, Component, Input, OnInit, inject } from '@angular/core'; +import { AbstractControl, ControlContainer, ReactiveFormsModule } from '@angular/forms'; + +import { DropdownModule } from 'primeng/dropdown'; + +import { DotCMSContentTypeField } from '@dotcms/dotcms-models'; + +import { DotEditContentFieldSingleSelectableDataTypes } from '../../models/dot-edit-content-field.type'; +import { getSingleSelectableFieldOptions } from '../../utils/functions.util'; + +@Component({ + selector: 'dot-edit-content-select-field', + standalone: true, + imports: [DropdownModule, ReactiveFormsModule], + templateUrl: './dot-edit-content-select-field.component.html', + styleUrls: ['./dot-edit-content-select-field.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + viewProviders: [ + { + provide: ControlContainer, + useFactory: () => inject(ControlContainer, { skipSelf: true }) + } + ] +}) +export class DotEditContentSelectFieldComponent implements OnInit { + @Input() field!: DotCMSContentTypeField; + private readonly controlContainer = inject(ControlContainer); + + options = []; + + ngOnInit() { + this.options = getSingleSelectableFieldOptions( + this.field?.values || '', + this.field.dataType + ); + + if (this.formControl.value === null) { + this.formControl.setValue(this.options[0]?.value); + } + } + + /** + * Returns the form control for the select field. + * @returns {AbstractControl} The form control for the select field. + */ + get formControl() { + return this.controlContainer.control.get( + this.field.variable + ) as AbstractControl; + } +} diff --git a/core-web/libs/edit-content/src/lib/models/dot-edit-content-field.enum.ts b/core-web/libs/edit-content/src/lib/models/dot-edit-content-field.enum.ts new file mode 100644 index 000000000000..d1c0ad05f3d0 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/models/dot-edit-content-field.enum.ts @@ -0,0 +1,8 @@ +/** + * Represents the selectable data types (Dropdown, Radio button) for a DotCMS content field. + */ +export enum DotEditContentFieldSingleSelectableDataType { + BOOL = 'BOOL', + INTEGER = 'INTEGER', + FLOAT = 'FLOAT' +} diff --git a/core-web/libs/edit-content/src/lib/models/dot-edit-content-field.type.ts b/core-web/libs/edit-content/src/lib/models/dot-edit-content-field.type.ts new file mode 100644 index 000000000000..0e50c1a04686 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/models/dot-edit-content-field.type.ts @@ -0,0 +1 @@ +export type DotEditContentFieldSingleSelectableDataTypes = string | boolean | number; diff --git a/core-web/libs/edit-content/src/lib/models/dot-edit-content-form.interface.ts b/core-web/libs/edit-content/src/lib/models/dot-edit-content-form.interface.ts new file mode 100644 index 000000000000..35a7aa5e88af --- /dev/null +++ b/core-web/libs/edit-content/src/lib/models/dot-edit-content-form.interface.ts @@ -0,0 +1,6 @@ +import { DotCMSContentTypeLayoutRow, DotCMSContentlet } from '@dotcms/dotcms-models'; + +export interface EditContentFormData { + layout: DotCMSContentTypeLayoutRow[]; + contentlet?: DotCMSContentlet; +} diff --git a/core-web/libs/edit-content/src/lib/utils/functions.util.ts b/core-web/libs/edit-content/src/lib/utils/functions.util.ts new file mode 100644 index 000000000000..d6274ced1766 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/utils/functions.util.ts @@ -0,0 +1,46 @@ +import { DotEditContentFieldSingleSelectableDataType } from '../models/dot-edit-content-field.enum'; +import { DotEditContentFieldSingleSelectableDataTypes } from '../models/dot-edit-content-field.type'; + +export const castSingleSelectableValue = ( + value: string, + type: string +): DotEditContentFieldSingleSelectableDataTypes | null => { + if (!value) { + return null; + } + + if (type === DotEditContentFieldSingleSelectableDataType.BOOL) { + return value === 'true'; + } + + if ( + type === DotEditContentFieldSingleSelectableDataType.INTEGER || + type === DotEditContentFieldSingleSelectableDataType.FLOAT + ) { + return Number(value); + } + + return value; +}; + +export const getSingleSelectableFieldOptions = ( + options: string, + dataType: string +): { label: string; value: DotEditContentFieldSingleSelectableDataTypes }[] => { + const lines = options?.split('\r\n'); + + if (lines.length === 0) { + return []; + } + + const result = lines?.map((line) => { + const [label, value] = line.split('|'); + if (!value) { + return { label, value: castSingleSelectableValue(label, dataType) }; + } + + return { label, value: castSingleSelectableValue(value, dataType) }; + }); + + return result; +}; diff --git a/core-web/libs/edit-content/src/lib/utils/mocks.ts b/core-web/libs/edit-content/src/lib/utils/mocks.ts index 0d33a0bb1558..d6ddddc3616a 100644 --- a/core-web/libs/edit-content/src/lib/utils/mocks.ts +++ b/core-web/libs/edit-content/src/lib/utils/mocks.ts @@ -60,14 +60,228 @@ export const TEXT_AREA_FIELD_MOCK: DotCMSContentTypeField = { variable: 'someTextArea' }; -export const FIELDS_MOCK: DotCMSContentTypeField[] = [TEXT_FIELD_MOCK, TEXT_AREA_FIELD_MOCK]; +export const SELECT_FIELD_TEXT_MOCK = { + clazz: 'com.dotcms.contenttype.model.field.ImmutableSelectField', + contentTypeId: '40e0cb1b57b3b1b7ec34191e942316d5', + dataType: 'TEXT', + defaultValue: '123-ad', + fieldType: 'Select', + fieldTypeLabel: 'Select', + fieldVariables: [], + fixed: false, + forceIncludeInApi: false, + iDate: 1697579843000, + hint: 'A hint Text', + id: 'a6f33b8941b6c06c8ab36e44c4bf6500', + indexed: false, + listed: false, + modDate: 1697661626000, + name: 'selectNormal', + readOnly: false, + required: false, + searchable: false, + sortOrder: 3, + unique: false, + values: 'Option 1|Test,1\r\nOption 2|2\r\nOption 3|3\r\n123-ad\r\nrules and weird code', + variable: 'selectNormal' +}; + +export const SELECT_FIELD_BOOLEAN_MOCK = { + clazz: 'com.dotcms.contenttype.model.field.ImmutableSelectField', + contentTypeId: '40e0cb1b57b3b1b7ec34191e942316d5', + dataType: 'BOOL', + fieldType: 'Select', + fieldTypeLabel: 'Select', + fieldVariables: [], + fixed: false, + hint: 'A hint Text', + forceIncludeInApi: false, + iDate: 1697661273000, + id: '8c5648fe4dedc06baf314f362c00431b', + indexed: false, + listed: false, + modDate: 1697661626000, + name: 'selectBoolean', + readOnly: false, + required: false, + searchable: false, + sortOrder: 4, + unique: false, + values: 'Truthy|true\r\nFalsy|false', + variable: 'selectBoolean' +}; + +export const SELECT_FIELD_FLOAT_MOCK = { + clazz: 'com.dotcms.contenttype.model.field.ImmutableSelectField', + contentTypeId: '40e0cb1b57b3b1b7ec34191e942316d5', + dataType: 'FLOAT', + fieldType: 'Select', + fieldTypeLabel: 'Select', + hint: 'A hint Text', + fieldVariables: [], + fixed: false, + forceIncludeInApi: false, + iDate: 1697661848000, + id: '8c2edc3ee461fa50041a9e5831f1a86a', + indexed: false, + listed: false, + modDate: 1697661848000, + name: 'selectDecimal', + readOnly: false, + required: false, + searchable: false, + sortOrder: 5, + unique: false, + values: 'One hundred point five|100.5\r\nThree point five|10.3', + variable: 'selectDecimal' +}; + +export const SELECT_FIELD_INTEGER_MOCK = { + clazz: 'com.dotcms.contenttype.model.field.ImmutableSelectField', + contentTypeId: '40e0cb1b57b3b1b7ec34191e942316d5', + dataType: 'INTEGER', + fieldType: 'Select', + fieldTypeLabel: 'Select', + fieldVariables: [], + fixed: false, + forceIncludeInApi: false, + iDate: 1697662296000, + id: '89bdd8e525ef9a4c923f4b54d9a0e4f8', + indexed: false, + listed: false, + modDate: 1697662296000, + name: 'selectWholeNumber', + readOnly: false, + required: false, + searchable: false, + hint: 'A hint Text', + sortOrder: 6, + unique: false, + values: 'One hundred|100\r\nOne thousand|1000\r\nTen thousand|10000', + variable: 'selectWholeNumber' +}; + +export const RADIO_FIELD_TEXT_MOCK = { + clazz: 'com.dotcms.contenttype.model.field.ImmutableRadioField', + contentTypeId: '40e0cb1b57b3b1b7ec34191e942316d5', + dataType: 'TEXT', + fieldType: 'Radio', + fieldTypeLabel: 'Radio', + fieldVariables: [], + fixed: false, + forceIncludeInApi: false, + iDate: 1697598313000, + id: '824b4e9907fe4f450ced438598cc0ce8', + indexed: false, + listed: false, + modDate: 1697662296000, + name: 'radio', + readOnly: false, + required: false, + hint: 'A hint Text', + searchable: false, + sortOrder: 8, + unique: false, + values: 'One|one\r\nTwo|two', + variable: 'radio' +}; + +export const RADIO_FIELD_BOOLEAN_MOCK = { + clazz: 'com.dotcms.contenttype.model.field.ImmutableRadioField', + contentTypeId: '40e0cb1b57b3b1b7ec34191e942316d5', + dataType: 'BOOL', + fieldType: 'Radio', + fieldTypeLabel: 'Radio', + fieldVariables: [], + fixed: false, + forceIncludeInApi: false, + iDate: 1697656862000, + id: 'e4b3ef6a8cb50ff77fe2534c2b237d71', + indexed: false, + listed: false, + modDate: 1697662296000, + name: 'radioTrueFalse', + readOnly: false, + required: false, + searchable: false, + sortOrder: 9, + hint: 'A hint Text', + unique: false, + values: 'Falsy|false\r\nTruthy|true', + variable: 'radioTrueFalse' +}; + +export const RADIO_FIELD_FLOAT_MOCK = { + clazz: 'com.dotcms.contenttype.model.field.ImmutableRadioField', + contentTypeId: '40e0cb1b57b3b1b7ec34191e942316d5', + dataType: 'FLOAT', + defaultValue: '9.3', + fieldType: 'Radio', + fieldTypeLabel: 'Radio', + fieldVariables: [], + fixed: false, + forceIncludeInApi: false, + iDate: 1697656895000, + id: 'b26138321e5a449cdf7b73f927643016', + indexed: false, + listed: false, + modDate: 1697662296000, + name: 'radioDecimal', + readOnly: false, + hint: 'A hint Text', + required: false, + searchable: false, + sortOrder: 10, + unique: false, + values: 'Five point two|5.2\r\nNine point three|9.3', + variable: 'radioDecimal' +}; + +export const RADIO_FIELD_INTEGER_MOCK = { + clazz: 'com.dotcms.contenttype.model.field.ImmutableRadioField', + contentTypeId: '40e0cb1b57b3b1b7ec34191e942316d5', + dataType: 'INTEGER', + defaultValue: '30', + fieldType: 'Radio', + fieldTypeLabel: 'Radio', + fieldVariables: [], + fixed: false, + forceIncludeInApi: false, + iDate: 1697656956000, + id: 'bdd0f00375e23b7a64608d78c8fcb2dc', + indexed: false, + listed: false, + modDate: 1697662296000, + name: 'radioWholeNumber', + readOnly: false, + hint: 'A hint Text', + required: true, + searchable: false, + sortOrder: 11, + unique: false, + values: 'Twelve|12\r\nTwenty|20\r\nThirty|30', + variable: 'radioWholeNumber' +}; + +export const FIELDS_MOCK: DotCMSContentTypeField[] = [ + TEXT_FIELD_MOCK, + TEXT_AREA_FIELD_MOCK, + SELECT_FIELD_TEXT_MOCK, + SELECT_FIELD_BOOLEAN_MOCK, + SELECT_FIELD_FLOAT_MOCK, + SELECT_FIELD_INTEGER_MOCK, + RADIO_FIELD_TEXT_MOCK, + RADIO_FIELD_BOOLEAN_MOCK, + RADIO_FIELD_FLOAT_MOCK, + RADIO_FIELD_INTEGER_MOCK +]; export const FIELD_MOCK: DotCMSContentTypeField = TEXT_FIELD_MOCK; // This creates a mock FormGroup from an array of fielda export const createFormControlObjectMock = (fields = FIELDS_MOCK) => { return fields.reduce((acc, field) => { - acc[field.variable] = new FormControl(''); + acc[field.variable] = new FormControl(null); return acc; }, {}); @@ -77,12 +291,13 @@ export const FORM_GROUP_MOCK = new FormGroup(createFormControlObjectMock()); // Create a mock FormGroupDirective export const createFormGroupDirectiveMock = ( + formGroup: FormGroup = FORM_GROUP_MOCK, validator: (Validator | ValidatorFn)[] = [], asyncValidators: AsyncValidator[] = [] ) => { const formGroupDirectiveMock = new FormGroupDirective(validator, asyncValidators); - formGroupDirectiveMock.form = FORM_GROUP_MOCK; + formGroupDirectiveMock.form = formGroup; return formGroupDirectiveMock; }; @@ -176,7 +391,9 @@ export const LAYOUT_MOCK: DotCMSContentTypeLayoutRow[] = [ searchable: false, sortOrder: 3, unique: false, - variable: 'text2' + variable: 'text2', + regexCheck: + '^([a-zA-Z0-9]+[a-zA-Z0-9._%+-]*@(?:[a-zA-Z0-9-]+.)+[a-zA-Z]{2,4})$' } ] }, @@ -231,6 +448,20 @@ export const LAYOUT_MOCK: DotCMSContentTypeLayoutRow[] = [ } ]; +export const JUST_FIELDS_MOCKS = getAllFields(LAYOUT_MOCK); + +function getAllFields(data: DotCMSContentTypeLayoutRow[]) { + let fields = []; + + data.forEach((row) => { + row.columns.forEach((column) => { + fields = [...fields, ...column.fields]; + }); + }); + + return fields; +} + export const CONTENT_TYPE_MOCK: DotCMSContentType = { baseType: 'CONTENT', clazz: 'com.dotcms.contenttype.model.type.ImmutableSimpleContentType',