From 72cdfb5c525cb08b086a86fb5c77d3aae59de37e Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Mon, 7 Oct 2024 21:04:37 -0400 Subject: [PATCH] feat(edit-content) refactor to use signal store as store Edit Content (#30201) ### Proposed Changes * refactor to use signal store as store Edit Content ### Checklist - [x] Tests - [ ] Translations - [ ] Security Implications Contemplated (add notes if applicable) ### Additional Info ** any additional useful context or info ** --- .../dot-edit-content-aside.component.html | 2 +- .../dot-edit-content-aside.component.ts | 9 +- .../dot-edit-content-form.component.ts | 6 +- .../src/lib/edit-content.routes.ts | 6 +- .../edit-content.layout.component.html | 67 ++- .../edit-content.layout.component.scss | 48 +- .../edit-content.layout.component.spec.ts | 363 +++++++------- .../edit-content.layout.component.ts | 166 +------ .../store/edit-content.store.spec.ts | 367 ++++++++------ .../edit-content/store/edit-content.store.ts | 465 +++++++++++------- .../models/dot-edit-content-form.interface.ts | 7 +- .../lib/models/dot-edit-content.constant.ts | 7 + .../src/lib/models/dot-edit-content.model.ts | 11 + .../src/lib/utils/functions.util.spec.ts | 46 +- .../src/lib/utils/functions.util.ts | 72 ++- .../libs/edit-content/src/lib/utils/mocks.ts | 3 +- 16 files changed, 895 insertions(+), 750 deletions(-) create mode 100644 core-web/libs/edit-content/src/lib/models/dot-edit-content.constant.ts create mode 100644 core-web/libs/edit-content/src/lib/models/dot-edit-content.model.ts diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-aside/dot-edit-content-aside.component.html b/core-web/libs/edit-content/src/lib/components/dot-edit-content-aside/dot-edit-content-aside.component.html index c49e1fd8885d..d54e81bac140 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-aside/dot-edit-content-aside.component.html +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-aside/dot-edit-content-aside.component.html @@ -36,7 +36,7 @@

{{ 'Workflow' | dm }}

- - -} @else { + + + + @if (showSidebar) { + @defer (when showSidebar) { + + } + } +} + +@if ($store.hasError()) { {{ 'edit.content.layout.no.content.to.show ' | dm }} } - + diff --git a/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.scss b/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.scss index 7249e54bcc3b..d58c957cc099 100644 --- a/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.scss +++ b/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.scss @@ -6,32 +6,19 @@ "topBar topBar" "header sidebar" "body sidebar"; - grid-template-columns: 1fr 21.875rem; + grid-template-columns: 1fr 0; grid-template-rows: auto auto 1fr; padding-bottom: 0; height: 100%; width: 100%; -} - -.edit-content-layout__sidebar-btn { - rect { - stroke: $color-palette-primary; - } + transition: grid-template-columns $basic-speed ease-in-out; - path { - fill: $color-palette-primary; - } - transition: all 300ms ease-in-out; - &.showSidebar { - width: 0; - min-width: 0; - height: 0; - padding: 0; - visibility: hidden; + &.edit-content--with-sidebar { + grid-template-columns: 1fr 21.875rem; } } -.topBar { +.edit-content-layout__topBar { grid-area: topBar; .pi { @@ -40,14 +27,33 @@ } } -.header { +.edit-content-layout__header { grid-area: header; } -.body { +.edit-content-layout__body { grid-area: body; } -.sidebar { +.edit-content-layout__sidebar { grid-area: sidebar; } + +.edit-content-layout__sidebar--btn { + rect { + stroke: $color-palette-primary; + } + + path { + fill: $color-palette-primary; + } + transition: all $basic-speed ease-in-out; + + &.hide { + width: 0; + min-width: 0; + height: 0; + padding: 0; + visibility: hidden; + } +} diff --git a/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.spec.ts b/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.spec.ts index bc1870a940bc..75387d8a702a 100644 --- a/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.spec.ts @@ -1,25 +1,29 @@ -import { expect } from '@jest/globals'; -import { byTestId } from '@ngneat/spectator'; -import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; -import { MockComponent, MockPipe } from 'ng-mocks'; +import { + byTestId, + createComponentFactory, + mockProvider, + Spectator, + SpyObject +} from '@ngneat/spectator/jest'; +import { MockComponent, MockModule } from 'ng-mocks'; import { of } from 'rxjs'; -import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { ActivatedRoute } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { fakeAsync, tick } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; import { MessageService } from 'primeng/api'; +import { ConfirmDialog } from 'primeng/confirmdialog'; import { MessagesModule } from 'primeng/messages'; +import { Toast, ToastModule } from 'primeng/toast'; import { DotContentTypeService, - DotMessageService, - DotRenderMode, + DotHttpErrorManagerService, DotWorkflowActionsFireService, - DotWorkflowsActionsService, - DotFormatDateService + DotWorkflowsActionsService } from '@dotcms/data-access'; -import { FeaturedFlags } from '@dotcms/dotcms-models'; -import { DotMessagePipe } from '@dotcms/ui'; import { mockWorkflowsActions } from '@dotcms/utils-testing'; import { EditContentLayoutComponent } from './edit-content.layout.component'; @@ -28,231 +32,210 @@ import { DotEditContentStore } from './store/edit-content.store'; import { DotEditContentAsideComponent } from '../../components/dot-edit-content-aside/dot-edit-content-aside.component'; import { DotEditContentFormComponent } from '../../components/dot-edit-content-form/dot-edit-content-form.component'; import { DotEditContentToolbarComponent } from '../../components/dot-edit-content-toolbar/dot-edit-content-toolbar.component'; -import { EditContentPayload } from '../../models/dot-edit-content-form.interface'; +import { DotWorkflowActionParams } from '../../models/dot-edit-content.model'; import { DotEditContentService } from '../../services/dot-edit-content.service'; -import { BINARY_FIELD_CONTENTLET, CONTENT_TYPE_MOCK } from '../../utils/mocks'; +import * as utils from '../../utils/functions.util'; +import { CONTENT_TYPE_MOCK } from '../../utils/mocks'; describe('EditContentLayoutComponent', () => { let spectator: Spectator; - let dotEditContentStore: DotEditContentStore; - let dotEditContentService: DotEditContentService; - let dotWorkflowsActionsService: DotWorkflowsActionsService; + let component: EditContentLayoutComponent; + let store: SpyObject>; + let dotContentTypeService: SpyObject; + let workflowActionsService: SpyObject; const createComponent = createComponentFactory({ component: EditContentLayoutComponent, imports: [ - HttpClientTestingModule, - MessagesModule, - MockPipe(DotMessagePipe), + MockModule(ToastModule), + + MockModule(MessagesModule), MockComponent(DotEditContentFormComponent), - MockComponent(DotEditContentToolbarComponent), - MockComponent(DotEditContentAsideComponent) + MockComponent(DotEditContentAsideComponent), + MockComponent(DotEditContentToolbarComponent) + ], + componentProviders: [ + DotEditContentStore, // Usign the real DotEditContentStore + mockProvider(DotWorkflowsActionsService), + mockProvider(DotWorkflowActionsFireService), + mockProvider(DotEditContentService), + mockProvider(DotContentTypeService) ], providers: [ + mockProvider(DotHttpErrorManagerService), mockProvider(MessageService), - mockProvider(DotContentTypeService), - mockProvider(DotMessageService), - mockProvider(DotFormatDateService), - mockProvider(DotEditContentStore), - mockProvider(DotWorkflowActionsFireService) - ] - }); - describe('Existing content', () => { - const mockData: EditContentPayload = { - actions: mockWorkflowsActions, - contentType: CONTENT_TYPE_MOCK, - contentlet: BINARY_FIELD_CONTENTLET, - loading: false, - layout: { - showSidebar: true - } - }; - - beforeEach(async () => { - spectator = createComponent({ - detectChanges: false, - providers: [ - { - provide: DotEditContentService, - useValue: { - getContentById: jest.fn().mockReturnValue(of(BINARY_FIELD_CONTENTLET)), - getContentType: jest.fn().mockReturnValue(of(CONTENT_TYPE_MOCK)) - } - }, - { - provide: DotWorkflowsActionsService, - useValue: { - getByInode: jest.fn().mockReturnValue(of(mockWorkflowsActions)), - getDefaultActions: jest.fn().mockReturnValue(of(mockWorkflowsActions)) - } - }, - { - provide: ActivatedRoute, - useValue: { snapshot: { params: { contentType: undefined, id: '1' } } } + { + provide: ActivatedRoute, + useValue: { + // Provide an empty snapshot to bypass the Store's onInit, + // allowing direct method calls for testing + get snapshot() { + return { params: { id: undefined, contentType: undefined } }; } - ] - }); + } + }, + mockProvider(Router, { + navigate: jest.fn().mockReturnValue(Promise.resolve(true)), + url: '/test-url', + events: of() + }), + + provideHttpClient(), + provideHttpClientTesting() + ] + }); - dotEditContentService = spectator.inject(DotEditContentService, true); - dotWorkflowsActionsService = spectator.inject(DotWorkflowsActionsService, true); - dotEditContentStore = spectator.inject(DotEditContentStore, true); + beforeEach(() => { + spectator = createComponent({ + detectChanges: false }); - it('should get content data', () => { - const spyContent = jest.spyOn(dotEditContentService, 'getContentById'); - const spyContentType = jest.spyOn(dotEditContentService, 'getContentType'); - const spyWorkflow = jest.spyOn(dotWorkflowsActionsService, 'getByInode'); + component = spectator.component; + spectator.detectChanges(); - spectator.detectChanges(); + store = spectator.inject(DotEditContentStore, true); + dotContentTypeService = spectator.inject(DotContentTypeService, true); - expect(spyContent).toHaveBeenCalledWith('1'); - expect(spyContentType).toHaveBeenCalledWith(BINARY_FIELD_CONTENTLET.contentType); - expect(spyWorkflow).toHaveBeenCalledWith('1', DotRenderMode.EDITING); - }); + workflowActionsService = spectator.inject(DotWorkflowsActionsService, true); - it('should pass the data to the DotEditContentForm Component', () => { - spectator.detectChanges(); - const formComponent = spectator.query(DotEditContentFormComponent); - expect(formComponent).toBeDefined(); - expect(formComponent.formData).toEqual(mockData); - }); + // By default, the local storage is set to true + jest.spyOn(utils, 'getPersistSidebarState').mockReturnValue(true); + }); - it('should pass the actions to the DotEditContentToolbar Component', () => { - spectator.detectChanges(); - const toolbarComponent = spectator.query(DotEditContentToolbarComponent); - expect(toolbarComponent).toBeDefined(); - expect(toolbarComponent.$actions).toEqual(mockData.actions); - }); + it('should have p-toast component', () => { + expect(spectator.query(Toast)).toBeTruthy(); + }); - it('should pass the contentlet and contentType to the DotEditContentAside Component', () => { - spectator.detectChanges(); - const asideComponent = spectator.query(DotEditContentAsideComponent); - expect(asideComponent).toBeDefined(); - expect(asideComponent.$contentlet).toEqual(mockData.contentlet); - expect(asideComponent.$contentType).toEqual(mockData.contentType); - }); + it('should have p-confirmDialog component', () => { + expect(spectator.query(ConfirmDialog)).toBeTruthy(); + }); - it('should fire workflow action', () => { - const spyStore = jest.spyOn(dotEditContentStore, 'fireWorkflowActionEffect'); - spectator.detectChanges(); - const toolbarComponent = spectator.query(DotEditContentToolbarComponent); - toolbarComponent.$actionFired.emit(mockWorkflowsActions[0]); + describe('New Content Editor', () => { + it('should initialize new content, show layout components and dialogs when new content editor is enabled', fakeAsync(() => { + dotContentTypeService.getContentType.mockReturnValue(of(CONTENT_TYPE_MOCK)); + workflowActionsService.getDefaultActions.mockReturnValue(of(mockWorkflowsActions)); - expect(spyStore).toHaveBeenCalledWith({ - actionId: mockWorkflowsActions[0].id, - inode: BINARY_FIELD_CONTENTLET.inode, - data: { - contentlet: expect.any(Object) // Expect any object - } - }); - }); + store.initializeNewContent('contentTypeName'); - it('should hide the beta topBar if metadata is not present', () => { spectator.detectChanges(); - const betaTopbar = spectator.query(byTestId('topBar')); - expect(betaTopbar).toBeNull(); - }); + tick(); // Wait for the defer to load - it('should show the beta topBar when the metadata is present', () => { - spectator.detectChanges(); - const metadata = {}; - metadata[FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED] = true; + expect(store.isEnabledNewContentEditor()).toBe(true); + expect(store.showSidebar()).toBe(true); + + expect(spectator.query(byTestId('edit-content-layout__topBar'))).toBeTruthy(); + expect(spectator.query(byTestId('edit-content-layout__header'))).toBeTruthy(); + expect(spectator.query(byTestId('edit-content-layout__body'))).toBeTruthy(); + expect(spectator.query(byTestId('edit-content-layout__sidebar'))).toBeTruthy(); + + expect(spectator.query(Toast)).toBeTruthy(); + expect(spectator.query(ConfirmDialog)).toBeTruthy(); + })); + + it('should not show top bar message when new content editor is disabled', () => { + const CONTENT_TYPE_MOCK_NO_METADATA = { + ...CONTENT_TYPE_MOCK, + metadata: undefined + }; + + dotContentTypeService.getContentType.mockReturnValue(of(CONTENT_TYPE_MOCK_NO_METADATA)); + workflowActionsService.getDefaultActions.mockReturnValue(of(mockWorkflowsActions)); + + store.initializeNewContent('contentTypeName'); - dotEditContentStore.patchState({ contentType: { ...CONTENT_TYPE_MOCK, metadata } }); spectator.detectChanges(); - const betaTopbar = spectator.query(byTestId('topBar')); - expect(betaTopbar).not.toBeNull(); + expect(store.isEnabledNewContentEditor()).toBe(false); + expect(spectator.query(byTestId('edit-content-layout__topBar'))).toBeNull(); }); + }); - it('should toggle the sidebar when the toggle button is clicked', () => { + describe('Sidebar', () => { + it('should toggle sidebar visibility when toggle button is clicked', fakeAsync(() => { + // Setup + dotContentTypeService.getContentType.mockReturnValue(of(CONTENT_TYPE_MOCK)); + workflowActionsService.getDefaultActions.mockReturnValue(of(mockWorkflowsActions)); + store.initializeNewContent('contentTypeName'); spectator.detectChanges(); - const toggleBtn = spectator.query(byTestId('sidebar-toggle')); + tick(); // Wait for the defer to load - expect(toggleBtn.classList).toContain('showSidebar'); + expect(spectator.query(byTestId('edit-content-layout__sidebar'))).toBeTruthy(); + expect(spectator.element).toHaveClass('edit-content--with-sidebar'); - spectator.click(toggleBtn); + // hide sidebar + const sidebarToggle = spectator.query(byTestId('sidebar-toggle')); + spectator.click(sidebarToggle); + tick(); // Wait for the defer to load - expect(toggleBtn.classList).not.toContain('showSidebar'); - spectator.component.toggleSidebar(); - }); + expect(spectator.query(byTestId('edit-content-layout__sidebar'))).toBeNull(); + expect(spectator.element).not.toHaveClass('edit-content--with-sidebar'); + + // show sidebar again + spectator.click(sidebarToggle); + tick(); // Wait for the defer to load + + expect(spectator.query(byTestId('edit-content-layout__sidebar'))).toBeTruthy(); + expect(spectator.element).toHaveClass('edit-content--with-sidebar'); + })); }); - describe('New content', () => { - const mockData: EditContentPayload = { - actions: mockWorkflowsActions, - contentType: CONTENT_TYPE_MOCK, - contentlet: null, - loading: false, - layout: { - showSidebar: true - } - }; - - beforeEach(async () => { - spectator = createComponent({ - detectChanges: false, - providers: [ - { - provide: DotEditContentService, - useValue: { - getContentById: jest.fn().mockReturnValue(of(BINARY_FIELD_CONTENTLET)), - getContentType: jest.fn().mockReturnValue(of(CONTENT_TYPE_MOCK)) - } - }, - { - provide: DotWorkflowsActionsService, - useValue: { - getByInode: jest.fn().mockReturnValue(of(mockWorkflowsActions)), - getDefaultActions: jest.fn().mockReturnValue(of(mockWorkflowsActions)) - } - }, - { - provide: ActivatedRoute, - useValue: { - snapshot: { params: { contentType: mockData.contentType, id: null } } - } + describe('fireWorkflowAction', () => { + it('should call store.fireWorkflowAction with correct parameters for new content', () => { + const formValue = { field1: 'value1', field2: 'value2' }; + component.setFormValue(formValue); + + const mockFireWorkflowAction = jest.fn(); + jest.spyOn(store, 'fireWorkflowAction').mockImplementation(mockFireWorkflowAction); + + const actionParams: DotWorkflowActionParams = { + actionId: 'action1', + inode: '', + contentType: 'contentType1' + }; + + component.fireWorkflowAction(actionParams); + + expect(mockFireWorkflowAction).toHaveBeenCalledWith({ + actionId: 'action1', + inode: '', + data: { + contentlet: { + ...formValue, + contentType: 'contentType1' } - ] + } }); - - dotEditContentService = spectator.inject(DotEditContentService, true); - dotWorkflowsActionsService = spectator.inject(DotWorkflowsActionsService, true); }); - it('should get new content data', () => { - const spyContent = jest.spyOn(dotEditContentService, 'getContentById'); - const spyContentType = jest.spyOn(dotEditContentService, 'getContentType'); - const spyWorkflow = jest.spyOn(dotWorkflowsActionsService, 'getDefaultActions'); + it('should call store.fireWorkflowAction with correct parameters for existing content', () => { + // Create a mock for fireWorkflowAction + const mockFireWorkflowAction = jest.fn(); - spectator.detectChanges(); + // Replace the real method with the mock + jest.spyOn(store, 'fireWorkflowAction').mockImplementation(mockFireWorkflowAction); - expect(spyContentType).toHaveBeenCalledWith(mockData.contentType); - expect(spyWorkflow).toHaveBeenCalledWith(mockData.contentType); - expect(spyContent).not.toHaveBeenCalledWith(); - }); + const formValue = { field1: 'value1', field2: 'value2' }; + component.setFormValue(formValue); - it('should pass the data to the DotEditContentForm Component', () => { - spectator.detectChanges(); - const formComponent = spectator.query(DotEditContentFormComponent); - expect(formComponent).toBeDefined(); - expect(formComponent.formData).toEqual(mockData); - }); + const actionParams: DotWorkflowActionParams = { + actionId: 'action1', + inode: 'existingInode', + contentType: 'contentType1' + }; - it('should pass the actions to the DotEditContentToolbar Component', () => { - spectator.detectChanges(); - const toolbarComponent = spectator.query(DotEditContentToolbarComponent); - expect(toolbarComponent).toBeDefined(); - expect(toolbarComponent.$actions).toEqual(mockData.actions); - }); + component.fireWorkflowAction(actionParams); - it('should pass the contentlet and contentType to the DotEditContentAside Component', () => { - spectator.detectChanges(); - const asideComponent = spectator.query(DotEditContentAsideComponent); - expect(asideComponent).toBeDefined(); - expect(asideComponent.$contentlet).toEqual(mockData.contentlet); - expect(asideComponent.$contentType).toEqual(mockData.contentType); + expect(mockFireWorkflowAction).toHaveBeenCalledWith({ + actionId: 'action1', + inode: 'existingInode', + data: { + contentlet: { + ...formValue, + contentType: 'contentType1' + } + } + }); }); }); }); diff --git a/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.ts b/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.ts index 86b4498675a0..93d51202bbc9 100644 --- a/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.ts +++ b/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.ts @@ -1,16 +1,6 @@ -import { Observable, forkJoin, of } from 'rxjs'; - -import { animate, state, style, transition, trigger } from '@angular/animations'; import { AsyncPipe } from '@angular/common'; -import { - ChangeDetectionStrategy, - Component, - OnInit, - inject, - HostBinding, - signal -} from '@angular/core'; -import { ActivatedRoute, RouterLink } from '@angular/router'; +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { RouterLink } from '@angular/router'; import { MessageService } from 'primeng/api'; import { ButtonModule } from 'primeng/button'; @@ -18,22 +8,15 @@ import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { MessagesModule } from 'primeng/messages'; import { ToastModule } from 'primeng/toast'; -import { switchMap } from 'rxjs/operators'; - -import { - DotRenderMode, - DotWorkflowActionsFireService, - DotWorkflowsActionsService -} from '@dotcms/data-access'; -import { FeaturedFlags } from '@dotcms/dotcms-models'; +import { DotWorkflowActionsFireService, DotWorkflowsActionsService } from '@dotcms/data-access'; import { DotMessagePipe } from '@dotcms/ui'; -import { DotEditContentStore, SIDEBAR_LOCAL_STORAGE_KEY } from './store/edit-content.store'; +import { DotEditContentStore } from './store/edit-content.store'; import { DotEditContentAsideComponent } from '../../components/dot-edit-content-aside/dot-edit-content-aside.component'; import { DotEditContentFormComponent } from '../../components/dot-edit-content-form/dot-edit-content-form.component'; import { DotEditContentToolbarComponent } from '../../components/dot-edit-content-toolbar/dot-edit-content-toolbar.component'; -import { EditContentPayload } from '../../models/dot-edit-content-form.interface'; +import { DotWorkflowActionParams } from '../../models/dot-edit-content.model'; import { DotEditContentService } from '../../services/dot-edit-content.service'; @Component({ @@ -51,9 +34,6 @@ import { DotEditContentService } from '../../services/dot-edit-content.service'; DotEditContentToolbarComponent, ConfirmDialogModule ], - templateUrl: './edit-content.layout.component.html', - styleUrls: ['./edit-content.layout.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, providers: [ DotWorkflowsActionsService, DotWorkflowActionsFireService, @@ -61,75 +41,18 @@ import { DotEditContentService } from '../../services/dot-edit-content.service'; MessageService, DotEditContentStore ], - animations: [ - trigger('sidebarAnimation', [ - state( - 'false', - style({ - 'grid-template-columns': '1fr 0rem' - }) - ), - state( - 'true', - style({ - 'grid-template-columns': '1fr 21.875rem' - }) - ), - transition('false <=> true', animate('300ms ease-in-out')) - ]) - ] -}) -export class EditContentLayoutComponent implements OnInit { - @HostBinding('@sidebarAnimation') showSidebar: boolean; - - readonly #store = inject(DotEditContentStore); - readonly #dotEditContentService = inject(DotEditContentService); - readonly #workflowActionService = inject(DotWorkflowsActionsService); - readonly #activatedRoute = inject(ActivatedRoute); - - #$contentType = signal( - this.#activatedRoute.snapshot.params['contentType'] - ).asReadonly(); - #$initialInode = signal(this.#activatedRoute.snapshot.params['id']).asReadonly(); - - #formValue: Record; - - vm$: Observable = this.#store.vm$; - $featuredFlagContentKEY = signal( - FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED - ).asReadonly(); - - ngOnInit(): void { - const obs$ = !this.#$initialInode() - ? this.getNewContent(this.#$contentType()) - : this.getExistingContent(this.#$initialInode()); - - obs$.subscribe(({ contentType, actions, contentlet }) => { - this.#store.setState({ - contentType, - actions, - contentlet, - loading: false, - layout: { - showSidebar: this.getSidebarSateFromLocalStorage() - } - }); - }); + host: { + '[class.edit-content--with-sidebar]': '$store.showSidebar()' + }, + templateUrl: './edit-content.layout.component.html', + styleUrls: ['./edit-content.layout.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class EditContentLayoutComponent { + readonly $store: InstanceType = inject(DotEditContentStore); - this.#store.layout$.subscribe((layout) => { - this.showSidebar = layout.showSidebar; - }); - } - - /** - * Toggle the show of the sidebar. - * - * @memberof EditContentLayoutComponent - */ - toggleSidebar() { - this.#store.updateSidebarState(!this.showSidebar); - } + formValue = signal>({}); /** * Set the form value to be saved. @@ -138,7 +61,7 @@ export class EditContentLayoutComponent implements OnInit { * @memberof EditContentLayoutComponent */ setFormValue(formValue: Record) { - this.#formValue = formValue; + this.formValue.set(formValue); } /** @@ -147,67 +70,16 @@ export class EditContentLayoutComponent implements OnInit { * @param {DotCMSWorkflowAction} action * @memberof EditContentLayoutComponent */ - fireWorkflowAction({ actionId, inode, contentType }): void { - this.#store.fireWorkflowActionEffect({ + fireWorkflowAction({ actionId, inode, contentType }: DotWorkflowActionParams): void { + this.$store.fireWorkflowAction({ actionId, inode, data: { contentlet: { - ...this.#formValue, + ...this.formValue(), contentType } } }); } - - /** - * Get the content type, actions and contentlet for the given contentTypeVar - * - * @private - * @param {string} contentTypeVar - * @return {*} - * @memberof EditContentLayoutComponent - */ - private getNewContent(contentTypeVar: string) { - return forkJoin({ - contentType: this.#dotEditContentService.getContentType(contentTypeVar), - actions: this.#workflowActionService.getDefaultActions(contentTypeVar), - contentlet: of(null) - }); - } - - /** - * Get the contentlet, content type and actions for the given inode - * - * @private - * @param {*} inode - * @return {*} - * @memberof EditContentLayoutComponent - */ - private getExistingContent(inode) { - return this.#dotEditContentService.getContentById(inode).pipe( - switchMap((contentlet) => { - const { contentType } = contentlet; - - return forkJoin({ - contentType: this.#dotEditContentService.getContentType(contentType), - actions: this.#workflowActionService.getByInode(inode, DotRenderMode.EDITING), - contentlet: of(contentlet) - }); - }) - ); - } - - /** - * Get the sidebar state from local storage - * - * @private - * @return {*} - * @memberof EditContentLayoutComponent - */ - private getSidebarSateFromLocalStorage() { - const localStorageData = localStorage.getItem(SIDEBAR_LOCAL_STORAGE_KEY); - - return localStorageData ? localStorageData === 'true' : true; - } } diff --git a/core-web/libs/edit-content/src/lib/feature/edit-content/store/edit-content.store.spec.ts b/core-web/libs/edit-content/src/lib/feature/edit-content/store/edit-content.store.spec.ts index 50aba4a65cb4..54cb5416b865 100644 --- a/core-web/libs/edit-content/src/lib/feature/edit-content/store/edit-content.store.spec.ts +++ b/core-web/libs/edit-content/src/lib/feature/edit-content/store/edit-content.store.spec.ts @@ -1,199 +1,252 @@ -import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest'; -import { of } from 'rxjs'; - -import { Location } from '@angular/common'; -import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { ActivatedRoute } from '@angular/router'; - -import { MessageService } from 'primeng/api'; +import { + createServiceFactory, + mockProvider, + SpectatorService, + SpyObject +} from '@ngneat/spectator/jest'; +import { of, throwError } from 'rxjs'; -import { skip } from 'rxjs/operators'; +import { HttpErrorResponse } from '@angular/common/http'; +import { fakeAsync, tick } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; import { - DotMessageService, + DotContentTypeService, + DotFireActionOptions, + DotHttpErrorManagerService, DotRenderMode, DotWorkflowActionsFireService, DotWorkflowsActionsService } from '@dotcms/data-access'; -import { MockDotMessageService, mockWorkflowsActions } from '@dotcms/utils-testing'; +import { + ComponentStatus, + DotCMSContentlet, + DotCMSContentType, + DotCMSWorkflowAction +} from '@dotcms/dotcms-models'; +import { mockWorkflowsActions } from '@dotcms/utils-testing'; import { DotEditContentStore } from './edit-content.store'; import { DotEditContentService } from '../../../services/dot-edit-content.service'; -import { BINARY_FIELD_CONTENTLET, CONTENT_TYPE_MOCK } from '../../../utils/mocks'; - -const messageServiceMock = new MockDotMessageService({ - 'dot.common.message.success': 'Success', - 'edit.content.fire.action.success': 'Content published' -}); +import { CONTENT_TYPE_MOCK } from '../../../utils/mocks'; describe('DotEditContentStore', () => { - let spectator: SpectatorService; - let dotWorkflowsActionsService: DotWorkflowsActionsService; - let dotWorkflowActionsFireService: DotWorkflowActionsFireService; - let location: Location; - let messageService: MessageService; + let spectator: SpectatorService>; + let store: InstanceType; + + let contentTypeService: SpyObject; + + let dotHttpErrorManagerService: SpyObject; + let dotEditContentService: SpyObject; + + let mockActivatedRouteParams: { [key: string]: unknown }; + let router: SpyObject; + + let workflowActionsService: SpyObject; + let workflowActionsFireService: SpyObject; const createService = createServiceFactory({ service: DotEditContentStore, - imports: [HttpClientTestingModule], + mocks: [ + DotWorkflowActionsFireService, + DotContentTypeService, + DotEditContentService, + DotHttpErrorManagerService, + DotWorkflowsActionsService + ], providers: [ - Location, - MessageService, - { - provide: DotMessageService, - useValue: messageServiceMock - }, - { - provide: DotWorkflowActionsFireService, - useValue: { - fireTo: jest.fn().mockReturnValue(of(BINARY_FIELD_CONTENTLET)) - } - }, { - provide: DotWorkflowsActionsService, - useValue: { - getByInode: jest.fn().mockReturnValue(of(mockWorkflowsActions)), - getDefaultActions: jest.fn().mockReturnValue(of(mockWorkflowsActions)) - } - }, - { - provide: DotEditContentService, + provide: ActivatedRoute, useValue: { - getContentType: jest.fn().mockReturnValue(of(CONTENT_TYPE_MOCK)), - getContentById: jest.fn().mockReturnValue(of(BINARY_FIELD_CONTENTLET)) + get snapshot() { + return { params: mockActivatedRouteParams }; + } } }, - { - provide: ActivatedRoute, - useValue: { snapshot: { params: { contentType: undefined, id: '1' } } } - } + + mockProvider(Router, { + navigate: jest.fn().mockReturnValue(Promise.resolve(true)) + }) ] }); beforeEach(() => { + mockActivatedRouteParams = {}; + spectator = createService(); - dotWorkflowsActionsService = spectator.inject(DotWorkflowsActionsService); - dotWorkflowActionsFireService = spectator.inject(DotWorkflowActionsFireService); - messageService = spectator.inject(MessageService); - location = spectator.inject(Location); - - spectator.service.setState({ - actions: mockWorkflowsActions, - contentType: CONTENT_TYPE_MOCK, - contentlet: BINARY_FIELD_CONTENTLET, - loading: false, - layout: { - showSidebar: true - } - }); + + store = spectator.service; + contentTypeService = spectator.inject(DotContentTypeService); + dotHttpErrorManagerService = spectator.inject(DotHttpErrorManagerService); + workflowActionsService = spectator.inject(DotWorkflowsActionsService); + workflowActionsFireService = spectator.inject(DotWorkflowActionsFireService); + dotEditContentService = spectator.inject(DotEditContentService); + + router = spectator.inject(Router); + }); + + afterEach(() => { + jest.resetAllMocks(); }); it('should create the store', () => { expect(spectator.service).toBeDefined(); }); - describe('updaters', () => { - it('should update the state', (done) => { - // Skip the initial value - spectator.service.vm$.pipe(skip(1)).subscribe((state) => { - expect(state).toEqual({ - actions: [], - contentType: CONTENT_TYPE_MOCK, - contentlet: BINARY_FIELD_CONTENTLET, - loading: false, - layout: { - showSidebar: true - } - }); - done(); - }); - spectator.service.updateState({ - actions: [], - contentType: CONTENT_TYPE_MOCK, - contentlet: BINARY_FIELD_CONTENTLET, - loading: false, - layout: { - showSidebar: true - } - }); - }); + describe('initializeNewContent', () => { + it('should initialize new content successfully', () => { + const testContentType = 'testContentType'; - it('should update the contentlet and actions', (done) => { - const NEW_BINARY_FIELD_CONTENTLET = { - ...BINARY_FIELD_CONTENTLET, - title: 'new title' - }; - - spectator.service.vm$.pipe(skip(1)).subscribe((state) => { - expect(state).toEqual({ - ...state, - actions: [], - contentlet: NEW_BINARY_FIELD_CONTENTLET - }); - done(); - }); - spectator.service.updateContentletAndActions({ - actions: [], - contentlet: NEW_BINARY_FIELD_CONTENTLET - }); + contentTypeService.getContentType.mockReturnValue(of(CONTENT_TYPE_MOCK)); + workflowActionsService.getDefaultActions.mockReturnValue(of(mockWorkflowsActions)); + + store.initializeNewContent(testContentType); + + // use the proper contentType for get the data + expect(contentTypeService.getContentType).toHaveBeenCalledWith(testContentType); + expect(workflowActionsService.getDefaultActions).toHaveBeenCalledWith(testContentType); + + expect(store.contentType()).toEqual(CONTENT_TYPE_MOCK); + expect(store.actions()).toEqual(mockWorkflowsActions); + expect(store.status()).toBe(ComponentStatus.LOADED); + expect(store.error()).toBeNull(); }); - it('should update the sidebar state', (done) => { - spectator.service.updateSidebarState(false); - spectator.service.layout$.pipe().subscribe((state) => { - expect(state).toEqual({ - showSidebar: false - }); - done(); - }); + it('should handle error when initializing new content', fakeAsync(() => { + const mockError = new HttpErrorResponse({ status: 404, statusText: 'Not Found' }); + + contentTypeService.getContentType.mockReturnValue(throwError(() => mockError)); + workflowActionsService.getDefaultActions.mockReturnValue(of(mockWorkflowsActions)); + + store.initializeNewContent('testContentType'); + + expect(store.error()).toBe('Error initializing content'); + expect(store.status()).toBe(ComponentStatus.ERROR); + expect(dotHttpErrorManagerService.handle).toHaveBeenCalled(); + })); + }); + + describe('initializeExistingContent', () => { + const testInode = '123-test-inode'; + it('should initialize existing content successfully', () => { + const mockContentlet = { + inode: testInode, + contentType: 'testContentType' + } as DotCMSContentlet; + + const mockContentType = { + id: '1', + name: 'Test Content Type' + } as DotCMSContentType; + + const mockActions = [{ id: '1', name: 'Test Action' }] as DotCMSWorkflowAction[]; + + dotEditContentService.getContentById.mockReturnValue(of(mockContentlet)); + contentTypeService.getContentType.mockReturnValue(of(mockContentType)); + workflowActionsService.getByInode.mockReturnValue(of(mockActions)); + + store.initializeExistingContent(testInode); + + expect(dotEditContentService.getContentById).toHaveBeenCalledWith(testInode); + expect(contentTypeService.getContentType).toHaveBeenCalledWith( + mockContentlet.contentType + ); + expect(workflowActionsService.getByInode).toHaveBeenCalledWith( + testInode, + expect.anything() + ); + + expect(store.contentlet()).toEqual(mockContentlet); + expect(store.contentType()).toEqual(mockContentType); + expect(store.actions()).toEqual(mockActions); + expect(store.status()).toBe(ComponentStatus.LOADED); + expect(store.error()).toBe(null); }); + + it('should handle error when initializing existing content', fakeAsync(() => { + const mockError = new HttpErrorResponse({ status: 404, statusText: 'Not Found' }); + + dotEditContentService.getContentById.mockReturnValue(throwError(() => mockError)); + + store.initializeExistingContent(testInode); + tick(); + + expect(dotEditContentService.getContentById).toHaveBeenCalledWith(testInode); + expect(dotHttpErrorManagerService.handle).toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith(['/c/content']); + + expect(store.status()).toBe(ComponentStatus.ERROR); + })); }); - describe('effects', () => { - it('should call fireWorkflowAction and update the state and url', (done) => { - const fireWorkflowActionSpy = jest.spyOn(dotWorkflowActionsFireService, 'fireTo'); - const workflowSpy = jest.spyOn(dotWorkflowsActionsService, 'getByInode'); - const updateStateSpy = jest.spyOn(spectator.service, 'updateContentletAndActions'); - const locationSpy = jest.spyOn(location, 'replaceState'); - const spyMessage = jest.spyOn(messageService, 'add'); - - const mockParams = { - actionId: mockWorkflowsActions[0].id, - data: { - contentlet: { - title: 'new title', - inode: '12345', - contentType: BINARY_FIELD_CONTENTLET.contentType - } - }, - inode: BINARY_FIELD_CONTENTLET.inode - }; - - spectator.service.fireWorkflowActionEffect(mockParams); - - spectator.service.vm$.subscribe(() => { - expect(fireWorkflowActionSpy).toHaveBeenCalledWith(mockParams); - expect(workflowSpy).toHaveBeenCalledWith( - BINARY_FIELD_CONTENTLET.inode, - DotRenderMode.EDITING - ); - expect(updateStateSpy).toHaveBeenCalledWith({ - contentlet: BINARY_FIELD_CONTENTLET, - actions: mockWorkflowsActions - }); - expect(locationSpy).toHaveBeenCalledWith( - `/content/${BINARY_FIELD_CONTENTLET.inode}` - ); - - expect(spyMessage).toHaveBeenCalledWith({ - severity: 'success', - summary: 'Success', - detail: 'Content published' - }); - - done(); + describe('fireWorkflowAction', () => { + const mockOptions: DotFireActionOptions<{ [key: string]: string | object }> = { + inode: '123', + actionId: 'publish' + }; + + it('should fire workflow action successfully', fakeAsync(() => { + const mockContentlet = { inode: '456', contentType: 'testType' } as DotCMSContentlet; + const mockActions = [{ id: '1', name: 'Test Action' }] as DotCMSWorkflowAction[]; + + workflowActionsFireService.fireTo.mockReturnValue(of(mockContentlet)); + workflowActionsService.getByInode.mockReturnValue(of(mockActions)); + + store.fireWorkflowAction(mockOptions); + tick(); + + expect(store.status()).toBe(ComponentStatus.LOADED); + expect(store.contentlet()).toEqual(mockContentlet); + expect(store.actions()).toEqual(mockActions); + expect(store.error()).toBeNull(); + + expect(workflowActionsFireService.fireTo).toHaveBeenCalledWith(mockOptions); + expect(workflowActionsService.getByInode).toHaveBeenCalledWith( + mockContentlet.inode, + DotRenderMode.EDITING + ); + expect(router.navigate).toHaveBeenCalledWith(['/content', mockContentlet.inode], { + replaceUrl: true, + queryParamsHandling: 'preserve' + }); + })); + + it('should handle error when firing workflow action', fakeAsync(() => { + const mockError = new HttpErrorResponse({ + status: 500, + statusText: 'Internal Server Error' }); + + workflowActionsFireService.fireTo.mockReturnValue(throwError(() => mockError)); + + store.fireWorkflowAction(mockOptions); + tick(); + + expect(store.status()).toBe(ComponentStatus.ERROR); + expect(store.error()).toBe('Error firing workflow action'); + expect(dotHttpErrorManagerService.handle).toHaveBeenCalled(); + })); + + it('should navigate to content list if contentlet has no inode', fakeAsync(() => { + const mockContentletWithoutInode = { contentType: 'testType' } as DotCMSContentlet; + + workflowActionsFireService.fireTo.mockReturnValue(of(mockContentletWithoutInode)); + + store.fireWorkflowAction(mockOptions); + tick(); + + expect(router.navigate).toHaveBeenCalledWith(['/c/content']); + })); + }); + + describe('toggleSidebar', () => { + it('should toggle sidebar state', () => { + expect(store.showSidebar()).toBe(true); + store.toggleSidebar(); + expect(store.showSidebar()).toBe(false); + store.toggleSidebar(); + expect(store.showSidebar()).toBe(true); }); }); }); diff --git a/core-web/libs/edit-content/src/lib/feature/edit-content/store/edit-content.store.ts b/core-web/libs/edit-content/src/lib/feature/edit-content/store/edit-content.store.ts index 02a86cabaffb..6b486d82bb14 100644 --- a/core-web/libs/edit-content/src/lib/feature/edit-content/store/edit-content.store.ts +++ b/core-web/libs/edit-content/src/lib/feature/edit-content/store/edit-content.store.ts @@ -1,203 +1,308 @@ -import { ComponentStore, tapResponse } from '@ngrx/component-store'; -import { Observable, forkJoin, of } from 'rxjs'; - -import { Location } from '@angular/common'; -import { Injectable, inject } from '@angular/core'; -import { Router } from '@angular/router'; +import { tapResponse } from '@ngrx/component-store'; +import { + patchState, + signalStore, + withComputed, + withHooks, + withMethods, + withState +} from '@ngrx/signals'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { forkJoin, of, pipe } from 'rxjs'; -import { MessageService } from 'primeng/api'; +import { HttpErrorResponse } from '@angular/common/http'; +import { computed, inject } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; import { switchMap, tap } from 'rxjs/operators'; +import { DotCMSContentlet } from '@dotcms/angular'; import { + DotContentTypeService, DotFireActionOptions, - DotMessageService, + DotHttpErrorManagerService, DotRenderMode, DotWorkflowActionsFireService, DotWorkflowsActionsService } from '@dotcms/data-access'; -import { DotCMSContentType, DotCMSContentlet, DotCMSWorkflowAction } from '@dotcms/dotcms-models'; +import { + ComponentStatus, + DotCMSContentType, + DotCMSWorkflowAction, + FeaturedFlags +} from '@dotcms/dotcms-models'; -export const SIDEBAR_LOCAL_STORAGE_KEY = 'dot-edit-content-form-sidebar'; +import { DotEditContentService } from '../../../services/dot-edit-content.service'; +import { getPersistSidebarState, setPersistSidebarState } from '../../../utils/functions.util'; interface EditContentState { actions: DotCMSWorkflowAction[]; - contentType: DotCMSContentType; - contentlet: DotCMSContentlet; - loading: boolean; - layout: { - showSidebar: boolean; - }; + contentType: DotCMSContentType | null; + contentlet: DotCMSContentlet | null; + status: ComponentStatus; + showSidebar: boolean; + error: string | null; } +const initialState: EditContentState = { + contentType: null, + contentlet: null, + actions: [], + status: ComponentStatus.INIT, + showSidebar: false, + error: null +}; + /** - * Temporary store to handle the edit content page state - * until we have a proper store for the edit content page [https://github.com/dotCMS/core/issues/27022] - * - * @export - * @class DotEditContentStore - * @extends {ComponentStore} + * The DotEditContentStore is a state management store used in the DotCMS content editing application. + * It provides state, computed properties, methods, and hooks for managing the application state + * related to content editing and workflow actions. */ -@Injectable() -export class DotEditContentStore extends ComponentStore { - private readonly router = inject(Router); - - private readonly workflowActionService = inject(DotWorkflowsActionsService); - private readonly WorkflowActionsFireService = inject(DotWorkflowActionsFireService); - - private readonly messageService = inject(MessageService); - private readonly dotMessageService = inject(DotMessageService); - private readonly location = inject(Location); - - readonly vm$ = this.select(({ actions, contentType, contentlet, loading, layout }) => ({ - actions, - contentType, - contentlet, - loading, - layout - })); - - readonly layout$ = this.select(({ layout }) => layout); - - /** - * Update the state - * - * @memberof DotEditContentStore - */ - readonly updateState = this.updater((state, newState: EditContentState) => ({ - ...state, - ...newState - })); - - /** - * Update the sidebar state and save it in local storage. - * - * @memberof DotEditContentStore - */ - readonly updateSidebarState = this.updater((state, showSidebar: boolean) => { - localStorage.setItem(SIDEBAR_LOCAL_STORAGE_KEY, String(showSidebar)); - - return { - ...state, - layout: { - ...state.layout, - showSidebar - } - }; - }); - - /** - * Update the loading state - * - * @memberof DotEditContentStore - */ - readonly updateLoading = this.updater((state, loading: boolean) => ({ - ...state, - loading - })); - - /** - * Update the contentlet and actions - * - * @memberof DotEditContentStore - */ - readonly updateContentletAndActions = this.updater<{ - actions: DotCMSWorkflowAction[]; - contentlet: DotCMSContentlet; - }>((state, { contentlet, actions }) => ({ - ...state, - contentlet, - actions, - loading: false - })); - - /** - * Fire the workflow action and update the contentlet and actions - * - * @memberof DotEditContentStore - */ - readonly fireWorkflowActionEffect = this.effect( - (data$: Observable>) => { - return data$.pipe( - tap(() => this.updateLoading(true)), - switchMap((options) => { - return this.fireWorkflowAction(options).pipe( - tapResponse( - ({ contentlet, actions }) => { - this.updateURL(contentlet.inode); - this.updateContentletAndActions({ - contentlet, - actions - }); +export const DotEditContentStore = signalStore( + withState(initialState), + withComputed((store) => ({ + /** + * Computed property that determines if the new content editor feature is enabled. + * + * This function retrieves the content type from the store, accesses its metadata, + * and checks whether the content editor feature flag is set to true. + * + * @returns {boolean} True if the new content editor feature is enabled, false otherwise. + */ + isEnabledNewContentEditor: computed(() => { + const contentType = store.contentType(); + const metadata = contentType?.metadata; + + return metadata?.[FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED] === true; + }), + + /** + * A computed property that checks if the store is in a loading or saving state. + * + * @returns {boolean} True if the store's status is either LOADING or SAVING, false otherwise. + */ + isLoading: computed( + () => + store.status() === ComponentStatus.LOADING || + store.status() === ComponentStatus.SAVING + ), + + /** + * Computed property that determines if the store's status is equal to ComponentStatus.LOADED. + * + * @returns {boolean} - Returns true if the store's status is LOADED, otherwise false. + */ + isLoaded: computed(() => store.status() === ComponentStatus.LOADED), + + /** + * A computed property that checks if an error exists in the store. + * + * @returns {boolean} True if there is an error in the store, false otherwise. + */ + hasError: computed(() => !!store.error()), + + /** + * Returns computed form data. + * + * @return {Object} The form data containing `contentlet` and `contentType`. + * - contentlet: The current contentlet from the store. + * - contentType: The current content type from the store. + */ + formData: computed(() => { + return { + contentlet: store.contentlet(), + contentType: store.contentType() + }; + }) + })), + withMethods( + ( + store, + workflowActionService = inject(DotWorkflowsActionsService), + workflowActionsFireService = inject(DotWorkflowActionsFireService), + dotContentTypeService = inject(DotContentTypeService), + dotEditContentService = inject(DotEditContentService), + dotHttpErrorManagerService = inject(DotHttpErrorManagerService), + router = inject(Router) + ) => ({ + /** + * Method to initialize new content of a given type. + * New content + * + * @param {string} contentType - The type of content to initialize. + * @returns {Observable} An observable that completes when the initialization is done. + */ + initializeNewContent: rxMethod( + pipe( + switchMap((contentType) => { + patchState(store, { status: ComponentStatus.LOADING }); + + return forkJoin({ + contentType: dotContentTypeService.getContentType(contentType), + actions: workflowActionService.getDefaultActions(contentType) + }).pipe( + tapResponse({ + next: ({ contentType, actions }) => { + patchState(store, { + contentType, + actions, + status: ComponentStatus.LOADED, + error: null + }); + }, + error: (error: HttpErrorResponse) => { + patchState(store, { + status: ComponentStatus.ERROR, + error: 'Error initializing content' + }); + dotHttpErrorManagerService.handle(error); + } + }) + ); + }) + ) + ), + + /** + * Initializes the existing content by loading its details and updating the state. + * Content existing + * + * @returns {Observable} An observable that emits the content ID. + */ + initializeExistingContent: rxMethod( + pipe( + switchMap((inode: string) => { + patchState(store, { status: ComponentStatus.LOADING }); + + return dotEditContentService.getContentById(inode).pipe( + switchMap((contentlet) => { + const { contentType } = contentlet; - this.messageService.add({ - severity: 'success', - summary: this.dotMessageService.get( - 'dot.common.message.success' + return forkJoin({ + contentType: dotContentTypeService.getContentType(contentType), + actions: workflowActionService.getByInode( + inode, + DotRenderMode.EDITING ), - detail: this.dotMessageService.get( - 'edit.content.fire.action.success' - ) + contentlet: of(contentlet) }); - }, - ({ error }) => { - this.updateLoading(false); - this.messageService.add({ - severity: 'error', - summary: this.dotMessageService.get('dot.common.message.error'), - detail: error.message + }), + tapResponse({ + next: ({ contentType, actions, contentlet }) => { + patchState(store, { + contentType, + actions, + contentlet, + status: ComponentStatus.LOADED + }); + }, + error: (error: HttpErrorResponse) => { + patchState(store, { + status: ComponentStatus.ERROR, + error: 'Error initializing content' + }); + dotHttpErrorManagerService.handle(error); + router.navigate(['/c/content']); + } + }) + ); + }) + ) + ), + + /** + * Fires a workflow action and updates the component state accordingly. + * + * This method triggers a sequence of events to fire a workflow action + * and handles the response or error. If the action is successful, + * it navigates to the content view with the updated contentlet and actions. + * In case of an error, it updates the state with an error message. + * + * @param options The options required to fire the workflow action. + */ + fireWorkflowAction: rxMethod>( + pipe( + tap(() => patchState(store, { status: ComponentStatus.SAVING })), + switchMap((options) => { + return workflowActionsFireService.fireTo(options).pipe( + tap((contentlet) => { + if (!contentlet.inode) { + router.navigate(['/c/content']); + } + }), + switchMap((contentlet) => { + return forkJoin({ + actions: workflowActionService.getByInode( + contentlet.inode, + DotRenderMode.EDITING + ), + contentlet: of(contentlet) }); - } - ) - ); - }) - ); - } - ); - - /** - * Fire the workflow action and update the contentlet and actions - * - * @private - * @param {(DotFireActionOptions<{ [key: string]: string | object }>)} options - * @return {*} {Observable<{ - * actions: DotCMSWorkflowAction[]; - * contentlet: DotCMSContentlet; - * }>} - * @memberof DotEditContentStore - */ - private fireWorkflowAction( - options: DotFireActionOptions<{ [key: string]: string | object }> - ): Observable<{ - actions: DotCMSWorkflowAction[]; - contentlet: DotCMSContentlet; - }> { - return this.WorkflowActionsFireService.fireTo(options).pipe( - tap((contentlet) => { - if (!contentlet.inode) { - this.router.navigate(['/c/content']); + }), + tapResponse({ + next: ({ contentlet, actions }) => { + router.navigate(['/content', contentlet.inode], { + replaceUrl: true, + queryParamsHandling: 'preserve' + }); + + patchState(store, { + contentlet, + actions, + status: ComponentStatus.LOADED, + error: null + }); + }, + error: (error: HttpErrorResponse) => { + patchState(store, { + status: ComponentStatus.ERROR, + error: 'Error firing workflow action' + }); + dotHttpErrorManagerService.handle(error); + } + }) + ); + }) + ) + ), + + /** + * Toggles the visibility of the sidebar by updating the application state + * and persists the sidebar's state to ensure consistency across sessions. + */ + toggleSidebar: () => { + const newSidebarState = !store.showSidebar(); + patchState(store, { showSidebar: newSidebarState }); + setPersistSidebarState(newSidebarState.toString()); + }, + + /** + * Fetches the persistence data from the local storage and updates the application state. + * Utilizes the `patchState` function to update the global store with the persisted sidebar state. + */ + getPersistenceDataFromLocalStore: () => { + patchState(store, { showSidebar: getPersistSidebarState() }); + } + }) + ), + withHooks({ + onInit(store) { + const activatedRoute = inject(ActivatedRoute); + const params = activatedRoute.snapshot?.params; + + if (params) { + const contentType = params['contentType']; + const inode = params['id']; + + // TODO: refactor this when we will use EditContent as sidebar + if (inode) { + store.initializeExistingContent(inode); + } else if (contentType) { + store.initializeNewContent(contentType); } - }), - switchMap((contentlet) => { - return forkJoin({ - actions: this.workflowActionService.getByInode( - contentlet.inode, - DotRenderMode.EDITING - ), - contentlet: of(contentlet) - }); - }) - ); - } - - /** - * Update the URL with the new inode without reloading the page - * - * @private - * @param {string} inode - * @memberof DotEditContentStore - */ - private updateURL(inode: string) { - this.location.replaceState(`/content/${inode}`); // Replace the URL with the new inode without reloading the page - } -} + } + + store.getPersistenceDataFromLocalStore(); + } + }) +); diff --git a/core-web/libs/edit-content/src/lib/models/dot-edit-content-form.interface.ts b/core-web/libs/edit-content/src/lib/models/dot-edit-content-form.interface.ts index 3d1fa111df9d..f0205b6a3e84 100644 --- a/core-web/libs/edit-content/src/lib/models/dot-edit-content-form.interface.ts +++ b/core-web/libs/edit-content/src/lib/models/dot-edit-content-form.interface.ts @@ -1,4 +1,4 @@ -import { DotCMSContentlet, DotCMSWorkflowAction, DotCMSContentType } from '@dotcms/dotcms-models'; +import { DotCMSContentlet, DotCMSContentType, DotCMSWorkflowAction } from '@dotcms/dotcms-models'; export interface EditContentPayload { contentType: DotCMSContentType; @@ -9,3 +9,8 @@ export interface EditContentPayload { showSidebar: boolean; }; } + +export interface EditContentForm { + contentType: DotCMSContentType; + contentlet?: DotCMSContentlet; +} diff --git a/core-web/libs/edit-content/src/lib/models/dot-edit-content.constant.ts b/core-web/libs/edit-content/src/lib/models/dot-edit-content.constant.ts new file mode 100644 index 000000000000..c26b63000a59 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/models/dot-edit-content.constant.ts @@ -0,0 +1,7 @@ +/** + * A constant key used for storing and retrieving the state of the sidebar's + * open or closed status from the browser's local storage. The value associated + * with this key determines whether the 'Edit Content Sidebar' is currently open. + * - 'EditContentSidebarOpen' when the sidebar is open. + */ +export const SIDEBAR_LOCAL_STORAGE_KEY = 'EditContentSidebarOpen'; diff --git a/core-web/libs/edit-content/src/lib/models/dot-edit-content.model.ts b/core-web/libs/edit-content/src/lib/models/dot-edit-content.model.ts new file mode 100644 index 000000000000..af652190a5b9 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/models/dot-edit-content.model.ts @@ -0,0 +1,11 @@ +/** + * Interface for workflow action parameters. + * + * @export + * @interface DotWorkflowActionParams + */ +export interface DotWorkflowActionParams { + actionId: string; + inode: string; + contentType: string; +} diff --git a/core-web/libs/edit-content/src/lib/utils/functions.util.spec.ts b/core-web/libs/edit-content/src/lib/utils/functions.util.spec.ts index 5eeded8819a3..f524a60e4f33 100644 --- a/core-web/libs/edit-content/src/lib/utils/functions.util.spec.ts +++ b/core-web/libs/edit-content/src/lib/utils/functions.util.spec.ts @@ -3,11 +3,19 @@ import { describe, expect, it } from '@jest/globals'; import { DotCMSContentTypeField, DotCMSContentTypeFieldVariable } from '@dotcms/dotcms-models'; import * as functionsUtil from './functions.util'; -import { getFieldVariablesParsed, isValidJson, stringToJson, createPaths } from './functions.util'; +import { + getFieldVariablesParsed, + isValidJson, + stringToJson, + createPaths, + getPersistSidebarState, + setPersistSidebarState +} from './functions.util'; import { CALENDAR_FIELD_TYPES, JSON_FIELD_MOCK, MULTIPLE_TABS_MOCK } from './mocks'; import { FLATTENED_FIELD_TYPES } from '../models/dot-edit-content-field.constant'; import { DotEditContentFieldSingleSelectableDataType } from '../models/dot-edit-content-field.enum'; +import { SIDEBAR_LOCAL_STORAGE_KEY } from '../models/dot-edit-content.constant'; describe('Utils Functions', () => { const { castSingleSelectableValue, getSingleSelectableFieldOptions, getFinalCastedValue } = @@ -540,7 +548,9 @@ describe('Utils Functions', () => { it('should return an empty object when the provided fieldVariables array is undefined', () => { const fieldVariables: DotCMSContentTypeFieldVariable[] | undefined = undefined; - const result = getFieldVariablesParsed(fieldVariables); + const result = getFieldVariablesParsed( + fieldVariables as unknown as DotCMSContentTypeFieldVariable[] + ); expect(result).toEqual({}); }); }); @@ -591,4 +601,36 @@ describe('Utils Functions', () => { expect(paths).toStrictEqual([]); }); }); + + describe('Sidebar State Persistence', () => { + beforeEach(() => { + localStorage.clear(); + }); + + describe('getPersistSidebarState', () => { + it('should return true when localStorage is empty', () => { + expect(getPersistSidebarState()).toBe(true); + }); + + it('should return true when localStorage value is "true"', () => { + localStorage.setItem(SIDEBAR_LOCAL_STORAGE_KEY, 'true'); + expect(getPersistSidebarState()).toBe(true); + }); + + it('should return false when localStorage value is "false"', () => { + localStorage.setItem(SIDEBAR_LOCAL_STORAGE_KEY, 'false'); + expect(getPersistSidebarState()).toBe(false); + }); + }); + + describe('setPersistSidebarState', () => { + it('should set the value in localStorage', () => { + setPersistSidebarState('true'); + expect(localStorage.getItem(SIDEBAR_LOCAL_STORAGE_KEY)).toBe('true'); + + setPersistSidebarState('false'); + expect(localStorage.getItem(SIDEBAR_LOCAL_STORAGE_KEY)).toBe('false'); + }); + }); + }); }); diff --git a/core-web/libs/edit-content/src/lib/utils/functions.util.ts b/core-web/libs/edit-content/src/lib/utils/functions.util.ts index 9819c0681600..efcc963226b3 100644 --- a/core-web/libs/edit-content/src/lib/utils/functions.util.ts +++ b/core-web/libs/edit-content/src/lib/utils/functions.util.ts @@ -16,6 +16,7 @@ import { FIELD_TYPES } from '../models/dot-edit-content-field.enum'; import { DotEditContentFieldSingleSelectableDataTypes } from '../models/dot-edit-content-field.type'; +import { SIDEBAR_LOCAL_STORAGE_KEY } from '../models/dot-edit-content.constant'; // This function is used to cast the value to a correct type for the Angular Form if the field is a single selectable field export const castSingleSelectableValue = ( @@ -47,11 +48,23 @@ export const getSingleSelectableFieldOptions = ( ): { label: string; value: DotEditContentFieldSingleSelectableDataTypes }[] => { const lines = (options?.split('\r\n') ?? []).filter((line) => line.trim() !== ''); - return lines?.map((line) => { - const [label, value = label] = line.split('|').map((value) => value.trim()); - - return { label, value: castSingleSelectableValue(value, dataType) }; - }); + return lines + .map((line) => { + const [label, value = label] = line.split('|').map((value) => value.trim()); + + const castedValue = castSingleSelectableValue(value, dataType); + if (castedValue === null) { + return null; + } + + return { label, value: castedValue }; + }) + .filter( + ( + item + ): item is { label: string; value: DotEditContentFieldSingleSelectableDataTypes } => + item !== null + ); }; // This function is used to cast the value to a correct type for the Angular Form @@ -86,13 +99,6 @@ export const transformLayoutToTabs = ( firstTabTitle: string, layout: DotCMSContentTypeLayoutRow[] ): DotCMSContentTypeLayoutTab[] => { - const initialTab = [ - { - title: firstTabTitle, - layout: [] - } - ]; - // Reduce the layout into tabs const tabs = layout.reduce((acc, row) => { const { clazz, name } = row.divider || {}; @@ -104,13 +110,19 @@ export const transformLayoutToTabs = ( title: name, layout: [] }); - } else { + } else if (lastTabIndex >= 0) { // Otherwise, add the row to the layout of the last tab acc[lastTabIndex].layout.push(row); + } else { + // If there's no tab yet, create the initial tab + acc.push({ + title: firstTabTitle, + layout: [row] + }); } return acc; - }, initialTab); + }, [] as DotCMSContentTypeLayoutTab[]); return tabs; }; @@ -152,12 +164,12 @@ export const getFieldVariablesParsed = { // If the value is a boolean string, convert it to a boolean if (value === 'true' || value === 'false') { - result[key] = value === 'true'; + (result as Record)[key] = value === 'true'; return; } - result[key] = value; + (result as Record)[key] = value; }); return result as T; @@ -208,5 +220,31 @@ export const createPaths = (path: string): string[] => { array.push(path); return array; - }, []); + }, [] as string[]); +}; + +/** + * Retrieves the sidebar state from the local storage. + * + * This function accesses the local storage using a predefined key `SIDEBAR_LOCAL_STORAGE_KEY` + * and returns the parsed state of the sidebar. If the value in local storage is 'true', + * it returns `true`; otherwise, it returns `false`. If there is no value stored under + * the key, it defaults to returning `true`. + * + * @returns {boolean} The state of the sidebar, either `true` (opened) or `false` (closed). + */ +export const getPersistSidebarState = (): boolean => { + const localStorageData = localStorage.getItem(SIDEBAR_LOCAL_STORAGE_KEY); + + return localStorageData ? localStorageData === 'true' : true; +}; + +/** + * Function to persist the state of the sidebar in local storage. + * + * @param {string} value - The state of the sidebar to persist. + * Typically a string representing whether the sidebar is open or closed. + */ +export const setPersistSidebarState = (value: string) => { + localStorage.setItem(SIDEBAR_LOCAL_STORAGE_KEY, value); }; diff --git a/core-web/libs/edit-content/src/lib/utils/mocks.ts b/core-web/libs/edit-content/src/lib/utils/mocks.ts index 70e235c7a5d4..63a70d4b21c9 100644 --- a/core-web/libs/edit-content/src/lib/utils/mocks.ts +++ b/core-web/libs/edit-content/src/lib/utils/mocks.ts @@ -1265,7 +1265,8 @@ export const CONTENT_TYPE_MOCK: DotCMSContentType = { system: true } ], - nEntries: 0 + nEntries: 0, + metadata: { [FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED]: true } }; export const MockResizeObserver = class {