Skip to content

Commit

Permalink
feat(edit-content): Edit Content: Render Radio and Select Field to th…
Browse files Browse the repository at this point in the history
…e Form #26448

* Added Select and Radio fields. Added basic tests on both components. Changed formData structure and architecture

* Added validation to set, in case dont have value or defaultValue, the first option as value in select and radio field

* Added enum to DataType

* Added tests. Changed mocks.

* Working on PR Suggestions. Commit before merge

* Added merge with master. Added Typing to forms on Select and Radio button. Added more test cases

* Pair programming select tests

* Changed tests. Changed language of mocks. Moved folders to shared/

* Replaced shared folder with models content

* Changed and added test to RadioButton field

* Changed name on util => function

* Working on DotEditContentForm tests

* Solved test on DotEditContentForm

* Pair programming tests for dot-edit-content

* Added more tests. Reduced html on radio field. Casting values on init form

---------

Co-authored-by: Freddy Montes <751424+fmontes@users.noreply.github.com>
  • Loading branch information
KevinDavilaDotCMS and fmontes authored Oct 24, 2023
1 parent 8d50af3 commit b103c86
Show file tree
Hide file tree
Showing 22 changed files with 962 additions and 76 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,23 @@
>{{ field.name }}</label
>
<ng-container [ngSwitch]="field.fieldType">
<dot-edit-content-select-field
*ngSwitchCase="fieldTypes.SELECT"
[field]="field"
[attr.data-testId]="'field-' + field.variable" />

<dot-edit-content-radio-field
*ngSwitchCase="fieldTypes.RADIO"
[field]="field"
[attr.data-testId]="'field-' + field.variable" />

<dot-edit-content-text-field
*ngSwitchCase="fieldTypes.TEXT"
[field]="field"
[attr.data-testId]="'field-' + field.variable"></dot-edit-content-text-field>
[attr.data-testId]="'field-' + field.variable" />
<dot-edit-content-text-area
*ngSwitchCase="fieldTypes.TEXTAREA"
[field]="field"
[attr.data-testId]="'field-' + field.variable"></dot-edit-content-text-area>
[attr.data-testId]="'field-' + field.variable" />
</ng-container>
<small *ngIf="field.hint" [attr.data-testId]="'hint-' + field.variable">{{ field.hint }}</small>
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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'],
Expand Down
Original file line number Diff line number Diff line change
@@ -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<FIELD_TYPES, Type<unknown>> = {
// 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
};
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
<form class="p-fluid edit-content-form" *ngIf="form" [formGroup]="form">
<ng-container *ngFor="let row of formData">
<div class="row">
<div class="column" *ngFor="let column of row.columns">
<form class="p-fluid" *ngIf="form" [formGroup]="form">
<ng-container *ngFor="let row of formData.layout">
<div class="row" data-testId="row">
<div class="column" *ngFor="let column of row.columns" data-testId="column">
<dot-edit-content-field
*ngFor="let field of column.fields"
[field]="field"></dot-edit-content-field>
[field]="field"
data-testId="field" />
</div>
</div>
</ng-container>
</form>

<aside>
<p-button [label]="'Save' | dm" (click)="saveContenlet()" data-testId="button-save"></p-button>
<pre><code>{{form.value | json}}</code></pre>
<pre><code>{{form?.value | json}}</code></pre>
</aside>
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,9 @@
flex-direction: column;
gap: $spacing-3;
}

code {
// Added temporaly. Only to show the form value.
display: block;
overflow: scroll;
}
Original file line number Diff line number Diff line change
@@ -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<DotEditContentFormComponent>;
const createComponent = createComponentFactory({
component: DotEditContentFormComponent,
imports: [
DotEditContentFieldComponent,
CommonModule,
ReactiveFormsModule,
ButtonModule,
DotMessagePipe
],
providers: [
{
provide: DotMessageService,
Expand All @@ -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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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) {
Expand All @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<ng-container *ngIf="formData$ | async as form; else noContent">
<dot-edit-content-form [formData]="form" (formSubmit)="saveContent($event)" />
<ng-container *ngIf="formData$ | async as formData; else noContent">
<dot-edit-content-form [formData]="formData" (formSubmit)="saveContent($event)" />
<p *ngIf="isContentSaved">Content saved!</p>
</ng-container>

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -25,27 +27,36 @@ export class EditContentLayoutComponent {

private readonly dotEditContentService = inject(DotEditContentService);
isContentSaved = false;
formData$ = this.identifier

formData$: Observable<EditContentFormData> = 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.
* @param value - An object containing the key-value pairs of the contentlet to be saved.
*/
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;
Expand Down
Loading

0 comments on commit b103c86

Please sign in to comment.