diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-actions-handler/dot-uve-actions-handler.service.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-actions-handler/dot-uve-actions-handler.service.spec.ts index 01a3568f58b3..cc4a5ebbc8a8 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-actions-handler/dot-uve-actions-handler.service.spec.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-actions-handler/dot-uve-actions-handler.service.spec.ts @@ -488,3 +488,51 @@ describe('DotUveActionsHandlerService – SECTION_OFFSET', () => { }); }); }); + +describe('DotUveActionsHandlerService – REGISTER_STYLE_SCHEMAS', () => { + let spectator: SpectatorService; + let service: DotUveActionsHandlerService; + + const createService = createServiceFactory({ + service: DotUveActionsHandlerService, + providers: [ + mockProvider(DotWorkflowActionsFireService), + mockProvider(DotMessageService), + mockProvider(MessageService), + mockProvider(DotCopyContentModalService), + { + provide: UVEStore, + useValue: buildMockStore() + } + ] + }); + + beforeEach(() => { + jest.clearAllMocks(); + spectator = createService(); + service = spectator.service; + }); + + it('should call setStyleSchemas on the store with the received schemas', () => { + const mockSchemas = [{ variable: 'Banner', schema: { color: { type: 'color' } } }]; + const setStyleSchemas = jest.fn(); + const mockStore = { ...buildMockStore(), setStyleSchemas }; + + service.handleAction( + { + action: DotCMSUVEAction.REGISTER_STYLE_SCHEMAS, + payload: { schemas: mockSchemas } + }, + { + uveStore: mockStore as unknown as InstanceType, + dialog: null, + inlineEditingService: null, + contentWindow: null, + host: 'http://localhost', + onCopyContent: jest.fn() + } + ); + + expect(setStyleSchemas).toHaveBeenCalledWith(mockSchemas); + }); +}); diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.spec.ts index 42151910daa0..684dc051c471 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.spec.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.spec.ts @@ -356,6 +356,85 @@ describe('withEditor', () => { expect(store.$styleSchema()).toBeUndefined(); }); + + it('should use schemas seeded from page load (via setPageAsset)', () => { + const mockSchema = { contentType: 'testContentType', sections: [] }; + + store.setPageAsset({ + pageAsset: { + ...MOCK_RESPONSE_HEADLESS, + page: { + ...MOCK_RESPONSE_HEADLESS.page, + styleEditorSchemas: [mockSchema] + } + } + }); + + patchStoreState(store, { + editorActiveContentlet: { + language_id: '1', + pageContainers: [], + pageId: '123', + container: { + identifier: 'test-container-id', + uuid: 'test-container-uuid', + acceptTypes: 'test', + maxContentlets: 1 + }, + contentlet: { + identifier: 'test-contentlet-id', + inode: 'test-inode', + title: 'Test Contentlet', + contentType: 'testContentType' + } + } + }); + + expect(store.$styleSchema()).toEqual(mockSchema); + }); + + it('should prefer editorStyleSchemas over pageAsset schemas when both exist', () => { + // pageSchema uses a different contentType than the active contentlet. + // If editorStyleSchemas were bypassed in favour of pageAsset schemas, + // nothing would match and the result would be undefined. + const pageSchema = { contentType: 'pageOnlyType', sections: [] }; + const iframeSchema = { contentType: 'iframeType', sections: [] }; + + store.setPageAsset({ + pageAsset: { + ...MOCK_RESPONSE_HEADLESS, + page: { + ...MOCK_RESPONSE_HEADLESS.page, + styleEditorSchemas: [pageSchema] + } + } + }); + + patchStoreState(store, { + editorActiveContentlet: { + language_id: '1', + pageContainers: [], + pageId: '123', + container: { + identifier: 'test-container-id', + uuid: 'test-container-uuid', + acceptTypes: 'test', + maxContentlets: 1 + }, + contentlet: { + identifier: 'test-contentlet-id', + inode: 'test-inode', + title: 'Test Contentlet', + contentType: 'iframeType' + } + }, + editorStyleSchemas: [iframeSchema] + }); + + // editorStyleSchemas is non-empty so it is used exclusively; + // the pageAsset schemas (which only have 'pageOnlyType') are ignored. + expect(store.$styleSchema()).toEqual(iframeSchema); + }); }); describe('$editorIsInDraggingState', () => { diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.ts index 3015f1424861..53f1ad3d3863 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.ts @@ -176,11 +176,12 @@ export function withEditor() { }), $styleSchema: computed(() => { const activeContentlet = store.editorActiveContentlet(); - const styleSchemas = store.editorStyleSchemas(); - const contentSchema = styleSchemas.find( - (schema) => schema.contentType === activeContentlet?.contentlet?.contentType - ); - return contentSchema; + return store + .editorStyleSchemas() + .find( + (schema) => + schema.contentType === activeContentlet?.contentlet?.contentType + ); }), $isDragging: computed(() => { const editorState = store.editorState(); diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/page/withPage.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/page/withPage.ts index a8eaee14fa69..14e661f9b9e0 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/page/withPage.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/page/withPage.ts @@ -148,7 +148,10 @@ export function withPage() { pageAsset: payload.pageAsset, ...(content !== undefined && { content }) }; - patchState(store, { pageAssetResponse: nextResponse }); + patchState(store, { + pageAssetResponse: nextResponse, + editorStyleSchemas: payload.pageAsset.page.styleEditorSchemas ?? [] + }); }, setPageAssetResponseOptimistic: ( pageAssetResponse: PageLoadingConfigState['pageAssetResponse'] diff --git a/core-web/libs/sdk/client/src/lib/client/page/page-api.spec.ts b/core-web/libs/sdk/client/src/lib/client/page/page-api.spec.ts index 9966950c438f..eee1aa239b34 100644 --- a/core-web/libs/sdk/client/src/lib/client/page/page-api.spec.ts +++ b/core-web/libs/sdk/client/src/lib/client/page/page-api.spec.ts @@ -83,13 +83,7 @@ describe('PageClient', () => { }) as Partial as FetchHttpClient ); - mockRequest.mockImplementation((url: string) => { - if (url.includes('/contenttype-schema')) { - return Promise.resolve({ entity: [] }); - } - - return Promise.resolve(mockGraphQLResponse); - }); + mockRequest.mockResolvedValue(mockGraphQLResponse); }); afterEach(() => { @@ -127,17 +121,6 @@ describe('PageClient', () => { body: expect.stringContaining(`... on Banner`) }); - expect(mockRequest).toHaveBeenCalledWith( - 'https://demo.dotcms.com/api/v1/page/test-page-id/contenttype-schema', - expect.objectContaining({ - method: 'GET', - headers: expect.objectContaining({ - Authorization: 'Bearer test-token', - Accept: 'application/json' - }) - }) - ); - expect(result).toEqual({ pageAsset: { layout: { @@ -840,15 +823,15 @@ describe('PageClient', () => { }); describe('styleEditorSchemas', () => { - it('should include styleEditorSchemas in result when endpoint returns schemas', async () => { + it('should include styleEditorSchemas in result when GQL returns schemas', async () => { const mockSchemas = [{ variable: 'Banner', schema: { color: { type: 'color' } } }]; - mockRequest.mockImplementation((url: string) => { - if (url.includes('/contenttype-schema')) { - return Promise.resolve({ entity: mockSchemas }); + mockRequest.mockResolvedValue({ + ...mockGraphQLResponse, + data: { + ...mockGraphQLResponse.data, + page: { ...mockGraphQLResponse.data.page, styleEditorSchemas: mockSchemas } } - - return Promise.resolve(mockGraphQLResponse); }); const pageClient = new PageClient( @@ -861,26 +844,13 @@ describe('PageClient', () => { expect(result.styleEditorSchemas).toEqual(mockSchemas); }); - it('should omit styleEditorSchemas from result when endpoint returns empty array', async () => { - const pageClient = new PageClient( - validConfig, - requestOptions, - new FetchHttpClient() - ); - const result = await pageClient.get('/graphql-page'); - - expect(result.styleEditorSchemas).toBeUndefined(); - }); - - it('should omit styleEditorSchemas and log debug when schema endpoint fails', async () => { - const debugSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); - - mockRequest.mockImplementation((url: string) => { - if (url.includes('/contenttype-schema')) { - return Promise.reject(new Error('Network error')); + it('should omit styleEditorSchemas from result when GQL returns null (not in EDIT_MODE)', async () => { + mockRequest.mockResolvedValue({ + ...mockGraphQLResponse, + data: { + ...mockGraphQLResponse.data, + page: { ...mockGraphQLResponse.data.page, styleEditorSchemas: null } } - - return Promise.resolve(mockGraphQLResponse); }); const pageClient = new PageClient( @@ -891,20 +861,15 @@ describe('PageClient', () => { const result = await pageClient.get('/graphql-page'); expect(result.styleEditorSchemas).toBeUndefined(); - expect(debugSpy).toHaveBeenCalledWith( - '[DotCMS PageClient]: Skipping style editor schemas:', - expect.any(Error) - ); - debugSpy.mockRestore(); }); - it('should omit styleEditorSchemas when endpoint returns a non-array entity', async () => { - mockRequest.mockImplementation((url: string) => { - if (url.includes('/contenttype-schema')) { - return Promise.resolve({ entity: null }); + it('should omit styleEditorSchemas from result when GQL returns an empty array', async () => { + mockRequest.mockResolvedValue({ + ...mockGraphQLResponse, + data: { + ...mockGraphQLResponse.data, + page: { ...mockGraphQLResponse.data.page, styleEditorSchemas: [] } } - - return Promise.resolve(mockGraphQLResponse); }); const pageClient = new PageClient( @@ -917,9 +882,7 @@ describe('PageClient', () => { expect(result.styleEditorSchemas).toBeUndefined(); }); - it('should warn and return empty when pageId is missing from the page response', async () => { - const consolaWarnSpy = jest.spyOn(console, 'warn'); - + it('should omit styleEditorSchemas when identifier is missing from the page response', async () => { mockRequest.mockResolvedValue({ ...mockGraphQLResponse, data: { @@ -936,9 +899,6 @@ describe('PageClient', () => { const result = await pageClient.get('/graphql-page'); expect(result.styleEditorSchemas).toBeUndefined(); - expect(consolaWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('"identifier"') - ); }); }); }); diff --git a/core-web/libs/sdk/client/src/lib/client/page/page-api.ts b/core-web/libs/sdk/client/src/lib/client/page/page-api.ts index 7e3bd78123ca..04a6b6160928 100644 --- a/core-web/libs/sdk/client/src/lib/client/page/page-api.ts +++ b/core-web/libs/sdk/client/src/lib/client/page/page-api.ts @@ -12,13 +12,7 @@ import { DotRequestOptions } from '@dotcms/types'; -import { - buildPageQuery, - buildQuery, - fetchGraphQL, - fetchStyleEditorSchemas, - mapContentResponse -} from './utils'; +import { buildPageQuery, buildQuery, fetchGraphQL, mapContentResponse } from './utils'; import { graphqlToPageEntity } from '../../utils'; import { BaseApiClient } from '../base/api/base-api'; @@ -253,6 +247,8 @@ export class PageClient extends BaseApiClient { ? graphqlToPageEntity(response.data.page) : null; + const styleEditorSchemas = pageResponse ? pageResponse.page.styleEditorSchemas : []; + if (!pageResponse) { throw new DotErrorPage( `Page '${normalizedUrl}' was not found`, @@ -268,13 +264,6 @@ export class PageClient extends BaseApiClient { ); } - const styleEditorSchemas = await fetchStyleEditorSchemas( - pageResponse.page.identifier, - this.config, - this.requestOptions, - this.httpClient - ); - // 5. Build response — include any non-fatal errors for consumers to inspect const contentResponse = mapContentResponse(response.data, Object.keys(content)); @@ -286,7 +275,7 @@ export class PageClient extends BaseApiClient { variables: requestVariables }, errors: response.errors?.length ? response.errors : undefined, - ...(styleEditorSchemas.length > 0 && { styleEditorSchemas }) + ...(styleEditorSchemas?.length && { styleEditorSchemas }) }; } catch (error) { if (error instanceof DotErrorPage) { diff --git a/core-web/libs/sdk/client/src/lib/client/page/utils.spec.ts b/core-web/libs/sdk/client/src/lib/client/page/utils.spec.ts index 369dddeb8e4b..6f2fc9f1b45b 100644 --- a/core-web/libs/sdk/client/src/lib/client/page/utils.spec.ts +++ b/core-web/libs/sdk/client/src/lib/client/page/utils.spec.ts @@ -1,6 +1,4 @@ -import { DotHttpError } from '@dotcms/types'; - -import { buildPageQuery, buildQuery, fetchStyleEditorSchemas, mapContentResponse } from './utils'; +import { buildPageQuery, buildQuery, mapContentResponse } from './utils'; describe('buildPageQuery()', () => { it('generates a query containing the PageContent operation', () => { @@ -59,6 +57,13 @@ describe('buildPageQuery()', () => { expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No page query was found')); warnSpy.mockRestore(); }); + + it('always includes styleEditorSchemas in the page fragment regardless of mode', () => { + // Gating is server-side (EDIT_MODE only); the field is always requested so the + // server can decide whether to populate it or return null. + expect(buildPageQuery({})).toContain('styleEditorSchemas'); + expect(buildPageQuery({ page: 'title url' })).toContain('styleEditorSchemas'); + }); }); describe('buildQuery()', () => { @@ -86,90 +91,6 @@ describe('buildQuery()', () => { }); }); -describe('fetchStyleEditorSchemas()', () => { - const config = { dotcmsUrl: 'https://demo.dotcms.com' } as Parameters< - typeof fetchStyleEditorSchemas - >[1]; - const requestOptions = {} as Parameters[2]; - - const makeHttpClient = (impl: () => unknown) => - ({ request: jest.fn().mockImplementation(impl) }) as Parameters< - typeof fetchStyleEditorSchemas - >[3]; - - it('warns and returns [] when pageId is undefined', async () => { - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); - const result = await fetchStyleEditorSchemas( - undefined, - config, - requestOptions, - makeHttpClient(() => undefined) - ); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('fetchStyleEditorSchemas called without a pageId') - ); - expect(result).toEqual([]); - warnSpy.mockRestore(); - }); - - it('returns entity array on success', async () => { - const schemas = [{ id: '1' }]; - const httpClient = makeHttpClient(() => Promise.resolve({ entity: schemas })); - const result = await fetchStyleEditorSchemas('page-id', config, requestOptions, httpClient); - expect(result).toEqual(schemas); - }); - - it('returns [] when entity is not an array', async () => { - const httpClient = makeHttpClient(() => Promise.resolve({ entity: null })); - const result = await fetchStyleEditorSchemas('page-id', config, requestOptions, httpClient); - expect(result).toEqual([]); - }); - - it('warns with auth message on 401 error', async () => { - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); - const error = new DotHttpError({ - status: 401, - statusText: 'Unauthorized', - message: 'Unauthorized' - }); - const httpClient = makeHttpClient(() => Promise.reject(error)); - const result = await fetchStyleEditorSchemas('page-id', config, requestOptions, httpClient); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('Style editor schemas request failed with 401') - ); - expect(result).toEqual([]); - warnSpy.mockRestore(); - }); - - it('warns with auth message on 403 error', async () => { - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); - const error = new DotHttpError({ - status: 403, - statusText: 'Forbidden', - message: 'Forbidden' - }); - const httpClient = makeHttpClient(() => Promise.reject(error)); - const result = await fetchStyleEditorSchemas('page-id', config, requestOptions, httpClient); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('Style editor schemas request failed with 403') - ); - expect(result).toEqual([]); - warnSpy.mockRestore(); - }); - - it('uses console warn for non-auth errors', async () => { - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); - const httpClient = makeHttpClient(() => Promise.reject(new Error('Network error'))); - const result = await fetchStyleEditorSchemas('page-id', config, requestOptions, httpClient); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('Skipping style editor schemas'), - expect.any(Error) - ); - expect(result).toEqual([]); - warnSpy.mockRestore(); - }); -}); - describe('mapContentResponse()', () => { it('returns undefined when responseData is undefined', () => { expect(mapContentResponse(undefined, ['blogs'])).toBeUndefined(); diff --git a/core-web/libs/sdk/client/src/lib/client/page/utils.ts b/core-web/libs/sdk/client/src/lib/client/page/utils.ts index 1ff342f3b859..8677638ef882 100644 --- a/core-web/libs/sdk/client/src/lib/client/page/utils.ts +++ b/core-web/libs/sdk/client/src/lib/client/page/utils.ts @@ -1,11 +1,4 @@ -import { - DotCMSClientConfig, - DotGraphQLApiResponse, - DotHttpClient, - DotHttpError, - DotRequestOptions -} from '@dotcms/types'; -import { StyleEditorFormSchema } from '@dotcms/types/internal'; +import { DotGraphQLApiResponse, DotHttpClient } from '@dotcms/types'; const DEFAULT_PAGE_CONTENTLETS_CONTENT = ` publishDate @@ -56,7 +49,7 @@ export const buildPageQuery = ({ fragments?: string[]; additionalQueries?: string; verbose?: boolean; -}) => { +}): string => { if (!page && verbose) { console.warn( "[DotCMS Client]: No page query was found, so we're loading all content using _map. This might slow things down. For better performance, we recommend adding a specific query in the page attribute." @@ -112,6 +105,7 @@ export const buildPageQuery = ({ lockedBy lockedByName numberContents + styleEditorSchemas urlContentMap { _map } @@ -271,59 +265,6 @@ export function mapContentResponse( ); } -/** - * Loads style editor schemas from GET /api/v1/page/{pageId}/contenttype-schema. - * Requires READ on the page; failures are silently ignored so callers still work without auth. - * - * @internal - */ -export async function fetchStyleEditorSchemas( - pageId: string | undefined, - config: DotCMSClientConfig, - requestOptions: DotRequestOptions, - httpClient: DotHttpClient -): Promise { - if (!pageId) { - console.warn( - '[DotCMS PageClient]: fetchStyleEditorSchemas called without a pageId — ' + - 'make sure "identifier" is included in your GraphQL page fragment.' - ); - - return []; - } - - try { - const url = new URL(config.dotcmsUrl); - url.pathname = `/api/v1/page/${encodeURIComponent(pageId)}/contenttype-schema`; - - const data = await httpClient.request<{ entity: StyleEditorFormSchema[] }>(url.toString(), { - ...requestOptions, - method: 'GET', - headers: { - Accept: 'application/json', - ...requestOptions.headers - } - }); - - const { entity } = data ?? {}; - if (!Array.isArray(entity)) { - return []; - } - - return entity as StyleEditorFormSchema[]; - } catch (error) { - if (error instanceof DotHttpError && (error.status === 401 || error.status === 403)) { - console.warn( - `[DotCMS PageClient]: Style editor schemas request failed with ${error.status} — ` + - 'make sure your DotCMS client is configured with a valid authToken that has READ access to the page.' - ); - } else { - console.warn('[DotCMS PageClient]: Skipping style editor schemas:', error); - } - return []; - } -} - /** * Executes a GraphQL query against the DotCMS API. * diff --git a/core-web/libs/sdk/client/src/lib/utils/graphql/transforms.ts b/core-web/libs/sdk/client/src/lib/utils/graphql/transforms.ts index 3fb8a7c264c4..ba228525f83f 100644 --- a/core-web/libs/sdk/client/src/lib/utils/graphql/transforms.ts +++ b/core-web/libs/sdk/client/src/lib/utils/graphql/transforms.ts @@ -72,7 +72,10 @@ export const graphqlToPageEntity = (page: DotCMSGraphQLPage): DotCMSPageAsset | containers: parseContainers(containers as []), page: { ...data, - ...typedPageAsset + ...typedPageAsset, + // GQL returns null when not in EDIT_MODE; normalize to undefined so the + // DotCMSPage type (StyleEditorFormSchema[], non-null) remains accurate. + styleEditorSchemas: typedPageAsset.styleEditorSchemas ?? undefined } }; }; diff --git a/core-web/libs/sdk/types/src/lib/page/public.ts b/core-web/libs/sdk/types/src/lib/page/public.ts index a2a6b69a5dc2..c7015ebc9a82 100644 --- a/core-web/libs/sdk/types/src/lib/page/public.ts +++ b/core-web/libs/sdk/types/src/lib/page/public.ts @@ -561,6 +561,7 @@ export interface DotCMSPage { liveInode: string; shortyLive: string; canSeeRules?: boolean; + styleEditorSchemas?: StyleEditorFormSchema[]; } /** @@ -1139,6 +1140,7 @@ export interface DotCMSGraphQLPage { host: DotCMSSite; vanityUrl: DotCMSVanityUrl; _map: Record; + styleEditorSchemas?: StyleEditorFormSchema[] | null; } /** diff --git a/dotCMS/src/main/java/com/dotcms/graphql/business/PageAPIGraphQLTypesProvider.java b/dotCMS/src/main/java/com/dotcms/graphql/business/PageAPIGraphQLTypesProvider.java index 96957cf2cc57..5e761eab1f55 100644 --- a/dotCMS/src/main/java/com/dotcms/graphql/business/PageAPIGraphQLTypesProvider.java +++ b/dotCMS/src/main/java/com/dotcms/graphql/business/PageAPIGraphQLTypesProvider.java @@ -18,10 +18,11 @@ import com.dotcms.graphql.datafetcher.UserDataFetcher; import com.dotcms.graphql.datafetcher.page.ContainersDataFetcher; import com.dotcms.graphql.datafetcher.page.LayoutDataFetcher; +import com.dotcms.graphql.datafetcher.page.NumberContentsDataFetcher; import com.dotcms.graphql.datafetcher.page.PageRenderDataFetcher; import com.dotcms.graphql.datafetcher.page.RenderedContainersDataFetcher; -import com.dotcms.graphql.datafetcher.page.NumberContentsDataFetcher; import com.dotcms.graphql.datafetcher.page.RunningExperimentFetcher; +import com.dotcms.graphql.datafetcher.page.StyleEditorSchemasDataFetcher; import com.dotcms.graphql.datafetcher.page.TemplateDataFetcher; import com.dotcms.graphql.datafetcher.page.VanityURLFetcher; import com.dotcms.graphql.datafetcher.page.ViewAsDataFetcher; @@ -49,6 +50,7 @@ import com.dotmarketing.portlets.templates.design.bean.TemplateLayoutRow; import com.dotmarketing.util.Logger; import eu.bitwalker.useragentutils.Browser; +import graphql.scalars.ExtendedScalars; import graphql.schema.GraphQLOutputType; import graphql.schema.GraphQLType; import graphql.schema.GraphQLTypeReference; @@ -167,7 +169,10 @@ public Collection getTypes() { GraphQLString, new RunningExperimentFetcher()) ); pageFields.put("numberContents", new TypeFetcher(GraphQLInt, new NumberContentsDataFetcher())); - + + pageFields.put("styleEditorSchemas", new TypeFetcher(list(ExtendedScalars.Json), + new StyleEditorSchemasDataFetcher())); + // Expose the page as its underlying contentlet type to enable inline fragments // for accessing content-type-specific fields like SEO metadata pageFields.put("page", new TypeFetcher( diff --git a/dotCMS/src/main/java/com/dotcms/graphql/datafetcher/page/StyleEditorSchemasDataFetcher.java b/dotCMS/src/main/java/com/dotcms/graphql/datafetcher/page/StyleEditorSchemasDataFetcher.java new file mode 100644 index 000000000000..0213d673c3cf --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/graphql/datafetcher/page/StyleEditorSchemasDataFetcher.java @@ -0,0 +1,61 @@ +package com.dotcms.graphql.datafetcher.page; + +import com.dotcms.featureflag.FeatureFlagName; +import com.dotcms.graphql.DotGraphQLContext; +import com.dotcms.rest.api.v1.DotObjectMapperProvider; +import com.dotcms.rest.api.v1.page.PageResourceHelper; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.util.ConfigUtils; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.PageMode; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import graphql.schema.DataFetchingEnvironment; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * DataFetcher that returns the style editor schemas for all distinct content types present on a + * page. Only content types that define a {@code DOT_STYLE_EDITOR_SCHEMA} metadata entry are + * included; the result is an empty list when none are found. + * + *

Schemas are only fetched in {@link PageMode#EDIT_MODE} to avoid the N+1 DB query cost on + * public page loads. The feature flag provides an additional gate. + */ +public class StyleEditorSchemasDataFetcher extends RedirectAwareDataFetcher> { + + @Override + public List safeGet(final DataFetchingEnvironment environment, + final DotGraphQLContext context) throws Exception { + + final boolean isStyleEditorFlagOn = ConfigUtils.isFeatureFlagOn( + FeatureFlagName.FEATURE_FLAG_UVE_STYLE_EDITOR); + + if (!isStyleEditorFlagOn) { + return Collections.emptyList(); + } + + final String pageModeAsString = (String) context.getParam("pageMode"); + final PageMode pageMode = PageMode.get(pageModeAsString); + + if (pageMode != PageMode.EDIT_MODE) { + return Collections.emptyList(); + } + + final Contentlet page = environment.getSource(); + Logger.debug(this, () -> "Fetching style editor schemas for page: " + page.getIdentifier()); + + final List jsonSchemas = PageResourceHelper.getInstance() + .getStyleEditorSchemasInPage(page.getIdentifier()); + final ObjectMapper mapper = DotObjectMapperProvider.getInstance().getDefaultObjectMapper(); + return jsonSchemas.stream() + .map(node -> mapper.convertValue(node, Object.class)) + .collect(Collectors.toList()); + } + + @Override + protected List onRedirect() { + return Collections.emptyList(); + } +} \ No newline at end of file diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/page/PageResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/page/PageResource.java index 297955c8afb1..6297731ec3cd 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/page/PageResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/page/PageResource.java @@ -1909,62 +1909,4 @@ class ContainerStylesData { .collect(Collectors.toList()); } - /** - * Returns the {@code DOT_STYLE_EDITOR_SCHEMA} metadata for each content type that is currently - * present on the specified page. Only content types that actually have a - * {@code DOT_STYLE_EDITOR_SCHEMA} entry in their metadata are included in the response. Returns - * an empty list when no such schemas are found. - * - *

Example: - *

GET /api/v1/page/{pageId}/contenttype-schema
- * - * @param request The current {@link HttpServletRequest}. - * @param response The current {@link HttpServletResponse}. - * @param pageId Identifier of the HTML Page whose content type schemas are requested. - * @return List of parsed JSON style editor schema objects - empty when none are found. - */ - @Operation( - operationId = "getPageContentTypeSchemas", - summary = "Get style editor schemas for content types on a page", - description = - "Returns the DOT_STYLE_EDITOR_SCHEMA metadata for each distinct content type " - + "present on the specified page. Content types without a DOT_STYLE_EDITOR_SCHEMA entry " - + "are excluded. Returns an empty list when no schemas are found." - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "Content type schemas retrieved successfully", - content = @Content(mediaType = "application/json", - schema = @Schema(implementation = ResponseEntityContentTypeSchemaView.class))), - @ApiResponse(responseCode = "401", description = "Authentication required"), - @ApiResponse(responseCode = "403", description = "User does not have READ permission on the page"), - @ApiResponse(responseCode = "404", description = "Page not found"), - @ApiResponse(responseCode = "500", description = "Error retrieving schema data") - }) - @GET - @Path("/{pageId}/contenttype-schema") - @NoCache - @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) - public Response getPageContentTypeSchemas( - @Context final HttpServletRequest request, - @Context final HttpServletResponse response, - @Parameter(description = "Identifier of the HTML Page", required = true) - @PathParam("pageId") final String pageId) - throws DotDataException, DotSecurityException { - - Logger.debug(this, () -> "Getting content type schemas for page: " + pageId); - - final User user = new WebResource.InitBuilder(webResource) - .requestAndResponse(request, response) - .rejectWhenNoUser(true) - .init() - .getUser(); - - final IHTMLPage page = pageResourceHelper.getPage(user, pageId, request); - - APILocator.getPermissionAPI().checkPermission(page, PermissionLevel.READ, user); - - return Response.ok(new ResponseEntityContentTypeSchemaView( - pageResourceHelper.getStyleEditorSchemasInPage(pageId))).build(); - } - } // E:O:F:PageResource diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/page/ResponseEntityContentTypeSchemaView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/page/ResponseEntityContentTypeSchemaView.java deleted file mode 100644 index 3c18958fc688..000000000000 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/page/ResponseEntityContentTypeSchemaView.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.dotcms.rest.api.v1.page; - -import com.dotcms.rest.ResponseEntityView; -import com.fasterxml.jackson.databind.JsonNode; -import java.util.List; - -/** - * Entity View for content type style editor schema responses. - * Contains the {@code DOT_STYLE_EDITOR_SCHEMA} metadata entries keyed by content type variable, - * for each content type present on a given page. - */ -public class ResponseEntityContentTypeSchemaView extends ResponseEntityView> { - public ResponseEntityContentTypeSchemaView(final List styleEditorSchemas) { - super(styleEditorSchemas); - } -} diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/htmlpageasset/business/render/page/HTMLPageAssetRenderedBuilder.java b/dotCMS/src/main/java/com/dotmarketing/portlets/htmlpageasset/business/render/page/HTMLPageAssetRenderedBuilder.java index c057ef97cf87..d96ef1f35e0a 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/htmlpageasset/business/render/page/HTMLPageAssetRenderedBuilder.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/htmlpageasset/business/render/page/HTMLPageAssetRenderedBuilder.java @@ -3,8 +3,10 @@ import com.dotcms.business.CloseDBIfOpened; import com.dotcms.enterprise.license.LicenseManager; import com.dotcms.experiments.model.Experiment; +import com.dotcms.featureflag.FeatureFlagName; import com.dotcms.rest.api.v1.DotObjectMapperProvider; import com.dotcms.rest.api.v1.page.PageResourceHelper; +import com.dotmarketing.portlets.htmlpageasset.business.render.page.PageView.Builder; import com.fasterxml.jackson.databind.JsonNode; import com.dotcms.rendering.velocity.directive.RenderParams; import com.dotcms.rendering.velocity.services.PageRenderUtil; @@ -33,6 +35,7 @@ import com.dotmarketing.portlets.templates.design.bean.ContainerUUID; import com.dotmarketing.portlets.templates.design.bean.TemplateLayout; import com.dotmarketing.portlets.templates.model.Template; +import com.dotmarketing.util.ConfigUtils; import com.dotmarketing.util.Logger; import com.dotmarketing.util.PageMode; import com.dotmarketing.util.UtilMethods; @@ -203,6 +206,7 @@ public PageView build(final boolean rendered, final PageMode mode) throws DotDat .runningExperiment(runningExperiment) .vanityUrl(this.vanityUrl); urlContentletOpt.ifPresent(pageViewBuilder::urlContent); + applyStyleEditorSchemas(resolveStyleEditorSchemas(mode, containers), mode, pageViewBuilder); return pageViewBuilder.build(); } else { @@ -216,12 +220,15 @@ public PageView build(final boolean rendered, final PageMode mode) throws DotDat pageRenderUtil.getContainersRaw(), velocityContext, mode) .build(); final String rawHTML = this.getPageHTML(mode); + // Compute schemas once here so both the UVE script block and the PageView builder + // consume the same result without a redundant DB traversal. + final List styleEditorSchemas = resolveStyleEditorSchemas(mode, containers); final String pageHTML; if (mode != PageMode.LIVE) { Logger.debug(this, () -> String.format( "Injecting UVE script for page '%s' in mode %s", htmlPageAsset.getPageUrl(), mode)); - pageHTML = injectUVEScript(rawHTML, containers); + pageHTML = injectUVEScript(rawHTML, styleEditorSchemas); } else { Logger.debug(this, () -> String.format( "Skipping UVE script injection for page '%s' (LIVE mode)", @@ -241,18 +248,64 @@ public PageView build(final boolean rendered, final PageMode mode) throws DotDat .runningExperiment(runningExperiment) .vanityUrl(this.vanityUrl); urlContentletOpt.ifPresent(pageViewBuilder::urlContent); + applyStyleEditorSchemas(styleEditorSchemas, mode, pageViewBuilder); return pageViewBuilder.build(); } } /** - * Returns the URL contentlet if it exists. This is used to get the contentlet that is associated with the URL of the page like a urlMapContent - * @param request - * @param mode - * @return - * @throws DotDataException - * @throws DotSecurityException + * Returns Style Editor schemas for all distinct ContentTypes on the page when the + * {@code FEATURE_FLAG_UVE_STYLE_EDITOR} feature flag is enabled and the page is not in + * {@link PageMode#LIVE} mode; returns an empty list otherwise. + *

