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 d79a4ccc4249..2dd47ffd99a9 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,285 +1,60 @@ -import { - createServiceFactory, - mockProvider, - SpectatorService, - SpyObject -} from '@ngneat/spectator/jest'; -import { of, throwError } from 'rxjs'; - -import { HttpErrorResponse } from '@angular/common/http'; -import { fakeAsync, tick } from '@angular/core/testing'; -import { ActivatedRoute, Router } from '@angular/router'; - -import { MessageService } from 'primeng/api'; - +import { ActivatedRoute } from '@angular/router'; import { DotContentTypeService, - DotFireActionOptions, DotHttpErrorManagerService, DotMessageService, - DotRenderMode, DotWorkflowActionsFireService, DotWorkflowsActionsService, DotWorkflowService } from '@dotcms/data-access'; -import { - ComponentStatus, - DotCMSContentlet, - DotCMSContentType, - DotCMSWorkflowAction -} from '@dotcms/dotcms-models'; -import { MOCK_SINGLE_WORKFLOW_ACTIONS, MockDotMessageService } from '@dotcms/utils-testing'; - -import { DotEditContentStore } from './edit-content.store'; - +import { ComponentStatus } from '@dotcms/dotcms-models'; +import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest'; +import { MessageService } from 'primeng/api'; import { DotEditContentService } from '../../../services/dot-edit-content.service'; -import { CONTENT_TYPE_MOCK } from '../../../utils/mocks'; - -const messageServiceMock = new MockDotMessageService({ - 'edit.content.success.workflow.message': 'Your changes have being applied.', - success: 'Success' -}); +import { DotEditContentStore } from './edit-content.store'; describe('DotEditContentStore', () => { 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; - let messageService: SpyObject; - const createService = createServiceFactory({ service: DotEditContentStore, - mocks: [ - DotWorkflowActionsFireService, - DotContentTypeService, - DotEditContentService, - DotHttpErrorManagerService, - DotWorkflowsActionsService, - DotWorkflowService, - MessageService - ], providers: [ { provide: ActivatedRoute, useValue: { get snapshot() { - return { params: mockActivatedRouteParams }; + return { params: {} }; } } }, - - mockProvider(Router, { - navigate: jest.fn().mockReturnValue(Promise.resolve(true)) - }), - { - provide: DotMessageService, - useValue: messageServiceMock - } + mockProvider(DotHttpErrorManagerService), + mockProvider(DotEditContentService), + mockProvider(DotContentTypeService), + mockProvider(DotWorkflowsActionsService), + mockProvider(DotWorkflowService), + mockProvider(DotWorkflowActionsFireService), + mockProvider(MessageService), + mockProvider(DotMessageService) ] }); beforeEach(() => { - mockActivatedRouteParams = {}; - spectator = createService(); - store = spectator.service; - contentTypeService = spectator.inject(DotContentTypeService); - dotHttpErrorManagerService = spectator.inject(DotHttpErrorManagerService); - workflowActionsService = spectator.inject(DotWorkflowsActionsService); - workflowActionsFireService = spectator.inject(DotWorkflowActionsFireService); - dotEditContentService = spectator.inject(DotEditContentService); - messageService = spectator.inject(MessageService); - - router = spectator.inject(Router); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('should create the store', () => { - expect(spectator.service).toBeDefined(); - }); - - describe('initializeNewContent', () => { - it('should initialize new content successfully', () => { - const testContentType = 'testContentType'; - - contentTypeService.getContentType.mockReturnValue(of(CONTENT_TYPE_MOCK)); - workflowActionsService.getDefaultActions.mockReturnValue( - of(MOCK_SINGLE_WORKFLOW_ACTIONS) - ); - workflowActionsService.getWorkFlowActions.mockReturnValue( - of(MOCK_SINGLE_WORKFLOW_ACTIONS) - ); - - 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.state()).toBe(ComponentStatus.LOADED); - expect(store.error()).toBeNull(); - }); - - 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(MOCK_SINGLE_WORKFLOW_ACTIONS) - ); - - store.initializeNewContent('testContentType'); - - expect(store.error()).toBe('Error initializing content'); - expect(store.state()).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)); - workflowActionsService.getWorkFlowActions.mockReturnValue( - of(MOCK_SINGLE_WORKFLOW_ACTIONS) - ); - - 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.state()).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.state()).toBe(ComponentStatus.ERROR); - })); - }); - - 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.state()).toBe(ComponentStatus.LOADED); - expect(store.contentlet()).toEqual(mockContentlet); - - 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' - }); - - expect(messageService.add).toHaveBeenCalledWith({ - severity: 'success', - summary: 'Success', - detail: 'Your changes have being applied.' - }); - })); - - 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.state()).toBe(ComponentStatus.LOADED); - 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']); - })); + it('should create store with initial state', () => { + expect(store.state()).toBe(ComponentStatus.INIT); + expect(store.error()).toBeNull(); }); - 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); - }); + it('should compose with all required features', () => { + // Verify features are composed into the store + expect(store.contentType).toBeDefined(); + expect(store.contentlet).toBeDefined(); + expect(store.showSidebar).toBeDefined(); + expect(store.showWorkflowActions).toBeDefined(); + expect(store.information).toBeDefined(); }); }); 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 4a3361d6de81..94d6a6bed2fe 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 @@ -10,12 +10,12 @@ import { withInformation } from './features/information.feature'; import { withSidebar } from './features/sidebar.feature'; import { withWorkflow } from './features/workflow.feature'; -export interface EditContentState { +export interface EditContentRootState { state: ComponentStatus; error: string | null; } -const initialState: EditContentState = { +export const initialState: EditContentRootState = { state: ComponentStatus.INIT, error: null }; @@ -26,7 +26,7 @@ const initialState: EditContentState = { * related to content editing and workflow actions. */ export const DotEditContentStore = signalStore( - withState(initialState), + withState(initialState), withContent(), withSidebar(), withInformation(), diff --git a/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/content.feature.spec.ts b/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/content.feature.spec.ts new file mode 100644 index 000000000000..1efcf8c0238f --- /dev/null +++ b/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/content.feature.spec.ts @@ -0,0 +1,215 @@ +import { Router } from '@angular/router'; +import { createServiceFactory, SpectatorService, SpyObject } from '@ngneat/spectator/jest'; +import { signalStore, withState } from '@ngrx/signals'; + +import { + DotContentTypeService, + DotHttpErrorManagerService, + DotWorkflowsActionsService +} from '@dotcms/data-access'; + +import { of, throwError } from 'rxjs'; + +import { HttpErrorResponse } from '@angular/common/http'; +import { fakeAsync, tick } from '@angular/core/testing'; +import { ComponentStatus, DotCMSContentlet, DotCMSWorkflowAction } from '@dotcms/dotcms-models'; +import { MOCK_SINGLE_WORKFLOW_ACTIONS } from '@dotcms/utils-testing'; +import { DotEditContentService } from '../../../../services/dot-edit-content.service'; +import { parseWorkflows } from '../../../../utils/functions.util'; +import { CONTENT_TYPE_MOCK } from '../../../../utils/mocks'; +import { initialState } from '../edit-content.store'; +import { withContent } from './content.feature'; +import { workflowInitialState } from './workflow.feature'; + +describe('ContentFeature', () => { + let spectator: SpectatorService; + let store: any; + let contentTypeService: SpyObject; + let dotHttpErrorManagerService: SpyObject; + let dotEditContentService: SpyObject; + let workflowActionService: SpyObject; + let router: SpyObject; + + const createStore = createServiceFactory({ + service: signalStore( + withState({ ...initialState, ...workflowInitialState }), + withContent() + ), + mocks: [ + DotContentTypeService, + DotEditContentService, + DotHttpErrorManagerService, + DotWorkflowsActionsService, + Router + ] + }); + + beforeEach(() => { + spectator = createStore(); + store = spectator.service; + contentTypeService = spectator.inject(DotContentTypeService); + dotHttpErrorManagerService = spectator.inject(DotHttpErrorManagerService); + dotEditContentService = spectator.inject(DotEditContentService); + workflowActionService = spectator.inject(DotWorkflowsActionsService); + router = spectator.inject(Router); + }); + + describe('computed properties', () => { + it('should return isNew as true when no contentlet exists', () => { + expect(store.isNew()).toBe(true); + }); + + it('should return isNew as false when contentlet exists', fakeAsync(() => { + const mockContentlet = { + inode: '123', + contentType: 'testContentType' + } as DotCMSContentlet; + + dotEditContentService.getContentById.mockReturnValue(of(mockContentlet)); + contentTypeService.getContentType.mockReturnValue(of(CONTENT_TYPE_MOCK)); + workflowActionService.getByInode.mockReturnValue(of([])); + workflowActionService.getWorkFlowActions.mockReturnValue(of([])); + + store.initializeExistingContent('123'); + tick(); + + expect(store.isNew()).toBe(false); + })); + + it('should return correct computed values for new content', fakeAsync(() => { + contentTypeService.getContentType.mockReturnValue(of(CONTENT_TYPE_MOCK)); + workflowActionService.getDefaultActions.mockReturnValue( + of(MOCK_SINGLE_WORKFLOW_ACTIONS) + ); + + store.initializeNewContent('testContentType'); + tick(); + + const parsedSchemes = parseWorkflows(MOCK_SINGLE_WORKFLOW_ACTIONS); + expect(store.schemes()).toEqual(parsedSchemes); + expect(store.currentSchemeId()).toBe(MOCK_SINGLE_WORKFLOW_ACTIONS[0].scheme.id); + })); + + it('should return correct computed values for existing content', fakeAsync(() => { + const mockContentlet = { + inode: '123', + contentType: 'testContentType' + } as DotCMSContentlet; + + const expectedActions = [ + { + actionInputs: [], + assignable: false, + commentable: false, + condition: '', + icon: 'workflowIcon', + id: 'ceca71a0-deee-4999-bd47-b01baa1bcfc8', + metadata: null, + name: 'Save', + nextAssign: '654b0931-1027-41f7-ad4d-173115ed8ec1', + nextStep: 'ee24a4cb-2d15-4c98-b1bd-6327126451f3', + nextStepCurrentStep: false, + order: 0, + owner: null, + roleHierarchyForAssign: false, + schemeId: 'd61a59e1-a49c-46f2-a929-db2b4bfa88b2', + showOn: ['NEW', 'EDITING', 'LOCKED', 'PUBLISHED', 'UNPUBLISHED'] + } + ]; + + // Mock all the requests that forkJoin is expecting + dotEditContentService.getContentById.mockReturnValue(of(mockContentlet)); + contentTypeService.getContentType.mockReturnValue(of(CONTENT_TYPE_MOCK)); + workflowActionService.getByInode.mockReturnValue(of(expectedActions)); + workflowActionService.getWorkFlowActions.mockReturnValue( + of(MOCK_SINGLE_WORKFLOW_ACTIONS) + ); + + store.initializeExistingContent('123'); + + tick(); + + // Verify all the expected values + expect(store.contentlet()).toEqual(mockContentlet); + expect(store.contentType()).toEqual(CONTENT_TYPE_MOCK); + expect(store.currentContentActions()).toEqual(expectedActions); + expect(store.schemes()).toEqual(parseWorkflows(MOCK_SINGLE_WORKFLOW_ACTIONS)); + })); + }); + + describe('initializeNewContent', () => { + beforeEach(() => { + contentTypeService.getContentType.mockReturnValue(of(CONTENT_TYPE_MOCK)); + workflowActionService.getDefaultActions.mockReturnValue( + of(MOCK_SINGLE_WORKFLOW_ACTIONS) + ); + }); + + it('should initialize new content successfully', fakeAsync(() => { + store.initializeNewContent('testContentType'); + tick(); + + expect(contentTypeService.getContentType).toHaveBeenCalledWith('testContentType'); + expect(workflowActionService.getDefaultActions).toHaveBeenCalledWith('testContentType'); + + const parsedSchemes = parseWorkflows(MOCK_SINGLE_WORKFLOW_ACTIONS); + + expect(store.contentType()).toEqual(CONTENT_TYPE_MOCK); + expect(store.state()).toBe(ComponentStatus.LOADED); + expect(store.schemes()).toEqual(parsedSchemes); + expect(store.currentSchemeId()).toBe(MOCK_SINGLE_WORKFLOW_ACTIONS[0].scheme.id); + })); + + it('should handle error when initializing new content', fakeAsync(() => { + const mockError = new HttpErrorResponse({ status: 404 }); + contentTypeService.getContentType.mockReturnValue(throwError(() => mockError)); + + store.initializeNewContent('testContentType'); + tick(); + + expect(store.state()).toBe(ComponentStatus.ERROR); + expect(store.error()).toBe('Error initializing content'); + })); + }); + + describe('initializeExistingContent', () => { + const testInode = '123-test-inode'; + const mockContentlet = { + inode: testInode, + contentType: 'testContentType' + } as DotCMSContentlet; + + const mockActions = [{ id: '1', name: 'Test Action' }] as DotCMSWorkflowAction[]; + + beforeEach(() => { + dotEditContentService.getContentById.mockReturnValue(of(mockContentlet)); + contentTypeService.getContentType.mockReturnValue(of(CONTENT_TYPE_MOCK)); + workflowActionService.getByInode.mockReturnValue(of(mockActions)); + workflowActionService.getWorkFlowActions.mockReturnValue( + of(MOCK_SINGLE_WORKFLOW_ACTIONS) + ); + }); + + it('should initialize existing content successfully', fakeAsync(() => { + store.initializeExistingContent('123'); + tick(); + + expect(store.contentlet()).toEqual(mockContentlet); + expect(store.contentType()).toEqual(CONTENT_TYPE_MOCK); + expect(store.currentContentActions()).toEqual(mockActions); + expect(store.state()).toBe(ComponentStatus.LOADED); + })); + + it('should handle error when initializing existing content', fakeAsync(() => { + const mockError = new HttpErrorResponse({ status: 404 }); + dotEditContentService.getContentById.mockReturnValue(throwError(() => mockError)); + + store.initializeExistingContent('123'); + tick(); + expect(store.state()).toBe(ComponentStatus.ERROR); + expect(store.error()).toBe('Error initializing content'); + + expect(router.navigate).toHaveBeenCalledWith(['/c/content']); + })); + }); +}); diff --git a/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/content.feature.ts b/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/content.feature.ts index 1ee3d1f52caf..599343cd5ea7 100644 --- a/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/content.feature.ts +++ b/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/content.feature.ts @@ -29,11 +29,10 @@ import { FeaturedFlags } from '@dotcms/dotcms-models'; -import { WorkflowState } from './workflow.feature'; - import { DotEditContentService } from '../../../../services/dot-edit-content.service'; import { parseWorkflows, transformFormDataFn } from '../../../../utils/functions.util'; -import { EditContentState } from '../edit-content.store'; +import { EditContentRootState } from '../edit-content.store'; +import { WorkflowState } from './workflow.feature'; export interface ContentState { /** ContentType full data */ @@ -47,9 +46,9 @@ const initialState: ContentState = { contentlet: null }; -export function withContent() { +export function withContent() { return signalStoreFeature( - { state: type() }, + { state: type() }, withState(initialState), withComputed((store) => ({ /** diff --git a/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/debug.feature.ts b/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/debug.feature.ts index f936562b2265..26d42fe664f3 100644 --- a/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/debug.feature.ts +++ b/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/debug.feature.ts @@ -1,6 +1,4 @@ -import { getState, signalStoreFeature, withHooks } from '@ngrx/signals'; - -import { effect } from '@angular/core'; +import { signalStoreFeature, watchState, withHooks } from '@ngrx/signals'; /** * Feature that adds debugging functionality to the Store @@ -10,9 +8,8 @@ export function withDebug() { return signalStoreFeature( withHooks({ onInit(store) { - effect(() => { - // eslint-disable-next-line no-console - console.info('🔄 Store state:', getState(store)); + watchState(store, (state) => { + console.info('🔄 Store state:', state); }); } }) diff --git a/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/information.feature.ts b/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/information.feature.ts index a52e08fdc6b4..4bcd67550f00 100644 --- a/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/information.feature.ts +++ b/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/information.feature.ts @@ -2,7 +2,6 @@ import { tapResponse } from '@ngrx/operators'; import { patchState, signalStoreFeature, - type, withComputed, withMethods, withState @@ -19,9 +18,8 @@ import { DotHttpErrorManagerService } from '@dotcms/data-access'; import { ComponentStatus } from '@dotcms/dotcms-models'; import { DotEditContentService } from '../../../../services/dot-edit-content.service'; -import { EditContentState } from '../edit-content.store'; -interface InformationState { +export interface InformationState { information: { status: ComponentStatus; error: string | null; @@ -41,11 +39,8 @@ const initialState: InformationState = { * Signal store feature that manages the information component state in the edit content sidebar * Handles loading states, error handling, and related content count for the current contentlet */ -export function withInformation() { +export function withInformation() { return signalStoreFeature( - { - state: type() - }, withState(initialState), withComputed(({ information }) => ({ isLoadingInformation: computed(() => information().status === ComponentStatus.LOADING) diff --git a/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/sidebar.feature.ts b/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/sidebar.feature.ts index c99cf31be558..8c9b83f3d31e 100644 --- a/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/sidebar.feature.ts +++ b/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/sidebar.feature.ts @@ -10,12 +10,10 @@ import { import { computed } from '@angular/core'; -import { ContentState } from './content.feature'; - import { getPersistSidebarState, setPersistSidebarState } from '../../../../utils/functions.util'; -import { EditContentState } from '../edit-content.store'; +import { ContentState } from './content.feature'; -interface SidebarState { +export interface SidebarState { showSidebar: boolean; } @@ -30,9 +28,7 @@ const initialState: SidebarState = { */ export function withSidebar() { return signalStoreFeature( - { - state: type() - }, + { state: type() }, withState(initialState), withComputed(({ contentlet }) => ({ getCurrentContentIdentifier: computed(() => contentlet()?.identifier) diff --git a/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/workflow.feature.ts b/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/workflow.feature.ts index 02cd800ee4bd..48316d50b977 100644 --- a/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/workflow.feature.ts +++ b/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/workflow.feature.ts @@ -35,10 +35,14 @@ import { WorkflowTask } from '@dotcms/dotcms-models'; +import { + getWorkflowActions, + shouldShowWorkflowActions, + shouldShowWorkflowWarning +} from '../../../../utils/functions.util'; +import { EditContentRootState } from '../edit-content.store'; import { ContentState } from './content.feature'; -import { EditContentState } from '../edit-content.store'; - export interface WorkflowState { /** Schemas available for the content type */ schemes: { @@ -68,7 +72,7 @@ export interface WorkflowState { }; } -const initialState: WorkflowState = { +export const workflowInitialState: WorkflowState = { schemes: {}, currentSchemeId: null, currentContentActions: [], @@ -86,12 +90,10 @@ const initialState: WorkflowState = { * * @returns */ -export function withWorkflow() { +export function withWorkflow() { return signalStoreFeature( - { - state: type() - }, - withState(initialState), + { state: type() }, + withState(workflowInitialState), withComputed((store) => ({ /** * Computed property that determines if the workflow component is in a loading state @@ -113,35 +115,14 @@ export function withWorkflow() { /** * Computed property that determines if workflow action buttons should be shown. - * Shows workflow buttons when: - * - Content type has only one workflow scheme OR - * - Content is existing AND has a selected workflow scheme OR - * - Content is new and content type has only one workflow scheme OR - * - Content is new and has selected a workflow scheme - * Hides workflow buttons when: - * - Content is new and has multiple schemes without selection - * - * @returns {boolean} True if workflow action buttons should be shown, false otherwise */ - showWorkflowActions: computed(() => { - const hasOneScheme = Object.keys(store.schemes()).length === 1; - const isExisting = !!store.contentlet(); - const hasSelectedScheme = !!store.currentSchemeId(); - - if (hasOneScheme) { - return true; - } - - if (isExisting && hasSelectedScheme) { - return true; - } - - if (!isExisting && hasSelectedScheme) { - return true; - } - - return false; - }), + showWorkflowActions: computed(() => + shouldShowWorkflowActions( + store.schemes(), + store.contentlet(), + store.currentSchemeId() + ) + ), /** * Computed property that determines if the workflow selection warning should be shown. @@ -149,43 +130,27 @@ export function withWorkflow() { * * @returns {boolean} True if warning should be shown, false otherwise */ - showSelectWorkflowWarning: computed(() => { - const isNew = !store.contentlet(); - const hasNoSchemeSelected = !store.currentSchemeId(); - const hasMultipleSchemas = Object.keys(store.schemes()).length > 1; - - return isNew && hasMultipleSchemas && hasNoSchemeSelected; - }), + showSelectWorkflowWarning: computed(() => + shouldShowWorkflowWarning( + store.schemes(), + store.contentlet(), + store.currentSchemeId() + ) + ), /** * Computed property that retrieves the actions for the current workflow scheme. * * @returns {DotCMSWorkflowAction[]} The actions for the current workflow scheme. */ - getActions: computed(() => { - const isNew = !store.contentlet(); - const currentSchemeId = store.currentSchemeId(); - const schemes = store.schemes(); - const currentContentActions = store.currentContentActions(); - - // If no scheme is selected, return empty array - if (!currentSchemeId || !schemes[currentSchemeId]) { - return []; - } - - // For existing content, use specific contentlet actions - if (!isNew && currentContentActions.length) { - return currentContentActions; - } - - // For new content, use scheme actions - return Object.values(schemes[currentSchemeId].actions).sort((a, b) => { - if (a.name === 'Save') return -1; - if (b.name === 'Save') return 1; - - return a.name.localeCompare(b.name); - }); - }), + getActions: computed(() => + getWorkflowActions( + store.schemes(), + store.contentlet(), + store.currentSchemeId(), + store.currentContentActions() + ) + ), /** * Computed property that transforms the workflow schemes into dropdown options diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.html index f1b9c20589ff..c8bc27a8dc06 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.html +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.html @@ -10,7 +10,6 @@ = { @@ -132,18 +124,9 @@ describe('DotEditContentWYSIWYGFieldComponent', () => { providers: [ mockProvider(DotLanguagesService), mockProvider(DotUploadFileService), - mockProvider(DotWorkflowsActionsService), - mockProvider(DotWorkflowActionsFireService), - mockProvider(DotContentTypeService), - mockProvider(DotEditContentService), - mockProvider(DotHttpErrorManagerService), - mockProvider(ActivatedRoute), - mockProvider(DotWorkflowService), - mockProvider(MessageService), provideHttpClient(), provideHttpClientTesting(), - ConfirmationService, - DotEditContentStore + ConfirmationService ] }); @@ -217,19 +200,4 @@ describe('DotEditContentWYSIWYGFieldComponent', () => { expect(spectator.query(byTestId('language-variable-selector'))).toBeTruthy(); }); }); - - describe('sidebar closed state', () => { - it('should add sidebar-closed class when sidebar is closed', () => { - const store = spectator.inject(DotEditContentStore); - - spectator.detectChanges(); - const element = spectator.query(byTestId('language-variable-selector')); - expect(element.classList).not.toContain('dot-wysiwyg__sidebar-closed'); - - store.toggleSidebar(); - spectator.detectChanges(); - - expect(element.classList).toContain('dot-wysiwyg__sidebar-closed'); - }); - }); }); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.ts index f8a04d520cf1..a1be6ec4430e 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.ts @@ -38,8 +38,6 @@ import { } from './dot-edit-content-wysiwyg-field.constant'; import { shouldUseDefaultEditor } from './dot-edit-content-wysiwyg-field.utils'; -import { DotEditContentStore } from '../../feature/edit-content/store/edit-content.store'; - interface LanguageVariable { key: string; value: string; @@ -104,12 +102,6 @@ export class DotEditContentWYSIWYGFieldComponent implements AfterViewInit { #confirmationService = inject(ConfirmationService); #dotMessageService = inject(DotMessageService); #dotLanguagesService = inject(DotLanguagesService); - #store = inject(DotEditContentStore); - - /** - * This variable represents if the sidebar is closed. - */ - $sidebarClosed = computed(() => !this.#store.showSidebar()); /** * This variable represents a required content type field in DotCMS. 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 334ebd8047da..0d27e5b640c9 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,6 +3,7 @@ import { describe, expect, it } from '@jest/globals'; import { DotCMSContentTypeField, DotCMSContentTypeFieldVariable } from '@dotcms/dotcms-models'; import { + MOCK_CONTENTLET_1_TAB, MOCK_CONTENTTYPE_2_TABS, MOCK_FORM_CONTROL_FIELDS, MOCK_WORKFLOW_DATA @@ -12,10 +13,12 @@ import { createPaths, getFieldVariablesParsed, getPersistSidebarState, + getWorkflowActions, isFilteredType, isValidJson, parseWorkflows, setPersistSidebarState, + shouldShowWorkflowWarning, stringToJson } from './functions.util'; import { CALENDAR_FIELD_TYPES, JSON_FIELD_MOCK, MULTIPLE_TABS_MOCK } from './mocks'; @@ -701,3 +704,111 @@ describe('Utils Functions', () => { }); }); }); + +describe('Workflow Utility Functions', () => { + describe('shouldShowWorkflowWarning', () => { + const mockSchemes = parseWorkflows(MOCK_WORKFLOW_DATA); + + it('should return true when content is new, has multiple schemes and no scheme selected', () => { + const result = shouldShowWorkflowWarning(mockSchemes, null, null); + expect(result).toBe(true); + }); + + it('should return false when content exists', () => { + const result = shouldShowWorkflowWarning(mockSchemes, MOCK_CONTENTLET_1_TAB, null); + expect(result).toBe(false); + }); + + it('should return false when only one scheme exists', () => { + const singleScheme = { + 'd61a59e1-a49c-46f2-a929-db2b4bfa88b2': + mockSchemes['d61a59e1-a49c-46f2-a929-db2b4bfa88b2'] + }; + const result = shouldShowWorkflowWarning(singleScheme, null, null); + expect(result).toBe(false); + }); + + it('should return false when scheme is selected', () => { + const result = shouldShowWorkflowWarning( + mockSchemes, + null, + 'd61a59e1-a49c-46f2-a929-db2b4bfa88b2' + ); + expect(result).toBe(false); + }); + }); + + describe('getWorkflowActions', () => { + const mockSchemes = parseWorkflows(MOCK_WORKFLOW_DATA); + + it('should return empty array when no scheme is selected', () => { + const result = getWorkflowActions(mockSchemes, null, null, []); + expect(result).toEqual([]); + }); + + it('should return empty array when selected scheme does not exist', () => { + const result = getWorkflowActions(mockSchemes, null, 'non-existent-scheme', []); + expect(result).toEqual([]); + }); + + it('should return current content actions for existing content', () => { + const currentActions = [MOCK_WORKFLOW_DATA[0].action]; + const result = getWorkflowActions( + mockSchemes, + MOCK_CONTENTLET_1_TAB, + 'd61a59e1-a49c-46f2-a929-db2b4bfa88b2', + currentActions + ); + expect(result).toEqual(currentActions); + }); + + it('should return sorted scheme actions for new content with Save first', () => { + const result = getWorkflowActions( + mockSchemes, + null, + 'd61a59e1-a49c-46f2-a929-db2b4bfa88b2', + [] + ); + + expect(result.length).toBeGreaterThan(0); + expect(result[0].name).toBe('Save'); + }); + + it('should return scheme actions when content exists but no current actions', () => { + const result = getWorkflowActions( + mockSchemes, + MOCK_CONTENTLET_1_TAB, + 'd61a59e1-a49c-46f2-a929-db2b4bfa88b2', + [] + ); + + expect(result.length).toBeGreaterThan(0); + expect(result[0].name).toBe('Save'); + }); + }); + + describe('parseWorkflows', () => { + it('should return empty object when input is not an array', () => { + expect(parseWorkflows(null)).toEqual({}); + expect(parseWorkflows(undefined)).toEqual({}); + }); + + it('should correctly parse workflow data', () => { + const result = parseWorkflows(MOCK_WORKFLOW_DATA); + + // Check structure for System Workflow + expect(result['d61a59e1-a49c-46f2-a929-db2b4bfa88b2']).toBeDefined(); + expect(result['d61a59e1-a49c-46f2-a929-db2b4bfa88b2'].scheme.name).toBe( + 'System Workflow' + ); + expect(result['d61a59e1-a49c-46f2-a929-db2b4bfa88b2'].actions).toHaveLength(1); + expect(result['d61a59e1-a49c-46f2-a929-db2b4bfa88b2'].firstStep.name).toBe('New'); + + // Check structure for Blogs workflow + expect(result['2a4e1d2e-5342-4b46-be3d-80d3a2d9c0dd']).toBeDefined(); + expect(result['2a4e1d2e-5342-4b46-be3d-80d3a2d9c0dd'].scheme.name).toBe('Blogs'); + expect(result['2a4e1d2e-5342-4b46-be3d-80d3a2d9c0dd'].actions).toHaveLength(1); + expect(result['2a4e1d2e-5342-4b46-be3d-80d3a2d9c0dd'].firstStep.name).toBe('Edit'); + }); + }); +}); 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 bce03497379b..beeae7ffb8fa 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 @@ -9,6 +9,8 @@ import { WorkflowStep } from '@dotcms/dotcms-models'; +import { ContentState } from '../feature/edit-content/store/features/content.feature'; +import { WorkflowState } from '../feature/edit-content/store/features/workflow.feature'; import { CALENDAR_FIELD_TYPES, FLATTENED_FIELD_TYPES, @@ -330,3 +332,102 @@ export const parseWorkflows = ( return acc; }, {}); }; + +/** + * Determines if workflow action buttons should be shown based on content and scheme state + * Shows workflow buttons when: + * - Content type has only one workflow scheme OR + * - Content is existing AND has a selected workflow scheme OR + * - Content is new and has selected a workflow scheme + * + * @param schemes - Available workflow schemes object + * @param contentlet - Current contentlet (if exists) + * @param currentSchemeId - Selected workflow scheme ID + * @returns boolean indicating if workflow actions should be shown + */ +export function shouldShowWorkflowActions( + schemes: WorkflowState['schemes'], + contentlet: any | null, + currentSchemeId: string | null +): boolean { + const hasOneScheme = Object.keys(schemes).length === 1; + const isExisting = !!contentlet; + const hasSelectedScheme = !!currentSchemeId; + + if (hasOneScheme) { + return true; + } + + if (isExisting && hasSelectedScheme) { + return true; + } + + if (!isExisting && hasSelectedScheme) { + return true; + } + + return false; +} + +/** + * Determines if workflow selection warning should be shown + * Shows warning when: + * - Content is new (no contentlet exists) AND + * - Content type has multiple workflow schemes AND + * - No workflow scheme has been selected + * + * @param schemes - Available workflow schemes object + * @param contentlet - Current contentlet (if exists) + * @param currentSchemeId - Selected workflow scheme ID + * @returns boolean indicating if workflow selection warning should be shown + */ +export function shouldShowWorkflowWarning( + schemes: WorkflowState['schemes'], + contentlet: ContentState['contentlet'], + currentSchemeId: string | null +): boolean { + const isNew = !contentlet; + const hasNoSchemeSelected = !currentSchemeId; + const hasMultipleSchemas = Object.keys(schemes).length > 1; + + return isNew && hasMultipleSchemas && hasNoSchemeSelected; +} + +/** + * Gets the appropriate workflow actions based on content state + * Returns: + * - Empty array if no scheme is selected + * - Current content actions for existing content + * - Sorted scheme actions for new content (with 'Save' action first) + * + * @param schemes - Available workflow schemes object + * @param contentlet - Current contentlet (if exists) + * @param currentSchemeId - Selected workflow scheme ID + * @param currentContentActions - Current content specific actions + * @returns Array of workflow actions + */ +export function getWorkflowActions( + schemes: WorkflowState['schemes'], + contentlet: ContentState['contentlet'], + currentSchemeId: string | null, + currentContentActions: DotCMSWorkflowAction[] +): DotCMSWorkflowAction[] { + const isNew = !contentlet; + + // If no scheme is selected, return empty array + if (!currentSchemeId || !schemes[currentSchemeId]) { + return []; + } + + // For existing content, use specific contentlet actions + if (!isNew && currentContentActions.length) { + return currentContentActions; + } + + // For new content, use scheme actions sorted with 'Save' first + return Object.values(schemes[currentSchemeId].actions).sort((a, b) => { + if (a.name === 'Save') return -1; + if (b.name === 'Save') return 1; + return a.name.localeCompare(b.name); + }); +}