Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5090f69
create and implement new endpoint to change content type metadata
dario-daza Jun 8, 2026
a381fdd
normalize DOT_STYLE_EDITOR_SCHEMA to JSON string
dario-daza Jun 8, 2026
7c19ae5
update and add test for the metadata endpoint
dario-daza Jun 9, 2026
a1fb012
add test to the MainSuite2a
dario-daza Jun 9, 2026
dce23c1
fix comments
dario-daza Jun 9, 2026
6ea2d3e
Merge branch 'main' into 35781-create-new-endpoint-to-save-style-edit…
dario-daza Jun 9, 2026
55260c9
Merge branch 'main' into 35781-create-new-endpoint-to-save-style-edit…
dario-daza Jun 9, 2026
ad76652
fix comments
dario-daza Jun 9, 2026
32202a0
Merge branch 'main' into 35781-create-new-endpoint-to-save-style-edit…
dario-daza Jun 9, 2026
0823f98
refactor(content-type): move metadata merge-save to helper with strip…
dario-daza Jun 11, 2026
45383c9
fix error handling in the new resource
dario-daza Jun 11, 2026
97649a9
fix test and id handling for content type
dario-daza Jun 11, 2026
307e15e
preserve content-type metadata when the metadata key is omitted in th…
dario-daza Jun 15, 2026
7956600
add metadata preservation test
dario-daza Jun 15, 2026
7e99241
Merge branch 'main' into 35781-create-new-endpoint-to-save-style-edit…
dario-daza Jun 15, 2026
93dd08b
Merge branch 'main' into 35781-create-new-endpoint-to-save-style-edit…
dario-daza Jun 16, 2026
934ff21
Merge branch 'main' into 35781-create-new-endpoint-to-save-style-edit…
dario-daza Jun 16, 2026
c790583
Merge branch 'main' into 35781-create-new-endpoint-to-save-style-edit…
dario-daza Jun 16, 2026
95a303e
Merge branch 'main' into 35781-create-new-endpoint-to-save-style-edit…
dario-daza Jun 16, 2026
16aed55
fix preserve metadata if absent
dario-daza Jun 16, 2026
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