+ * This is the single computation point — call it once per request and share the result with + * both the UVE script-injection block and the {@link PageView.Builder}. The UVE script + * injection uses schemas for all non-LIVE modes; the REST/GQL response only exposes them in + * {@link PageMode#EDIT_MODE} (enforced by {@link #applyStyleEditorSchemas}). + * + * @param mode The {@link PageMode} the page is being rendered in. + * @param containers The containers whose contentlets are inspected for schemas. + * @return A (possibly empty) list of JSON schema nodes. + */ + private static List resolveStyleEditorSchemas(final PageMode mode, + final Collection containers) { + if (mode == PageMode.LIVE || !ConfigUtils.isFeatureFlagOn( + FeatureFlagName.FEATURE_FLAG_UVE_STYLE_EDITOR)) { + return Collections.emptyList(); + } + final List pageContentlets = containers.stream() + .flatMap(c -> c.getContentlets().values().stream()) + .flatMap(List::stream) + .collect(Collectors.toList()); + return PageResourceHelper.getStyleEditorSchemas(pageContentlets); + } + + /** + * Conditionally populates the {@link PageView.Builder} with pre-resolved Style Editor schemas. + *

+ * Schemas are only written to the REST/GQL response in {@link PageMode#EDIT_MODE}; other + * non-LIVE modes receive schemas only in the injected UVE script block, not in the page JSON. + * + * @param schemas Pre-resolved schemas from {@link #resolveStyleEditorSchemas}. + * @param mode The current {@link PageMode}; schemas are applied only in EDIT_MODE. + * @param pageViewBuilder The builder that will receive the resolved schemas. + */ + private static void applyStyleEditorSchemas(final List schemas, + final PageMode mode, final Builder pageViewBuilder) { + if (mode == PageMode.EDIT_MODE && !schemas.isEmpty()) { + pageViewBuilder.styleEditorSchemas(schemas); + } + } + + /** + * Returns the URL contentlet associated with the page's URL content map, if one exists. + * + * @param request The current HTTP request, used to read URL contentlet attributes. + * @param mode The {@link PageMode} the page is being rendered in. + * @return An {@link Optional} containing the URL contentlet, or empty if none is found. + * @throws DotDataException An error occurred when accessing the data source. + * @throws DotSecurityException The user does not have the required permissions. */ private Optional findUrlMapContentlet(final HttpServletRequest request, final PageMode mode) throws DotDataException, DotSecurityException { @@ -387,21 +440,21 @@ private void transformLegacyContainerUUIDs(TemplateLayout layout) { /** * Injects UVE scripts before the closing {@code } tag in the given HTML string. If - * ContentType schemas are found in the containers, the full {@code UVE_SCRIPTS_TEMPLATE} is - * injected (init function + {@code