Skip to content

Commit 73a99ad

Browse files
feat(edit-content): implement Rules dialog component for Page content type (#35121)
## Summary - Creates `DotEditContentSidebarRulesComponent` — a clickable card (matching the Permissions pattern) that opens a Rules dialog - Creates `DotRulesDialogComponent` — wraps the existing `DotRuleEngineContainerComponent`, providing a component-level `ActivatedRoute` stub to supply the `pageId` - Adds both components inside the **settings tab (cog)** of the Edit Content sidebar — Rules appears below Permissions, visible only when the content type is `HTMLPAGE` (Page) - Adds i18n key `edit.content.sidebar.rules.title=Rules` to `Language.properties` Closes #31679 ## Acceptance Criteria - [x] Rules button appears in the settings tab (same tab as Permissions) - [x] Rules button is only visible for Page content types (`baseType === 'HTMLPAGE'`) - [x] Clicking the card opens `DotRulesDialogComponent` with the existing Rules engine - [x] Dialog uses `closable: true`, `closeOnEscape: false` (matches Permissions pattern) - [x] Keyboard accessible — responds to `Enter` and `Space` - [x] Guards against double-open and empty identifier - [x] Unit tests for both new components (21 tests) - [x] Sidebar spec updated to cover Rules visibility conditions ## Test plan - [ ] Open a **Page** contentlet in Edit Content → settings tab → verify Rules card appears below Permissions - [ ] Open a **non-Page** contentlet → verify Rules card does NOT appear - [ ] Click the Rules card → verify the Rules dialog opens with the correct page rules - [ ] Verify ESC does not close the dialog (controlled close via X button) - [ ] Verify creating a new Page contentlet → Rules card does NOT appear (only for existing) ## Visual Changes https://github.com/user-attachments/assets/a08fd8e1-46b1-40d0-9927-c3cd8f3bde27 --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f1dfe81 commit 73a99ad

13 files changed

Lines changed: 863 additions & 9 deletions

File tree

core-web/apps/dotcms-ui-e2e/CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,3 +423,6 @@ await page.waitForResponse(...); // May miss the response
423423
11. **Direct URL navigation** — Don't go directly to `/content/new/{type}`. Use `goToNew()` which goes through the content listing (Dojo) first
424424
12. **Dojo widget IDs are fragile** — Never use `#dijit_form_DropDownButton_0`. Use role or class selectors instead
425425
13. **Multiple instances** — When a page has multiple relationship fields, pass `fieldVariable` to scope the helper to a specific field via `data-testid="field-{variable}"`
426+
14. **HTML page navigation** — HTML pages don't have `data-testid="title"`, so `goToContent()` (which waits for that field) hangs. Navigate directly with `page.goto('/dotAdmin/#/content/{inode}')` and wait for `dot-edit-content-sidebar` instead.
427+
15. **`hostFolder` for HTML pages** — The API rejects the string `'default'`. Resolve the default site via `GET /api/v1/site` and use `defaultSite.identifier` as `hostFolder`.
428+
16. **New editor for custom content types** — Navigating to `/dotAdmin/#/content/{inode}` shows the legacy editor unless the content type has `metadata: { CONTENT_EDITOR2_ENABLED: true }`. HTML pages have this enabled by default; custom types created in tests must set it explicitly.

core-web/apps/dotcms-ui-e2e/src/requests/pages.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,36 @@ import { APIRequestContext, expect } from '@playwright/test';
22
import { admin1 } from '@utils/credentials';
33
import { generateBase64Credentials } from '@utils/generateBase64Credential';
44

5+
import { getSites } from './sites';
6+
57
export interface Page {
68
identifier: string;
79
contentType: 'htmlpageasset';
810
title: string;
911
url: string;
10-
hostFolder: 'default';
11-
template: 'SYSTEM_TEMPLATE';
12+
hostFolder: string;
13+
template: string;
1214
friendlyName: string;
1315
cachettl: number;
1416
inode: string;
1517
}
1618

17-
type CreatePage = Omit<Page, 'identifier' | 'inode'>;
19+
type CreatePage = Omit<Page, 'identifier' | 'inode' | 'hostFolder'>;
20+
21+
export async function createPage(request: APIRequestContext, data: CreatePage): Promise<Page> {
22+
const sites = await getSites(request);
23+
const defaultSite = sites.find((site) => site.default && !site.systemHost);
24+
if (!defaultSite) {
25+
throw new Error('No default site found. Cannot create HTML page.');
26+
}
1827

19-
export async function createPage(request: APIRequestContext, data: CreatePage) {
2028
const endpoint = `/api/v1/workflow/actions/default/fire/PUBLISH?indexPolicy=WAIT_FOR`;
2129
const response = await request.post(endpoint, {
2230
data: {
23-
contentlet: data
31+
contentlet: {
32+
...data,
33+
hostFolder: defaultSite.identifier
34+
}
2435
},
2536
headers: {
2637
Authorization: generateBase64Credentials(admin1.username, admin1.password)
@@ -30,10 +41,18 @@ export async function createPage(request: APIRequestContext, data: CreatePage) {
3041
expect(response.status()).toBe(200);
3142

3243
const responseData = await response.json();
33-
const results = responseData.entity.results as Page[];
44+
const results = responseData.entity.results;
3445
expect(results).toHaveLength(1);
46+
47+
// The API wraps the contentlet under its content type variable key
48+
// e.g. results[0] = { "htmlpageasset": { identifier, inode, ... } }
3549
const key = Object.keys(results[0])[0];
36-
return results[0][key as keyof Page];
50+
const page = results[0][key] as Page;
51+
52+
expect(page.inode).toBeTruthy();
53+
expect(page.identifier).toBeTruthy();
54+
55+
return page;
3756
}
3857

3958
export async function executeAction(request: APIRequestContext, actionId: string, inode: string) {
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
import { expect, test } from '@playwright/test';
2+
import { deleteContentlets } from '@requests/contentlets';
3+
import { ContentType, deleteContentType } from '@requests/contentType';
4+
import { createPage, Page as DotPage } from '@requests/pages';
5+
import { admin1 } from '@utils/credentials';
6+
import { generateBase64Credentials } from '@utils/generateBase64Credential';
7+
8+
/**
9+
* SystemWorkflow ID — standard across all dotCMS instances.
10+
*/
11+
const SYSTEM_WORKFLOW_ID = 'd61a59e1-a49c-46f2-a929-db2b4bfa88b2';
12+
13+
type ContentTypeWithVariable = ContentType & { variable: string };
14+
15+
/**
16+
* Navigate to edit content for a given inode and click the Settings tab (tab index 3).
17+
* Uses direct navigation instead of goToContent() because HTML pages don't have
18+
* the data-testid="title" field that goToContent() waits for.
19+
*/
20+
async function navigateToSettingsTab(page: import('@playwright/test').Page, inode: string) {
21+
await page.goto(`/dotAdmin/#/content/${inode}`);
22+
await page.waitForLoadState('domcontentloaded');
23+
// Wait for the edit content sidebar to confirm the editor has loaded
24+
await page.locator('dot-edit-content-sidebar').waitFor({ state: 'visible', timeout: 15000 });
25+
26+
// Settings tab is the p-tab containing the cog icon (tab index 3)
27+
const settingsTab = page.locator('p-tab:has(.pi-cog)');
28+
await settingsTab.waitFor({ state: 'visible', timeout: 10000 });
29+
await settingsTab.click();
30+
}
31+
32+
// ─── Permissions Dialog ───────────────────────────────────────────────────────
33+
34+
test.describe('Permissions Dialog', () => {
35+
test.describe.configure({ mode: 'serial' });
36+
37+
let htmlPage: DotPage;
38+
39+
test.beforeAll(async ({ request }) => {
40+
htmlPage = await createPage(request, {
41+
contentType: 'htmlpageasset',
42+
title: `E2E Permissions Test Page ${Date.now()}`,
43+
url: `e2e-permissions-test-${Date.now()}`,
44+
template: 'SYSTEM_TEMPLATE',
45+
friendlyName: 'E2E Permissions Test Page',
46+
cachettl: 0
47+
});
48+
});
49+
50+
test.afterAll(async ({ request }) => {
51+
if (htmlPage?.identifier) {
52+
await deleteContentlets(request, [htmlPage.identifier]);
53+
}
54+
});
55+
56+
test.describe('permissions card visibility', () => {
57+
test('permissions card is visible in settings tab for any saved contentlet @critical', async ({
58+
page
59+
}) => {
60+
await navigateToSettingsTab(page, htmlPage.inode);
61+
62+
await expect(page.getByTestId('permissions')).toBeAttached();
63+
await expect(page.getByTestId('permissions-card')).toBeVisible();
64+
});
65+
});
66+
67+
test.describe('open permissions dialog', () => {
68+
test('clicking the permissions card opens the permissions dialog @critical', async ({
69+
page
70+
}) => {
71+
await navigateToSettingsTab(page, htmlPage.inode);
72+
73+
await expect(page.getByTestId('permissions-card')).toBeVisible();
74+
await page.getByTestId('permissions-card').click();
75+
76+
await expect(page.locator('.p-dialog')).toBeVisible({ timeout: 10000 });
77+
await expect(page.getByTestId('permissions-iframe')).toBeVisible({ timeout: 10000 });
78+
});
79+
});
80+
81+
test.describe('close permissions dialog', () => {
82+
test('close button dismisses the permissions dialog @critical', async ({ page }) => {
83+
await navigateToSettingsTab(page, htmlPage.inode);
84+
await page.getByTestId('permissions-card').click();
85+
86+
await expect(page.locator('.p-dialog')).toBeVisible({ timeout: 10000 });
87+
await page.locator('.p-dialog-header-close, .p-dialog-close-button').click();
88+
89+
await expect(page.locator('.p-dialog')).toBeHidden();
90+
await expect(page.getByTestId('permissions-card')).toBeVisible();
91+
});
92+
93+
test('escape key does NOT close the permissions dialog @smoke', async ({ page }) => {
94+
await navigateToSettingsTab(page, htmlPage.inode);
95+
await page.getByTestId('permissions-card').click();
96+
97+
await expect(page.locator('.p-dialog')).toBeVisible({ timeout: 10000 });
98+
await page.keyboard.press('Escape');
99+
100+
// Dialog must remain open — closeOnEscape: false by design
101+
await expect(page.locator('.p-dialog')).toBeVisible();
102+
await expect(page.getByTestId('permissions-iframe')).toBeVisible();
103+
});
104+
});
105+
});
106+
107+
// ─── Rules Dialog ─────────────────────────────────────────────────────────────
108+
109+
test.describe('Rules Dialog', () => {
110+
// Serial mode ensures beforeAll runs once and all tests share the same page instance
111+
test.describe.configure({ mode: 'serial' });
112+
113+
let htmlPage: DotPage;
114+
115+
test.beforeAll(async ({ request }) => {
116+
htmlPage = await createPage(request, {
117+
contentType: 'htmlpageasset',
118+
title: `E2E Rules Test Page ${Date.now()}`,
119+
url: `e2e-rules-test-${Date.now()}`,
120+
template: 'SYSTEM_TEMPLATE',
121+
friendlyName: 'E2E Rules Test Page',
122+
cachettl: 0
123+
});
124+
});
125+
126+
test.afterAll(async ({ request }) => {
127+
if (htmlPage?.identifier) {
128+
await deleteContentlets(request, [htmlPage.identifier]);
129+
}
130+
});
131+
132+
test.describe('rules card visibility', () => {
133+
test('rules card is visible in settings tab for saved HTML Page @critical', async ({
134+
page
135+
}) => {
136+
await navigateToSettingsTab(page, htmlPage.inode);
137+
138+
await expect(page.getByTestId('rules')).toBeAttached();
139+
await expect(page.getByTestId('rules-card')).toBeVisible();
140+
});
141+
});
142+
143+
test.describe('open rules dialog', () => {
144+
test('clicking the rules card opens the rules dialog @critical', async ({ page }) => {
145+
await navigateToSettingsTab(page, htmlPage.inode);
146+
147+
await expect(page.getByTestId('rules-card')).toBeVisible();
148+
await page.getByTestId('rules-card').click();
149+
150+
await expect(page.locator('.p-dialog')).toBeVisible({ timeout: 10000 });
151+
await expect(page.getByTestId('rules-container')).toBeVisible({ timeout: 10000 });
152+
await expect(page.getByTestId('rules-empty')).not.toBeAttached();
153+
});
154+
});
155+
156+
test.describe('close rules dialog', () => {
157+
test('close button dismisses the rules dialog @critical', async ({ page }) => {
158+
await navigateToSettingsTab(page, htmlPage.inode);
159+
await page.getByTestId('rules-card').click();
160+
161+
await expect(page.locator('.p-dialog')).toBeVisible({ timeout: 10000 });
162+
await page.locator('.p-dialog-header-close, .p-dialog-close-button').click();
163+
164+
await expect(page.locator('.p-dialog')).toBeHidden();
165+
await expect(page.getByTestId('rules-card')).toBeVisible();
166+
});
167+
168+
test('escape key does NOT close the rules dialog @smoke', async ({ page }) => {
169+
await navigateToSettingsTab(page, htmlPage.inode);
170+
await page.getByTestId('rules-card').click();
171+
172+
await expect(page.locator('.p-dialog')).toBeVisible({ timeout: 10000 });
173+
await page.keyboard.press('Escape');
174+
175+
// Dialog must remain open — closeOnEscape: false by design
176+
await expect(page.locator('.p-dialog')).toBeVisible();
177+
await expect(page.getByTestId('rules-container')).toBeVisible();
178+
});
179+
});
180+
181+
test.describe('prevent duplicate dialogs', () => {
182+
test('rapid repeated clicks open only one dialog @critical', async ({ page }) => {
183+
await navigateToSettingsTab(page, htmlPage.inode);
184+
185+
const rulesCard = page.getByTestId('rules-card');
186+
await expect(rulesCard).toBeVisible();
187+
188+
// After the first click, the dialog mask intercepts real pointer events on the card.
189+
// Programmatic HTMLElement.click() still fires on the card (stress test for duplicate opens).
190+
for (let i = 0; i < 3; i++) {
191+
await rulesCard.evaluate((el) => (el as HTMLElement).click());
192+
}
193+
194+
await expect(page.locator('.p-dialog')).toHaveCount(1, { timeout: 10000 });
195+
});
196+
});
197+
198+
test.describe('rules card not visible for non-Page content type', () => {
199+
test.describe.configure({ mode: 'serial' });
200+
201+
let contentType: ContentTypeWithVariable;
202+
let contentletInode: string;
203+
let contentletIdentifier: string;
204+
205+
test.beforeEach(async ({ request }) => {
206+
const suffix = Date.now();
207+
208+
// Create a simple (non-Page) content type with the new editor enabled.
209+
// Avoids createFakeContentType which calls /api/v1/schemas (404).
210+
const ctResponse = await request.post('/api/v1/contenttype', {
211+
data: {
212+
clazz: 'com.dotcms.contenttype.model.type.ImmutableSimpleContentType',
213+
name: `RulesTestType${suffix}`,
214+
variable: `rulesCT${suffix}`,
215+
host: 'SYSTEM_HOST',
216+
folder: 'SYSTEM_FOLDER',
217+
metadata: { CONTENT_EDITOR2_ENABLED: true },
218+
workflow: [SYSTEM_WORKFLOW_ID],
219+
fields: [
220+
{
221+
clazz: 'com.dotcms.contenttype.model.field.ImmutableTextField',
222+
name: 'Title',
223+
variable: 'title',
224+
sortOrder: 1
225+
}
226+
]
227+
},
228+
headers: {
229+
Authorization: generateBase64Credentials(admin1.username, admin1.password)
230+
}
231+
});
232+
const ctData = await ctResponse.json();
233+
contentType = (ctData.entity[0] ?? ctData.entity) as ContentTypeWithVariable;
234+
235+
const contentletResponse = await request.post(
236+
'/api/v1/workflow/actions/default/fire/PUBLISH?indexPolicy=WAIT_FOR',
237+
{
238+
data: {
239+
contentlet: {
240+
contentType: contentType.variable,
241+
title: `Rules Non-Page Test ${suffix}`
242+
}
243+
},
244+
headers: {
245+
Authorization: generateBase64Credentials(admin1.username, admin1.password)
246+
}
247+
}
248+
);
249+
const contentletData = await contentletResponse.json();
250+
const key = Object.keys(contentletData.entity.results[0])[0];
251+
const contentlet = contentletData.entity.results[0][key];
252+
contentletInode = contentlet.inode;
253+
contentletIdentifier = contentlet.identifier;
254+
});
255+
256+
test.afterEach(async ({ request }) => {
257+
if (contentletIdentifier) {
258+
await deleteContentlets(request, [contentletIdentifier]);
259+
}
260+
if (contentType?.id) {
261+
await deleteContentType(request, contentType.id);
262+
}
263+
});
264+
265+
test('rules card is absent in settings tab for non-Page contentlet @critical', async ({
266+
page
267+
}) => {
268+
await navigateToSettingsTab(page, contentletInode);
269+
270+
await expect(page.getByTestId('rules')).not.toBeAttached();
271+
await expect(page.getByTestId('rules-card')).not.toBeAttached();
272+
});
273+
});
274+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
@if (identifier) {
2+
<div data-testId="rules-container" class="w-full h-full">
3+
<dot-rule-engine-container />
4+
</div>
5+
} @else {
6+
<div data-testId="rules-empty" class="flex align-items-center justify-content-center p-4">
7+
No content selected
8+
</div>
9+
}

0 commit comments

Comments
 (0)