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 on lines +244 to 250

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are different error cases?
Maybe can add console.warn in the first too


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
import com.dotcms.api.web.HttpServletRequestThreadLocal;
import com.dotcms.api.web.HttpServletResponseThreadLocal;
import com.dotcms.business.WrapInTransaction;
import com.dotcms.concurrent.DotConcurrentFactory;
import com.dotcms.concurrent.lock.IdentifierStripedLock;
import com.dotcms.contenttype.business.ContentTypeAPI;
import com.dotcms.contenttype.exception.NotFoundInDbException;
import com.dotcms.rest.api.v1.DotObjectMapperProvider;
import com.dotcms.rest.exception.BadRequestException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.dotcms.contenttype.model.field.CustomField;
import com.dotcms.contenttype.model.field.Field;
import com.dotcms.contenttype.model.field.ImmutableCustomField;
Expand Down Expand Up @@ -52,6 +58,7 @@
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
Expand Down Expand Up @@ -987,4 +994,103 @@ public List<String> getEnsuredContentTypes(final String ensuredContentTypes) {
.collect(Collectors.toList());
}

/**
* Atomically merges the given metadata patch into the specified Content Type's metadata and
* persists the result.
* <p>
* The operation is serialized per Content Type using a JVM-local striped lock keyed on the
* content type ID. This prevents lost updates when two concurrent PATCH requests target the
* same Content Type: the second request waits until the first has committed, then re-reads the
* freshest metadata before applying its own changes.
* <p>
* Note: the lock is JVM-local. Concurrent writes across cluster nodes are still subject to
* last-write-wins; true multi-node safety requires either optimistic locking on {@code modDate}
* or an atomic DB-level JSON merge, and should be addressed in a follow-up.
*
* @param idOrVar ID or velocity variable name of the Content Type to patch
* @param metadataPatch keys to merge; a {@code null} value removes the key from the map
* @param contentTypeAPI the API instance to use for reading and saving the Content Type
* @return the saved Content Type with the merged metadata
* @throws DotDataException if a database error occurs
* @throws DotSecurityException if the user does not have permission to edit the Content Type
* @throws BadRequestException if {@code DOT_STYLE_EDITOR_SCHEMA} is present but cannot be
* serialized to a JSON string
*/
public ContentType mergeAndSaveMetadata(
final String idOrVar,
final Map<String, Object> metadataPatch,
final ContentTypeAPI contentTypeAPI) throws DotDataException, DotSecurityException {

// Defensive copy — protects against callers passing immutable maps (e.g. Map.of(...)),
// which would cause UnsupportedOperationException inside normalizeStyleEditorSchemaToString
final Map<String, Object> patch = new HashMap<>(metadataPatch);

// Validate/normalize before acquiring the lock — fail fast on bad input
normalizeStyleEditorSchemaToString(patch);

final IdentifierStripedLock lockManager =
DotConcurrentFactory.getInstance().getIdentifierStripedLock();
try {
// Initial find to obtain the stable ID used as the lock key
final ContentType initial = contentTypeAPI.find(idOrVar);

return lockManager.tryLock("ct-metadata-" + initial.id(), () -> {
// Re-read inside the lock to pick up any writes committed before we acquired it
final ContentType current = contentTypeAPI.find(initial.id());
final Map<String, Object> merged = new HashMap<>(
current.metadata() != null ? current.metadata() : Map.of());
patch.forEach((k, v) -> {
if (v == null) {
merged.remove(k);
} else {
merged.put(k, v);
}
});
return contentTypeAPI.save(
ContentTypeBuilder.builder(current).metadata(merged).build());
});
} catch (final DotDataException | DotSecurityException e) {
throw e;
} catch (final Throwable t) {
throw new DotDataException(
"Error patching metadata for Content Type '" + idOrVar + "': " + t.getMessage(), t);
}
}

/**
* Normalizes the {@code DOT_STYLE_EDITOR_SCHEMA} entry in the given metadata patch map so that
* its value is always stored as a JSON string rather than a raw JSON object.
* <p>
* When a client sends the schema as a JSON object (e.g. a deserialized {@link java.util.Map}),
* Jackson binds it as a {@code LinkedHashMap}. Page-rendering code downstream casts the stored
* value directly to {@code String}, so tolerating a non-String value would cause a
* {@link ClassCastException} at render time. This method serializes any non-String, non-null
* value back to a compact JSON string and writes it in-place into {@code metadataPatch}.
* <p>
* Keys other than {@code DOT_STYLE_EDITOR_SCHEMA}, and a {@code null} value for that key
* (which signals "remove the key"), are left untouched.
*
* @param metadataPatch the mutable metadata patch map; modified in place when normalization
* is needed
* @throws BadRequestException if the value cannot be serialized to a JSON string
*/
private static void normalizeStyleEditorSchemaToString(final Map<String, Object> metadataPatch) {
final Object rawSchema = metadataPatch.get("DOT_STYLE_EDITOR_SCHEMA");
if (rawSchema == null || rawSchema instanceof String) {
return;
}

final ObjectMapper mapper = DotObjectMapperProvider.getInstance().getDefaultObjectMapper();
final String schemaStr;
try {
schemaStr = mapper.writeValueAsString(rawSchema);
} catch (final Exception e) {
Logger.warn(ContentTypeHelper.class,
"Could not serialize DOT_STYLE_EDITOR_SCHEMA to JSON string: " + e.getMessage());
throw new BadRequestException(
"DOT_STYLE_EDITOR_SCHEMA must be a serializable JSON value");
}
metadataPatch.put("DOT_STYLE_EDITOR_SCHEMA", schemaStr);
}

} // E:O:F:ContentTypeHelper.
Loading
Loading