From a67f2327a1b623bcc14f79f69f1def041471e247 Mon Sep 17 00:00:00 2001 From: KevinDavilaDotCMS <144152756+KevinDavilaDotCMS@users.noreply.github.com> Date: Mon, 16 Oct 2023 10:25:19 -0500 Subject: [PATCH] feat(edit-content) Develop basic form with text field and save button #26330 * Working on EditContenlet MVP * Maked changes based on PR suggestions * Finalized new arch of edit-content lib. * Working on PR Suggestions * Working on tests. Have problem on edit-content.layout.spec * Find issue in tests * Finished tests * Make a PR suggestion changes. Added new tests * Maked PR suggestions. Added new tests. Added new behavior on content-edit.layout * Changed way to test dot-edit-content.service, now use createHttpFactory * Remove unused imports and variables * Add sidebar and styles rows spacing --------- Co-authored-by: Freddy Montes <751424+fmontes@users.noreply.github.com> Co-authored-by: Jalinson Diaz --- core-web/libs/edit-content/jest.config.ts | 4 +- .../dot-edit-content-field.component.html | 20 ++ .../dot-edit-content-field.component.scss} | 0 .../dot-edit-content-field.component.spec.ts | 93 ++++++ .../dot-edit-content-field.component.ts | 26 ++ .../dot-edit-content-form.component.html | 16 + .../dot-edit-content-form.component.scss | 20 ++ .../dot-edit-content-form.component.spec.ts | 224 ++++++++++++++ .../dot-edit-content-form.component.ts | 89 ++++++ .../src/lib/edit-content.routes.ts | 10 +- .../edit-content.layout.component.html | 6 + .../edit-content.layout.component.scss | 0 .../edit-content.layout.component.spec.ts | 283 ++++++++++++++++++ .../edit-content.layout.component.ts | 56 ++++ .../src/lib/feature/form/form.component.html | 1 - .../lib/feature/form/form.component.spec.ts | 23 -- .../src/lib/feature/form/form.component.ts | 18 -- .../services/dot-edit-content.service.spec.ts | 65 ++++ .../lib/services/dot-edit-content.service.ts | 44 +++ core-web/libs/edit-content/tsconfig.json | 2 - 20 files changed, 951 insertions(+), 49 deletions(-) create mode 100644 core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.html rename core-web/libs/edit-content/src/lib/{feature/form/form.component.scss => components/dot-edit-content-field/dot-edit-content-field.component.scss} (100%) create mode 100644 core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.spec.ts create mode 100644 core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.ts create mode 100644 core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.html create mode 100644 core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.scss create mode 100644 core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts create mode 100644 core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.ts create mode 100644 core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.html create mode 100644 core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.scss create mode 100644 core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.spec.ts create mode 100644 core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.ts delete mode 100644 core-web/libs/edit-content/src/lib/feature/form/form.component.html delete mode 100644 core-web/libs/edit-content/src/lib/feature/form/form.component.spec.ts delete mode 100644 core-web/libs/edit-content/src/lib/feature/form/form.component.ts create mode 100644 core-web/libs/edit-content/src/lib/services/dot-edit-content.service.spec.ts create mode 100644 core-web/libs/edit-content/src/lib/services/dot-edit-content.service.ts diff --git a/core-web/libs/edit-content/jest.config.ts b/core-web/libs/edit-content/jest.config.ts index 657ede88d120..21c38bc6b94a 100644 --- a/core-web/libs/edit-content/jest.config.ts +++ b/core-web/libs/edit-content/jest.config.ts @@ -5,8 +5,8 @@ export default { setupFilesAfterEnv: ['/src/test-setup.ts'], globals: { 'ts-jest': { - tsconfig: '/tsconfig.spec.json', - stringifyContentPathRegex: '\\.(html|svg)$' + stringifyContentPathRegex: '\\.(html|svg)$', + tsconfig: '/tsconfig.spec.json' } }, coverageDirectory: '../../coverage/libs/edit-content', 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 new file mode 100644 index 000000000000..b90a995d9bb6 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.html @@ -0,0 +1,20 @@ + +
+ + + {{ + field.hint + }} +
+
diff --git a/core-web/libs/edit-content/src/lib/feature/form/form.component.scss b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.scss similarity index 100% rename from core-web/libs/edit-content/src/lib/feature/form/form.component.scss rename to core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.scss diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.spec.ts new file mode 100644 index 000000000000..d00e9d2f1761 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.spec.ts @@ -0,0 +1,93 @@ +import { Spectator, byTestId, createComponentFactory } from '@ngneat/spectator'; + +import { CommonModule } from '@angular/common'; +import { + ControlContainer, + FormControl, + FormGroup, + FormGroupDirective, + ReactiveFormsModule +} from '@angular/forms'; + +import { InputTextModule } from 'primeng/inputtext'; + +import { DotCMSContentTypeField } from '@dotcms/dotcms-models'; +import { DotFieldRequiredDirective } from '@dotcms/ui'; + +import { DotEditContentFieldComponent } from './dot-edit-content-field.component'; + +export const FIELD_MOCK: DotCMSContentTypeField = { + clazz: 'com.dotcms.contenttype.model.field.ImmutableTextField', + contentTypeId: 'd46d6404125ac27e6ab68fad09266241', + dataType: 'TEXT', + fieldType: 'Text', + fieldTypeLabel: 'Text', + fieldVariables: [], + fixed: false, + iDate: 1696896882000, + id: 'c3b928bc2b59fc22c67022de4dd4b5c4', + indexed: false, + listed: false, + hint: 'A helper text', + modDate: 1696896882000, + name: 'testVariable', + readOnly: false, + required: false, + searchable: false, + sortOrder: 2, + unique: false, + variable: 'testVariable' +}; + +const FORM_GROUP_MOCK = new FormGroup({ + testVariable: new FormControl('') +}); +const FORM_GROUP_DIRECTIVE_MOCK: FormGroupDirective = new FormGroupDirective([], []); +FORM_GROUP_DIRECTIVE_MOCK.form = FORM_GROUP_MOCK; + +describe('DotFieldComponent', () => { + let spectator: Spectator; + const createComponent = createComponentFactory({ + component: DotEditContentFieldComponent, + imports: [ + DotEditContentFieldComponent, + CommonModule, + ReactiveFormsModule, + InputTextModule, + DotFieldRequiredDirective + ], + componentViewProviders: [ + { + provide: ControlContainer, + useValue: FORM_GROUP_DIRECTIVE_MOCK + } + ], + providers: [FormGroupDirective] + }); + + beforeEach(async () => { + spectator = createComponent({ + props: { + field: FIELD_MOCK + } + }); + }); + + it('should render the label', () => { + spectator.detectChanges(); + const label = spectator.query(byTestId(`label-${FIELD_MOCK.variable}`)); + expect(label?.textContent).toContain(FIELD_MOCK.name); + }); + + it('should render the hint', () => { + spectator.detectChanges(); + const hint = spectator.query(byTestId(`hint-${FIELD_MOCK.variable}`)); + expect(hint?.textContent).toContain(FIELD_MOCK.hint); + }); + + it('should render the input', () => { + spectator.detectChanges(); + const input = spectator.query(byTestId(`input-${FIELD_MOCK.variable}`)); + expect(input).toBeDefined(); + }); +}); 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 new file mode 100644 index 000000000000..06b930f69c15 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.ts @@ -0,0 +1,26 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, Input, inject } from '@angular/core'; +import { ControlContainer, ReactiveFormsModule } from '@angular/forms'; + +import { InputTextModule } from 'primeng/inputtext'; + +import { DotCMSContentTypeField } from '@dotcms/dotcms-models'; +import { DotFieldRequiredDirective } from '@dotcms/ui'; + +@Component({ + selector: 'dot-edit-content-field', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, InputTextModule, DotFieldRequiredDirective], + templateUrl: './dot-edit-content-field.component.html', + styleUrls: ['./dot-edit-content-field.component.scss'], + viewProviders: [ + { + provide: ControlContainer, + useFactory: () => inject(ControlContainer, { skipSelf: true }) + } + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotEditContentFieldComponent { + @Input() field!: DotCMSContentTypeField; +} 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 new file mode 100644 index 000000000000..02ed3c823e6b --- /dev/null +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.html @@ -0,0 +1,16 @@ +
+ +
+
+ +
+
+
+
+ + 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 new file mode 100644 index 000000000000..f83f561bea28 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.scss @@ -0,0 +1,20 @@ +@use "variables" as *; + +:host { + display: grid; + grid-template-columns: 1fr 16rem; + gap: $spacing-4; + padding: $spacing-4; +} + +.row { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + gap: $spacing-2; + + .column { + display: grid; + min-height: $spacing-7; + } +} 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 new file mode 100644 index 000000000000..918bff75098c --- /dev/null +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts @@ -0,0 +1,224 @@ +import { Spectator, byTestId, createComponentFactory } from '@ngneat/spectator'; + +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule } from '@angular/forms'; + +import { ButtonModule } from 'primeng/button'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DotCMSContentTypeLayoutRow } from '@dotcms/dotcms-models'; +import { DotMessagePipe } from '@dotcms/ui'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotEditContentFormComponent } from './dot-edit-content-form.component'; + +import { DotEditContentFieldComponent } from '../dot-edit-content-field/dot-edit-content-field.component'; +import { FIELD_MOCK } from '../dot-edit-content-field/dot-edit-content-field.component.spec'; + +export const LAYOUT_MOCK: DotCMSContentTypeLayoutRow[] = [ + { + divider: { + clazz: 'com.dotcms.contenttype.model.field.ImmutableRowField', + contentTypeId: 'd46d6404125ac27e6ab68fad09266241', + dataType: 'SYSTEM', + fieldType: 'Row', + fieldTypeLabel: 'Row', + fieldVariables: [], + fixed: false, + iDate: 1697051073000, + id: 'a31ea895f80eb0a3754e4a2292e09a52', + indexed: false, + listed: false, + modDate: 1697051077000, + name: 'fields-0', + readOnly: false, + required: false, + searchable: false, + sortOrder: 0, + unique: false, + variable: 'fields0' + }, + columns: [ + { + columnDivider: { + clazz: 'com.dotcms.contenttype.model.field.ImmutableColumnField', + contentTypeId: 'd46d6404125ac27e6ab68fad09266241', + dataType: 'SYSTEM', + fieldType: 'Column', + fieldTypeLabel: 'Column', + fieldVariables: [], + fixed: false, + iDate: 1697051073000, + id: 'd4c32b4b9fb5b11c58c245d4a02bef47', + indexed: false, + listed: false, + modDate: 1697051077000, + name: 'fields-1', + readOnly: false, + required: false, + searchable: false, + sortOrder: 1, + unique: false, + variable: 'fields1' + }, + fields: [ + { + clazz: 'com.dotcms.contenttype.model.field.ImmutableTextField', + contentTypeId: 'd46d6404125ac27e6ab68fad09266241', + dataType: 'TEXT', + defaultValue: 'Placeholder', + fieldType: 'Text', + fieldTypeLabel: 'Text', + fieldVariables: [], + fixed: false, + hint: 'A hint Text', + iDate: 1697051093000, + id: '1d1505a4569681b923769acb785fd093', + indexed: false, + listed: false, + modDate: 1697051093000, + name: 'name1', + readOnly: false, + required: true, + searchable: false, + sortOrder: 2, + unique: false, + variable: 'name1' + }, + { + clazz: 'com.dotcms.contenttype.model.field.ImmutableTextField', + contentTypeId: 'd46d6404125ac27e6ab68fad09266241', + dataType: 'TEXT', + fieldType: 'Text', + fieldTypeLabel: 'Text', + fieldVariables: [], + fixed: false, + iDate: 1697051107000, + id: 'fc776c45044f2d043f5e98eaae36c9ff', + indexed: false, + listed: false, + modDate: 1697051107000, + name: 'text2', + readOnly: false, + required: true, + searchable: false, + sortOrder: 3, + unique: false, + variable: 'text2' + } + ] + }, + { + columnDivider: { + clazz: 'com.dotcms.contenttype.model.field.ImmutableColumnField', + contentTypeId: 'd46d6404125ac27e6ab68fad09266241', + dataType: 'SYSTEM', + fieldType: 'Column', + fieldTypeLabel: 'Column', + fieldVariables: [], + fixed: false, + iDate: 1697051077000, + id: '848fc78a11e7290efad66eb39333ae2b', + indexed: false, + listed: false, + modDate: 1697051107000, + name: 'fields-2', + readOnly: false, + required: false, + searchable: false, + sortOrder: 4, + unique: false, + variable: 'fields2' + }, + fields: [ + { + clazz: 'com.dotcms.contenttype.model.field.ImmutableTextField', + contentTypeId: 'd46d6404125ac27e6ab68fad09266241', + dataType: 'TEXT', + fieldType: 'Text', + fieldTypeLabel: 'Text', + fieldVariables: [], + fixed: false, + hint: 'A hint text2', + iDate: 1697051118000, + id: '1f6765de8d4ad069ff308bfca56b9255', + indexed: false, + listed: false, + modDate: 1697051118000, + name: 'text3', + readOnly: false, + required: false, + searchable: false, + sortOrder: 5, + unique: false, + variable: 'text3' + } + ] + } + ] + } +]; + +describe('DotFormComponent', () => { + let spectator: Spectator; + const createComponent = createComponentFactory({ + component: DotEditContentFormComponent, + imports: [ + DotEditContentFieldComponent, + CommonModule, + ReactiveFormsModule, + ButtonModule, + DotMessagePipe + ], + providers: [ + { + provide: DotMessageService, + useValue: new MockDotMessageService({ + Save: 'Save' + }) + } + ] + }); + + beforeEach(() => { + spectator = createComponent({ + props: { + formData: LAYOUT_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(); + + expect(component.form.controls['name1']).toBeDefined(); + expect(component.form.controls['text2']).toBeDefined(); + }); + }); + + describe('initializeFormControl', () => { + it('should initialize a form control for a given DotCMSContentTypeField', () => { + const formControl = spectator.component.initializeFormControl(FIELD_MOCK); + + expect(formControl).toBeDefined(); + expect(formControl.validator).toBeDefined(); + }); + }); + + describe('saveContent', () => { + it('should emit the form value through the `formSubmit` event', () => { + const component = spectator.component; + component.formData = LAYOUT_MOCK; + component.initilizeForm(); + + jest.spyOn(component.formSubmit, 'emit'); + const button = spectator.query(byTestId('button-save')); + spectator.click(button); + + expect(component.formSubmit.emit).toHaveBeenCalledWith(component.form.value); + }); + }); +}); 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 new file mode 100644 index 000000000000..95a6424a67a8 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.ts @@ -0,0 +1,89 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnInit, + Output, + inject +} from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { ButtonModule } from 'primeng/button'; + +import { DotCMSContentTypeField, DotCMSContentTypeLayoutRow } from '@dotcms/dotcms-models'; +import { DotMessagePipe } from '@dotcms/ui'; + +import { DotEditContentFieldComponent } from '../dot-edit-content-field/dot-edit-content-field.component'; +@Component({ + selector: 'dot-edit-content-form', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + DotEditContentFieldComponent, + ButtonModule, + DotMessagePipe + ], + templateUrl: './dot-edit-content-form.component.html', + styleUrls: ['./dot-edit-content-form.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotEditContentFormComponent implements OnInit { + @Input() formData: DotCMSContentTypeLayoutRow[] = []; + @Output() formSubmit = new EventEmitter(); + + private fb = inject(FormBuilder); + form!: FormGroup; + + ngOnInit() { + if (this.formData) { + this.initilizeForm(); + } + } + + /** + * Initializes the form group with form controls for each field in the `formData` array. + * @returns void + */ + initilizeForm() { + this.form = this.fb.group({}); + this.formData.forEach(({ columns }) => { + columns?.forEach((column) => { + column.fields.forEach((field) => { + const fieldControl = this.initializeFormControl(field); + this.form.addControl(field.variable, fieldControl); + }); + }); + }); + } + + /** + * Initializes a form control for a given DotCMSContentTypeField. + * @param field - The DotCMSContentTypeField to initialize the form control for. + * @returns The initialized form control. + */ + initializeFormControl(field: DotCMSContentTypeField) { + const validators = []; + if (field.required) validators.push(Validators.required); + if (field.regexCheck) { + try { + const regex = new RegExp(field.regexCheck); + validators.push(Validators.pattern(regex)); + } catch (e) { + console.error('Invalid regex', e); + } + } + + return this.fb.control(null, { validators }); + } + + /** + * Saves the content of the form by emitting the form value through the `formSubmit` event. + * @returns void + */ + saveContenlet() { + this.formSubmit.emit(this.form.value); + } +} diff --git a/core-web/libs/edit-content/src/lib/edit-content.routes.ts b/core-web/libs/edit-content/src/lib/edit-content.routes.ts index d473280c66c3..6c19fc5b37a4 100644 --- a/core-web/libs/edit-content/src/lib/edit-content.routes.ts +++ b/core-web/libs/edit-content/src/lib/edit-content.routes.ts @@ -1,15 +1,19 @@ import { Route } from '@angular/router'; import { EditContentShellComponent } from './edit-content.shell.component'; -import { FormComponent } from './feature/form/form.component'; +import { EditContentLayoutComponent } from './feature/edit-content/edit-content.layout.component'; export const DotEditContentRoutes: Route[] = [ { path: '', component: EditContentShellComponent, children: [ - { path: 'new/:contentType', title: 'Create Content', component: FormComponent }, - { path: ':id', title: 'Edit Content', component: FormComponent } + { + path: 'new/:contentType', + title: 'Create Content', + component: EditContentLayoutComponent + }, + { path: ':id', title: 'Edit Content', component: EditContentLayoutComponent } ] } ]; 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 new file mode 100644 index 000000000000..62da2afede3f --- /dev/null +++ b/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.html @@ -0,0 +1,6 @@ + + +

Content saved!

+
+ +No content to show diff --git a/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.scss b/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.scss new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.spec.ts b/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.spec.ts new file mode 100644 index 000000000000..09cc48d45467 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.spec.ts @@ -0,0 +1,283 @@ +import { Spectator, createComponentFactory, SpyObject } from '@ngneat/spectator/jest'; +import { of } from 'rxjs'; + +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ActivatedRoute } from '@angular/router'; + +import { DotCMSContentType } from '@dotcms/dotcms-models'; + +import { EditContentLayoutComponent } from './edit-content.layout.component'; + +import { LAYOUT_MOCK } from '../../components/dot-edit-content-form/dot-edit-content-form.component.spec'; +import { DotEditContentService } from '../../services/dot-edit-content.service'; + +export const CONTENT_TYPE_MOCK: DotCMSContentType = { + baseType: 'CONTENT', + clazz: 'com.dotcms.contenttype.model.type.ImmutableSimpleContentType', + defaultType: false, + fields: [ + { + clazz: 'com.dotcms.contenttype.model.field.ImmutableRowField', + contentTypeId: 'd46d6404125ac27e6ab68fad09266241', + dataType: 'SYSTEM', + fieldType: 'Row', + fieldTypeLabel: 'Row', + fieldVariables: [], + fixed: false, + iDate: 1697051073000, + id: 'a31ea895f80eb0a3754e4a2292e09a52', + indexed: false, + listed: false, + modDate: 1697051077000, + name: 'fields-0', + readOnly: false, + required: false, + searchable: false, + sortOrder: 0, + unique: false, + variable: 'fields0' + }, + { + clazz: 'com.dotcms.contenttype.model.field.ImmutableColumnField', + contentTypeId: 'd46d6404125ac27e6ab68fad09266241', + dataType: 'SYSTEM', + fieldType: 'Column', + fieldTypeLabel: 'Column', + fieldVariables: [], + fixed: false, + iDate: 1697051073000, + id: 'd4c32b4b9fb5b11c58c245d4a02bef47', + indexed: false, + listed: false, + modDate: 1697051077000, + name: 'fields-1', + readOnly: false, + required: false, + searchable: false, + sortOrder: 1, + unique: false, + variable: 'fields1' + }, + { + clazz: 'com.dotcms.contenttype.model.field.ImmutableTextField', + contentTypeId: 'd46d6404125ac27e6ab68fad09266241', + dataType: 'TEXT', + defaultValue: 'Placeholder', + fieldType: 'Text', + fieldTypeLabel: 'Text', + fieldVariables: [], + fixed: false, + hint: 'A hint Text', + iDate: 1697051093000, + id: '1d1505a4569681b923769acb785fd093', + indexed: false, + listed: false, + modDate: 1697051093000, + name: 'name1', + readOnly: false, + required: true, + searchable: false, + sortOrder: 2, + unique: false, + variable: 'name1' + }, + { + clazz: 'com.dotcms.contenttype.model.field.ImmutableTextField', + contentTypeId: 'd46d6404125ac27e6ab68fad09266241', + dataType: 'TEXT', + fieldType: 'Text', + fieldTypeLabel: 'Text', + fieldVariables: [], + fixed: false, + iDate: 1697051107000, + id: 'fc776c45044f2d043f5e98eaae36c9ff', + indexed: false, + listed: false, + modDate: 1697051107000, + name: 'text2', + readOnly: false, + required: true, + searchable: false, + sortOrder: 3, + unique: false, + variable: 'text2' + }, + { + clazz: 'com.dotcms.contenttype.model.field.ImmutableColumnField', + contentTypeId: 'd46d6404125ac27e6ab68fad09266241', + dataType: 'SYSTEM', + fieldType: 'Column', + fieldTypeLabel: 'Column', + fieldVariables: [], + fixed: false, + iDate: 1697051077000, + id: '848fc78a11e7290efad66eb39333ae2b', + indexed: false, + listed: false, + modDate: 1697051107000, + name: 'fields-2', + readOnly: false, + required: false, + searchable: false, + sortOrder: 4, + unique: false, + variable: 'fields2' + }, + { + clazz: 'com.dotcms.contenttype.model.field.ImmutableTextField', + contentTypeId: 'd46d6404125ac27e6ab68fad09266241', + dataType: 'TEXT', + fieldType: 'Text', + fieldTypeLabel: 'Text', + fieldVariables: [], + fixed: false, + hint: 'A hint text2', + iDate: 1697051118000, + id: '1f6765de8d4ad069ff308bfca56b9255', + indexed: false, + listed: false, + modDate: 1697051118000, + name: 'text3', + readOnly: false, + required: false, + searchable: false, + sortOrder: 5, + unique: false, + variable: 'text3' + } + ], + fixed: false, + folder: 'SYSTEM_FOLDER', + host: '48190c8c-42c4-46af-8d1a-0cd5db894797', + iDate: 1697051073000, + icon: 'event_note', + id: 'd46d6404125ac27e6ab68fad09266241', + layout: LAYOUT_MOCK, + modDate: 1697051118000, + multilingualable: false, + name: 'Test', + contentType: 'Test', + system: false, + systemActionMappings: {}, + variable: 'Test', + versionable: true, + workflows: [ + { + archived: false, + creationDate: new Date(1697047303976), + defaultScheme: false, + description: '', + entryActionId: null, + id: 'd61a59e1-a49c-46f2-a929-db2b4bfa88b2', + mandatory: false, + modDate: new Date(1697047292887), + name: 'System Workflow', + system: true + } + ], + nEntries: 0 +}; + +const createEditContentLayoutComponent = (params: { contentType?: string; id?: string }) => { + return createComponentFactory({ + component: EditContentLayoutComponent, + imports: [HttpClientTestingModule], + componentProviders: [ + { + provide: ActivatedRoute, + useValue: { snapshot: { params } } + } + ] + }); +}; + +describe('EditContentLayoutComponent with identifier', () => { + let spectator: Spectator; + let dotEditContentService: SpyObject; + + const createComponent = createEditContentLayoutComponent({ contentType: undefined, id: '1' }); + + beforeEach(async () => { + spectator = createComponent({ + detectChanges: false, + providers: [ + { + provide: DotEditContentService, + useValue: { + getContentTypeFormData: jest.fn().mockReturnValue(of(LAYOUT_MOCK)), + getContentById: jest.fn().mockReturnValue(of(CONTENT_TYPE_MOCK)), + saveContentlet: jest.fn().mockReturnValue(of({})) + } + } + ] + }); + + dotEditContentService = spectator.inject(DotEditContentService, true); + }); + + it('should set identifier from activatedRoute and contentType undefined', () => { + expect(spectator.component.contentType).toEqual(undefined); + expect(spectator.component.identifier).toEqual('1'); + }); + + it('should call getContentById and getContentTypeFormData with contentType if identifier is present', () => { + spectator.detectChanges(); + + expect(dotEditContentService.getContentById).toHaveBeenCalledWith('1'); + }); + + it('should call dotEditContentService.saveContentlet with the correct parameters - Using contentType from getContentById', () => { + spectator.detectChanges(); + spectator.component.saveContent({ key: 'value' }); + expect(dotEditContentService.saveContentlet).toHaveBeenCalledWith({ + key: 'value', + inode: '1', + contentType: 'Test' + }); + }); + + it('should have a [formData] reference on the ', async () => { + spectator.detectChanges(); + const formElement = spectator.query('dot-edit-content-form'); + expect(formElement.hasAttribute('ng-reflect-form-data')).toBe(true); + expect(formElement).toBeDefined(); + }); +}); + +describe('EditContentLayoutComponent without identifier', () => { + let spectator: Spectator; + let dotEditContentService: SpyObject; + + const createComponent = createEditContentLayoutComponent({ + contentType: 'test', + id: undefined + }); + + beforeEach(() => { + spectator = createComponent({ + detectChanges: false, + providers: [ + { + provide: DotEditContentService, + useValue: { + getContentTypeFormData: jest.fn().mockReturnValue(of(LAYOUT_MOCK)), + getContentById: jest.fn().mockReturnValue(of(CONTENT_TYPE_MOCK)) + } + } + ] + }); + + dotEditContentService = spectator.inject(DotEditContentService, true); + }); + + it('should set contentType from activatedRoute - Identifier undefined.', () => { + expect(spectator.component.contentType).toEqual('test'); + expect(spectator.component.identifier).toEqual(undefined); + }); + + it('should call getContentById and getContentTypeFormData with contentType if identifier is NOT present', () => { + spectator.detectChanges(); + expect(dotEditContentService.getContentById).not.toHaveBeenCalled(); + expect(dotEditContentService.getContentTypeFormData).toHaveBeenCalledWith('test'); + }); +}); 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 new file mode 100644 index 000000000000..b26c02e33b7c --- /dev/null +++ b/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.ts @@ -0,0 +1,56 @@ +import { EMPTY } 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 { DotEditContentFormComponent } from '../../components/dot-edit-content-form/dot-edit-content-form.component'; +import { DotEditContentService } from '../../services/dot-edit-content.service'; +@Component({ + selector: 'dot-edit-content-form-layout', + standalone: true, + imports: [CommonModule, DotEditContentFormComponent], + templateUrl: './edit-content.layout.component.html', + styleUrls: ['./edit-content.layout.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [DotEditContentService] +}) +export class EditContentLayoutComponent { + private activatedRoute = inject(ActivatedRoute); + + public contentType = this.activatedRoute.snapshot.params['contentType']; + public identifier = this.activatedRoute.snapshot.params['id']; + + private readonly dotEditContentService = inject(DotEditContentService); + isContentSaved = false; + formData$ = this.identifier + ? this.dotEditContentService.getContentById(this.identifier).pipe( + switchMap(({ contentType }) => { + if (contentType) { + this.contentType = contentType; + + return this.dotEditContentService.getContentTypeFormData(contentType); + } else { + return EMPTY; + } + }) + ) + : this.dotEditContentService.getContentTypeFormData(this.contentType); + + /** + * 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 }) + .subscribe({ + next: () => { + this.isContentSaved = true; + setTimeout(() => (this.isContentSaved = false), 3000); + } + }); + } +} diff --git a/core-web/libs/edit-content/src/lib/feature/form/form.component.html b/core-web/libs/edit-content/src/lib/feature/form/form.component.html deleted file mode 100644 index 3721d8ef64f3..000000000000 --- a/core-web/libs/edit-content/src/lib/feature/form/form.component.html +++ /dev/null @@ -1 +0,0 @@ -

form works for {{ contentType ?? identifier }}

diff --git a/core-web/libs/edit-content/src/lib/feature/form/form.component.spec.ts b/core-web/libs/edit-content/src/lib/feature/form/form.component.spec.ts deleted file mode 100644 index bcb0b5b1a787..000000000000 --- a/core-web/libs/edit-content/src/lib/feature/form/form.component.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { RouterTestingModule } from '@angular/router/testing'; - -import { FormComponent } from './form.component'; - -describe('FormComponent', () => { - let component: FormComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [FormComponent, RouterTestingModule] - }).compileComponents(); - - fixture = TestBed.createComponent(FormComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/core-web/libs/edit-content/src/lib/feature/form/form.component.ts b/core-web/libs/edit-content/src/lib/feature/form/form.component.ts deleted file mode 100644 index c0a5f2092328..000000000000 --- a/core-web/libs/edit-content/src/lib/feature/form/form.component.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; - -@Component({ - selector: 'dot-edit-content-form', - standalone: true, - imports: [CommonModule], - templateUrl: './form.component.html', - styleUrls: ['./form.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class FormComponent { - private activatedRoute = inject(ActivatedRoute); - - public contentType = this.activatedRoute.snapshot.params['contentType']; - public identifier = this.activatedRoute.snapshot.params['id']; -} diff --git a/core-web/libs/edit-content/src/lib/services/dot-edit-content.service.spec.ts b/core-web/libs/edit-content/src/lib/services/dot-edit-content.service.spec.ts new file mode 100644 index 000000000000..f92b3d85f140 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/services/dot-edit-content.service.spec.ts @@ -0,0 +1,65 @@ +import { + createHttpFactory, + HttpMethod, + mockProvider, + SpectatorHttp, + SpyObject +} from '@ngneat/spectator/jest'; +import { of } from 'rxjs'; + +import { DotContentTypeService, DotWorkflowActionsFireService } from '@dotcms/data-access'; + +import { DotEditContentService } from './dot-edit-content.service'; + +import { CONTENT_TYPE_MOCK } from '../feature/edit-content/edit-content.layout.component.spec'; + +const API_ENDPOINT = '/api/v1/content'; + +describe('DotEditContentService', () => { + let spectator: SpectatorHttp; + let dotContentTypeService: SpyObject; + let dotWorkflowActionsFireService: SpyObject; + + const createHttp = createHttpFactory({ + service: DotEditContentService, + providers: [ + mockProvider(DotContentTypeService), + mockProvider(DotWorkflowActionsFireService) + ] + }); + beforeEach(() => { + spectator = createHttp(); + dotContentTypeService = spectator.inject(DotContentTypeService); + dotWorkflowActionsFireService = spectator.inject(DotWorkflowActionsFireService); + }); + + describe('Endpoints', () => { + it('should get content by id', () => { + const ID = '1'; + spectator.service.getContentById(ID).subscribe(); + spectator.expectOne(`${API_ENDPOINT}/${ID}`, HttpMethod.GET); + }); + }); + + describe('Facades', () => { + it('should get content type form data', (done) => { + const CONTENTID_OR_VAR = '456'; + dotContentTypeService.getContentType.mockReturnValue(of(CONTENT_TYPE_MOCK)); + + spectator.service.getContentTypeFormData(CONTENTID_OR_VAR).subscribe(() => { + expect(dotContentTypeService.getContentType).toHaveBeenCalledWith(CONTENTID_OR_VAR); + done(); + }); + }); + + it('should call dotWorkflowActionsFireService.saveContentlet with the provided data', (done) => { + const DATA = { title: 'Test Contentlet', body: 'This is a test' }; + dotWorkflowActionsFireService.saveContentlet.mockReturnValue(of({})); + + spectator.service.saveContentlet(DATA).subscribe(() => { + expect(dotWorkflowActionsFireService.saveContentlet).toHaveBeenCalledWith(DATA); + done(); + }); + }); + }); +}); diff --git a/core-web/libs/edit-content/src/lib/services/dot-edit-content.service.ts b/core-web/libs/edit-content/src/lib/services/dot-edit-content.service.ts new file mode 100644 index 000000000000..dca9637b9783 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/services/dot-edit-content.service.ts @@ -0,0 +1,44 @@ +import { Observable } from 'rxjs'; + +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; + +import { pluck } from 'rxjs/operators'; + +import { DotContentTypeService, DotWorkflowActionsFireService } from '@dotcms/data-access'; +import { DotCMSContentType, DotCMSContentTypeLayoutRow } from '@dotcms/dotcms-models'; + +@Injectable() +export class DotEditContentService { + private readonly dotContentTypeService = inject(DotContentTypeService); + private readonly dotWorkflowActionsFireService = inject(DotWorkflowActionsFireService); + private readonly http = inject(HttpClient); + + /** + * Retrieves the content by its ID. + * @param id - The ID of the content to retrieve. + * @returns An observable of the DotCMSContentType object. + */ + getContentById(id: string): Observable { + return this.http.get(`/api/v1/content/${id}`).pipe(pluck('entity')); + } + + /** + * Returns an Observable of an array of DotCMSContentTypeLayoutRow objects representing the form data for a given content type. + * @param idOrVar - The identifier or variable name of the content type to retrieve form data for. + * @returns An Observable of an array of DotCMSContentTypeLayoutRow objects representing the form data for the given content type. + */ + getContentTypeFormData(idOrVar: string): Observable { + return this.dotContentTypeService.getContentType(idOrVar).pipe(pluck('layout')); + } + + /** + * Saves a contentlet with the provided data. + * @param data An object containing key-value pairs of data to be saved. + * @returns An observable that emits the saved contentlet. + * The type of the emitted contentlet is determined by the generic type parameter. + */ + saveContentlet(data: { [key: string]: string }): Observable { + return this.dotWorkflowActionsFireService.saveContentlet(data); + } +} diff --git a/core-web/libs/edit-content/tsconfig.json b/core-web/libs/edit-content/tsconfig.json index d789346cd607..56e37bfd9172 100644 --- a/core-web/libs/edit-content/tsconfig.json +++ b/core-web/libs/edit-content/tsconfig.json @@ -3,9 +3,7 @@ "target": "es2022", "useDefineForClassFields": false, "forceConsistentCasingInFileNames": true, - "strict": true, "noImplicitOverride": true, - "noPropertyAccessFromIndexSignature": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true },