Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
import {
Spectator,
SpyObject,
byTestId,
createComponentFactory,
mockProvider
} from '@ngneat/spectator/jest';
import { of, throwError } from 'rxjs';
import { Spectator, byTestId, createComponentFactory, mockProvider } from '@ngneat/spectator/jest';

import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';

import {
DotCrudService,
DotHttpErrorManagerService,
DotMessageDisplayService,
DotMessageService
Expand Down Expand Up @@ -38,13 +33,26 @@ const MOCK_CONTENT_TYPE = {
metadata: {}
} as DotCMSContentType;

// Content type with fields, workflows, and systemActionMappings to verify none of these
// bleed into the metadata-only PATCH payload.
const MOCK_CONTENT_TYPE_WITH_FIELDS_AND_WORKFLOWS = {
...MOCK_CONTENT_TYPE,
fields: [{ id: 'field-1', variable: 'title', name: 'Title' }],
workflows: [{ id: 'workflow-scheme-id', name: 'Default Workflow' }],
systemActionMappings: { NEW: { id: 'wf-action-id' } }
} as unknown as DotCMSContentType;

const METADATA_URL = `v1/contenttype/id/${MOCK_CONTENT_TYPE.id}/metadata`;

describe('DotStyleEditorBuilderComponent', () => {
let spectator: Spectator<DotStyleEditorBuilderComponent>;
let httpController: HttpTestingController;

const createComponent = createComponentFactory({
component: DotStyleEditorBuilderComponent,
providers: [
mockProvider(DotCrudService, { putData: jest.fn().mockReturnValue(of({})) }),
provideHttpClient(),
provideHttpClientTesting(),
mockProvider(DotHttpErrorManagerService, { handle: jest.fn() }),
mockProvider(DotMessageDisplayService, { push: jest.fn() }),
{
Expand All @@ -62,6 +70,7 @@ describe('DotStyleEditorBuilderComponent', () => {

function setup(contentType?: DotCMSContentType): void {
spectator = createComponent();
httpController = spectator.inject(HttpTestingController);
if (contentType) {
spectator.setInput('contentType', contentType);
}
Expand Down Expand Up @@ -93,6 +102,10 @@ describe('DotStyleEditorBuilderComponent', () => {
spectator.detectChanges();
}

afterEach(() => {
httpController.verify();
});

describe('Sections', () => {
it('should add a section when "Add New Section" is clicked', () => {
setup();
Expand Down Expand Up @@ -249,39 +262,58 @@ describe('DotStyleEditorBuilderComponent', () => {
spectator.detectChanges();

expect(spectator.component.$saveAttempted()).toBe(true);
expect(spectator.inject(DotCrudService).putData).not.toHaveBeenCalled();
httpController.expectNone(METADATA_URL);
});

it('should call the CRUD API when the form is valid', () => {
it('should call the metadata PATCH endpoint when the form is valid', () => {
setup(MOCK_CONTENT_TYPE);
// No sections → empty form is valid (nothing to validate)

spectator.query(byTestId('save-btn'))?.querySelector('button')?.click();
spectator.detectChanges();

expect(spectator.inject(DotCrudService).putData).toHaveBeenCalledWith(
`v1/contenttype/id/${MOCK_CONTENT_TYPE.id}`,
expect.anything()
);
const req = httpController.expectOne(METADATA_URL);
expect(req.request.method).toBe('PATCH');
req.flush({ entity: {} });
});

it('should handle API errors by calling the error manager', () => {
it('should send null for the schema key when there are no sections', () => {
setup(MOCK_CONTENT_TYPE);

const crudService: SpyObject<DotCrudService> = spectator.inject(DotCrudService);
crudService.putData.mockReturnValue(throwError(() => new Error('Server error')));
spectator.query(byTestId('save-btn'))?.querySelector('button')?.click();
spectator.detectChanges();

const req = httpController.expectOne(METADATA_URL);
expect(req.request.body).toEqual({ DOT_STYLE_EDITOR_SCHEMA: null });
req.flush({ entity: {} });
});

it('should send only the schema key in the payload — no fields, workflows or other CT properties', () => {
setup(MOCK_CONTENT_TYPE_WITH_FIELDS_AND_WORKFLOWS);

spectator.query(byTestId('save-btn'))?.querySelector('button')?.click();
spectator.detectChanges();

const req = httpController.expectOne(METADATA_URL);
expect(Object.keys(req.request.body)).toEqual(['DOT_STYLE_EDITOR_SCHEMA']);
req.flush({ entity: {} });
});

it('should handle API errors by calling the error manager', () => {
setup(MOCK_CONTENT_TYPE);

spectator.query(byTestId('save-btn'))?.querySelector('button')?.click();
spectator.detectChanges();

httpController
.expectOne(METADATA_URL)
.flush('Server error', { status: 500, statusText: 'Internal Server Error' });
spectator.detectChanges();

expect(spectator.inject(DotHttpErrorManagerService).handle).toHaveBeenCalled();
});
});

describe('Duplicate identifier validation', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should detect a duplicate when two fields in the same section share an identifier', () => {
setup(MOCK_CONTENT_TYPE);

Expand Down Expand Up @@ -321,7 +353,7 @@ describe('DotStyleEditorBuilderComponent', () => {
spectator.detectChanges();

expect(spectator.component.$saveAttempted()).toBe(true);
expect(spectator.inject(DotCrudService).putData).not.toHaveBeenCalled();
httpController.expectNone(METADATA_URL);
});

it('should call the API after the user renames one of the duplicate identifiers to make it unique', () => {
Expand All @@ -339,7 +371,9 @@ describe('DotStyleEditorBuilderComponent', () => {
spectator.query(byTestId('save-btn'))?.querySelector('button')?.click();
spectator.detectChanges();

expect(spectator.inject(DotCrudService).putData).toHaveBeenCalled();
const req = httpController.expectOne(METADATA_URL);
expect(req.request.method).toBe('PATCH');
req.flush({ entity: {} });
});
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { patchState, signalState } from '@ngrx/signals';

import { HttpClient } from '@angular/common/http';
import {
ChangeDetectionStrategy,
Component,
Expand All @@ -20,12 +21,16 @@ import { TooltipModule } from 'primeng/tooltip';
import { take } from 'rxjs/operators';

import {
DotCrudService,
DotHttpErrorManagerService,
DotMessageDisplayService,
DotMessageService
} from '@dotcms/data-access';
import { DotCMSContentType, DotMessageSeverity, DotMessageType } from '@dotcms/dotcms-models';
import {
DotCMSContentType,
DotCMSResponse,
DotMessageSeverity,
DotMessageType
} from '@dotcms/dotcms-models';
import { StyleEditorFieldSchema, StyleEditorFormSchema } from '@dotcms/types/internal';
import { DotMessagePipe } from '@dotcms/ui';
import { StyleEditorField, defineStyleEditorSchema, styleEditorField } from '@dotcms/uve/internal';
Expand Down Expand Up @@ -83,7 +88,7 @@ interface DotStyleEditorBuilderState {
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DotStyleEditorBuilderComponent {
readonly #crudService = inject(DotCrudService);
readonly #http = inject(HttpClient);
readonly #dotHttpErrorManagerService = inject(DotHttpErrorManagerService);
readonly #dotMessageDisplayService = inject(DotMessageDisplayService);
readonly #dotMessageService = inject(DotMessageService);
Expand Down Expand Up @@ -187,15 +192,11 @@ export class DotStyleEditorBuilderComponent {
const contentType = this.$contentType();
if (!contentType) return;

const existingMetadata = { ...(contentType.metadata ?? {}) };

let updatedMetadata: typeof existingMetadata;
let metadataPatch: Record<string, string | null>;

if (this.$sections().length === 0) {
// Empty form — remove the key so metadata stays clean (no empty schema noise)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [STYLE_EDITOR_SCHEMA_KEY]: _removed, ...rest } = existingMetadata;
updatedMetadata = rest;
// Null tells the PATCH endpoint to remove the key entirely
metadataPatch = { [STYLE_EDITOR_SCHEMA_KEY]: null };
} else {
const schema = defineStyleEditorSchema({
contentType: contentType.variable,
Expand All @@ -204,25 +205,12 @@ export class DotStyleEditorBuilderComponent {
fields: section.fields.map((field) => this.#toStyleEditorField(field))
}))
});
updatedMetadata = {
...existingMetadata,
[STYLE_EDITOR_SCHEMA_KEY]: JSON.stringify(schema)
};
metadataPatch = { [STYLE_EDITOR_SCHEMA_KEY]: JSON.stringify(schema) };
}

// `systemActionMappings` contains full workflow-action objects that the API
// misinterprets as action IDs when round-tripped in a PUT body. Strip it out.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { systemActionMappings: _wf, ...contentTypeData } = contentType;

const payload: DotCMSContentType = {
...contentTypeData,
metadata: updatedMetadata
};

patchState(this.#state, { saving: true });
this.#crudService
.putData<DotCMSContentType>(`v1/contenttype/id/${contentType.id}`, payload)
this.#http
.patch<DotCMSResponse>(`v1/contenttype/id/${contentType.id}/metadata`, metadataPatch)
.pipe(take(1), takeUntilDestroyed(this.#destroyRef))
.subscribe({
next: () => {
Expand Down Expand Up @@ -253,8 +241,11 @@ export class DotStyleEditorBuilderComponent {
*/
#loadFromMetadata(contentType: DotCMSContentType): void {
const raw = contentType.metadata?.[STYLE_EDITOR_SCHEMA_KEY];
if (!raw || typeof raw !== 'string') {
console.warn('[StyleEditorBuilder] Invalid schema in metadata');
if (!raw) {
return;
}
if (typeof raw !== 'string') {
console.warn('[StyleEditorBuilder] DOT_STYLE_EDITOR_SCHEMA is not a string; ignoring');
return;
}
Comment thread
dario-daza marked this conversation as resolved.

Expand Down
Loading
Loading