diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-report-issue/dot-report-issue.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-report-issue/dot-report-issue.component.spec.ts index 2c5a5d979b7..f9a13ea2f17 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-report-issue/dot-report-issue.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-report-issue/dot-report-issue.component.spec.ts @@ -7,7 +7,6 @@ import { FileSelectEvent } from 'primeng/fileupload'; import { DotGlobalMessageService, - DotHttpErrorManagerService, DotMessageService, DotPropertiesService } from '@dotcms/data-access'; @@ -23,7 +22,6 @@ describe('DotReportIssueComponent', () => { const reportIssueMock = jest.fn(() => of('')); const successMock = jest.fn(); - const handleMock = jest.fn(() => of({})); const getKeyMock = jest.fn(() => of(true as string | boolean)); const createComponent = createComponentFactory({ @@ -35,9 +33,6 @@ describe('DotReportIssueComponent', () => { mockProvider(DotGlobalMessageService, { success: successMock }), - mockProvider(DotHttpErrorManagerService, { - handle: handleMock - }), mockProvider(DotMessageService, { get: (key: string) => key }), @@ -57,8 +52,6 @@ describe('DotReportIssueComponent', () => { reportIssueMock.mockReset(); reportIssueMock.mockReturnValue(of('')); successMock.mockReset(); - handleMock.mockReset(); - handleMock.mockReturnValue(of({})); getKeyMock.mockReset(); getKeyMock.mockReturnValue(of(true)); @@ -232,10 +225,27 @@ describe('DotReportIssueComponent', () => { expect(component.form.get('description')?.value).toBe('Broken publish button'); expect(component.screenshotFile()).toBe(screenshot); expect(component.shutdown.emit).not.toHaveBeenCalled(); - expect(handleMock).toHaveBeenCalled(); expect(component.errorMessage()).toBeTruthy(); }); + it('should show the fallback message when the backend returns an HTML error page', () => { + reportIssueMock.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 404, + error: 'Not Found' + }) + ) + ); + + component.form.get('description')?.setValue('Broken publish button'); + component.save(); + + expect(component.isSubmitting()).toBe(false); + expect(component.errorMessage()).toBe('report-an-issue.error.unavailable'); + }); + it('should surface a backend media type error and stop loading', () => { const screenshot = new File(['image'], 'screenshot.png', { type: 'image/png' }); reportIssueMock.mockReturnValue( @@ -260,7 +270,6 @@ describe('DotReportIssueComponent', () => { expect(component.isSubmitting()).toBe(false); expect(component.errorMessage()).toBe('HTTP 415 Unsupported Media Type'); - expect(handleMock).toHaveBeenCalled(); expect(spectator.query('[data-testid="dot-report-issue-error-message"]'))?.toHaveText( 'HTTP 415 Unsupported Media Type' ); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-report-issue/dot-report-issue.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-report-issue/dot-report-issue.component.ts index 6449b56750f..e3caf5c9f65 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-report-issue/dot-report-issue.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-report-issue/dot-report-issue.component.ts @@ -31,7 +31,6 @@ import { map } from 'rxjs/operators'; import { DotGlobalMessageService, - DotHttpErrorManagerService, DotMessageService, DotPropertiesService } from '@dotcms/data-access'; @@ -71,7 +70,6 @@ export class DotReportIssueComponent { private readonly fb = inject(FormBuilder); private readonly dotMessageService = inject(DotMessageService); private readonly dotGlobalMessageService = inject(DotGlobalMessageService); - private readonly dotHttpErrorManagerService = inject(DotHttpErrorManagerService); private readonly dotReportIssueService = inject(DotReportIssueService); private readonly dotPropertiesService = inject(DotPropertiesService); private readonly location = inject(LOCATION_TOKEN); @@ -228,7 +226,6 @@ export class DotReportIssueComponent { error: (error) => { this.isSubmitting.set(false); this.errorMessage.set(this.getRequestErrorMessage(error)); - this.dotHttpErrorManagerService.handle(error).subscribe(); } }); } @@ -294,6 +291,11 @@ export class DotReportIssueComponent { }; if (typeof httpError.error === 'string') { + // HTML responses (e.g. dotCMS 404 pages) mean the endpoint is not reachable. + if (httpError.error.trimStart().startsWith('<')) { + return this.dotMessageService.get('report-an-issue.error.unavailable'); + } + try { const parsed = JSON.parse(httpError.error) as { message?: string }; return parsed.message || httpError.message || fallback; diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/dot-toolbar-user.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/dot-toolbar-user.component.spec.ts index 79a49dbdd88..9f3a3533661 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/dot-toolbar-user.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/dot-toolbar-user.component.spec.ts @@ -11,12 +11,13 @@ import { By } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { RouterTestingModule } from '@angular/router/testing'; -import { take } from 'rxjs/operators'; +import { filter, take } from 'rxjs/operators'; import { DotGlobalMessageService, DotHttpErrorManagerService, DotMessageService, + DotPropertiesService, DotUiColorsService } from '@dotcms/data-access'; import { LoggerService, LoginService } from '@dotcms/dotcms-js'; @@ -66,6 +67,13 @@ describe('DotToolbarUserComponent', () => { provide: DotReportIssueService, useValue: { reportIssue: jest.fn(() => of('')) } }, + { + provide: DotPropertiesService, + useValue: { + getKey: jest.fn(() => of('true')), + getFeatureFlagWithDefault: jest.fn(() => of(true)) + } + }, { provide: DotUiColorsService, useClass: MockDotUiColorsService }, DotToolbarUserStore ], @@ -241,15 +249,35 @@ describe('DotToolbarUserComponent', () => { }); it('should open the report issue dialog from the menu item command', fakeAsync(() => { + jest.spyOn(loginService, 'watchUser').mockImplementation((callback) => { + callback({ + user: { + emailAddress: 'admin@dotcms.com', + name: 'Admin User', + fullName: 'Admin User' + }, + loginAsUser: null, + isLoginAs: false + } as any); + }); + fixture.detectChanges(); let reportIssueCommand: (() => void) | undefined; - fixture.componentInstance.vm$.pipe(take(1)).subscribe((vm) => { - reportIssueCommand = vm.items.find( - (item) => item.id === 'dot-toolbar-user-link-report-issue' - )?.command as (() => void) | undefined; - }); + // filter skips the startWith(false) emission where the item is absent. + fixture.componentInstance.vm$ + .pipe( + filter((vm) => + vm.items.some((item) => item.id === 'dot-toolbar-user-link-report-issue') + ), + take(1) + ) + .subscribe((vm) => { + reportIssueCommand = vm.items.find( + (item) => item.id === 'dot-toolbar-user-link-report-issue' + )?.command as (() => void) | undefined; + }); tick(); reportIssueCommand?.(); @@ -257,4 +285,24 @@ describe('DotToolbarUserComponent', () => { expect(fixture.componentInstance.$showReportIssue()).toBe(true); })); + + it('should hide the report issue menu item when the feature flag is disabled', fakeAsync(() => { + const dotPropertiesService = TestBed.inject(DotPropertiesService); + (dotPropertiesService.getFeatureFlagWithDefault as jest.Mock).mockReturnValue(of(false)); + + // Rebuild the component so the new mock value is what vm$ sees. + fixture = TestBed.createComponent(DotToolbarUserComponent); + fixture.detectChanges(); + + let reportIssueItem: { id?: string } | undefined; + fixture.componentInstance.vm$.pipe(take(1)).subscribe((vm) => { + reportIssueItem = vm.items.find( + (item) => item.id === 'dot-toolbar-user-link-report-issue' + ); + }); + + tick(); + + expect(reportIssueItem).toBeUndefined(); + })); }); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/dot-toolbar-user.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/dot-toolbar-user.component.ts index 5a491d6c508..4afb1acea0b 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/dot-toolbar-user.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/dot-toolbar-user.component.ts @@ -1,4 +1,4 @@ -import { map } from 'rxjs'; +import { combineLatest, map, of } from 'rxjs'; import { AsyncPipe } from '@angular/common'; import { @@ -14,6 +14,10 @@ import { MenuItem } from 'primeng/api'; import { AvatarModule } from 'primeng/avatar'; import { Menu, MenuModule } from 'primeng/menu'; +import { catchError, startWith } from 'rxjs/operators'; + +import { DotPropertiesService } from '@dotcms/data-access'; +import { FeaturedFlags } from '@dotcms/dotcms-models'; import { DotGravatarDirective } from '@dotcms/ui'; import { DotToolbarUserStore } from './store/dot-toolbar-user.store'; @@ -39,12 +43,30 @@ import { DotMyAccountComponent } from '../dot-my-account/dot-my-account.componen }) export class DotToolbarUserComponent implements OnInit { readonly store = inject(DotToolbarUserStore); + readonly #dotPropertiesService = inject(DotPropertiesService); readonly $showReportIssue = signal(false); - readonly vm$ = this.store.vm$.pipe( - map((vm) => ({ + // Flag check lives here (not in the store) because this is meant to be + // removed after launch — easier to rip out from the component than to + // unwind from the store wiring. + readonly vm$ = combineLatest([ + this.store.vm$, + this.#dotPropertiesService + .getFeatureFlagWithDefault(FeaturedFlags.FEATURE_FLAG_REPORT_ISSUE_ENABLED, false) + // startWith renders the toolbar immediately at flag=off; catchError keeps it + // visible if /api/v1/configuration/config errors instead of taking it down. + .pipe( + startWith(false), + catchError(() => of(false)) + ) + ]).pipe( + map(([vm, reportIssueEnabled]) => ({ ...vm, - items: this.withReportIssueCommand(vm.items) + items: this.withReportIssueCommand( + reportIssueEnabled + ? vm.items + : vm.items.filter((item) => item.id !== 'dot-toolbar-user-link-report-issue') + ) })) ); $menu = viewChild('menu'); diff --git a/core-web/libs/dotcms-models/src/lib/shared-models.ts b/core-web/libs/dotcms-models/src/lib/shared-models.ts index f3daca73f54..8afa02e4eee 100644 --- a/core-web/libs/dotcms-models/src/lib/shared-models.ts +++ b/core-web/libs/dotcms-models/src/lib/shared-models.ts @@ -34,7 +34,8 @@ export const enum FeaturedFlags { FEATURE_FLAG_UVE_STYLE_EDITOR = 'FEATURE_FLAG_UVE_STYLE_EDITOR', FEATURE_FLAG_PAGE_SCANNER = 'FEATURE_FLAG_PAGE_SCANNER', FEATURE_FLAG_UVE_LEGACY_SCRIPT_INJECTION = 'FEATURE_FLAG_UVE_LEGACY_SCRIPT_INJECTION', - FEATURE_FLAG_NEW_BLOCK_EDITOR = 'FEATURE_FLAG_NEW_BLOCK_EDITOR' + FEATURE_FLAG_NEW_BLOCK_EDITOR = 'FEATURE_FLAG_NEW_BLOCK_EDITOR', + FEATURE_FLAG_REPORT_ISSUE_ENABLED = 'FEATURE_FLAG_REPORT_ISSUE_ENABLED' } export const enum DotConfigurationVariables { diff --git a/dotCMS/src/main/java/com/dotcms/featureflag/FeatureFlagName.java b/dotCMS/src/main/java/com/dotcms/featureflag/FeatureFlagName.java index 3181ca7caf0..df516ec1a2b 100644 --- a/dotCMS/src/main/java/com/dotcms/featureflag/FeatureFlagName.java +++ b/dotCMS/src/main/java/com/dotcms/featureflag/FeatureFlagName.java @@ -65,6 +65,7 @@ public interface FeatureFlagName { String FEATURE_FLAG_NEW_BLOCK_EDITOR = "FEATURE_FLAG_NEW_BLOCK_EDITOR"; + String FEATURE_FLAG_REPORT_ISSUE_ENABLED = "FEATURE_FLAG_REPORT_ISSUE_ENABLED"; /** * Enables the new content editor (Edit Content v2). * Also checked in content-type metadata to opt individual types out. diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java index a792efd16b4..23d4176d487 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java @@ -105,7 +105,8 @@ public class ConfigurationResource implements Serializable { PageScannerResource.API_URL_PROPERTY, FeatureFlagName.FEATURE_FLAG_UVE_LEGACY_SCRIPT_INJECTION, FeatureFlagName.FEATURE_FLAG_NEW_BLOCK_EDITOR, - "REPORT_ISSUE_INCLUDE_USER_PII" })); + REPORT_ISSUE_INCLUDE_USER_PII, + FeatureFlagName.FEATURE_FLAG_REPORT_ISSUE_ENABLED })); private boolean isOnBlackList(final String key) { return null != JVMInfoResource.obfuscatePattern ? JVMInfoResource.obfuscatePattern.matcher(key).find() : false; diff --git a/dotCMS/src/main/resources/dotmarketing-config.properties b/dotCMS/src/main/resources/dotmarketing-config.properties index 06d920eb4f0..7fa9ad12b72 100644 --- a/dotCMS/src/main/resources/dotmarketing-config.properties +++ b/dotCMS/src/main/resources/dotmarketing-config.properties @@ -869,6 +869,9 @@ FEATURE_FLAG_UVE_LEGACY_SCRIPT_INJECTION=false ## New TipTap-v3 Block Editor (rollback safety: legacy editor renders by default) FEATURE_FLAG_NEW_BLOCK_EDITOR=false +## Report Issue entry in the toolbar user menu (opt-in) +FEATURE_FLAG_REPORT_ISSUE_ENABLED=false + STARTER_BUILD_VERSION=${starter.deploy.version} ##LTS properties to show EOL message diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 8ab370fbc73..33667ecc4a3 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -3049,6 +3049,7 @@ report-an-issue.screenshot=Screenshot (optional) report-an-issue.screenshot.invalid-type=Screenshot must be a PNG, JPEG, or WebP image report-an-issue.screenshot.max-size=Screenshot must be 10 MB or smaller report-an-issue.error=Unable to submit the issue report +report-an-issue.error.unavailable=The report issue service is unavailable. If the issue continues, contact your administrator. report-an-issue.success=Issue report submitted successfully report-an-issue.anonymous=Send anonymously (do not include my name or email) report-an-issue.anonymous.enforced=Anonymous submission is enforced by your administrator.