Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
592c663
Add report issue workflow to backend and UI
fmontes May 15, 2026
0dbce49
refactor(report-issue): adopt signal-based inputs/outputs
fmontes May 15, 2026
9bfeed6
Type report issue service response
fmontes May 15, 2026
861834f
refactor(report-issue): relocate component to view/components
fmontes May 15, 2026
0697fc5
Merge branch 'main' into fmontes/report-bug-button
fmontes May 15, 2026
893ead0
refactor(report-issue): address review feedback
fmontes May 15, 2026
e5a999b
feat(report-issue): add anonymous toggle and operator PII opt-out
fmontes May 15, 2026
5f5cad0
refactor(report-issue): namespace PII config constant
fmontes May 15, 2026
ebdd4cb
style(report-issue): fix import order lint errors
fmontes May 18, 2026
70d14c9
fix format
fmontes May 18, 2026
0cdc2cc
feat(report-issue): reflect operator PII opt-out in the dialog
fmontes May 18, 2026
757c483
Merge branch 'main' into fmontes/report-bug-button
fmontes May 18, 2026
09ceaac
fix(report-issue): match submit button type to actual behavior
fmontes May 18, 2026
b47bc19
Fix affected lint warnings
fmontes May 18, 2026
c9a310b
fix format
fmontes May 18, 2026
a006d25
Merge branch 'main' into fmontes/report-bug-button
fmontes May 18, 2026
2da6787
Remove unrelated e2e lint changes
fmontes May 18, 2026
0e40582
Address report issue review feedback
fmontes May 18, 2026
8b8a301
Fix report issue PII config lookup
fmontes May 18, 2026
2b4072c
Remove unrelated spec lint changes
fmontes May 18, 2026
2df574c
Simplify report issue toolbar state
fmontes May 18, 2026
b76a26a
Add TSDoc for report issue UI
fmontes May 18, 2026
3177d2a
Merge branch 'main' into fmontes/report-bug-button
fmontes May 18, 2026
70c662f
Merge branch 'main' into fmontes/report-bug-button
fmontes May 19, 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
23 changes: 6 additions & 17 deletions core-web/apps/dotcms-ui-e2e/src/pages/newEditContentForm.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,27 +20,16 @@ export class NewEditContentFormPage {
*/
async clickNewContentFromList() {
const frame = getLegacyFrame(this.page);

// Wait for the Dojo iframe to fully load and widgets to initialize
await frame
.locator('.dijitDropDownButton')
.first()
.waitFor({ state: 'visible', timeout: 15000 });
// Small delay for Dojo widget initialization after DOM is visible
await this.page.waitForTimeout(500);

// Click the Dojo "+" dropdown button
const addButton = frame.locator('.dijitDropDownButton [role="button"]').first();
const addNewOption = frame.locator('.dijitMenuItemLabel', { hasText: 'Add New Content' });

await expect(addButton).toBeVisible({ timeout: 15000 });
await addButton.click();

// The dropdown menu renders inside the iframe.
// Use force:true because Dojo menus can flicker during animation.
const addNewOption = frame.locator('.dijitMenuItemLabel', { hasText: 'Add New Content' });
await addNewOption.waitFor({ state: 'visible', timeout: 10000 });
await addNewOption.click({ force: true });
await expect(addNewOption).toBeVisible({ timeout: 10000 });
await addNewOption.click();

// Wait for the Angular form to render (replaces networkidle which is unreliable in SPAs)
await this.page.getByTestId('title').waitFor({ state: 'visible', timeout: 15000 });
await expect(this.page.getByTestId('title')).toBeVisible({ timeout: 15000 });
}

async fillTextField(text: string) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ const IMMUTABLE_SIMPLE_CT = 'com.dotcms.contenttype.model.type.ImmutableSimpleCo
const IMMUTABLE_TEXT_FIELD = 'com.dotcms.contenttype.model.field.ImmutableTextField';
const IMMUTABLE_REL_FIELD = 'com.dotcms.contenttype.model.field.ImmutableRelationshipField';

async function getNonEmptyLowercaseHeaderTexts(headers: {
allTextContents(): Promise<string[]>;
}): Promise<string[]> {
return (await headers.allTextContents())
.map((text) => text.trim().toLowerCase())
.filter(Boolean);
}

/**
* Blog content type with title + `authors` (1:N) and optional extra relationship fields.
*/
Expand Down Expand Up @@ -251,14 +259,7 @@ test.describe('Custom Columns (showFields)', () => {
const table = relationshipField.table;
const headers = table.locator('thead th');

const headerTexts: string[] = [];
const headerCount = await headers.count();
for (let i = 0; i < headerCount; i++) {
const text = await headers.nth(i).textContent();
if (text?.trim()) {
headerTexts.push(text.trim().toLowerCase());
}
}
const headerTexts = await getNonEmptyLowercaseHeaderTexts(headers);

expect(headerTexts.some((h) => h.includes('title'))).toBe(true);
expect(headerTexts.some((h) => h.includes('bio'))).toBe(true);
Expand Down Expand Up @@ -292,14 +293,7 @@ test.describe('Custom Columns (showFields)', () => {
const table = adminPage.getByTestId('relationship-field-table');
const headers = table.locator('thead th');

const headerTexts: string[] = [];
const headerCount = await headers.count();
for (let i = 0; i < headerCount; i++) {
const text = await headers.nth(i).textContent();
if (text?.trim()) {
headerTexts.push(text.trim().toLowerCase());
}
}
const headerTexts = await getNonEmptyLowercaseHeaderTexts(headers);

expect(headerTexts.some((h) => h.includes('title'))).toBe(true);
expect(headerTexts.some((h) => h.includes('language'))).toBe(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ import {
type TestContentlet
} from '../../../../fixtures/relationship.fixture';

async function findConstrainedAndFreeRowIndexes(
dialog: SelectExistingContentDialog,
constrainedText: string
): Promise<{ constrainedIdx: number; freeIdx: number }> {
const rowTexts = await dialog.rows.allTextContents();
const constrainedIdx = rowTexts.findIndex((rowText) => rowText.includes(constrainedText));
const freeIdx = rowTexts.findIndex((_, index) => index !== constrainedIdx);

return { constrainedIdx, freeIdx };
}

/**
* Verifies that the "Select Existing Content" dialog disables items
* already related to another parent (ONE_TO_MANY cardinality).
Expand Down Expand Up @@ -111,17 +122,10 @@ test.describe('Cardinality Constraints', () => {

await dialog.expectRowCount(2);

let constrainedIdx = -1;
let freeIdx = -1;

for (let i = 0; i < 2; i++) {
const rowText = await dialog.rows.nth(i).textContent();
if (rowText?.includes('Comment 1')) {
constrainedIdx = i;
} else {
freeIdx = i;
}
}
const { constrainedIdx, freeIdx } = await findConstrainedAndFreeRowIndexes(
dialog,
'Comment 1'
);

expect(constrainedIdx, 'Comment 1 should exist in dialog').toBeGreaterThanOrEqual(0);

Expand Down Expand Up @@ -226,17 +230,10 @@ test.describe('Cardinality Constraints', () => {

await dialog.expectRowCount(2);

let constrainedIdx = -1;
let freeIdx = -1;

for (let i = 0; i < 2; i++) {
const rowText = await dialog.rows.nth(i).textContent();
if (rowText?.includes('Child 1')) {
constrainedIdx = i;
} else {
freeIdx = i;
}
}
const { constrainedIdx, freeIdx } = await findConstrainedAndFreeRowIndexes(
dialog,
'Child 1'
);

expect(constrainedIdx, 'Child 1 should exist in dialog').toBeGreaterThanOrEqual(0);

Expand All @@ -263,17 +260,10 @@ test.describe('Cardinality Constraints', () => {

await dialog.expectRowCount(2);

let constrainedIdx = -1;
let freeIdx = -1;

for (let i = 0; i < 2; i++) {
const rowText = await dialog.rows.nth(i).textContent();
if (rowText?.includes('Child 1')) {
constrainedIdx = i;
} else {
freeIdx = i;
}
}
const { constrainedIdx, freeIdx } = await findConstrainedAndFreeRowIndexes(
dialog,
'Child 1'
);

expect(constrainedIdx, 'Child 1 should exist in dialog').toBeGreaterThanOrEqual(0);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,19 @@ import {
TestContentlet
} from '../../../../fixtures/relationship.fixture';

function assertBounds(
bounds: { x: number; y: number; width: number; height: number } | null,
label: string
) {
expect(bounds, `${label} bounds should be available`).toBeTruthy();

if (!bounds) {
throw new Error(`${label} bounds should be available`);
}

return bounds;
}

// ─── Reorder (Drag & Drop) ──────────────────────────────────────

test.describe('Reorder (Drag & Drop)', () => {
Expand Down Expand Up @@ -74,18 +87,17 @@ test.describe('Reorder (Drag & Drop)', () => {

const sourceBounds = await sourceHandle.boundingBox();
const targetBounds = await targetHandle.boundingBox();

expect(sourceBounds).toBeTruthy();
expect(targetBounds).toBeTruthy();
const safeSourceBounds = assertBounds(sourceBounds, 'source');
const safeTargetBounds = assertBounds(targetBounds, 'target');

await adminPage.mouse.move(
sourceBounds!.x + sourceBounds!.width / 2,
sourceBounds!.y + sourceBounds!.height / 2
safeSourceBounds.x + safeSourceBounds.width / 2,
safeSourceBounds.y + safeSourceBounds.height / 2
);
await adminPage.mouse.down();
await adminPage.mouse.move(
targetBounds!.x + targetBounds!.width / 2,
targetBounds!.y + targetBounds!.height / 2,
safeTargetBounds.x + safeTargetBounds.width / 2,
safeTargetBounds.y + safeTargetBounds.height / 2,
{ steps: 10 }
);
await adminPage.mouse.up();
Expand Down
13 changes: 7 additions & 6 deletions core-web/apps/dotcms-ui-e2e/src/tests/login/translations.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { expect, test } from '@playwright/test';
import { waitForVisibleAndCallback } from '@utils/utils';

import { assert } from 'console';

const languages = [
{ language: 'español (España)', translation: '¡Bienvenido!' },
{ language: 'italiano (Italia)', translation: 'Benvenuto!' },
Expand All @@ -27,10 +25,13 @@ languages.forEach((list) => {
dropdownTriggerLocator.click()
);

const pageByTextLocator = page.getByText(language);
await waitForVisibleAndCallback(pageByTextLocator, () => pageByTextLocator.click());
const pageContentLocator = dropdownTriggerLocator.locator('xpath=ancestor::body');
const languageOptionLocator = pageContentLocator.getByText(language);
await languageOptionLocator.waitFor({ state: 'visible' });
await languageOptionLocator.click();

// Assertion of the translation
assert(await expect(page.getByTestId('header')).toContainText(translation));
await expect(pageContentLocator.locator('[data-testid="header"]')).toContainText(
translation
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';

import {
DotReportIssueContentlet,
DotReportIssuePayload,
DotReportIssueService
} from './dot-report-issue.service';

const MOCK_REPORT_ISSUE_CONTENTLET: DotReportIssueContentlet = {
archived: false,
baseType: 'CONTENT',
contentType: 'Bug',
folder: 'SYSTEM_FOLDER',
hasTitleImage: false,
host: 'host-id',
hostName: 'dotcms.dev',
identifier: 'identifier-id',
inode: 'inode-id',
languageId: 1,
live: false,
locked: false,
modDate: '1778806040235',
modUser: 'user-id',
modUserName: 'Bug Reporter',
owner: 'user-id',
sortOrder: 0,
stInode: 'stInode-id',
title: '/plugins [1.0.0-SNAPSHOT - Chrome]',
titleImage: 'screenshot',
url: '/content.inode-id',
working: true,
metadata: {
browser: 'Chrome',
url: 'http://localhost:8080/dotAdmin/#/plugins?mId=1a87'
}
};

describe('DotReportIssueService', () => {
let service: DotReportIssueService;
let httpTesting: HttpTestingController;

beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), DotReportIssueService]
});

service = TestBed.inject(DotReportIssueService);
httpTesting = TestBed.inject(HttpTestingController);
});

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

it('should submit report issue multipart payload with description and metadata', () => {
const payload: DotReportIssuePayload = {
description: 'Login button is unresponsive',
metadata: {
browser: 'Chrome',
url: 'http://localhost:8080/dotAdmin'
}
};

service.reportIssue(payload).subscribe((response) => {
expect(response).toEqual(MOCK_REPORT_ISSUE_CONTENTLET);
});

const req = httpTesting.expectOne('/api/v1/report-issue');
expect(req.request.method).toBe('POST');
expect(req.request.body).toBeInstanceOf(FormData);
expect(req.request.body.get('description')).toBe(payload.description);
expect(req.request.body.get('metadata')).toBe(JSON.stringify(payload.metadata));
expect(req.request.body.get('anonymous')).toBe('false');
expect(req.request.body.get('screenshot')).toBeNull();

req.flush({ entity: MOCK_REPORT_ISSUE_CONTENTLET });
});

it('should send anonymous=true when payload anonymous is true', () => {
const payload: DotReportIssuePayload = {
description: 'Anonymous report',
metadata: { browser: 'Chrome' },
anonymous: true
};

service.reportIssue(payload).subscribe();

const req = httpTesting.expectOne('/api/v1/report-issue');
expect(req.request.body.get('anonymous')).toBe('true');

req.flush({ entity: MOCK_REPORT_ISSUE_CONTENTLET });
});

it('should include screenshot when provided', () => {
const screenshot = new File(['image'], 'screenshot.png', { type: 'image/png' });
const payload: DotReportIssuePayload = {
description: 'Issue with screenshot',
metadata: {
browser: 'Safari'
},
screenshot
};

service.reportIssue(payload).subscribe();

const req = httpTesting.expectOne('/api/v1/report-issue');
expect(req.request.method).toBe('POST');
expect(req.request.body.get('screenshot')).toBe(screenshot);

req.flush({ entity: MOCK_REPORT_ISSUE_CONTENTLET });
});
});
Loading
Loading