Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 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
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 });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { Observable } from 'rxjs';

import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';

import { map } from 'rxjs/operators';

import { DotCMSContentlet, DotCMSResponse } from '@dotcms/dotcms-models';

export interface DotReportIssuePayload {
description: string;
metadata: Record<string, string>;
screenshot?: File | null;
anonymous?: boolean;
}

export interface DotReportIssueUserMetadata {
email: string;
fullName: string;
userId: string;
}

export interface DotReportIssueMetadata {
browser?: string;
dotcmsBuildDate?: string;
dotcmsVersion?: string;
platform?: string;
referer?: string;
referrer?: string;
remoteAddress?: string;
requestUrl?: string;
serverName?: string;
submittedAt?: string;
url?: string;
user?: DotReportIssueUserMetadata;
userAgent?: string;
viewport?: string;
[key: string]: unknown;
}

export interface DotReportIssueScreenshotMetadata {
contentType: string;
editableAsText: boolean;
fileSize: number;
height?: number;
isImage: boolean;
length: number;
modDate: number;
name: string;
sha256: string;
title: string;
version: number;
width?: number;
}

export interface DotReportIssueContentlet extends DotCMSContentlet {
metadata?: DotReportIssueMetadata;
screenshot?: string;
screenshotContentAsset?: string;
screenshotMetaData?: DotReportIssueScreenshotMetadata;
screenshotVersion?: string;
}

@Injectable()
export class DotReportIssueService {
private readonly http = inject(HttpClient);

reportIssue(payload: DotReportIssuePayload): Observable<DotReportIssueContentlet> {
const formData = new FormData();

formData.append('description', payload.description);
formData.append('metadata', JSON.stringify(payload.metadata));
formData.append('anonymous', String(payload.anonymous === true));

if (payload.screenshot) {
formData.append('screenshot', payload.screenshot);
}

return this.http
.post<DotCMSResponse<DotReportIssueContentlet>>('/api/v1/report-issue', formData)
.pipe(map((response) => response.entity));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ describe('DynamicRouteService', () => {
mockMainRoute.children = [];
});

const getFirstChildRoute = (): Route => {
const [firstChild] = mockMainRoute.children ?? [];
expect(firstChild).toBeDefined();

return firstChild as Route;
};

describe('registerRoute', () => {
it('should register a route with a component', () => {
const result = service.registerRoute({
Expand All @@ -54,8 +61,8 @@ describe('DynamicRouteService', () => {

expect(result).toBe(true);
expect(mockMainRoute.children).toHaveLength(1);
expect(mockMainRoute.children![0].path).toBe('test-portlet');
expect(mockMainRoute.children![0].component).toBe(MockComponent);
expect(getFirstChildRoute().path).toBe('test-portlet');
expect(getFirstChildRoute().component).toBe(MockComponent);
});

it('should register a route with loadComponent', () => {
Expand All @@ -67,7 +74,7 @@ describe('DynamicRouteService', () => {
});

expect(result).toBe(true);
expect(mockMainRoute.children![0].loadComponent).toBe(loadFn);
expect(getFirstChildRoute().loadComponent).toBe(loadFn);
});

it('should add MenuGuardService by default', () => {
Expand All @@ -76,8 +83,8 @@ describe('DynamicRouteService', () => {
component: MockComponent
});

expect(mockMainRoute.children![0].canActivate).toContain(MenuGuardService);
expect(mockMainRoute.children![0].canActivateChild).toContain(MenuGuardService);
expect(getFirstChildRoute().canActivate).toContain(MenuGuardService);
expect(getFirstChildRoute().canActivateChild).toContain(MenuGuardService);
});

it('should not add guards when canActivate is false', () => {
Expand All @@ -87,7 +94,7 @@ describe('DynamicRouteService', () => {
canActivate: false
});

expect(mockMainRoute.children![0].canActivate).toBeUndefined();
expect(getFirstChildRoute().canActivate).toBeUndefined();
});

it('should not register duplicate routes', () => {
Expand Down Expand Up @@ -121,7 +128,7 @@ describe('DynamicRouteService', () => {
data: { customKey: 'customValue' }
});

expect(mockMainRoute.children![0].data).toEqual({
expect(getFirstChildRoute().data).toEqual({
reuseRoute: false,
customKey: 'customValue'
});
Expand Down
Loading
Loading