From cc77a5c0d6c5d9d0a40008af7600b915ede27d4c Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Wed, 4 Dec 2024 17:24:10 -0500 Subject: [PATCH] Add Reset Workflow Button in Edit Contentlet Sidebar Workflow Section (#30767) ### Proposed Changes * Allow resetting a contentlet if the sub-action for that. This pull request includes several changes to the `core-web` library, focusing on enhancing the `dot-edit-content-form` and `dot-edit-content-sidebar-workflow` components. The most important changes include the addition of new render modes, modifications to form value handling, and updates to the workflow component structure and functionality. ### Enhancements to `dot-edit-content-form`: * Added new render modes to the `DotRenderMode` enum in `dot-workflows-actions.service.ts`. * Updated the `changeValue` output type to handle various data types, including `string[]` and `Date`. * Introduced a new computed property `$formFields` to filter out certain fields from the form. * Refactored the `initializeFormListener` method to handle form value changes and emit the processed value. * Simplified the `initializeForm` method to use the new `$formFields` computed property. ### Updates to `dot-edit-content-sidebar-workflow`: * Consolidated workflow-related variables into a single `workflow` object and updated the template to use this object. [[1]](diffhunk://#diff-1e29fca586a864571ecaaf1794485fbb1b5d6a39bac17bcdcb67e44fb8f8e451L1-R3) [[2]](diffhunk://#diff-1e29fca586a864571ecaaf1794485fbb1b5d6a39bac17bcdcb67e44fb8f8e451L16-R13) [[3]](diffhunk://#diff-1e29fca586a864571ecaaf1794485fbb1b5d6a39bac17bcdcb67e44fb8f8e451L26-R58) [[4]](diffhunk://#diff-1e29fca586a864571ecaaf1794485fbb1b5d6a39bac17bcdcb67e44fb8f8e451L61-R67) [[5]](diffhunk://#diff-1e29fca586a864571ecaaf1794485fbb1b5d6a39bac17bcdcb67e44fb8f8e451L84-R90) * Introduced new interfaces `WorkflowSelection` and `DotWorkflowState` to manage workflow selection and state. [[1]](diffhunk://#diff-3465f2dbecb29cf855c8f271b85d396eff6ac9c64b1c1440147cdca68392b1d0L10-R27) [[2]](diffhunk://#diff-3465f2dbecb29cf855c8f271b85d396eff6ac9c64b1c1440147cdca68392b1d0L36-R54) * Added an output event `onResetWorkflow` to handle workflow resets. * Updated the workflow component to use the new `workflowSelection` and `resetWorkflowAction` inputs. ### General improvements: * Removed the unused `SlicePipe` import from `dot-edit-content-sidebar.component.ts`. [[1]](diffhunk://#diff-7de680fdc62a1bf1fd1243d8fcf077fee3a7e08ed213d2821b2eb53b2d9248feL1) [[2]](diffhunk://#diff-7de680fdc62a1bf1fd1243d8fcf077fee3a7e08ed213d2821b2eb53b2d9248feL44-R43) * Updated the `dot-edit-content-sidebar.component.html` to use the new workflow structure and properties. [[1]](diffhunk://#diff-96315ade336a88f5f2f51d7a64dec588aa9fa44384539792c69e1a3346eaa5d0L3-L13) [[2]](diffhunk://#diff-96315ade336a88f5f2f51d7a64dec588aa9fa44384539792c69e1a3346eaa5d0L26-R20) These changes enhance the flexibility and maintainability of the form and workflow components, ensuring better data handling and user experience. ### Checklist - [x] Tests - [ ] Translations - [ ] Security Implications Contemplated (add notes if applicable) ### Additional Info ** any additional useful context or info ** ### Screenshots Contenttype with 2 Workflows (1 resettable and 1 not) https://github.com/user-attachments/assets/e1da7a1e-886c-4270-a98a-4d3ce062a47c Contenttype with 1 resettable Workflow https://github.com/user-attachments/assets/7d0be90b-ba6a-4dad-ba2c-75abb3758d0b --- .../dot-workflows-actions.service.ts | 6 + .../dot-edit-content-form.component.spec.ts | 9 +- .../dot-edit-content-form.component.ts | 75 +++--- ...it-content-sidebar-workflow.component.html | 38 +-- ...content-sidebar-workflow.component.spec.ts | 233 ++++++++++++++++-- ...edit-content-sidebar-workflow.component.ts | 66 ++--- .../dot-edit-content-sidebar.component.html | 26 +- ...dot-edit-content-sidebar.component.spec.ts | 5 +- .../dot-edit-content-sidebar.component.ts | 51 ++-- .../edit-content.layout.component.html | 1 + .../edit-content.layout.component.ts | 17 +- .../edit-content/store/edit-content.store.ts | 2 + .../store/features/content.feature.spec.ts | 141 ++++++++++- .../store/features/content.feature.ts | 106 ++++++-- .../store/features/form.feature.ts | 34 +++ .../store/features/workflow.feature.spec.ts | 149 +++++------ .../store/features/workflow.feature.ts | 233 ++++++++++-------- .../models/dot-edit-content-form.interface.ts | 12 + .../src/lib/models/dot-edit-content.model.ts | 15 ++ .../libs/edit-content/src/lib/utils/mocks.ts | 179 ++++++++++++++ .../src/lib/utils/workflows.utils.spec.ts | 107 +------- .../src/lib/utils/workflows.utils.ts | 118 ++------- .../WEB-INF/messages/Language.properties | 6 +- 23 files changed, 1070 insertions(+), 559 deletions(-) create mode 100644 core-web/libs/edit-content/src/lib/feature/edit-content/store/features/form.feature.ts diff --git a/core-web/libs/data-access/src/lib/dot-workflows-actions/dot-workflows-actions.service.ts b/core-web/libs/data-access/src/lib/dot-workflows-actions/dot-workflows-actions.service.ts index ef7652e34eff..debbf0b26b6e 100644 --- a/core-web/libs/data-access/src/lib/dot-workflows-actions/dot-workflows-actions.service.ts +++ b/core-web/libs/data-access/src/lib/dot-workflows-actions/dot-workflows-actions.service.ts @@ -13,7 +13,13 @@ import { } from '@dotcms/dotcms-models'; export enum DotRenderMode { + LOCKED = 'LOCKED', LISTING = 'LISTING', + ARCHIVED = 'ARCHIVED', + UNPUBLISHED = 'UNPUBLISHED', + PUBLISHED = 'PUBLISHED', + UNLOCKED = 'UNLOCKED', + NEW = 'NEW', EDITING = 'EDITING' } diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts index 877f29577054..e1e2d4a702ca 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts @@ -40,7 +40,8 @@ import { MOCK_CONTENTLET_1_TAB as MOCK_CONTENTLET_1_OR_2_TABS, MOCK_CONTENTTYPE_1_TAB, MOCK_CONTENTTYPE_2_TABS, - MOCK_WORKFLOW_ACTIONS_NEW_ITEMNTTYPE_1_TAB + MOCK_WORKFLOW_ACTIONS_NEW_ITEMNTTYPE_1_TAB, + MOCK_WORKFLOW_STATUS } from '../../utils/edit-content.mock'; import { MockResizeObserver } from '../../utils/mocks'; @@ -52,6 +53,7 @@ describe('DotFormComponent', () => { let workflowActionsService: SpyObject; let workflowActionsFireService: SpyObject; let dotEditContentService: SpyObject; + let dotWorkflowService: SpyObject; let router: SpyObject; const createComponent = createComponentFactory({ @@ -71,6 +73,7 @@ describe('DotFormComponent', () => { mockProvider(DotWorkflowService), mockProvider(MessageService), mockProvider(DotContentletService), + { provide: ActivatedRoute, useValue: { @@ -95,6 +98,7 @@ describe('DotFormComponent', () => { workflowActionsService = spectator.inject(DotWorkflowsActionsService); dotEditContentService = spectator.inject(DotEditContentService); workflowActionsFireService = spectator.inject(DotWorkflowActionsFireService); + dotWorkflowService = spectator.inject(DotWorkflowService); router = spectator.inject(Router); }); @@ -112,6 +116,7 @@ describe('DotFormComponent', () => { workflowActionsService.getWorkFlowActions.mockReturnValue( of(MOCK_SINGLE_WORKFLOW_ACTIONS) ); + dotWorkflowService.getWorkflowStatus.mockReturnValue(of(MOCK_WORKFLOW_STATUS)); store.initializeExistingContent(MOCK_CONTENTLET_1_OR_2_TABS.inode); // called with the inode of the contentlet @@ -189,6 +194,7 @@ describe('DotFormComponent', () => { workflowActionsService.getWorkFlowActions.mockReturnValue( of(MOCK_SINGLE_WORKFLOW_ACTIONS) ); + dotWorkflowService.getWorkflowStatus.mockReturnValue(of(MOCK_WORKFLOW_STATUS)); store.initializeExistingContent(MOCK_CONTENTLET_1_OR_2_TABS.inode); // called with the inode of the contentlet spectator.detectChanges(); @@ -279,6 +285,7 @@ describe('DotFormComponent', () => { workflowActionsService.getWorkFlowActions.mockReturnValue( of(MOCK_SINGLE_WORKFLOW_ACTIONS) ); + dotWorkflowService.getWorkflowStatus.mockReturnValue(of(MOCK_WORKFLOW_STATUS)); store.initializeExistingContent(MOCK_CONTENTLET_1_OR_2_TABS.inode); spectator.detectChanges(); diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.ts index 9b5d7d95aeab..bbb34de04816 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.ts @@ -36,6 +36,7 @@ import { FLATTENED_FIELD_TYPES } from '../../models/dot-edit-content-field.constant'; import { FIELD_TYPES } from '../../models/dot-edit-content-field.enum'; +import { FormValues } from '../../models/dot-edit-content-form.interface'; import { DotWorkflowActionParams } from '../../models/dot-edit-content.model'; import { getFinalCastedValue, isFilteredType } from '../../utils/functions.util'; import { DotEditContentFieldComponent } from '../dot-edit-content-field/dot-edit-content-field.component'; @@ -98,7 +99,7 @@ export class DotEditContentFormComponent implements OnInit { * * @memberof DotEditContentFormComponent */ - changeValue = output>(); + changeValue = output(); /** * Computed property that retrieves the filtered fields from the store. @@ -110,6 +111,10 @@ export class DotEditContentFormComponent implements OnInit { () => this.$store.contentType()?.fields?.filter(isFilteredType) ?? [] ); + $formFields = computed( + () => this.$store.contentType()?.fields?.filter((field) => !isFilteredType(field)) ?? [] + ); + /** * FormGroup instance that contains the form controls for the fields in the content type * @@ -158,72 +163,62 @@ export class DotEditContentFormComponent implements OnInit { } /** - * Initializes a listener for form value changes. - * When the form value changes, it calls the onFormChange method with the new value. - * The listener is automatically unsubscribed when the component is destroyed. + * Handles form value changes and emits the processed value. * - * @private + * @param {Record} value The raw form value * @memberof DotEditContentFormComponent */ - private initializeFormListener() { - this.form.valueChanges.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe((value) => { - const processedValue = this.processFormValue(value); - this.changeValue.emit(processedValue); - }); + onFormChange(value: Record) { + const processedValue = this.processFormValue(value); + this.changeValue.emit(processedValue); } /** - * Emits the form value through the `formSubmit` event. + * Initializes a listener for form value changes. * - * @param {*} value + * @private * @memberof DotEditContentFormComponent */ - onFormChange(value) { - this.$filteredFields().forEach(({ variable, fieldType }) => { - if (FLATTENED_FIELD_TYPES.includes(fieldType as FIELD_TYPES)) { - value[variable] = value[variable]?.join(','); - } - - if (CALENDAR_FIELD_TYPES.includes(fieldType as FIELD_TYPES)) { - value[variable] = value[variable] - ?.toISOString() - .replace(/T|\.\d{3}Z/g, (match: string) => (match === 'T' ? ' ' : '')); // To remove the T and .000Z from the date) - } + private initializeFormListener() { + this.form.valueChanges.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe((value) => { + this.onFormChange(value); }); - - this.changeValue.emit(value); } /** * Processes the form value, applying specific transformations for certain field types. * * @private - * @param {Record} value The raw form value + * @param {Record} value The raw form value * @returns {Record} The processed form value * @memberof DotEditContentFormComponent */ private processFormValue( value: Record - ): Record { + ): FormValues { return Object.fromEntries( - this.$filteredFields().map(({ variable, fieldType }) => { - let fieldValue = value[variable]; + Object.entries(value).map(([key, fieldValue]) => { + const field = this.$formFields().find((f) => f.variable === key); + + if (!field) { + return [key, fieldValue]; + } if ( Array.isArray(fieldValue) && - FLATTENED_FIELD_TYPES.includes(fieldType as FIELD_TYPES) + FLATTENED_FIELD_TYPES.includes(field.fieldType as FIELD_TYPES) ) { fieldValue = fieldValue.join(','); } else if ( fieldValue instanceof Date && - CALENDAR_FIELD_TYPES.includes(fieldType as FIELD_TYPES) + CALENDAR_FIELD_TYPES.includes(field.fieldType as FIELD_TYPES) ) { fieldValue = fieldValue .toISOString() .replace(/T|\.\d{3}Z/g, (match) => (match === 'T' ? ' ' : '')); } - return [variable, fieldValue?.toString() ?? '']; + return [key, fieldValue ?? '']; }) ); } @@ -236,13 +231,15 @@ export class DotEditContentFormComponent implements OnInit { * @memberof DotEditContentFormComponent */ private initializeForm() { - this.form = this.#fb.group({}); - this.$store.contentType().fields.forEach((field) => { - if (!isFilteredType(field)) { - const control = this.createFormControl(field); - this.form.addControl(field.variable, control); - } - }); + const controls = this.$formFields().reduce( + (acc, field) => ({ + ...acc, + [field.variable]: this.createFormControl(field) + }), + {} + ); + + this.form = this.#fb.group(controls); } /** diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-workflow/dot-edit-content-sidebar-workflow.component.html b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-workflow/dot-edit-content-sidebar-workflow.component.html index 6e22f664f42c..2bf55101b5e2 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-workflow/dot-edit-content-sidebar-workflow.component.html +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-workflow/dot-edit-content-sidebar-workflow.component.html @@ -1,9 +1,6 @@ -@let scheme = $workflow().scheme; -@let task = $workflow().task; - +@let workflow = $workflow(); @let isLoading = $isLoading(); -@let noWorkflowSelectedYet = $noWorkflowSelectedYet(); -@let currentStep = $workflow().step; +@let workflowSelection = $workflowSelection();
{{ 'Workflow' | dm }}
@@ -13,7 +10,7 @@ data-testId="workflow-name"> @if (isLoading) { - } @else if (noWorkflowSelectedYet) { + } @else if (workflowSelection.isWorkflowSelected) { } @else { - {{ scheme?.name }} + {{ workflow.scheme?.name }} - - @if ($showWorkflowDialogIcon()) { + @if ($showWorkflowSelection()) { + } + + @if (workflow.resetAction) { + } } - @if (!noWorkflowSelectedYet) { -
{{ 'Step' | dm }}
+ @if (!workflowSelection.isWorkflowSelected) { +
+ {{ 'Step' | dm }} +
} @else { - {{ currentStep?.name }} + {{ workflow.step?.name }} }
- @if (task) { + @if (workflow.task) {
{{ 'Assignee' | dm }}
} @else { - {{ task.assignedTo }} + {{ workflow.task.assignedTo }} }
} @@ -81,7 +89,7 @@ - + - @if (!isNew) { + @if (!store.isNew()) { diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.spec.ts index 39a3dddf9874..0f207ea8a45a 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.spec.ts @@ -30,11 +30,13 @@ import { DotEditContentSidebarComponent } from './dot-edit-content-sidebar.compo import { DotEditContentStore } from '../../feature/edit-content/store/edit-content.store'; import { DotEditContentService } from '../../services/dot-edit-content.service'; +import { MOCK_WORKFLOW_STATUS } from '../../utils/edit-content.mock'; import { MockResizeObserver } from '../../utils/mocks'; describe('DotEditContentSidebarComponent', () => { let spectator: Spectator; let dotEditContentService: SpyObject; + let dotWorkflowService: SpyObject; const createComponent = createComponentFactory({ component: DotEditContentSidebarComponent, @@ -73,8 +75,9 @@ describe('DotEditContentSidebarComponent', () => { spectator = createComponent({ detectChanges: false }); dotEditContentService = spectator.inject(DotEditContentService); - + dotWorkflowService = spectator.inject(DotWorkflowService); dotEditContentService.getReferencePages.mockReturnValue(of(1)); + dotWorkflowService.getWorkflowStatus.mockReturnValue(of(MOCK_WORKFLOW_STATUS)); spectator.detectChanges(); }); diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.ts index a1be33580045..621153a08db9 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.ts @@ -1,7 +1,7 @@ -import { SlicePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, + computed, effect, inject, model, @@ -22,6 +22,7 @@ import { DotEditContentSidebarWorkflowComponent } from './components/dot-edit-co import { TabViewInsertDirective } from '../../directives/tab-view-insert/tab-view-insert.directive'; import { DotEditContentStore } from '../../feature/edit-content/store/edit-content.store'; +import { DotWorkflowState } from '../../models/dot-edit-content.model'; /** * The DotEditContentSidebarComponent is a component that displays the sidebar for the DotCMS content editing application. @@ -41,7 +42,7 @@ import { DotEditContentStore } from '../../feature/edit-content/store/edit-conte TabViewInsertDirective, DotEditContentSidebarSectionComponent, DotCopyButtonComponent, - SlicePipe, + DialogModule, DropdownModule, ButtonModule, @@ -51,25 +52,34 @@ import { DotEditContentStore } from '../../feature/edit-content/store/edit-conte export class DotEditContentSidebarComponent { readonly store: InstanceType = inject(DotEditContentStore); readonly $identifier = this.store.getCurrentContentIdentifier; + readonly $formValues = this.store.formValues; + readonly $contentType = this.store.contentType; + readonly $contentlet = this.store.contentlet; /** - * Model for the showDialog property. + * Computed property that returns the workflow state of the content. */ - readonly $showDialog = model(false, { - alias: 'showDialog' - }); + readonly $workflow = computed(() => ({ + scheme: this.store.getScheme(), + step: this.store.getCurrentStep(), + task: this.store.lastTask(), + contentState: this.store.initialContentletState(), + resetAction: this.store.getResetWorkflowAction() + })); /** - * Effect that triggers the workflow status and new content status based on the contentlet and content type ID. + * Computed property that returns the workflow selection state. */ - #workflowEffect = effect(() => { - const inode = this.store.contentlet()?.inode; + readonly $workflowSelection = computed(() => ({ + schemeOptions: this.store.workflowSchemeOptions(), + isWorkflowSelected: this.store.showSelectWorkflowWarning() + })); - untracked(() => { - if (inode) { - this.store.getWorkflowStatus(inode); - } - }); + /** + * Model for the showDialog property. + */ + readonly $showDialog = model(false, { + alias: 'showDialog' }); /** @@ -84,4 +94,17 @@ export class DotEditContentSidebarComponent { } }); }); + + fireWorkflowAction(actionId: string): void { + this.store.fireWorkflowAction({ + actionId, + inode: this.$contentlet().inode, + data: { + contentlet: { + ...this.$formValues(), + contentType: this.$contentType().variable + } + } + }); + } } diff --git a/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.html b/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.html index ae6e7a4d8c78..3f989d4ee916 100644 --- a/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.html +++ b/core-web/libs/edit-content/src/lib/feature/edit-content/edit-content.layout.component.html @@ -61,6 +61,7 @@ @if (showSidebar) { 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 b766a00ab673..6aef93920769 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 @@ -17,6 +17,7 @@ import { DotEditContentStore } from './store/edit-content.store'; import { DotEditContentFormComponent } from '../../components/dot-edit-content-form/dot-edit-content-form.component'; import { DotEditContentSidebarComponent } from '../../components/dot-edit-content-sidebar/dot-edit-content-sidebar.component'; +import { FormValues } from '../../models/dot-edit-content-form.interface'; import { DotEditContentService } from '../../services/dot-edit-content.service'; /** @@ -54,6 +55,14 @@ import { DotEditContentService } from '../../services/dot-edit-content.service'; changeDetection: ChangeDetectionStrategy.OnPush }) export class EditContentLayoutComponent { + /** + * The store instance. + * + * @type {InstanceType} + * @memberof EditContentLayoutComponent + */ + readonly $store: InstanceType = inject(DotEditContentStore); + /** * Whether the select workflow dialog should be shown. * @@ -72,10 +81,12 @@ export class EditContentLayoutComponent { } /** - * The store instance. + * Handles the form change event. * - * @type {InstanceType} + * @param {Record} value * @memberof EditContentLayoutComponent */ - readonly $store: InstanceType = inject(DotEditContentStore); + onFormChange(value: FormValues) { + this.$store.onFormChange(value); + } } 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 e8f0f9e0c2f9..8ac5843f7cbd 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 @@ -7,6 +7,7 @@ import { ComponentStatus } from '@dotcms/dotcms-models'; import { withLocales } from '@dotcms/edit-content/feature/edit-content/store/features/locales.feature'; import { withContent } from './features/content.feature'; +import { withForm } from './features/form.feature'; import { withInformation } from './features/information.feature'; import { withSidebar } from './features/sidebar.feature'; import { withWorkflow } from './features/workflow.feature'; @@ -33,6 +34,7 @@ export const DotEditContentStore = signalStore( withInformation(), withWorkflow(), withLocales(), + withForm(), withHooks({ onInit(store) { const activatedRoute = inject(ActivatedRoute); 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 index ecfb8d568cf0..92f4750cb8ad 100644 --- 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 @@ -11,17 +11,24 @@ import { Router } from '@angular/router'; import { DotContentTypeService, DotHttpErrorManagerService, - DotWorkflowsActionsService + DotWorkflowsActionsService, + DotWorkflowService } from '@dotcms/data-access'; -import { ComponentStatus, DotCMSContentlet, DotCMSWorkflowAction } from '@dotcms/dotcms-models'; +import { + ComponentStatus, + DotCMSContentlet, + DotCMSWorkflowAction, + FeaturedFlags +} from '@dotcms/dotcms-models'; import { MOCK_SINGLE_WORKFLOW_ACTIONS } from '@dotcms/utils-testing'; import { withContent } from './content.feature'; import { workflowInitialState } from './workflow.feature'; import { DotEditContentService } from '../../../../services/dot-edit-content.service'; +import { MOCK_WORKFLOW_STATUS } from '../../../../utils/edit-content.mock'; import { CONTENT_TYPE_MOCK } from '../../../../utils/mocks'; -import { parseWorkflows } from '../../../../utils/workflows.utils'; +import { parseCurrentActions, parseWorkflows } from '../../../../utils/workflows.utils'; import { initialRootState } from '../edit-content.store'; describe('ContentFeature', () => { @@ -31,6 +38,7 @@ describe('ContentFeature', () => { let contentTypeService: SpyObject; let dotEditContentService: SpyObject; let workflowActionService: SpyObject; + let workflowService: SpyObject; let router: SpyObject; const createStore = createServiceFactory({ @@ -43,6 +51,7 @@ describe('ContentFeature', () => { DotEditContentService, DotHttpErrorManagerService, DotWorkflowsActionsService, + DotWorkflowService, Router ] }); @@ -53,6 +62,7 @@ describe('ContentFeature', () => { contentTypeService = spectator.inject(DotContentTypeService); dotEditContentService = spectator.inject(DotEditContentService); workflowActionService = spectator.inject(DotWorkflowsActionsService); + workflowService = spectator.inject(DotWorkflowService); router = spectator.inject(Router); }); @@ -71,6 +81,7 @@ describe('ContentFeature', () => { contentTypeService.getContentType.mockReturnValue(of(CONTENT_TYPE_MOCK)); workflowActionService.getByInode.mockReturnValue(of([])); workflowActionService.getWorkFlowActions.mockReturnValue(of([])); + workflowService.getWorkflowStatus.mockReturnValue(of(MOCK_WORKFLOW_STATUS)); store.initializeExistingContent('123'); tick(); @@ -119,13 +130,13 @@ describe('ContentFeature', () => { } ]; - // 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) ); + workflowService.getWorkflowStatus.mockReturnValue(of(MOCK_WORKFLOW_STATUS)); store.initializeExistingContent('123'); @@ -134,9 +145,95 @@ describe('ContentFeature', () => { // Verify all the expected values expect(store.contentlet()).toEqual(mockContentlet); expect(store.contentType()).toEqual(CONTENT_TYPE_MOCK); - expect(store.currentContentActions()).toEqual(expectedActions); + expect(store.currentContentActions()).toEqual(parseCurrentActions(expectedActions)); expect(store.schemes()).toEqual(parseWorkflows(MOCK_SINGLE_WORKFLOW_ACTIONS)); })); + + it('should return isLoaded as true when state is LOADED', fakeAsync(() => { + contentTypeService.getContentType.mockReturnValue(of(CONTENT_TYPE_MOCK)); + workflowActionService.getDefaultActions.mockReturnValue( + of(MOCK_SINGLE_WORKFLOW_ACTIONS) + ); + + store.initializeNewContent('testContentType'); + tick(); + + expect(store.isLoaded()).toBe(true); + })); + + it('should return hasError as true when error exists', fakeAsync(() => { + const mockError = new HttpErrorResponse({ status: 404 }); + workflowActionService.getDefaultActions.mockReturnValue( + of(MOCK_SINGLE_WORKFLOW_ACTIONS) + ); + contentTypeService.getContentType.mockReturnValue(throwError(() => mockError)); + + store.initializeNewContent('testContentType'); + tick(); + + expect(store.hasError()).toBe(true); + })); + + it('should return correct formData', 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(MOCK_SINGLE_WORKFLOW_ACTIONS) + ); + workflowService.getWorkflowStatus.mockReturnValue(of(MOCK_WORKFLOW_STATUS)); + + store.initializeExistingContent('123'); + tick(); + + expect(store.formData()).toEqual({ + contentlet: mockContentlet, + contentType: CONTENT_TYPE_MOCK + }); + })); + + it('should return isEnabledNewContentEditor based on content type metadata', fakeAsync(() => { + // Test when feature flag is false + const contentTypeWithoutEditor = { + ...CONTENT_TYPE_MOCK, + metadata: { + [FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED]: false + } + }; + + contentTypeService.getContentType.mockReturnValue(of(contentTypeWithoutEditor)); + workflowActionService.getDefaultActions.mockReturnValue( + of(MOCK_SINGLE_WORKFLOW_ACTIONS) + ); + + store.initializeNewContent('testContentType'); + tick(); + + expect(store.isEnabledNewContentEditor()).toBe(false); + + // Test when feature flag is true + const contentTypeWithEditor = { + ...CONTENT_TYPE_MOCK, + metadata: { + [FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED]: true + } + }; + + contentTypeService.getContentType.mockReturnValue(of(contentTypeWithEditor)); + workflowActionService.getDefaultActions.mockReturnValue( + of(MOCK_SINGLE_WORKFLOW_ACTIONS) + ); + + store.initializeNewContent('testContentType'); + tick(); + + expect(store.isEnabledNewContentEditor()).toBe(true); + })); }); describe('initializeNewContent', () => { @@ -145,6 +242,11 @@ describe('ContentFeature', () => { workflowActionService.getDefaultActions.mockReturnValue( of(MOCK_SINGLE_WORKFLOW_ACTIONS) ); + workflowService.getWorkflowStatus.mockReturnValue(of(MOCK_WORKFLOW_STATUS)); + workflowActionService.getWorkFlowActions.mockReturnValue( + of(MOCK_SINGLE_WORKFLOW_ACTIONS) + ); + workflowActionService.getByInode.mockReturnValue(of([])); }); it('should initialize new content successfully', fakeAsync(() => { @@ -192,6 +294,7 @@ describe('ContentFeature', () => { workflowActionService.getWorkFlowActions.mockReturnValue( of(MOCK_SINGLE_WORKFLOW_ACTIONS) ); + workflowService.getWorkflowStatus.mockReturnValue(of(MOCK_WORKFLOW_STATUS)); }); it('should initialize existing content successfully', fakeAsync(() => { @@ -200,7 +303,7 @@ describe('ContentFeature', () => { expect(store.contentlet()).toEqual(mockContentlet); expect(store.contentType()).toEqual(CONTENT_TYPE_MOCK); - expect(store.currentContentActions()).toEqual(mockActions); + expect(store.currentContentActions()).toEqual(parseCurrentActions(mockActions)); expect(store.state()).toBe(ComponentStatus.LOADED); })); @@ -217,5 +320,31 @@ describe('ContentFeature', () => { expect(router.navigate).toHaveBeenCalledWith(['/c/content']); })); + + it('should set initialContentletState to reset when no scheme or step', fakeAsync(() => { + const mockContentlet = { + inode: '123', + contentType: 'testContentType' + } as DotCMSContentlet; + + const workflowStatusWithoutScheme = { + ...MOCK_WORKFLOW_STATUS, + scheme: null, + step: null + }; + + dotEditContentService.getContentById.mockReturnValue(of(mockContentlet)); + contentTypeService.getContentType.mockReturnValue(of(CONTENT_TYPE_MOCK)); + workflowActionService.getByInode.mockReturnValue(of([])); + workflowActionService.getWorkFlowActions.mockReturnValue( + of(MOCK_SINGLE_WORKFLOW_ACTIONS) + ); + workflowService.getWorkflowStatus.mockReturnValue(of(workflowStatusWithoutScheme)); + + store.initializeExistingContent('123'); + tick(); + + expect(store.initialContentletState()).toBe('reset'); + })); }); }); 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 c0f2dfe734e9..5ac754929ef7 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 @@ -20,7 +20,8 @@ import { DotContentTypeService, DotHttpErrorManagerService, DotRenderMode, - DotWorkflowsActionsService + DotWorkflowsActionsService, + DotWorkflowService } from '@dotcms/data-access'; import { ComponentStatus, @@ -36,7 +37,7 @@ import { WorkflowState } from './workflow.feature'; import { DotEditContentService } from '../../../../services/dot-edit-content.service'; import { transformFormDataFn } from '../../../../utils/functions.util'; -import { parseWorkflows } from '../../../../utils/workflows.utils'; +import { parseCurrentActions, parseWorkflows } from '../../../../utils/workflows.utils'; import { EditContentRootState } from '../edit-content.store'; export interface ContentState { @@ -45,19 +46,22 @@ export interface ContentState { /** Contentlet full data */ contentlet: DotCMSContentlet | null; /** Schemas available for the content type */ - schemes: { - [key: string]: { + schemes: Record< + string, + { scheme: DotCMSWorkflow; actions: DotCMSWorkflowAction[]; firstStep: WorkflowStep; - }; - }; + } + >; + initialContentletState: 'new' | 'existing' | 'reset'; } export const contentInitialState: ContentState = { contentType: null, contentlet: null, - schemes: {} + schemes: {}, + initialContentletState: 'new' }; export function withContent() { @@ -71,7 +75,7 @@ export function withContent() { * * @returns {boolean} True if content is new, false otherwise */ - isNew: computed(() => !store.contentlet()), + isNew: computed(() => store.initialContentletState() === 'new'), /** * Computed property that determines if the store's status is equal to ComponentStatus.LOADED. @@ -145,14 +149,25 @@ export function withContent() { dotEditContentService = inject(DotEditContentService), workflowActionService = inject(DotWorkflowsActionsService), dotHttpErrorManagerService = inject(DotHttpErrorManagerService), - router = inject(Router) + router = inject(Router), + dotWorkflowService = inject(DotWorkflowService) ) => ({ /** - * Method to initialize new content of a given type. - * New content + * Initializes the state for creating new content of a specified type. + * This method orchestrates the following operations: * - * @param {string} contentType - The type of content to initialize. - * @returns {Observable} An observable that completes when the initialization is done. + * 1. Sets the component state to loading + * 2. Makes parallel API calls to: + * - Fetch the complete content type definition + * - Retrieve all available workflow schemes and their default actions + * 3. Processes the workflow schemes: + * - Parses and organizes schemes by their IDs + * - Automatically selects default scheme if only one exists + * - Sets up initial available actions based on the default scheme + * + * @param {string} contentType - The identifier of the content type to initialize + * @returns {Observable} An observable that completes when all initialization data is loaded and processed + * @throws Will set error state and display error message if initialization fails */ initializeNewContent: rxMethod( pipe( @@ -165,17 +180,24 @@ export function withContent() { }).pipe( tapResponse({ next: ({ contentType, schemes }) => { + // Convert the schemes to an object with the schemeId as the key const parsedSchemes = parseWorkflows(schemes); const schemeIds = Object.keys(parsedSchemes); + // If we have only one scheme, we set it as the default one const defaultSchemeId = schemeIds.length === 1 ? schemeIds[0] : null; + // Parse the actions as an object with the schemeId as the key + const parsedCurrentActions = parseCurrentActions( + parsedSchemes[defaultSchemeId]?.actions || [] + ); patchState(store, { contentType, - schemes: parsedSchemes, currentSchemeId: defaultSchemeId, + currentContentActions: parsedCurrentActions, state: ComponentStatus.LOADED, + initialContentletState: 'new', error: null }); }, @@ -193,10 +215,22 @@ export function withContent() { ), /** - * Initializes the existing content by loading its details and updating the state. - * Content existing + * Initializes and loads all necessary data for an existing content by its inode. + * This method orchestrates multiple API calls to set up the complete content state: + * + * 1. Fetches the contentlet data using the inode + * 2. Based on the contentlet's content type: + * - Loads the full content type definition + * - Retrieves available workflow actions for the current inode + * - Fetches all possible workflow schemes for the content type + * - Gets the current workflow status including step and task information * - * @returns {Observable} An observable that emits the content ID. + * All this information is then consolidated and stored in the state to manage + * the content's workflow progression and available actions. + * + * @param {string} inode - The unique identifier for the content to be loaded + * @returns {Observable} An observable that emits the content's inode when initialization is complete + * @throws Will redirect to /c/content and show error if initialization fails */ initializeExistingContent: rxMethod( pipe( @@ -210,13 +244,17 @@ export function withContent() { return forkJoin({ contentType: dotContentTypeService.getContentType(contentType), + // Allowed actions for this inode currentContentActions: workflowActionService.getByInode( inode, DotRenderMode.EDITING ), + // Allowed actions for this content type schemes: workflowActionService.getWorkFlowActions(contentType), - contentlet: of(contentlet) + contentlet: of(contentlet), + // Workflow status for this inode + workflowStatus: dotWorkflowService.getWorkflowStatus(inode) }); }), tapResponse({ @@ -224,15 +262,41 @@ export function withContent() { contentType, currentContentActions, schemes, - contentlet + contentlet, + workflowStatus }) => { + // Convert the schemes to an object with the schemeId as the key const parsedSchemes = parseWorkflows(schemes); + // Parse the actions as an object with the schemeId as the key + const parsedCurrentActions = + parseCurrentActions(currentContentActions); + + const { step, task, scheme } = workflowStatus; + // If there's only one workflow scheme, use that scheme's ID + // Otherwise use the ID from the workflow status if available + const schemeIds = Object.keys(parsedSchemes); + const currentSchemeId = + schemeIds.length === 1 + ? schemeIds[0] + : scheme?.id || null; + + // If there's no scheme or step, content is considered in 'reset' state + const initialContentletState = + !scheme || !step ? 'reset' : 'existing'; + + // The current step is the first step of the selected scheme + const currentScheme = parsedSchemes[currentSchemeId]; + patchState(store, { contentType, + currentSchemeId, schemes: parsedSchemes, - currentContentActions, + currentContentActions: parsedCurrentActions, contentlet, - state: ComponentStatus.LOADED + state: ComponentStatus.LOADED, + currentStep: currentScheme?.firstStep, + lastTask: task, + initialContentletState }); }, error: (error: HttpErrorResponse) => { diff --git a/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/form.feature.ts b/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/form.feature.ts new file mode 100644 index 000000000000..d9b26d57d44c --- /dev/null +++ b/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/form.feature.ts @@ -0,0 +1,34 @@ +import { patchState, signalStoreFeature, withMethods, withState } from '@ngrx/signals'; + +import { FormValues } from '../../../../models/dot-edit-content-form.interface'; + +export interface FormState { + formValues: FormValues; +} + +const initialState: FormState = { + formValues: {} +}; + +/** + * Feature that handles the form's state. + * + * @returns {SignalStoreFeature} The feature object. + */ +export function withForm() { + return signalStoreFeature( + withState(initialState), + + withMethods((store) => ({ + /** + * Handles the form change event and stores the form values. + * + * @param {FormValues} formValues + * @memberof withForm + */ + onFormChange: (formValues: FormValues) => { + patchState(store, { formValues }); + } + })) + ); +} diff --git a/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/workflow.feature.spec.ts b/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/workflow.feature.spec.ts index e20470e58600..97ae7d4f1c2d 100644 --- a/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/workflow.feature.spec.ts +++ b/core-web/libs/edit-content/src/lib/feature/edit-content/store/features/workflow.feature.spec.ts @@ -13,10 +13,9 @@ import { DotHttpErrorManagerService, DotMessageService, DotWorkflowActionsFireService, - DotWorkflowsActionsService, - DotWorkflowService + DotWorkflowsActionsService } from '@dotcms/data-access'; -import { ComponentStatus } from '@dotcms/dotcms-models'; +import { ComponentStatus, DotCMSContentlet } from '@dotcms/dotcms-models'; import { contentInitialState, ContentState } from './content.feature'; import { withWorkflow } from './workflow.feature'; @@ -24,11 +23,10 @@ import { withWorkflow } from './workflow.feature'; import { MOCK_CONTENTLET_1_TAB, MOCK_WORKFLOW_ACTIONS_NEW_ITEMNTTYPE_1_TAB, - MOCK_WORKFLOW_DATA, - MOCK_WORKFLOW_STATUS + MOCK_WORKFLOW_DATA } from '../../../../utils/edit-content.mock'; import { CONTENT_TYPE_MOCK } from '../../../../utils/mocks'; -import { parseWorkflows } from '../../../../utils/workflows.utils'; +import { parseCurrentActions, parseWorkflows } from '../../../../utils/workflows.utils'; import { initialRootState } from '../edit-content.store'; const mockInitialStateWithContent: ContentState = { @@ -41,7 +39,6 @@ const mockInitialStateWithContent: ContentState = { describe('WorkflowFeature', () => { let spectator: SpectatorService; let store: any; - let workflowService: SpyObject; let workflowActionService: SpyObject; let workflowActionsFireService: SpyObject; let router: SpyObject; @@ -54,7 +51,6 @@ describe('WorkflowFeature', () => { withWorkflow() ), mocks: [ - DotWorkflowService, DotWorkflowsActionsService, DotWorkflowActionsFireService, DotHttpErrorManagerService, @@ -67,7 +63,6 @@ describe('WorkflowFeature', () => { beforeEach(() => { spectator = createStore(); store = spectator.service; - workflowService = spectator.inject(DotWorkflowService); workflowActionService = spectator.inject(DotWorkflowsActionsService); workflowActionsFireService = spectator.inject(DotWorkflowActionsFireService); router = spectator.inject(Router); @@ -78,36 +73,6 @@ describe('WorkflowFeature', () => { }); describe('methods', () => { - describe('getWorkflowStatus', () => { - it('should get workflow status successfully', fakeAsync(() => { - workflowService.getWorkflowStatus.mockReturnValue(of(MOCK_WORKFLOW_STATUS)); - - store.getWorkflowStatus('123'); - tick(); - - expect(store.workflow()).toEqual({ - status: ComponentStatus.LOADED, - error: null - }); - expect(store.currentSchemeId()).toBe(MOCK_WORKFLOW_STATUS.scheme.id); - expect(store.currentStep()).toEqual(MOCK_WORKFLOW_STATUS.step); - expect(store.lastTask()).toEqual(MOCK_WORKFLOW_STATUS.task); - })); - - it('should handle error when getting workflow status', fakeAsync(() => { - const mockError = new HttpErrorResponse({ status: 404 }); - workflowService.getWorkflowStatus.mockReturnValue(throwError(() => mockError)); - - store.getWorkflowStatus('123'); - tick(); - - expect(store.workflow()).toEqual({ - status: ComponentStatus.ERROR, - error: 'Error getting workflow status' - }); - })); - }); - describe('fireWorkflowAction', () => { const mockOptions = { inode: '123', @@ -128,7 +93,7 @@ describe('WorkflowFeature', () => { expect(store.state()).toBe(ComponentStatus.LOADED); expect(store.contentlet()).toEqual(updatedContentlet); expect(store.currentContentActions()).toEqual( - MOCK_WORKFLOW_ACTIONS_NEW_ITEMNTTYPE_1_TAB + parseCurrentActions(MOCK_WORKFLOW_ACTIONS_NEW_ITEMNTTYPE_1_TAB) ); expect(router.navigate).toHaveBeenCalledWith( @@ -148,16 +113,6 @@ describe('WorkflowFeature', () => { expect(store.state()).toBe(ComponentStatus.LOADED); expect(store.error()).toBe('Error firing workflow action'); })); - - it('should redirect to content when contentlet has no inode', fakeAsync(() => { - const contentletWithoutInode = { ...MOCK_CONTENTLET_1_TAB, inode: undefined }; - workflowActionsFireService.fireTo.mockReturnValue(of(contentletWithoutInode)); - - store.fireWorkflowAction(mockOptions); - tick(); - - expect(router.navigate).toHaveBeenCalledWith(['/c/content']); - })); }); describe('setSelectedWorkflow', () => { @@ -167,36 +122,86 @@ describe('WorkflowFeature', () => { expect(store.currentSchemeId()).toBe(newSchemeId); }); }); + }); + + describe('computed properties', () => { + describe('getScheme', () => { + it('should return undefined when no scheme is selected', () => { + expect(store.getScheme()).toBeUndefined(); + }); + + it('should return the correct scheme when one is selected', () => { + const schemeId = MOCK_WORKFLOW_DATA[0].scheme.id; + store.setSelectedWorkflow(schemeId); + expect(store.getScheme()).toEqual(MOCK_WORKFLOW_DATA[0].scheme); + }); + }); + + describe('fireWorkflowAction', () => { + const mockOptions = { + inode: '123', + actionId: MOCK_WORKFLOW_ACTIONS_NEW_ITEMNTTYPE_1_TAB[0].id, + formData: {} + }; - describe('computed properties', () => { - beforeEach(fakeAsync(() => { - const mockStatus = { - ...MOCK_WORKFLOW_STATUS, - scheme: MOCK_WORKFLOW_DATA[0].scheme, - step: MOCK_WORKFLOW_STATUS.step - }; - workflowService.getWorkflowStatus.mockReturnValue(of(mockStatus)); + it('should handle reset action correctly', fakeAsync(() => { + workflowActionsFireService.fireTo.mockReturnValue(of({} as DotCMSContentlet)); + workflowActionService.getByInode.mockReturnValue( + of(MOCK_WORKFLOW_ACTIONS_NEW_ITEMNTTYPE_1_TAB) + ); - store.getWorkflowStatus('123'); + store.fireWorkflowAction(mockOptions); tick(); + + expect(store.getCurrentStep()).toBeNull(); + expect(messageService.add).toHaveBeenCalledWith( + expect.objectContaining({ + severity: 'success' + }) + ); })); - it('should return correct scheme', () => { - expect(store.currentSchemeId()).toBe(MOCK_WORKFLOW_DATA[0].scheme.id); - expect(store.getScheme()).toEqual(MOCK_WORKFLOW_DATA[0].scheme); - }); + it('should show processing message when action starts', fakeAsync(() => { + workflowActionsFireService.fireTo.mockReturnValue(of(MOCK_CONTENTLET_1_TAB)); - it('should return correct workflow scheme options', () => { - const expected = MOCK_WORKFLOW_DATA.map((workflow) => ({ - value: workflow.scheme.id, - label: workflow.scheme.name - })); - expect(store.workflowSchemeOptions()).toEqual(expected); - }); + store.fireWorkflowAction(mockOptions); + + expect(messageService.add).toHaveBeenCalledWith( + expect.objectContaining({ + severity: 'info', + icon: 'pi pi-spin pi-spinner' + }) + ); + tick(); + })); + }); + + describe('getCurrentStep', () => { + it('should return first step for new content with selected workflow', () => { + // Set up a new content scenario by selecting a workflow + const schemeId = MOCK_WORKFLOW_DATA[0].scheme.id; + store.setSelectedWorkflow(schemeId); - it('should return current step of workflow', () => { - expect(store.currentStep()).toEqual(MOCK_WORKFLOW_STATUS.step); + expect(store.getCurrentStep()).toEqual(MOCK_WORKFLOW_DATA[0].firstStep); }); + + it('should return current step for existing content', fakeAsync(() => { + // Mock a workflow action that would update the current step + const updatedContentlet = { ...MOCK_CONTENTLET_1_TAB, inode: '456' }; + workflowActionsFireService.fireTo.mockReturnValue(of(updatedContentlet)); + workflowActionService.getByInode.mockReturnValue( + of(MOCK_WORKFLOW_ACTIONS_NEW_ITEMNTTYPE_1_TAB) + ); + + store.fireWorkflowAction({ + inode: '123', + actionId: MOCK_WORKFLOW_ACTIONS_NEW_ITEMNTTYPE_1_TAB[0].id, + formData: {} + }); + tick(); + + expect(store.getCurrentStep()).toBeDefined(); + })); }); }); }); 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 3835ed5a9aea..aa0d2cc2913e 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 @@ -24,8 +24,7 @@ import { DotMessageService, DotRenderMode, DotWorkflowActionsFireService, - DotWorkflowsActionsService, - DotWorkflowService + DotWorkflowsActionsService } from '@dotcms/data-access'; import { ComponentStatus, @@ -37,19 +36,17 @@ import { import { ContentState } from './content.feature'; -import { - getWorkflowActions, - shouldShowWorkflowActions, - shouldShowWorkflowWarning -} from '../../../../utils/workflows.utils'; +import { parseCurrentActions } from '../../../../utils/workflows.utils'; import { EditContentRootState } from '../edit-content.store'; +export type CurrentContentActionsWithScheme = Record; + export interface WorkflowState { /** Current workflow scheme id */ currentSchemeId: string | null; /** Actions available for the current content */ - currentContentActions: DotCMSWorkflowAction[]; + currentContentActions: CurrentContentActionsWithScheme; /** Current workflow step */ currentStep: WorkflowStep | null; @@ -66,7 +63,7 @@ export interface WorkflowState { export const workflowInitialState: WorkflowState = { currentSchemeId: null, - currentContentActions: [], + currentContentActions: {}, currentStep: null, lastTask: null, workflow: { @@ -108,17 +105,19 @@ export function withWorkflow() { * Computed property that determines if workflow action buttons should be shown. */ showWorkflowActions: computed(() => { - const schemes = store.schemes(); - const contentlet = store.contentlet(); const currentSchemeId = store.currentSchemeId(); + const currentActions = store.currentContentActions()[currentSchemeId] || []; - return shouldShowWorkflowActions({ - schemes, - contentlet, - currentSchemeId - }); + return currentActions.length > 0; }), + /** + * Computed property that determines if the reset action should be shown. + * + * @returns {boolean} True if the reset action should be shown, false otherwise. + */ + resetActionState: computed(() => !store.currentStep()), + /** * Computed property that determines if the workflow selection warning should be shown. * Shows warning when content is new AND no workflow scheme has been selected yet. @@ -126,15 +125,26 @@ export function withWorkflow() { * @returns {boolean} True if warning should be shown, false otherwise */ showSelectWorkflowWarning: computed(() => { - const schemes = store.schemes(); - const contentlet = store.contentlet(); const currentSchemeId = store.currentSchemeId(); - return shouldShowWorkflowWarning({ - schemes, - contentlet, - currentSchemeId - }); + return !currentSchemeId; + }), + + /** + * Gets the first workflow action that has reset capability and is shown on EDITING. + * + * @returns {DotCMSWorkflowAction | undefined} First workflow action with reset capability shown on EDITING + */ + getResetWorkflowAction: computed(() => { + const currentActions = store.currentContentActions()[store.currentSchemeId()] || []; + + return ( + currentActions.find( + (action) => + action.hasResetActionlet && + action.showOn?.includes(DotRenderMode.EDITING) + ) || undefined + ); }), /** @@ -143,17 +153,10 @@ export function withWorkflow() { * @returns {DotCMSWorkflowAction[]} The actions for the current workflow scheme. */ getActions: computed(() => { - const schemes = store.schemes(); - const contentlet = store.contentlet(); const currentSchemeId = store.currentSchemeId(); const currentContentActions = store.currentContentActions(); - return getWorkflowActions({ - schemes, - contentlet, - currentSchemeId, - currentContentActions - }); + return currentSchemeId ? currentContentActions[currentSchemeId] : []; }), /** @@ -192,7 +195,6 @@ export function withWorkflow() { withMethods( ( store, - dotWorkflowService = inject(DotWorkflowService), workflowActionService = inject(DotWorkflowsActionsService), workflowActionsFireService = inject(DotWorkflowActionsFireService), dotHttpErrorManagerService = inject(DotHttpErrorManagerService), @@ -201,61 +203,32 @@ export function withWorkflow() { router = inject(Router) ) => ({ /** - * Get workflow status for an existing contentlet - * we use the inode to get the workflow status - */ - getWorkflowStatus: rxMethod( - pipe( - tap(() => - patchState(store, { - workflow: { - ...store.workflow(), - status: ComponentStatus.LOADING, - error: null - } - }) - ), - switchMap((inode: string) => { - return dotWorkflowService.getWorkflowStatus(inode).pipe( - tapResponse({ - next: (response) => { - const { scheme, step, task } = response; - patchState(store, { - currentSchemeId: scheme?.id, - currentStep: step, - lastTask: task, - workflow: { - ...store.workflow(), - status: ComponentStatus.LOADED - } - }); - }, - - error: (error: HttpErrorResponse) => { - patchState(store, { - workflow: { - ...store.workflow(), - status: ComponentStatus.ERROR, - error: 'Error getting workflow status' - } - }); - dotHttpErrorManagerService.handle(error); - } - }) - ); - }) - ) - ), - - /** - * Sets the selected workflow scheme ID in the store. + * Sets the selected workflow scheme ID and updates related state in the store. + * For new content, it sets the current scheme ID, parses and sets the workflow actions, + * and sets the first step of the selected scheme. + * For existing content, it only updates the current scheme ID and first step. * - * @param {string} schemeId - The ID of the workflow scheme to be selected. + * @param {string} currentSchemeId - The ID of the workflow scheme to be selected */ - setSelectedWorkflow: (schemeId: string) => { - patchState(store, { - currentSchemeId: schemeId - }); + setSelectedWorkflow: (currentSchemeId: string) => { + const schemes = store.schemes(); + const currentScheme = schemes[currentSchemeId]; + const actions = currentScheme.actions; + const isNew = !store.contentlet()?.inode; + + if (isNew) { + patchState(store, { + currentSchemeId, + currentContentActions: parseCurrentActions(actions), + currentStep: currentScheme.firstStep + }); + } else { + // Existing content + patchState(store, { + currentSchemeId, + currentStep: currentScheme.firstStep + }); + } }, /** @@ -272,40 +245,88 @@ export function withWorkflow() { DotFireActionOptions<{ [key: string]: string | object }> >( pipe( - tap(() => patchState(store, { state: ComponentStatus.SAVING })), + tap(() => { + patchState(store, { state: ComponentStatus.SAVING }); + messageService.clear(); + messageService.add({ + severity: 'info', + icon: 'pi pi-spin pi-spinner', + summary: dotMessageService.get( + 'edit.content.processing.workflow.message.title' + ), + detail: dotMessageService.get( + 'edit.content.processing.workflow.message' + ) + }); + }), switchMap((options) => { + const currentContentlet = store.contentlet(); + return workflowActionsFireService.fireTo(options).pipe( - tap((contentlet) => { - if (!contentlet.inode) { - router.navigate(['/c/content']); - } - }), - switchMap((contentlet) => { + switchMap((updatedContentlet) => { + // Use current contentlet if response is empty (reset action) + // otherwise use the updated contentlet from response + const contentlet = + Object.keys(updatedContentlet).length === 0 + ? currentContentlet + : updatedContentlet; + + const inode = contentlet.inode; + + // A reset action will return an empty object + const isReset = Object.keys(updatedContentlet).length === 0; + return forkJoin({ currentContentActions: workflowActionService.getByInode( - contentlet.inode, + inode, DotRenderMode.EDITING ), - contentlet: of(contentlet) + contentlet: of(contentlet), + isReset: of(isReset) }); }), tapResponse({ - next: ({ contentlet, currentContentActions }) => { - router.navigate(['/content', contentlet.inode], { - replaceUrl: true, - queryParamsHandling: 'preserve' - }); + next: ({ contentlet, currentContentActions, isReset }) => { + // Always navigate if the inode has changed + if (contentlet.inode !== currentContentlet?.inode) { + router.navigate(['/content', contentlet.inode], { + replaceUrl: true, + queryParamsHandling: 'preserve' + }); + } - patchState(store, { - contentlet, - currentContentActions, - state: ComponentStatus.LOADED, - error: null - }); + const parsedCurrentActions = + parseCurrentActions(currentContentActions); + + if (isReset) { + patchState(store, { + contentlet, + currentContentActions: parsedCurrentActions, + currentSchemeId: + Object.keys(store.schemes()).length > 1 + ? null + : store.currentSchemeId(), + initialContentletState: 'reset', + state: ComponentStatus.LOADED, + currentStep: null, + error: null + }); + } else { + patchState(store, { + contentlet, + currentContentActions: parsedCurrentActions, + currentSchemeId: store.currentSchemeId(), + state: ComponentStatus.LOADED, + error: null + }); + } + messageService.clear(); messageService.add({ severity: 'success', - summary: dotMessageService.get('success'), + summary: dotMessageService.get( + 'edit.content.success.workflow.title' + ), detail: dotMessageService.get( 'edit.content.success.workflow.message' ) 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 d7ef381c333a..d24fe67a986c 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 @@ -44,3 +44,15 @@ export interface DotFormData { contentlet: DotCMSContentlet | null; tabs: Tab[]; } + +/** + * Represents the form field value. + * @type FormFieldValue + */ +type FormFieldValue = string | string[] | Date; + +/** + * Represents the form values. + * @interface FormValues + */ +export type FormValues = Record; 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 index af652190a5b9..60ac557f739a 100644 --- 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 @@ -1,3 +1,5 @@ +import { DotCMSWorkflowAction, DotCMSWorkflowStatus } from '@dotcms/dotcms-models'; + /** * Interface for workflow action parameters. * @@ -9,3 +11,16 @@ export interface DotWorkflowActionParams { inode: string; contentType: string; } + +/** + * Type for the internal contentlet state. + * + * @export + * @type {DotContentletState} + */ +export type DotContentletState = 'new' | 'existing' | 'reset'; + +export interface DotWorkflowState extends DotCMSWorkflowStatus { + contentState: DotContentletState; + resetAction?: DotCMSWorkflowAction; +} 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 94bb16290940..ff3f6dc553f4 100644 --- a/core-web/libs/edit-content/src/lib/utils/mocks.ts +++ b/core-web/libs/edit-content/src/lib/utils/mocks.ts @@ -25,6 +25,7 @@ import { CustomTreeNode, TreeNodeItem } from '../models/dot-edit-content-host-folder-field.interface'; +import { DotWorkflowState } from '../models/dot-edit-content.model'; /* FIELDS MOCK BY TYPE */ export const TEXT_FIELD_MOCK: DotCMSContentTypeField = { @@ -1678,3 +1679,181 @@ export const NEW_WORKFLOW_MOCK: DotCMSWorkflowStatus = { schemeId: 'd61a59e1-a49c-46f2-a929-db2b4bfa88b2' } }; + +/** + * Mock for input of the sidebar workflow component + */ +export const WORKFLOW_MOCKS: Record<'EXISTING' | 'NEW' | 'RESET', DotWorkflowState> = { + EXISTING: { + scheme: { + archived: false, + creationDate: new Date(1732809856947), + defaultScheme: false, + description: '', + entryActionId: null, + id: '2a4e1d2e-5342-4b46-be3d-80d3a2d9c0dd', + mandatory: false, + modDate: new Date(1732554197546), + name: 'Blogs', + system: false, + variableName: 'Blogs' + }, + step: { + creationDate: 1732859987790, + enableEscalation: false, + escalationAction: null, + escalationTime: 0, + id: '5865d447-5df7-4fa8-81c8-f8f183f3d1a2', + myOrder: 0, + name: 'Editing', + resolved: false, + schemeId: '2a4e1d2e-5342-4b46-be3d-80d3a2d9c0dd' + }, + task: { + assignedTo: 'Admin User', + belongsTo: null, + createdBy: 'e7d4e34e-5127-45fc-8123-d48b62d510e3', + creationDate: 1732809812333, + description: null, + dueDate: null, + id: '9cc41c12-f72d-431a-9b22-ef9f1067e6d9', + inode: '9cc41c12-f72d-431a-9b22-ef9f1067e6d9', + languageId: 1, + modDate: 1732809830428, + new: false, + status: 'f43c5d5a-fc51-4c67-a750-cc8f8e4a87f7', + title: '6b102831-e96e-459f-aa41-b5b451f8b8e1', + webasset: '6b102831-e96e-459f-aa41-b5b451f8b8e1' + }, + contentState: 'existing', + resetAction: { + actionInputs: [], + assignable: false, + commentable: false, + condition: '', + hasArchiveActionlet: false, + hasCommentActionlet: false, + hasDeleteActionlet: false, + hasDestroyActionlet: false, + hasMoveActionletActionlet: false, + hasMoveActionletHasPathActionlet: false, + hasOnlyBatchActionlet: false, + hasPublishActionlet: false, + hasPushPublishActionlet: false, + hasResetActionlet: true, + hasSaveActionlet: false, + hasUnarchiveActionlet: true, + hasUnpublishActionlet: false, + icon: 'workflowIcon', + id: '2d1dc771-8fda-4b43-9e81-71d43a8c73e4', + name: 'Reset Workflow', + nextAssign: '654b0931-1027-41f7-ad4d-173115ed8ec1', + nextStep: '5865d447-5df7-4fa8-81c8-f8f183f3d1a2', + nextStepCurrentStep: false, + order: 0, + owner: null, + roleHierarchyForAssign: false, + schemeId: '2a4e1d2e-5342-4b46-be3d-80d3a2d9c0dd', + showOn: [ + 'LOCKED', + 'PUBLISHED', + 'ARCHIVED', + 'UNPUBLISHED', + 'LISTING', + 'UNLOCKED', + 'EDITING', + 'NEW' + ] + } + }, + NEW: { + scheme: { + archived: false, + creationDate: new Date(1732809856947), + defaultScheme: false, + description: '', + entryActionId: null, + id: '2a4e1d2e-5342-4b46-be3d-80d3a2d9c0dd', + mandatory: false, + modDate: new Date(1732554197546), + name: 'Blogs', + system: false, + variableName: 'Blogs' + }, + step: { + creationDate: 1732859904768, + enableEscalation: false, + escalationAction: null, + escalationTime: 0, + id: '5865d447-5df7-4fa8-81c8-f8f183f3d1a2', + myOrder: 0, + name: 'Editing', + resolved: false, + schemeId: '2a4e1d2e-5342-4b46-be3d-80d3a2d9c0dd' + }, + task: null, + contentState: 'new', + resetAction: null + }, + RESET: { + scheme: { + archived: false, + creationDate: new Date(1732809856947), + defaultScheme: false, + description: '', + entryActionId: null, + id: '2a4e1d2e-5342-4b46-be3d-80d3a2d9c0dd', + mandatory: false, + modDate: new Date(1732554197546), + name: 'Blogs', + system: false, + variableName: 'Blogs' + }, + step: { + creationDate: 1732860056894, + enableEscalation: false, + escalationAction: null, + escalationTime: 0, + id: '5865d447-5df7-4fa8-81c8-f8f183f3d1a2', + myOrder: 0, + name: 'Editing', + resolved: false, + schemeId: '2a4e1d2e-5342-4b46-be3d-80d3a2d9c0dd' + }, + task: { + assignedTo: 'Admin User', + belongsTo: null, + createdBy: 'e7d4e34e-5127-45fc-8123-d48b62d510e3', + creationDate: 1732854838710, + description: null, + dueDate: null, + id: 'f485675d-9e34-485d-9ec8-39a6e03b0272', + inode: 'f485675d-9e34-485d-9ec8-39a6e03b0272', + languageId: 1, + modDate: 1732854838710, + new: false, + status: null, + title: '74968ffd-7692-47d5-bd3a-44eeb5fbe551', + webasset: '74968ffd-7692-47d5-bd3a-44eeb5fbe551' + }, + contentState: 'reset', + resetAction: null + } +}; + +/** + * Mock for input of the sidebar workflow component + */ +export const WORKFLOW_SELECTION_MOCK = { + WITH_OPTIONS: { + schemeOptions: [ + { label: 'System Workflow', value: '1' }, + { label: 'Marketing Workflow', value: '2' } + ], + isWorkflowSelected: false + }, + NO_WORKFLOW: { + schemeOptions: [], + isWorkflowSelected: true + } +}; diff --git a/core-web/libs/edit-content/src/lib/utils/workflows.utils.spec.ts b/core-web/libs/edit-content/src/lib/utils/workflows.utils.spec.ts index 322de4727203..0e37d49b547e 100644 --- a/core-web/libs/edit-content/src/lib/utils/workflows.utils.spec.ts +++ b/core-web/libs/edit-content/src/lib/utils/workflows.utils.spec.ts @@ -1,5 +1,5 @@ -import { MOCK_CONTENTLET_1_TAB, MOCK_WORKFLOW_DATA } from './edit-content.mock'; -import { getWorkflowActions, parseWorkflows, shouldShowWorkflowWarning } from './workflows.utils'; +import { MOCK_WORKFLOW_DATA } from './edit-content.mock'; +import { parseWorkflows } from './workflows.utils'; describe('Workflow Utils', () => { describe('parseWorkflows', () => { @@ -37,109 +37,6 @@ describe('Workflow Utils', () => { }); }); - 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({ - schemes: mockSchemes, - contentlet: null, - currentSchemeId: null - }); - expect(result).toBe(true); - }); - - it('should return false when content exists', () => { - const result = shouldShowWorkflowWarning({ - schemes: mockSchemes, - contentlet: MOCK_CONTENTLET_1_TAB, - currentSchemeId: 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({ - schemes: singleScheme, - contentlet: null, - currentSchemeId: null - }); - expect(result).toBe(false); - }); - - it('should return false when scheme is selected', () => { - const result = shouldShowWorkflowWarning({ - schemes: mockSchemes, - contentlet: null, - currentSchemeId: '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({ - schemes: mockSchemes, - contentlet: null, - currentSchemeId: null, - currentContentActions: [] - }); - expect(result).toEqual([]); - }); - - it('should return empty array when selected scheme does not exist', () => { - const result = getWorkflowActions({ - schemes: mockSchemes, - contentlet: null, - currentSchemeId: 'non-existent-scheme', - currentContentActions: [] - }); - expect(result).toEqual([]); - }); - - it('should return current content actions for existing content', () => { - const currentActions = [MOCK_WORKFLOW_DATA[0].action]; - const result = getWorkflowActions({ - schemes: mockSchemes, - contentlet: MOCK_CONTENTLET_1_TAB, - currentSchemeId: 'd61a59e1-a49c-46f2-a929-db2b4bfa88b2', - currentContentActions: currentActions - }); - expect(result).toEqual(currentActions); - }); - - it('should return sorted scheme actions for new content with Save first', () => { - const result = getWorkflowActions({ - schemes: mockSchemes, - contentlet: null, - currentSchemeId: 'd61a59e1-a49c-46f2-a929-db2b4bfa88b2', - currentContentActions: [] - }); - - 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({ - schemes: mockSchemes, - contentlet: MOCK_CONTENTLET_1_TAB, - currentSchemeId: 'd61a59e1-a49c-46f2-a929-db2b4bfa88b2', - currentContentActions: [] - }); - - 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({}); diff --git a/core-web/libs/edit-content/src/lib/utils/workflows.utils.ts b/core-web/libs/edit-content/src/lib/utils/workflows.utils.ts index 86168633c7dd..df9f7389329d 100644 --- a/core-web/libs/edit-content/src/lib/utils/workflows.utils.ts +++ b/core-web/libs/edit-content/src/lib/utils/workflows.utils.ts @@ -1,6 +1,6 @@ import { DotCMSWorkflow, DotCMSWorkflowAction, WorkflowStep } from '@dotcms/dotcms-models'; -import { ContentState } from '../feature/edit-content/store/features/content.feature'; +import { CurrentContentActionsWithScheme } from '../feature/edit-content/store/features/workflow.feature'; /** * Parses an array of workflow data and returns a new object with key-value pairs. @@ -37,111 +37,27 @@ export const parseWorkflows = ( }; /** - * 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 + * Parses current workflow actions into a map of scheme ID to actions * - * @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 + * @param actions Array of workflow actions + * @returns CurrentContentActionsWithScheme - Record of scheme IDs mapped to their corresponding actions */ -export const shouldShowWorkflowActions = ({ - schemes, - contentlet, - currentSchemeId -}: { - schemes: ContentState['schemes']; - contentlet: ContentState['contentlet']; - 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; +export const parseCurrentActions = ( + actions: DotCMSWorkflowAction[] +): CurrentContentActionsWithScheme => { + if (!Array.isArray(actions)) { + return {}; } - 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 const shouldShowWorkflowWarning = ({ - schemes, - contentlet, - currentSchemeId -}: { - schemes: ContentState['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 const getWorkflowActions = ({ - schemes, - contentlet, - currentSchemeId, - currentContentActions -}: { - schemes: ContentState['schemes']; - contentlet: ContentState['contentlet']; - currentSchemeId: string | null; - currentContentActions: DotCMSWorkflowAction[]; -}): DotCMSWorkflowAction[] => { - const isNew = !contentlet; + return actions.reduce((acc, action) => { + const { schemeId } = action; - if (!currentSchemeId || !schemes[currentSchemeId]) { - return []; - } - - if (!isNew && currentContentActions.length) { - return currentContentActions; - } + if (!acc[schemeId]) { + acc[schemeId] = []; + } - return Object.values(schemes[currentSchemeId].actions).sort((a, b) => { - if (a.name === 'Save') return -1; - if (b.name === 'Save') return 1; + acc[schemeId].push(action); - return a.name.localeCompare(b.name); - }); + return acc; + }, {} as CurrentContentActionsWithScheme); }; diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 3bcd36a3b631..3403d6d5413d 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -5842,7 +5842,10 @@ edit.content.wysiwyg-field.language-variable-tooltip=Start typing to see matchin edit.content.sidebar.information.references-with.pages.tooltip=Used in {0} pages edit.content.sidebar.information.references-with.pages.not.used=Not used on any page yet -edit.content.success.workflow.message=Your changes have being applied. +edit.content.success.workflow.title=Success +edit.content.success.workflow.message=Your changes have been applied. +edit.content.processing.workflow.message=Your changes are being applied. +edit.content.processing.workflow.message.title=Processing edit.content.layout.back.to.old.edit.content=Try out the new Edit Content experience, which makes it easier than ever to edit and manage content. You can easily edit.content.layout.back.to.old.edit.content.switch=switch back @@ -5851,6 +5854,7 @@ edit.content.layout.back.to.old.edit.content.subtitle=any time. edit.content.layout.select.workflow.warning=You haven't selected a Workflow yet. edit.content.layout.select.workflow.warning.switch=Select a Workflow edit.content.layout.select.workflow.warning.subtitle=to take action on this content. +edit.content.sidebar.general.title=General edit.content.sidebar.workflow.dialog.title=Select the workflow you want to work on. edit.content.sidebar.workflow.dialog.dropdown.placeholder=Select a Workflow edit.content.sidebar.workflow.select.workflow=Select Workflow