diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-tab/content-type-fields-tab.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-tab/content-type-fields-tab.component.spec.ts index 54265ea3be7f..2418cd3b6d61 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-tab/content-type-fields-tab.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-tab/content-type-fields-tab.component.spec.ts @@ -17,6 +17,7 @@ import { dotcmsContentTypeFieldBasicMock, MockDotMessageService } from '@dotcms/ import { ContentTypeFieldsTabComponent } from '.'; import { DOTTestBed } from '../../../../../../test/dot-test-bed'; +import { DotMaxlengthDirective } from '../../../../../../view/directives/dot-maxlength/dot-maxlength.directive'; const tabField: DotCMSContentTypeField = { ...dotcmsContentTypeFieldBasicMock, @@ -59,7 +60,7 @@ describe('ContentTypeFieldsTabComponent', () => { beforeEach(waitForAsync(() => { DOTTestBed.configureTestingModule({ declarations: [ContentTypeFieldsTabComponent, DotTestHostComponent], - imports: [TooltipModule, ButtonModule, DotMessagePipe], + imports: [TooltipModule, ButtonModule, DotMessagePipe, DotMaxlengthDirective], providers: [ DotAlertConfirmService, { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/layout/content-types-layout.component.html b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/layout/content-types-layout.component.html index ee5b19615af0..d98e4b532252 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/layout/content-types-layout.component.html +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/layout/content-types-layout.component.html @@ -1,23 +1,26 @@
- + - + {{ 'contenttypes.tab.fields.header' | dm }} @if ($contentType() && $showStyleEditorTab()) { - + {{ 'contenttypes.tab.style.editor.header' | dm }} } - @if ($contentType() && showPermissionsTab | async) { - + @if ($contentType() && $showPermissionsTab()) { + {{ 'contenttypes.tab.permissions.header' | dm }} } @if ($contentType()) { - + {{ 'contenttypes.tab.publisher.push.history.header' | dm }} } @@ -42,7 +45,7 @@
- +
@if ($contentType() && $showStyleEditorTab()) { - + } - @if ($contentType() && showPermissionsTab | async) { - + @if ($contentType() && $showPermissionsTab()) { +
@@ -75,9 +81,7 @@ } @if ($contentType()) { - +
diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/layout/content-types-layout.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/layout/content-types-layout.component.spec.ts index 20309467bbec..16da6e4228f7 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/layout/content-types-layout.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/layout/content-types-layout.component.spec.ts @@ -15,13 +15,14 @@ Object.defineProperty(window, 'matchMedia', { })) }); -import { BehaviorSubject, of } from 'rxjs'; +import { EMPTY, of } from 'rxjs'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { Component, DebugElement, EventEmitter, Input, Output } from '@angular/core'; -import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; +import { ActivatedRoute, Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { MenuItem } from 'primeng/api'; @@ -35,12 +36,11 @@ import { DotHttpErrorManagerService, DotIframeService, DotMessageService, - DotPropertiesService, DotRouterService, DotUiColorsService } from '@dotcms/data-access'; import { DotcmsEventsService, LoggerService, LoginService } from '@dotcms/dotcms-js'; -import { DotCMSContentType } from '@dotcms/dotcms-models'; +import { DotCMSContentType, FeaturedFlags } from '@dotcms/dotcms-models'; import { DotApiLinkComponent, DotCopyButtonComponent, @@ -136,12 +136,8 @@ const fakeContentType: DotCMSContentType = { describe('ContentTypesLayoutComponent', () => { let fixture: ComponentFixture; let de: DebugElement; - let featureFlagSubject: BehaviorSubject; beforeEach(() => { - // Default: feature enabled. Tests that need it disabled can emit false. - featureFlagSubject = new BehaviorSubject(true); - const messageServiceMock = new MockDotMessageService({ 'contenttypes.sidebar.components.title': 'Field Title', 'contenttypes.tab.fields.header': 'Fields Header Tab', @@ -234,9 +230,18 @@ describe('ContentTypesLayoutComponent', () => { useValue: { confirm: jest.fn(), alert: jest.fn() } }, { - provide: DotPropertiesService, + provide: ActivatedRoute, useValue: { - getFeatureFlag: jest.fn().mockReturnValue(featureFlagSubject.asObservable()) + snapshot: { + data: { + featuredFlags: { + [FeaturedFlags.FEATURE_FLAG_UVE_STYLE_EDITOR]: true + }, + tabPermissions: { showPermissionsTab: true } + } + }, + firstChild: null, + events: EMPTY } } ] @@ -274,7 +279,7 @@ describe('ContentTypesLayoutComponent', () => { }); it('should not have a Permissions tab', () => { - const pTabPanel = de.query(By.css('p-tabpanel[value="2"]')); + const pTabPanel = de.query(By.css('p-tabpanel[value="permissions"]')); expect(pTabPanel).toBeFalsy(); }); @@ -287,21 +292,21 @@ describe('ContentTypesLayoutComponent', () => { expect(fieldDragDropService.setBagOptions).toHaveBeenCalledTimes(1); }); - it('should have dot-portlet-box in the Permissions tab after it has been clicked', fakeAsync(() => { + it('should navigate to the route and immediately update $activeTab when clicking a tab', () => { fixture.componentRef.setInput('contentType', fakeContentType); fixture.detectChanges(); - const tabs = de.queryAll(By.css('p-tab')); - // tabs[0]=Fields, [1]=StyleEditor, [2]=Permissions - tabs[2].nativeElement.click(); - fixture.detectChanges(); + const router = fixture.debugElement.injector.get(Router); + jest.spyOn(router, 'navigate'); - fixture.whenStable().then(() => { - const panels = de.queryAll(By.css('p-tabpanel')); - const contentTypePushHistoryPortletBox = panels[2].query(By.css('dot-portlet-box')); - expect(contentTypePushHistoryPortletBox).not.toBeNull(); - }); - })); + de.componentInstance.onTabChange('permissions'); + + expect(de.componentInstance.$activeTab()).toBe('permissions'); + expect(router.navigate).toHaveBeenCalledWith( + ['permissions'], + expect.objectContaining({ relativeTo: expect.anything() }) + ); + }); describe('Edit toolBar', () => { beforeEach(() => { @@ -335,13 +340,9 @@ describe('ContentTypesLayoutComponent', () => { describe('Tabs', () => { let iframe: DebugElement; - let dotCurrentUserService: DotCurrentUserService; beforeEach(() => { fixture.componentRef.setInput('contentType', fakeContentType); - dotCurrentUserService = fixture.debugElement.injector.get(DotCurrentUserService); - jest.spyOn(dotCurrentUserService, 'hasAccessToPortlet').mockReturnValue(of(true)); - fixture.detectChanges(); }); @@ -498,7 +499,7 @@ describe('ContentTypesLayoutComponent', () => { }); it('should hide the style editor tab when feature flag is disabled', () => { - featureFlagSubject.next(false); + de.componentInstance.$showStyleEditorTab.set(false); fixture.detectChanges(); const styleEditorPanel = de.query(By.css('[data-testid="style-editor-panel"]')); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/layout/content-types-layout.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/layout/content-types-layout.component.ts index 384b7173d8d3..1c2cd7bfa9f6 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/layout/content-types-layout.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/layout/content-types-layout.component.ts @@ -1,6 +1,3 @@ -import { Observable } from 'rxjs'; - -import { AsyncPipe } from '@angular/common'; import { Component, ElementRef, @@ -10,9 +7,11 @@ import { inject, input, output, + signal, viewChild } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { MenuItem } from 'primeng/api'; import { ButtonModule } from 'primeng/button'; @@ -21,12 +20,9 @@ import { MenuModule } from 'primeng/menu'; import { SplitButtonModule } from 'primeng/splitbutton'; import { TabsModule } from 'primeng/tabs'; -import { - DotCurrentUserService, - DotEventsService, - DotMessageService, - DotPropertiesService -} from '@dotcms/data-access'; +import { filter, map } from 'rxjs/operators'; + +import { DotEventsService, DotMessageService } from '@dotcms/data-access'; import { DotCMSContentType, FeaturedFlags } from '@dotcms/dotcms-models'; import { DotClipboardUtil, DotMessagePipe } from '@dotcms/ui'; @@ -43,7 +39,6 @@ import { DotStyleEditorBuilderComponent } from '../style-editor/dot-style-editor templateUrl: 'content-types-layout.component.html', providers: [DotClipboardUtil], imports: [ - AsyncPipe, TabsModule, SplitButtonModule, ButtonModule, @@ -61,9 +56,9 @@ export class ContentTypesLayoutComponent implements OnInit { #dotMessageService = inject(DotMessageService); #fieldDragDropService = inject(FieldDragDropService); #dotEventsService = inject(DotEventsService); - #dotCurrentUserService = inject(DotCurrentUserService); - #dotPropertiesService = inject(DotPropertiesService); #dotClipboardUtil = inject(DotClipboardUtil); + #router = inject(Router); + #route = inject(ActivatedRoute); $contentType = input.required({ alias: 'contentType' }); openEditDialog = output(); @@ -74,11 +69,14 @@ export class ContentTypesLayoutComponent implements OnInit { permissionURL: string; pushHistoryURL: string; contentTypeNameInputSize: number; - showPermissionsTab: Observable; - readonly $showStyleEditorTab = toSignal( - this.#dotPropertiesService.getFeatureFlag(FeaturedFlags.FEATURE_FLAG_UVE_STYLE_EDITOR), - { initialValue: false } + readonly $showStyleEditorTab = signal( + this.#route.snapshot.data['featuredFlags']?.[FeaturedFlags.FEATURE_FLAG_UVE_STYLE_EDITOR] ?? + false + ); + readonly $showPermissionsTab = signal( + this.#route.snapshot.data['tabPermissions']?.showPermissionsTab ?? false ); + readonly $activeTab = signal(this.#route.firstChild?.snapshot.url[0]?.path ?? 'fields'); addToMenuContentType = false; actions: MenuItem[]; @@ -115,7 +113,6 @@ export class ContentTypesLayoutComponent implements OnInit { }); ngOnInit(): void { - this.showPermissionsTab = this.#dotCurrentUserService.hasAccessToPortlet('permissions'); this.#fieldDragDropService.setBagOptions(); this.loadActions(); } @@ -128,6 +125,15 @@ export class ContentTypesLayoutComponent implements OnInit { this.pushHistoryURL = `/html/content_types/push_history.jsp?contentTypeId=${ct.id}&popup=true`; } }); + + // Keep $activeTab in sync with browser back/forward navigation. + this.#router.events + .pipe( + filter((e) => e instanceof NavigationEnd), + map(() => this.#route.firstChild?.snapshot.url[0]?.path ?? 'fields'), + takeUntilDestroyed() + ) + .subscribe((tab) => this.$activeTab.set(tab)); } /** @@ -178,6 +184,11 @@ export class ContentTypesLayoutComponent implements OnInit { } } + onTabChange(tab: unknown): void { + this.$activeTab.set(tab as string); + this.#router.navigate([tab as string], { relativeTo: this.#route }); + } + private loadActions(): void { this.actions = [ { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-type-tabs.guard.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-type-tabs.guard.spec.ts new file mode 100644 index 000000000000..2bb3cae6f2ed --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-type-tabs.guard.spec.ts @@ -0,0 +1,152 @@ +import { of } from 'rxjs'; + +import { HttpClient } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; + +import { DotCurrentUserService, DotPropertiesService } from '@dotcms/data-access'; +import { FeaturedFlags } from '@dotcms/dotcms-models'; + +import { permissionsTabGuard, styleEditorTabGuard } from './dot-content-type-tabs.guard'; + +const BASE_URL = '/content-types-angular/edit/123'; +const STYLE_EDITOR_URL = `${BASE_URL}/style-editor`; +const PERMISSIONS_URL = `${BASE_URL}/permissions`; +const FIELDS_URL = `${BASE_URL}/fields`; + +const mockRoute = {} as ActivatedRouteSnapshot; + +function mockState(url: string): RouterStateSnapshot { + return { url } as RouterStateSnapshot; +} + +describe('styleEditorTabGuard', () => { + let dotPropertiesService: DotPropertiesService; + let router: Router; + + const setup = (featureFlagEnabled: boolean) => { + TestBed.configureTestingModule({ + providers: [ + HttpClient, + { + provide: DotPropertiesService, + useValue: { getFeatureFlag: jest.fn().mockReturnValue(of(featureFlagEnabled)) } + }, + { + provide: Router, + useValue: { + parseUrl: jest.fn((url: string) => ({ url }) as unknown as UrlTree) + } + } + ], + imports: [HttpClientTestingModule] + }); + + dotPropertiesService = TestBed.inject(DotPropertiesService); + router = TestBed.inject(Router); + }; + + it('should allow access when feature flag is enabled', (done) => { + setup(true); + + TestBed.runInInjectionContext(() => + styleEditorTabGuard(mockRoute, mockState(STYLE_EDITOR_URL)) + ).subscribe((result) => { + expect(result).toBe(true); + expect(dotPropertiesService.getFeatureFlag).toHaveBeenCalledWith( + FeaturedFlags.FEATURE_FLAG_UVE_STYLE_EDITOR + ); + done(); + }); + }); + + it('should redirect to fields when feature flag is disabled', (done) => { + setup(false); + + TestBed.runInInjectionContext(() => + styleEditorTabGuard(mockRoute, mockState(STYLE_EDITOR_URL)) + ).subscribe((result) => { + expect(router.parseUrl).toHaveBeenCalledWith(FIELDS_URL); + expect(result).not.toBe(false); + done(); + }); + }); + + it('should preserve query params in the redirect url', (done) => { + setup(false); + const urlWithQuery = `${STYLE_EDITOR_URL}?foo=bar`; + + TestBed.runInInjectionContext(() => + styleEditorTabGuard(mockRoute, mockState(urlWithQuery)) + ).subscribe(() => { + expect(router.parseUrl).toHaveBeenCalledWith(`${FIELDS_URL}?foo=bar`); + done(); + }); + }); +}); + +describe('permissionsTabGuard', () => { + let dotCurrentUserService: DotCurrentUserService; + let router: Router; + + const setup = (hasAccess: boolean) => { + TestBed.configureTestingModule({ + providers: [ + HttpClient, + { + provide: DotCurrentUserService, + useValue: { + hasAccessToPortlet: jest.fn().mockReturnValue(of(hasAccess)) + } + }, + { + provide: Router, + useValue: { + parseUrl: jest.fn((url: string) => ({ url }) as unknown as UrlTree) + } + } + ], + imports: [HttpClientTestingModule] + }); + + dotCurrentUserService = TestBed.inject(DotCurrentUserService); + router = TestBed.inject(Router); + }; + + it('should allow access when user has permissions portlet access', (done) => { + setup(true); + + TestBed.runInInjectionContext(() => + permissionsTabGuard(mockRoute, mockState(PERMISSIONS_URL)) + ).subscribe((result) => { + expect(result).toBe(true); + expect(dotCurrentUserService.hasAccessToPortlet).toHaveBeenCalledWith('permissions'); + done(); + }); + }); + + it('should redirect to fields when user lacks permissions portlet access', (done) => { + setup(false); + + TestBed.runInInjectionContext(() => + permissionsTabGuard(mockRoute, mockState(PERMISSIONS_URL)) + ).subscribe((result) => { + expect(router.parseUrl).toHaveBeenCalledWith(FIELDS_URL); + expect(result).not.toBe(false); + done(); + }); + }); + + it('should preserve query params in the redirect url', (done) => { + setup(false); + const urlWithQuery = `${PERMISSIONS_URL}?foo=bar`; + + TestBed.runInInjectionContext(() => + permissionsTabGuard(mockRoute, mockState(urlWithQuery)) + ).subscribe(() => { + expect(router.parseUrl).toHaveBeenCalledWith(`${FIELDS_URL}?foo=bar`); + done(); + }); + }); +}); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-type-tabs.guard.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-type-tabs.guard.ts new file mode 100644 index 000000000000..b3b74a9dd75d --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-type-tabs.guard.ts @@ -0,0 +1,26 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; + +import { map } from 'rxjs/operators'; + +import { DotCurrentUserService, DotPropertiesService } from '@dotcms/data-access'; +import { FeaturedFlags } from '@dotcms/dotcms-models'; + +const redirectToFields = (router: Router, url: string) => + router.parseUrl(url.replace(/\/[^/?]+(\?.*)?$/, '/fields$1')); + +export const styleEditorTabGuard: CanActivateFn = (_route, state) => { + const router = inject(Router); + + return inject(DotPropertiesService) + .getFeatureFlag(FeaturedFlags.FEATURE_FLAG_UVE_STYLE_EDITOR) + .pipe(map((enabled) => enabled || redirectToFields(router, state.url))); +}; + +export const permissionsTabGuard: CanActivateFn = (_route, state) => { + const router = inject(Router); + + return inject(DotCurrentUserService) + .hasAccessToPortlet('permissions') + .pipe(map((hasAccess) => hasAccess || redirectToFields(router, state.url))); +}; diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-type-tabs.resolver.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-type-tabs.resolver.spec.ts new file mode 100644 index 000000000000..310f7a023ebf --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-type-tabs.resolver.spec.ts @@ -0,0 +1,59 @@ +import { of } from 'rxjs'; + +import { HttpClient } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; + +import { DotCurrentUserService } from '@dotcms/data-access'; + +import { + DotContentTypeTabsResolvedData, + dotContentTypeTabsResolver +} from './dot-content-type-tabs.resolver'; + +const mockRoute = {} as ActivatedRouteSnapshot; +const mockState = {} as RouterStateSnapshot; + +describe('dotContentTypeTabsResolver', () => { + let dotCurrentUserService: DotCurrentUserService; + + const setup = (hasAccess: boolean) => { + TestBed.configureTestingModule({ + providers: [ + HttpClient, + { + provide: DotCurrentUserService, + useValue: { hasAccessToPortlet: jest.fn().mockReturnValue(of(hasAccess)) } + } + ], + imports: [HttpClientTestingModule] + }); + + dotCurrentUserService = TestBed.inject(DotCurrentUserService); + }; + + it('should resolve showPermissionsTab as true when user has access', (done) => { + setup(true); + + TestBed.runInInjectionContext(() => + dotContentTypeTabsResolver(mockRoute, mockState) + ).subscribe((result: DotContentTypeTabsResolvedData) => { + expect(dotCurrentUserService.hasAccessToPortlet).toHaveBeenCalledWith('permissions'); + expect(result).toEqual({ showPermissionsTab: true }); + done(); + }); + }); + + it('should resolve showPermissionsTab as false when user lacks access', (done) => { + setup(false); + + TestBed.runInInjectionContext(() => + dotContentTypeTabsResolver(mockRoute, mockState) + ).subscribe((result: DotContentTypeTabsResolvedData) => { + expect(dotCurrentUserService.hasAccessToPortlet).toHaveBeenCalledWith('permissions'); + expect(result).toEqual({ showPermissionsTab: false }); + done(); + }); + }); +}); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-type-tabs.resolver.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-type-tabs.resolver.ts new file mode 100644 index 000000000000..aa416ffbba92 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-type-tabs.resolver.ts @@ -0,0 +1,15 @@ +import { inject } from '@angular/core'; +import { ResolveFn } from '@angular/router'; + +import { map } from 'rxjs/operators'; + +import { DotCurrentUserService } from '@dotcms/data-access'; + +export interface DotContentTypeTabsResolvedData { + showPermissionsTab: boolean; +} + +export const dotContentTypeTabsResolver: ResolveFn = () => + inject(DotCurrentUserService) + .hasAccessToPortlet('permissions') + .pipe(map((showPermissionsTab) => ({ showPermissionsTab }))); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit-resolver.service.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit-resolver.service.ts index 83f71be3ee31..3bbd7fb59aa7 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit-resolver.service.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit-resolver.service.ts @@ -4,7 +4,7 @@ import { HttpErrorResponse } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; import { ActivatedRouteSnapshot, Resolve } from '@angular/router'; -import { catchError, map, take, tap } from 'rxjs/operators'; +import { catchError, map, take } from 'rxjs/operators'; import { DotContentTypesInfoService, @@ -15,7 +15,6 @@ import { } from '@dotcms/data-access'; import { LoginService } from '@dotcms/dotcms-js'; import { DotCMSContentType } from '@dotcms/dotcms-models'; -import { GlobalStore } from '@dotcms/store'; /** * With the url return a content type by id or a default content type @@ -31,31 +30,14 @@ export class DotContentTypeEditResolver implements Resolve { private dotHttpErrorManagerService = inject(DotHttpErrorManagerService); private dotRouterService = inject(DotRouterService); private loginService = inject(LoginService); - readonly #globalStore = inject(GlobalStore); resolve(route: ActivatedRouteSnapshot): Observable { if (route.paramMap.get('id')) { - return this.getContentType(route.paramMap.get('id')).pipe( - tap((contentType) => { - this.#globalStore.addNewBreadcrumb({ - label: contentType.name, - target: '_self', - url: `/dotAdmin/#/content-types-angular/edit/${contentType.id}` - }); - }) - ); + return this.getContentType(route.paramMap.get('id')); } else { const contentType = this.getFilterByParam(route) || route.paramMap.get('type'); - return this.getDefaultContentType(contentType).pipe( - tap((contentType) => { - this.#globalStore.addNewBreadcrumb({ - label: contentType.name, - target: '_self', - url: `/dotAdmin/#/content-types-angular/create/${contentType.variable}` - }); - }) - ); + return this.getDefaultContentType(contentType); } } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit.component.html b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit.component.html index cfca0cbcea89..19ee349680e1 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit.component.html +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit.component.html @@ -1,3 +1,4 @@ + @if (isEditMode()) { { DotMenuService, DotEventsService, FieldService, - Location + Location, + { provide: GlobalStore, useValue: { addNewBreadcrumb: jest.fn() } } ] }; }; @@ -521,6 +523,7 @@ describe('DotContentTypesEditComponent', () => { { provide: DotMessageService, useValue: messageServiceMock }, { provide: DotRouterService, useClass: MockDotRouterService }, { provide: DotMessageDisplayService, useClass: DotMessageDisplayServiceMock }, + { provide: GlobalStore, useValue: { addNewBreadcrumb: jest.fn() } }, ConfirmationService, DotAlertConfirmService, DotContentTypesInfoService, diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit.component.ts index 98fd2cd4aaf4..747afc83b753 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit.component.ts @@ -24,6 +24,7 @@ import { DotCMSWorkflow, DotDialogActions } from '@dotcms/dotcms-models'; +import { GlobalStore } from '@dotcms/store'; import { DotEditContentTypeCacheService } from './components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/services/dot-edit-content-type-cache.service'; import { ContentTypeFieldsDropZoneComponent } from './components/fields/index'; @@ -53,6 +54,7 @@ export class DotContentTypesEditComponent implements OnInit, OnDestroy { private dotMessageService = inject(DotMessageService); router = inject(Router); private dotEditContentTypeCacheService = inject(DotEditContentTypeCacheService); + readonly #globalStore = inject(GlobalStore); readonly $contentTypesForm = viewChild('form'); readonly $fieldsDropZone = viewChild('fieldsDropZone'); @@ -87,6 +89,12 @@ export class DotContentTypesEditComponent implements OnInit, OnDestroy { if (isFirstLoad) { this.checkAndOpenFormDialog(); } + + this.#globalStore.addNewBreadcrumb({ + label: contentType.name, + target: '_self', + url: `/dotAdmin/#/content-types-angular/edit/${contentType.id}` + }); }); this.contentTypeActions = [ diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit.routes.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit.routes.ts index 4aab841af320..6e79ca662c8f 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit.routes.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit.routes.ts @@ -4,6 +4,9 @@ import { FeaturedFlags } from '@dotcms/dotcms-models'; import { DotContentTypesEditComponent } from '.'; +import { permissionsTabGuard, styleEditorTabGuard } from './dot-content-type-tabs.guard'; +import { dotContentTypeTabsResolver } from './dot-content-type-tabs.resolver'; + import { DotFeatureFlagResolver } from '../resolvers/dot-feature-flag-resolver.service'; export const dotContentTypesEditRoutes: Routes = [ @@ -11,10 +14,21 @@ export const dotContentTypesEditRoutes: Routes = [ component: DotContentTypesEditComponent, path: '', resolve: { - featuredFlags: DotFeatureFlagResolver + featuredFlags: DotFeatureFlagResolver, + tabPermissions: dotContentTypeTabsResolver }, data: { - featuredFlagsToCheck: [FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED] - } + featuredFlagsToCheck: [ + FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED, + FeaturedFlags.FEATURE_FLAG_UVE_STYLE_EDITOR + ] + }, + children: [ + { path: '', redirectTo: 'fields', pathMatch: 'full' }, + { path: 'fields', children: [] }, + { path: 'style-editor', canActivate: [styleEditorTabGuard], children: [] }, + { path: 'permissions', canActivate: [permissionsTabGuard], children: [] }, + { path: 'push-history', children: [] } + ] } ]; diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-empty-state/dot-uve-style-editor-empty-state.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-empty-state/dot-uve-style-editor-empty-state.component.html index d23d89e386ad..8a69da85786f 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-empty-state/dot-uve-style-editor-empty-state.component.html +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-empty-state/dot-uve-style-editor-empty-state.component.html @@ -1,11 +1,20 @@ -
+
-
+
{{ 'uve.palette.style-editor.empty-state.title' | dm }}

+@if ($contentTypeVar()) { + +} diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-empty-state/dot-uve-style-editor-empty-state.component.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-empty-state/dot-uve-style-editor-empty-state.component.spec.ts new file mode 100644 index 000000000000..889578558142 --- /dev/null +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-empty-state/dot-uve-style-editor-empty-state.component.spec.ts @@ -0,0 +1,125 @@ +import { Spectator, byTestId, createComponentFactory, mockProvider } from '@ngneat/spectator/jest'; + +import { Router } from '@angular/router'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DotMessagePipe } from '@dotcms/ui'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotUveStyleEditorEmptyStateComponent } from './dot-uve-style-editor-empty-state.component'; + +const messagesMock = { + 'uve.palette.style-editor.empty-state.title': 'Style editor', + 'uve.palette.style-editor.empty-state.message': + 'Customize your components. Learn more', + 'uve.palette.style-editor.empty-state.cta': 'Define styles' +}; + +describe('DotUveStyleEditorEmptyStateComponent', () => { + let spectator: Spectator; + let router: Router; + + const createComponent = createComponentFactory({ + component: DotUveStyleEditorEmptyStateComponent, + imports: [DotMessagePipe], + providers: [ + { + provide: DotMessageService, + useValue: new MockDotMessageService(messagesMock) + }, + mockProvider(Router, { + navigate: jest.fn().mockResolvedValue(true) + }) + ] + }); + + beforeEach(() => { + spectator = createComponent(); + router = spectator.inject(Router); + spectator.detectChanges(); + }); + + it('should create', () => { + expect(spectator.component).toBeTruthy(); + }); + + it('should expose the content type variable input via setInput', () => { + spectator.setInput('contentTypeVar', 'Blog'); + spectator.detectChanges(); + + expect(spectator.component.$contentTypeVar()).toBe('Blog'); + }); + + describe('layout and copy', () => { + it('should set the root host data-testid for the empty state', () => { + expect(spectator.element.getAttribute('data-testid')).toBe( + 'uve-style-editor-empty-state' + ); + }); + + it('should render the sliders icon', () => { + const iconHost = spectator.query(byTestId('uve-style-editor-empty-state-icon')); + expect(iconHost).toBeTruthy(); + expect(iconHost?.querySelector('i.pi-sliders-h')).toBeTruthy(); + }); + + it('should render the title from DotMessageService', () => { + const title = spectator.query(byTestId('uve-style-editor-empty-state-title')); + expect(title?.textContent?.trim()).toBe('Style editor'); + }); + + it('should render the message HTML from DotMessageService', () => { + const message = spectator.query(byTestId('uve-style-editor-empty-state-message')); + expect(message?.innerHTML).toContain('Customize your components'); + expect(message?.querySelector('a')).toBeTruthy(); + }); + }); + + describe('CTA button', () => { + it('should not render the CTA when contentTypeVar is empty', () => { + spectator.setInput('contentTypeVar', ''); + spectator.detectChanges(); + + expect(spectator.query(byTestId('uve-style-editor-empty-state-cta'))).toBeFalsy(); + }); + + it('should render the CTA when contentTypeVar is set', () => { + spectator.setInput('contentTypeVar', 'Blog'); + spectator.detectChanges(); + + const cta = spectator.query(byTestId('uve-style-editor-empty-state-cta')); + expect(cta).toBeTruthy(); + expect(cta?.textContent?.trim()).toContain('Define styles'); + }); + + it('should navigate to the style editor route when the CTA is clicked', () => { + spectator.setInput('contentTypeVar', 'Blog'); + spectator.detectChanges(); + + const cta = spectator.query(byTestId('uve-style-editor-empty-state-cta')); + const nativeButton = cta?.querySelector('button'); + expect(nativeButton).toBeTruthy(); + spectator.click(nativeButton as HTMLElement); + + expect(router.navigate).toHaveBeenCalledTimes(1); + expect(router.navigate).toHaveBeenCalledWith([ + '/content-types-angular/edit', + 'Blog', + 'style-editor' + ]); + }); + + it('should navigate using the current content type variable when navigateToStyleEditor runs', () => { + spectator.setInput('contentTypeVar', 'Product'); + spectator.detectChanges(); + + spectator.component.navigateToStyleEditor(); + + expect(router.navigate).toHaveBeenCalledWith([ + '/content-types-angular/edit', + 'Product', + 'style-editor' + ]); + }); + }); +}); diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-empty-state/dot-uve-style-editor-empty-state.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-empty-state/dot-uve-style-editor-empty-state.component.ts index 9119b182134b..bd7b23bd7650 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-empty-state/dot-uve-style-editor-empty-state.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-empty-state/dot-uve-style-editor-empty-state.component.ts @@ -1,14 +1,30 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; +import { Router, RouterLink } from '@angular/router'; + +import { ButtonModule } from 'primeng/button'; import { DotMessagePipe } from '@dotcms/ui'; @Component({ selector: 'dot-uve-style-editor-empty-state', - imports: [DotMessagePipe], + imports: [DotMessagePipe, RouterLink, ButtonModule], templateUrl: './dot-uve-style-editor-empty-state.component.html', changeDetection: ChangeDetectionStrategy.OnPush, host: { - class: 'flex h-full w-full flex-col items-center justify-center gap-4 px-6 py-10 text-center' + class: 'flex h-full w-full flex-col items-center justify-center gap-4 px-6 py-10 text-center', + '[attr.data-testid]': "'uve-style-editor-empty-state'" } }) -export class DotUveStyleEditorEmptyStateComponent {} +export class DotUveStyleEditorEmptyStateComponent { + readonly $contentTypeVar = input('', { alias: 'contentTypeVar' }); + + readonly #router = inject(Router); + + navigateToStyleEditor() { + this.#router.navigate([ + '/content-types-angular/edit', + this.$contentTypeVar(), + 'style-editor' + ]); + } +} diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html index c0114b007dae..81bd3cf69cc3 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html @@ -257,7 +257,8 @@ @if ($styleSchema()) { } @else { - + }
} diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts index 70514797f4dd..5cdb79108b65 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts @@ -204,6 +204,9 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit readonly $styleSchema = computed(() => { return this.uveStore.$styleSchema(); }); + readonly $styleSchemaContentTypeVar = computed( + () => this.uveStore.editorActiveContentlet()?.contentlet?.contentType ?? '' + ); protected readonly $contentletEditData = computed(() => { const { container, contentlet: contentletPayload } = diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index faafefec8c03..dc3c4aa52f6c 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -6173,6 +6173,7 @@ uve.palette.favorite.search.state.label=Searching for more results… uve.palette.style-editor.empty-state.title=Style editor uve.palette.style-editor.empty-state.message=allows you to change
the look and feel of your
components. Learn more style.editor.text.field.default.placeholder=Enter value +uve.palette.style-editor.empty-state.cta=Define styles # Page Scanner page.scanner.a11y.dialog.title=Accessibility Report