From 895b439fbcd44ba2292ae3f19bf5c71dadcdbe29 Mon Sep 17 00:00:00 2001 From: Dfdez Date: Fri, 16 May 2025 09:40:54 +0200 Subject: [PATCH 1/8] Merge with feature/PNW-2564_duplicate--wip --- packages/decap-cms-core/index.d.ts | 8 ++ .../src/actions/editorialWorkflow.ts | 94 +++++++++++++------ .../decap-cms-core/src/actions/entries.ts | 76 +++++++++------ packages/decap-cms-core/src/backend.ts | 79 ++++++++++------ .../src/components/App/StackToolbar.js | 8 ++ .../src/components/Editor/Editor.js | 71 +++++++++++++- .../Editor/EditorControlPane/EditorControl.js | 10 +- .../EditorControlPane/EditorControlPane.js | 8 +- .../Editor/EditorControlPane/Widget.js | 5 + .../src/components/Editor/EditorToolbar.js | 8 ++ .../src/components/Editor/withWorkflow.js | 4 +- .../src/components/Workflow/WorkflowList.js | 23 ++++- .../src/constants/publishModes.ts | 1 + packages/decap-cms-core/src/lib/registry.js | 12 +++ packages/decap-cms-locales/src/en/index.js | 6 ++ .../src/ListItemTopBar.js | 23 +++-- packages/decap-cms-ui-default/src/Toggle.js | 6 +- packages/decap-cms-ui-default/src/styles.js | 3 + .../decap-cms-widget-list/src/ListControl.js | 62 +++++++++--- .../src/ObjectControl.js | 16 +++- .../src/RelationControl.js | 22 +++-- .../src/StringControl.js | 6 +- 22 files changed, 414 insertions(+), 137 deletions(-) diff --git a/packages/decap-cms-core/index.d.ts b/packages/decap-cms-core/index.d.ts index c132a8054d51..ffb9ed93d8ae 100644 --- a/packages/decap-cms-core/index.d.ts +++ b/packages/decap-cms-core/index.d.ts @@ -516,12 +516,20 @@ declare module 'decap-cms-core' { handler: ({ entry, author, + context, }: { entry: Map; author: { login: string; name: string }; + context?: HookContext; }) => any; } + export interface HookContext { + publishStack?: boolean; + actions?: Record; + [key: string]: any; + } + export type CmsEventListenerOptions = any; // TODO: type properly export type CmsLocalePhrases = any; // TODO: type properly diff --git a/packages/decap-cms-core/src/actions/editorialWorkflow.ts b/packages/decap-cms-core/src/actions/editorialWorkflow.ts index b19914c98937..4841b72c9873 100644 --- a/packages/decap-cms-core/src/actions/editorialWorkflow.ts +++ b/packages/decap-cms-core/src/actions/editorialWorkflow.ts @@ -39,6 +39,7 @@ import type { AnyAction } from 'redux'; import type { EntryValue } from '../valueObjects/Entry'; import type { Status } from '../constants/publishModes'; import type { ThunkDispatch } from 'redux-thunk'; +import type { HookContext } from '../backend'; /* * Constant Declarations @@ -67,6 +68,8 @@ export const UNPUBLISHED_ENTRY_DELETE_REQUEST = 'UNPUBLISHED_ENTRY_DELETE_REQUES export const UNPUBLISHED_ENTRY_DELETE_SUCCESS = 'UNPUBLISHED_ENTRY_DELETE_SUCCESS'; export const UNPUBLISHED_ENTRY_DELETE_FAILURE = 'UNPUBLISHED_ENTRY_DELETE_FAILURE'; +export const EDITORIAL_WORKFLOW_DISMISS_ERROR = 'EDITORIAL_WORKFLOW_DISMISS_ERROR'; + /* * Simple Action Creators (Internal) */ @@ -271,6 +274,7 @@ export function loadUnpublishedEntry(collection: Collection, slug: string) { dispatch(unpublishedEntryLoaded(collection, entry)); dispatch(createDraftFromEntry(entry)); } catch (error) { + if (error.name === EDITORIAL_WORKFLOW_DISMISS_ERROR) return if (error.name === EDITORIAL_WORKFLOW_ERROR && error.notUnderEditorialWorkflow) { dispatch(unpublishedEntryRedirected(collection, slug)); dispatch(loadEntry(collection, slug)); @@ -321,39 +325,21 @@ export function loadUnpublishedEntries(collections: Collections) { }; } -export function persistUnpublishedEntry(collection: Collection, existingUnpublishedEntry: boolean) { +export function persistCustomUnpublishedEntry( + collection: Collection, + existingUnpublishedEntry: boolean, + entryDraft: EntryDraft, + context: HookContext, +) { return async (dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); - const entryDraft = state.entryDraft; - const fieldsErrors = entryDraft.get('fieldsErrors'); const unpublishedSlugs = selectUnpublishedSlugs(state, collection.get('name')); const publishedSlugs = selectPublishedSlugs(state, collection.get('name')); const usedSlugs = publishedSlugs.concat(unpublishedSlugs) as List; const entriesLoaded = get(state.editorialWorkflow.toJS(), 'pages.ids', false); - //load unpublishedEntries !entriesLoaded && dispatch(loadUnpublishedEntries(state.collections)); - // Early return if draft contains validation errors - if (!fieldsErrors.isEmpty()) { - const hasPresenceErrors = fieldsErrors.some(errors => - errors.some(error => error.type && error.type === ValidationErrorTypes.PRESENCE), - ); - - if (hasPresenceErrors) { - dispatch( - addNotification({ - message: { - key: 'ui.toast.missingRequiredField', - }, - type: 'error', - dismissAfter: 8000, - }), - ); - } - return Promise.reject(); - } - const backend = currentBackend(state.config); const entry = entryDraft.get('entry'); const assetProxies = getMediaAssets({ @@ -375,6 +361,7 @@ export function persistUnpublishedEntry(collection: Collection, existingUnpublis entryDraft: serializedEntryDraft, assetProxies, usedSlugs, + context, }); dispatch( addNotification({ @@ -392,6 +379,7 @@ export function persistUnpublishedEntry(collection: Collection, existingUnpublis navigateToEntry(collection.get('name'), newSlug); } } catch (error) { + if (error.name === EDITORIAL_WORKFLOW_DISMISS_ERROR) return; dispatch( addNotification({ message: { @@ -409,6 +397,37 @@ export function persistUnpublishedEntry(collection: Collection, existingUnpublis }; } +export function persistUnpublishedEntry(collection: Collection, existingUnpublishedEntry: boolean, context: HookContext) { + return async (dispatch: ThunkDispatch, getState: () => State) => { + const state = getState(); + const entryDraft = state.entryDraft; + const fieldsErrors = entryDraft.get('fieldsErrors'); + + // Early return if draft contains validation errors + if (!fieldsErrors.isEmpty()) { + const hasPresenceErrors = fieldsErrors.some(errors => + errors.some(error => error.type && error.type === ValidationErrorTypes.PRESENCE), + ); + + if (hasPresenceErrors) { + dispatch( + addNotification({ + message: { + key: 'ui.toast.missingRequiredField', + }, + type: 'error', + dismissAfter: 8000, + }), + ); + } + return Promise.reject(); + } + + const persistFunc = persistCustomUnpublishedEntry(collection, existingUnpublishedEntry, entryDraft, context); + return persistFunc(dispatch, getState); + }; +} + export function updateUnpublishedEntryStatus( collection: string, slug: string, @@ -483,7 +502,7 @@ export function deleteUnpublishedEntry(collection: string, slug: string) { export function publishUnpublishedEntry( collectionName: string, slug: string, - publishStack: boolean, + context: HookContext, ) { return async (dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); @@ -493,7 +512,7 @@ export function publishUnpublishedEntry( const isDeleteWorkflow = entry.get('isDeleteWorkflow'); dispatch(unpublishedEntryPublishRequest(collectionName, slug)); try { - if (!publishStack && state.stack.status.status) { + if (!context.publishStack && state.stack.status.status) { dispatch( addNotification({ message: { @@ -507,7 +526,7 @@ export function publishUnpublishedEntry( return dispatch(unpublishedEntryPublishError(collectionName, slug)); } - await backend.publishUnpublishedEntry(entry, publishStack); + await backend.publishUnpublishedEntry(entry, context); await dispatch(checkStackStatus()); @@ -537,6 +556,7 @@ export function publishUnpublishedEntry( return dispatch(loadEntry(collection, slug)); } } catch (error) { + if (error.name === EDITORIAL_WORKFLOW_DISMISS_ERROR) return; dispatch( addNotification({ message: { key: 'ui.toast.onFailToPublishEntry', details: error }, @@ -549,15 +569,19 @@ export function publishUnpublishedEntry( }; } -export function unpublishPublishedEntry(collection: Collection, slug: string) { +export function unpublishCustomPublishedEntry( + collection: Collection, + slug: string, + entry: EntryMap, + context: HookContext, +) { return (dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); const backend = currentBackend(state.config); - const entry = selectEntry(state, collection.get('name'), slug); const entryDraft = Map().set('entry', entry) as unknown as EntryDraft; dispatch(unpublishedEntryPersisting(collection, slug)); return backend - .deleteEntry(state, collection, slug) + .deleteEntry(state, collection, slug, context) .then(() => { if (!backend.implementation.deleteCollectionFiles) { backend.persistEntry({ @@ -567,6 +591,7 @@ export function unpublishPublishedEntry(collection: Collection, slug: string) { assetProxies: [], usedSlugs: List(), status: status.get('PENDING_PUBLISH'), + context, }); } }) @@ -598,3 +623,12 @@ export function unpublishPublishedEntry(collection: Collection, slug: string) { }); }; } + +export function unpublishPublishedEntry(collection: Collection, slug: string, context: HookContext) { + return (dispatch: ThunkDispatch, getState: () => State) => { + const state = getState(); + const entry = selectEntry(state, collection.get('name'), slug); + const unpublishFunc = unpublishCustomPublishedEntry(collection, slug, entry, context); + return unpublishFunc(dispatch, getState); + }; +} diff --git a/packages/decap-cms-core/src/actions/entries.ts b/packages/decap-cms-core/src/actions/entries.ts index c8721996892b..3b569fbcb19d 100644 --- a/packages/decap-cms-core/src/actions/entries.ts +++ b/packages/decap-cms-core/src/actions/entries.ts @@ -35,9 +35,11 @@ import type { ViewFilter, ViewGroup, Entry, + EntryDraft, + Entries, } from '../types/redux'; import type { EntryValue } from '../valueObjects/Entry'; -import type { Backend } from '../backend'; +import type { Backend, HookContext } from '../backend'; import type AssetProxy from '../valueObjects/AssetProxy'; import type { Set } from 'immutable'; @@ -886,33 +888,19 @@ export function getSerializedEntry(collection: Collection, entry: Entry) { return serializedEntry; } -export function persistEntry(collection: Collection, publishStack?: boolean) { +export function persistCustomEntry( + collection: Collection, + entryDraft: EntryDraft, + context: HookContext, + entries?: Entries, +) { return async (dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); - const entryDraft = state.entryDraft; - const fieldsErrors = entryDraft.get('fieldsErrors'); - const usedSlugs = selectPublishedSlugs(state, collection.get('name')); - - // Early return if draft contains validation errors - if (!fieldsErrors.isEmpty()) { - const hasPresenceErrors = fieldsErrors.some(errors => - errors.some(error => error.type && error.type === ValidationErrorTypes.PRESENCE), - ); - if (hasPresenceErrors) { - dispatch( - addNotification({ - message: { - key: 'ui.toast.missingRequiredField', - }, - type: 'error', - dismissAfter: 8000, - }), - ); - } - - return Promise.reject(); - } + const usedSlugs = selectPublishedSlugs( + entries ? { ...state, entries: fromJS(entries) } : state, + collection.get('name'), + ); const backend = currentBackend(state.config); const entry = entryDraft.get('entry'); @@ -930,7 +918,7 @@ export function persistEntry(collection: Collection, publishStack?: boolean) { entryDraft: serializedEntryDraft, assetProxies, usedSlugs, - publishStack, + context, }) .then(async (newSlug: string) => { dispatch( @@ -973,14 +961,46 @@ export function persistEntry(collection: Collection, publishStack?: boolean) { }; } -export function deleteEntry(collection: Collection, slug: string) { +export function persistEntry(collection: Collection, context: HookContext) { + return async (dispatch: ThunkDispatch, getState: () => State) => { + const state = getState(); + const entryDraft = state.entryDraft; + + const fieldsErrors = entryDraft.get('fieldsErrors'); + + // Early return if draft contains validation errors + if (!fieldsErrors.isEmpty()) { + const hasPresenceErrors = fieldsErrors.some(errors => + errors.some(error => error.type && error.type === ValidationErrorTypes.PRESENCE), + ); + + if (hasPresenceErrors) { + dispatch( + addNotification({ + message: { + key: 'ui.toast.missingRequiredField', + }, + type: 'error', + dismissAfter: 8000, + }), + ); + } + + return Promise.reject(); + } + const persistFunc = persistCustomEntry(collection, entryDraft, context); + return persistFunc(dispatch, getState); + }; +} + +export function deleteEntry(collection: Collection, slug: string, context: HookContext) { return (dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); const backend = currentBackend(state.config); dispatch(entryDeleting(collection, slug)); return backend - .deleteEntry(state, collection, slug) + .deleteEntry(state, collection, slug, context) .then(async () => { dispatch(entryDeleted(collection, slug)); dispatch( diff --git a/packages/decap-cms-core/src/backend.ts b/packages/decap-cms-core/src/backend.ts index d95b4a89234c..3c1f3f831703 100644 --- a/packages/decap-cms-core/src/backend.ts +++ b/packages/decap-cms-core/src/backend.ts @@ -146,7 +146,7 @@ export function extractSearchFields(searchFields: string[]) { searchFields.reduce((acc, field) => { const value = getEntryField(field, entry); if (value) { - return `${acc} ${value}`; + return acc ? `${acc} ${value}` : value; } else { return acc; } @@ -276,9 +276,9 @@ interface PersistArgs { entryDraft: EntryDraft; assetProxies: AssetProxy[]; usedSlugs: List; - publishStack?: boolean; unpublished?: boolean; status?: string; + context?: HookContext; } interface ImplementationInitOptions { @@ -291,6 +291,11 @@ type Implementation = BackendImplementation & { init: (config: CmsConfig, options: ImplementationInitOptions) => Implementation; }; +export interface HookContext { + publishStack?: boolean; + actions?: Record; +} + function prepareMetaPath(path: string, collection: Collection) { if (!selectHasMetaPath(collection)) { return path; @@ -617,6 +622,17 @@ export class Backend { // Perform a local search by requesting all entries. For each // collection, load it, search, and call onCollectionResults with // its results. + + if (searchTerm === null) { + const allEntries = await Promise.all( + collections.map(async collection => { + const entries = await this.listAllEntries(collection); + return entries; + }), + ); + return { entries: flatten(allEntries) }; + } + const errors: Error[] = []; const collectionEntriesRequests = collections .map(async collection => { @@ -703,7 +719,7 @@ export class Backend { } const merged = mergeExpandedEntries(hits); - return { query: searchTerm, hits: merged }; + return { query: searchTerm, hits: merged, collection }; } traverseCursor(cursor: Cursor, action: string) { @@ -949,7 +965,7 @@ export class Backend { data, dataFile.path, dataFile.newFile, - dataFile.deletedFile, + dataFile.deleteFile, ); return entryWithFormat; }; @@ -1103,11 +1119,11 @@ export class Backend { entryDraft: draft, assetProxies, usedSlugs, - publishStack = false, unpublished = false, status, + context, }: PersistArgs) { - const updatedEntity = await this.invokePreSaveEvent(draft.get('entry')); + const updatedEntity = await this.invokePreSaveEvent(draft.get('entry'), context); let entryDraft; if (updatedEntity.get('data') === undefined) { @@ -1192,12 +1208,12 @@ export class Backend { commitMessage, collectionName, useWorkflow, - publishStack, + publishStack: context?.publishStack || false, ...updatedOptions, }; if (!useWorkflow) { - await this.invokePrePublishEvent(entryDraft.get('entry')); + await this.invokePrePublishEvent(entryDraft.get('entry'), context); } await this.implementation.persistEntry( @@ -1208,7 +1224,7 @@ export class Backend { opts, ); - await this.invokePostSaveEvent(entryDraft.get('entry')); + await this.invokePostSaveEvent(entryDraft.get('entry'), context); if (!useWorkflow) { await this.invokePostPublishEvent(entryDraft.get('entry')); @@ -1217,33 +1233,36 @@ export class Backend { return slug; } - async invokeEventWithEntry(event: string, entry: EntryMap) { + async invokeEventWithEntry(event: string, entry: EntryMap, context: HookContext = {}) { const { login, name } = (await this.currentUser()) as User; - return await invokeEvent({ name: event, data: { entry, author: { login, name } } }); + return await invokeEvent({ + name: event, + data: { entry, author: { login, name }, context }, + }); } - async invokePrePublishEvent(entry: EntryMap) { - await this.invokeEventWithEntry('prePublish', entry); + async invokePrePublishEvent(entry: EntryMap, context?: HookContext) { + await this.invokeEventWithEntry('prePublish', entry, context); } - async invokePostPublishEvent(entry: EntryMap) { - await this.invokeEventWithEntry('postPublish', entry); + async invokePostPublishEvent(entry: EntryMap, context?: HookContext) { + await this.invokeEventWithEntry('postPublish', entry, context); } - async invokePreUnpublishEvent(entry: EntryMap) { - await this.invokeEventWithEntry('preUnpublish', entry); + async invokePreUnpublishEvent(entry: EntryMap, context?: HookContext) { + await this.invokeEventWithEntry('preUnpublish', entry, context); } - async invokePostUnpublishEvent(entry: EntryMap) { - await this.invokeEventWithEntry('postUnpublish', entry); + async invokePostUnpublishEvent(entry: EntryMap, context?: HookContext) { + await this.invokeEventWithEntry('postUnpublish', entry, context); } - async invokePreSaveEvent(entry: EntryMap) { - return await this.invokeEventWithEntry('preSave', entry); + async invokePreSaveEvent(entry: EntryMap, context?: HookContext) { + return await this.invokeEventWithEntry('preSave', entry, context); } - async invokePostSaveEvent(entry: EntryMap) { - await this.invokeEventWithEntry('postSave', entry); + async invokePostSaveEvent(entry: EntryMap, context?: HookContext) { + await this.invokeEventWithEntry('postSave', entry, context); } async persistMedia(config: CmsConfig, file: AssetProxy) { @@ -1263,7 +1282,7 @@ export class Backend { return this.implementation.persistMedia(file, options); } - async deleteEntry(state: State, collection: Collection, slug: string) { + async deleteEntry(state: State, collection: Collection, slug: string, context?: HookContext) { const config = state.config; const path = selectEntryPath(collection, slug) as string; const extension = selectFolderEntryExtension(collection) as string; @@ -1287,7 +1306,7 @@ export class Backend { ); const entry = selectEntry(state.entries, collection.get('name'), slug); - await this.invokePreUnpublishEvent(entry); + await this.invokePreUnpublishEvent(entry, context); let paths = [path]; if (hasI18n(collection)) { paths = getFilePaths(collection, extension, path, slug); @@ -1304,7 +1323,7 @@ export class Backend { await this.implementation.deleteFiles(paths, commitMessage); } - await this.invokePostUnpublishEvent(entry); + await this.invokePostUnpublishEvent(entry, context); } async deleteMedia(config: CmsConfig, path: string) { @@ -1330,11 +1349,11 @@ export class Backend { return this.implementation.updateUnpublishedEntryStatus!(collection, slug, newStatus); } - async publishUnpublishedEntry(entry: EntryMap, publishStack?: boolean) { + async publishUnpublishedEntry(entry: EntryMap, context: HookContext) { const collection = entry.get('collection'); const slug = entry.get('slug'); - await this.invokePrePublishEvent(entry); + await this.invokePrePublishEvent(entry, context); const config = this.config; if (config.backend.stack) { @@ -1351,13 +1370,13 @@ export class Backend { ); await this.implementation.publishUnpublishedEntryStack!(collection, slug, { stackCommitMessage, - publishStack, + publishStack: context.publishStack, }); } else { await this.implementation.publishUnpublishedEntry!(collection, slug); } - await this.invokePostPublishEvent(entry); + await this.invokePostPublishEvent(entry, context); } deleteUnpublishedEntry(collection: string, slug: string) { diff --git a/packages/decap-cms-core/src/components/App/StackToolbar.js b/packages/decap-cms-core/src/components/App/StackToolbar.js index 7ff2ce385889..f7653fe2bc1f 100644 --- a/packages/decap-cms-core/src/components/App/StackToolbar.js +++ b/packages/decap-cms-core/src/components/App/StackToolbar.js @@ -90,6 +90,13 @@ const StatusButton = styled(DropdownButton)` background-color: ${colorsRaw.tealLight}; color: ${colorsRaw.teal}; + ${props => + props.label === 'processing' && + css` + background-color: ${colors.processingBackground}; + color: ${colors.processingText}; + `} + ${props => props.label === 'stale' && css` @@ -164,6 +171,7 @@ export class EditorToolbar extends React.Component { [status.get('DRAFT')]: t('editor.editorToolbar.draft'), [status.get('PENDING_REVIEW')]: t('editor.editorToolbar.inReview'), [status.get('PENDING_PUBLISH')]: t('editor.editorToolbar.ready'), + [status.get('PROCESSING')]: t('editor.editorToolbar.inProcessing'), [status.get('STALE')]: t('editor.editorToolbar.inStale'), }; diff --git a/packages/decap-cms-core/src/components/Editor/Editor.js b/packages/decap-cms-core/src/components/Editor/Editor.js index 385c95733505..c4f16c5743eb 100644 --- a/packages/decap-cms-core/src/components/Editor/Editor.js +++ b/packages/decap-cms-core/src/components/Editor/Editor.js @@ -16,6 +16,7 @@ import { discardDraft, changeDraftField, changeDraftFieldValidation, + persistCustomEntry, persistEntry, deleteEntry, // persistLocalBackup, @@ -32,6 +33,7 @@ import { } from '../../actions/editorialWorkflow'; import { removeAssets } from '../../actions/media'; import { loadDeployPreview } from '../../actions/deploys'; +import { searchEntries } from '../../actions/search'; import { selectEntry, selectUnpublishedEntry, selectDeployPreview } from '../../reducers'; import { selectFields } from '../../reducers/collections'; import { status, EDITORIAL_WORKFLOW } from '../../constants/publishModes'; @@ -49,6 +51,7 @@ export class Editor extends React.Component { entry: ImmutablePropTypes.map, entryDraft: ImmutablePropTypes.map.isRequired, loadEntry: PropTypes.func.isRequired, + persistCustomEntry: PropTypes.func.isRequired, persistEntry: PropTypes.func.isRequired, deleteEntry: PropTypes.func.isRequired, showDelete: PropTypes.bool.isRequired, @@ -71,6 +74,7 @@ export class Editor extends React.Component { loadDeployPreview: PropTypes.func.isRequired, currentStatus: PropTypes.string, user: PropTypes.object, + searchEntries: PropTypes.func.isRequired, location: PropTypes.shape({ pathname: PropTypes.string, search: PropTypes.string, @@ -210,6 +214,10 @@ export class Editor extends React.Component { handleChangeStatus = newStatusName => { const { entryDraft, updateUnpublishedEntryStatus, collection, slug, currentStatus, t } = this.props; + if (currentStatus === status.get('PROCESSING')) { + window.alert(t('editor.editor.onProcessingUpdate')); + return; + } if (entryDraft.get('hasChanged')) { window.alert(t('editor.editor.onUpdatingWithUnsavedChanges')); return; @@ -224,6 +232,47 @@ export class Editor extends React.Component { // deleteLocalBackup(collection, !newEntry && slug); // } + createHookContext = (context) => { + const defaultContext = { + actions: { + navigateToCollection, + searchEntries: this.props.searchEntries, + handleChangeStatus: this.handleChangeStatus, + getCollection: (name) => { + return this.props.collections.get(name); + }, + persistEntry: async (collection, entry, opts = {}) => { + const context = this.createHookContext(opts); + const { entries } = (await this.props.searchEntries(null, [collection.get('name')])).payload; + return this.props.persistCustomEntry(collection, context, entry, entries); + }, + persistUnpublishedEntry: async (collection, existingUnpublishedEntry, entry, opts = {}) => { + const context = this.createHookContext(opts); + return this.props.persistCustomUnpublishedEntry(collection, existingUnpublishedEntry, entry, context); + }, + publishEntry: async (collection, slug, opts = {}) => { + const context = this.createHookContext(opts); + return this.props.publishUnpublishedEntry(collection.get('name'), slug, context); + }, + unpublishEntry: async (collection, slug, opts = {}) => { + const context = this.createHookContext(opts); + return this.props.unpublishPublishedEntry(collection, slug, context); + }, + unpublishPublishedEntry: async (collection, slug, entry, opts = {}) => { + const context = this.createHookContext(opts); + return this.props.unpublishCustomPublishedEntry(collection, slug, entry, context); + }, + deleteEntry: async (collection, slug, opts = {}) => { + const context = this.createHookContext(opts); + return this.props.deleteEntry(collection, slug, context); + }, + } + } + if (!context) return defaultContext + + return Object.assign(defaultContext, context) + } + handlePersistEntry = async (opts = {}) => { const { createNew = false, duplicate = false, publishStack = false } = opts; const { @@ -237,7 +286,7 @@ export class Editor extends React.Component { entryDraft, } = this.props; - await persistEntry(collection, publishStack); + await persistEntry(collection, this.createHookContext({ publishStack })); // this.deleteBackup(); @@ -279,7 +328,7 @@ export class Editor extends React.Component { return; } - await publishUnpublishedEntry(collection.get('name'), slug, publishStack); + await publishUnpublishedEntry(collection.get('name'), slug, this.createHookContext({ publishStack })); // this.deleteBackup(); @@ -294,7 +343,7 @@ export class Editor extends React.Component { const { unpublishPublishedEntry, collection, slug, t } = this.props; if (!window.confirm(t('editor.editor.onUnpublishing'))) return; - await unpublishPublishedEntry(collection, slug); + await unpublishPublishedEntry(collection, slug, this.createHookContext()); // return navigateToCollection(collection.get('name')); }; @@ -307,7 +356,11 @@ export class Editor extends React.Component { }; handleDeleteEntry = () => { - const { entryDraft, newEntry, collection, deleteEntry, slug, t } = this.props; + const { entryDraft, newEntry, collection, deleteEntry, slug, currentStatus, t } = this.props; + if (currentStatus === status.get('PROCESSING')) { + window.alert(t('editor.editor.onProcessingUpdate')); + return; + } if (entryDraft.get('hasChanged')) { if (!window.confirm(t('editor.editor.onDeleteWithUnsavedChanges'))) { return; @@ -320,7 +373,7 @@ export class Editor extends React.Component { } setTimeout(async () => { - await deleteEntry(collection, slug); + await deleteEntry(collection, slug, this.createHookContext()); // this.deleteBackup(); // return navigateToCollection(collection.get('name')); }, 0); @@ -331,6 +384,7 @@ export class Editor extends React.Component { entryDraft, collection, slug, + currentStatus, removeAssets, removeDraftEntryMediaFiles, deleteUnpublishedEntry, @@ -339,6 +393,11 @@ export class Editor extends React.Component { isDeleteWorkflow, t, } = this.props; + if (currentStatus === status.get('PROCESSING')) { + window.alert(t('editor.editor.onProcessingUpdate')); + return; + } + if ( entryDraft.get('hasChanged') && !window.confirm( @@ -522,6 +581,7 @@ const mapDispatchToProps = { createDraftDuplicateFromEntry, createEmptyDraft, discardDraft, + persistCustomEntry, persistEntry, deleteEntry, updateUnpublishedEntryStatus, @@ -531,6 +591,7 @@ const mapDispatchToProps = { removeAssets, removeDraftEntryMediaFiles, logoutUser, + searchEntries, }; export default connect(mapStateToProps, mapDispatchToProps)(withWorkflow(translate()(Editor))); diff --git a/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControl.js b/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControl.js index b5f09b75df49..a577cc1ccad9 100644 --- a/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControl.js +++ b/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControl.js @@ -12,7 +12,7 @@ import ReactMarkdown from 'react-markdown'; import gfm from 'remark-gfm'; import { List, Map } from 'immutable'; -import { resolveWidget, getEditorComponents } from '../../../lib/registry'; +import { resolveWidget, getEditorComponents, getWidget } from '../../../lib/registry'; import { clearFieldErrors, tryLoadEntry, validateMetaField } from '../../../actions/entries'; import { addAsset, boundGetAsset } from '../../../actions/media'; import { selectIsLoadingAsset } from '../../../reducers/medias'; @@ -70,6 +70,9 @@ const styleStrings = { unused: ` opacity: 0.5; `, + flat: ` + margin-top: 0 !important; + ` }; const ControlContainer = styled.div` @@ -204,6 +207,7 @@ class EditorControl extends React.Component { value, entry, collection, + collections, config, field, fieldsMetaData, @@ -268,6 +272,7 @@ class EditorControl extends React.Component { css={css` ${!this.state.use && unused && styleStrings.unused} ${isHidden && styleStrings.hidden}; + ${isFlat && styleStrings.flat} `} > {widgetTitle &&

{widgetTitle}

} @@ -335,6 +340,7 @@ class EditorControl extends React.Component { controlComponent={widget.control} entry={entry} collection={collection} + collections={collections} config={config} field={field} uniqueFieldId={this.uniqueFieldId} @@ -360,6 +366,7 @@ class EditorControl extends React.Component { resolveWidget={resolveWidget} widget={widget} getEditorComponents={getEditorComponents} + getWidget={getWidget} controlRef={controlRef} editorControl={ConnectedEditorControl} query={query} @@ -436,6 +443,7 @@ function mapStateToProps(state) { config: state.config, entry, collection, + collections: state.collections, isLoadingAsset, loadEntry, validateMetaField: (field, value, t) => validateMetaField(state, collection, field, value, t), diff --git a/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js b/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js index 393143359015..341e96b8d935 100644 --- a/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js +++ b/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js @@ -113,10 +113,12 @@ export default class ControlPane extends React.Component { const parentName = field.get('parentName'); const name = field.get('name'); const validateName = parentName ? `${parentName}.${name}` : name; + this.childRefs[validateName] = wrappedControl; + }; - this.componentValidate[validateName] = - wrappedControl.innerWrappedControl?.validate || wrappedControl.validate; - } + getControlRef = field => wrappedControl => { + this.controlRef(field, wrappedControl); + }; handleLocaleChange = val => { this.setState({ selectedLocale: val }); diff --git a/packages/decap-cms-core/src/components/Editor/EditorControlPane/Widget.js b/packages/decap-cms-core/src/components/Editor/EditorControlPane/Widget.js index d381ef807ddb..7e8980da8423 100644 --- a/packages/decap-cms-core/src/components/Editor/EditorControlPane/Widget.js +++ b/packages/decap-cms-core/src/components/Editor/EditorControlPane/Widget.js @@ -55,6 +55,7 @@ export default class Widget extends Component { resolveWidget: PropTypes.func.isRequired, widget: PropTypes.object.isRequired, getEditorComponents: PropTypes.func.isRequired, + getWidget: PropTypes.func.isRequired, isFetching: PropTypes.bool, query: PropTypes.func.isRequired, clearSearch: PropTypes.func.isRequired, @@ -310,6 +311,7 @@ export default class Widget extends Component { controlComponent, entry, collection, + collections, config, field, value, @@ -336,6 +338,7 @@ export default class Widget extends Component { resolveWidget, widget, getEditorComponents, + getWidget, query, queryHits, clearSearch, @@ -360,6 +363,7 @@ export default class Widget extends Component { return React.createElement(controlComponent, { entry, collection, + collections, config, field, value, @@ -390,6 +394,7 @@ export default class Widget extends Component { resolveWidget, widget, getEditorComponents, + getWidget, getRemarkPlugins, query, queryHits, diff --git a/packages/decap-cms-core/src/components/Editor/EditorToolbar.js b/packages/decap-cms-core/src/components/Editor/EditorToolbar.js index c139a31737d1..911f857d44db 100644 --- a/packages/decap-cms-core/src/components/Editor/EditorToolbar.js +++ b/packages/decap-cms-core/src/components/Editor/EditorToolbar.js @@ -219,6 +219,13 @@ const StatusButton = styled(DropdownButton)` background-color: ${colorsRaw.tealLight}; color: ${colorsRaw.teal}; + ${props => + props.label === 'processing' && + css` + background-color: ${colors.processingBackground}; + color: ${colors.processingText}; + `} + ${props => props.label === 'stale' && css` @@ -390,6 +397,7 @@ export class EditorToolbar extends React.Component { [status.get('DRAFT')]: t('editor.editorToolbar.draft'), [status.get('PENDING_REVIEW')]: t('editor.editorToolbar.inReview'), [status.get('PENDING_PUBLISH')]: t('editor.editorToolbar.ready'), + [status.get('PROCESSING')]: t('editor.editorToolbar.inProcessing'), [status.get('STALE')]: t('editor.editorToolbar.inStale'), }; diff --git a/packages/decap-cms-core/src/components/Editor/withWorkflow.js b/packages/decap-cms-core/src/components/Editor/withWorkflow.js index 47fb52c0cfb8..6869f83c6c86 100644 --- a/packages/decap-cms-core/src/components/Editor/withWorkflow.js +++ b/packages/decap-cms-core/src/components/Editor/withWorkflow.js @@ -35,8 +35,8 @@ function mergeProps(stateProps, dispatchProps, ownProps) { returnObj.loadEntry = (collection, slug) => dispatch(loadUnpublishedEntry(collection, slug)); // Overwrite persistEntry to persistUnpublishedEntry - returnObj.persistEntry = collection => - dispatch(persistUnpublishedEntry(collection, unpublishedEntry)); + returnObj.persistEntry = (collection, context) => + dispatch(persistUnpublishedEntry(collection, unpublishedEntry, context)); } return { diff --git a/packages/decap-cms-core/src/components/Workflow/WorkflowList.js b/packages/decap-cms-core/src/components/Workflow/WorkflowList.js index 855a79a2a211..87b6b8a9266f 100644 --- a/packages/decap-cms-core/src/components/Workflow/WorkflowList.js +++ b/packages/decap-cms-core/src/components/Workflow/WorkflowList.js @@ -15,7 +15,7 @@ import { selectEntryCollectionTitle } from '../../reducers/collections'; const WorkflowListContainer = styled.div` min-height: 60%; display: grid; - grid-template-columns: 25% 25% 25% 25%; + grid-template-columns: 20% 20% 20% 20% 20%; `; const WorkflowListContainerOpenAuthoring = styled.div` @@ -105,6 +105,13 @@ const ColumnHeader = styled.h2` color: ${colors.statusReadyText}; `} + ${props => + props.name === 'processing' && + css` + background-color: ${colors.processingBackground}; + color: ${colors.processingText}; + `} + ${props => props.name === 'stale' && css` @@ -128,12 +135,14 @@ function getColumnHeaderText(columnName, t) { switch (columnName) { case 'draft': return t('workflow.workflowList.draftHeader'); - case 'stale': - return t('workflow.workflowList.inStaleHeader'); case 'pending_review': return t('workflow.workflowList.inReviewHeader'); case 'pending_publish': return t('workflow.workflowList.readyHeader'); + case 'processing': + return t('workflow.workflowList.inProcessingHeader'); + case 'stale': + return t('workflow.workflowList.inStaleHeader'); } } @@ -152,6 +161,10 @@ class WorkflowList extends React.Component { const slug = dragProps.slug; const collection = dragProps.collection; const oldStatus = dragProps.ownStatus; + if (oldStatus === 'processing') { + window.alert(this.props.t('workflow.workflowList.onProcessingUpdate')) + return; + } if (newStatus === 'stale') { window.alert(this.props.t('workflow.workflowList.onStaleUpdate')); return; @@ -160,6 +173,10 @@ class WorkflowList extends React.Component { }; requestDelete = (collection, slug, ownStatus) => { + if (ownStatus === 'processing') { + window.alert(this.props.t('workflow.workflowList.onProcessingUpdate')) + return; + } if (window.confirm(this.props.t('workflow.workflowList.onDeleteEntry'))) { this.props.handleDelete(collection, slug, ownStatus); } diff --git a/packages/decap-cms-core/src/constants/publishModes.ts b/packages/decap-cms-core/src/constants/publishModes.ts index 62be9769ff49..a64fd344ee66 100644 --- a/packages/decap-cms-core/src/constants/publishModes.ts +++ b/packages/decap-cms-core/src/constants/publishModes.ts @@ -8,6 +8,7 @@ export const Statues = { DRAFT: 'draft', PENDING_REVIEW: 'pending_review', PENDING_PUBLISH: 'pending_publish', + PROCESSING: 'processing', STALE: 'stale', }; diff --git a/packages/decap-cms-core/src/lib/registry.js b/packages/decap-cms-core/src/lib/registry.js index 573d8867762c..ef094fe37a57 100644 --- a/packages/decap-cms-core/src/lib/registry.js +++ b/packages/decap-cms-core/src/lib/registry.js @@ -1,6 +1,7 @@ import { Map } from 'immutable'; import { produce } from 'immer'; import { oneLine } from 'common-tags'; +import * as immutable from 'immutable'; import EditorComponent from '../valueObjects/EditorComponent'; @@ -17,10 +18,15 @@ allowedEvents.forEach(e => { eventHandlers[e] = []; }); +const lib = { + immutable, +} + /** * Global Registry Object */ const registry = { + lib, backends: {}, templates: {}, previewStyles: [], @@ -35,6 +41,7 @@ const registry = { }; export default { + getLib, registerPreviewStyle, getPreviewStyles, registerPreviewTemplate, @@ -65,6 +72,10 @@ export default { getCustomFormatsFormatters, }; +export function getLib() { + return registry.lib; +} + /** * Preview Styles * @@ -310,3 +321,4 @@ export function getCustomFormatsFormatters() { export function getFormatter(name) { return registry.formats[name]?.formatter; } + diff --git a/packages/decap-cms-locales/src/en/index.js b/packages/decap-cms-locales/src/en/index.js index c75762c44b55..025d1114a069 100644 --- a/packages/decap-cms-locales/src/en/index.js +++ b/packages/decap-cms-locales/src/en/index.js @@ -115,6 +115,8 @@ const en = { 'All changes to this entry will be deleted.\n\n Do you still want to delete?', loadingEntry: 'Loading entry...', confirmLoadBackup: 'A local backup was recovered for this entry, would you like to use it?', + onProcessingUpdate: + "Entry can't change the status while processing!\n\n Please wait until the process end.", onStackPublishing: 'Are you sure you want to publish all changes?', onStackClosing: 'Are you sure you want to discard all changes?', }, @@ -153,6 +155,7 @@ const en = { draft: 'Draft', inReview: 'In review', ready: 'Ready', + inProcessing: 'Processing', inStale: 'Stale', publishNow: 'Publish now', stackChange: 'Stack change', @@ -323,6 +326,9 @@ const en = { draftHeader: 'Drafts', inReviewHeader: 'In Review', readyHeader: 'Ready', + inProcessingHeader: 'Processing', + onProcessingUpdate: + "Entry can't change the status while processing!\n\n Please wait until the process end.", inStaleHeader: 'Stale', onStaleUpdate: "Entry can't be manually updated to stale status! Please discard changes insted of using stale status.", diff --git a/packages/decap-cms-ui-default/src/ListItemTopBar.js b/packages/decap-cms-ui-default/src/ListItemTopBar.js index cc09183b6652..dfa1d2d0b346 100644 --- a/packages/decap-cms-ui-default/src/ListItemTopBar.js +++ b/packages/decap-cms-ui-default/src/ListItemTopBar.js @@ -28,6 +28,10 @@ const TopBarButton = styled.button` align-items: center; `; +const TopBarButtonGroups = styled.div` + display: flex; +`; + const TopBarButtonSpan = TopBarButton.withComponent('span'); const DragIconContainer = styled(TopBarButtonSpan)` @@ -46,7 +50,7 @@ function DragHandle({ Wrapper, id }) { } function ListItemTopBar(props) { - const { className, collapsed, onCollapseToggle, onRemove, dragHandle, id } = props; + const { className, collapsed, onCollapseToggle, onDuplicate, onRemove, dragHandle, id } = props; return ( {onCollapseToggle ? ( @@ -55,11 +59,18 @@ function ListItemTopBar(props) { ) : null} {dragHandle ? : null} - {onRemove ? ( - - - - ) : null} + + {onDuplicate ? ( + + + + ) : null} + {onRemove ? ( + + + + ) : null} + ); } diff --git a/packages/decap-cms-ui-default/src/Toggle.js b/packages/decap-cms-ui-default/src/Toggle.js index 46a4d25b9c39..50923f5cd6d7 100644 --- a/packages/decap-cms-ui-default/src/Toggle.js +++ b/packages/decap-cms-ui-default/src/Toggle.js @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import styled from '@emotion/styled'; import { css } from '@emotion/react'; @@ -57,6 +57,10 @@ function Toggle({ }) { const [isActive, setIsActive] = useState(active); + useEffect(() => { + setIsActive(active); + }, [active]); + function handleToggle() { setIsActive(prevIsActive => !prevIsActive); if (onChange) { diff --git a/packages/decap-cms-ui-default/src/styles.js b/packages/decap-cms-ui-default/src/styles.js index 684eb78019ea..ae9266248acc 100644 --- a/packages/decap-cms-ui-default/src/styles.js +++ b/packages/decap-cms-ui-default/src/styles.js @@ -42,6 +42,7 @@ const colorsRaw = { greenLight: '#caef6f', brown: '#754e00', yellow: '#ffee9c', + yellowDark: '#ffe150', red: '#ff003b', redDark: '#D60032', redLight: '#fcefea', @@ -59,6 +60,8 @@ const colors = { statusReviewBackground: colorsRaw.yellow, statusReadyText: colorsRaw.green, statusReadyBackground: colorsRaw.greenLight, + processingBackground: colorsRaw.yellowDark, + processingText: colorsRaw.grayDark, staleBackground: colorsRaw.redDark, staleText: colorsRaw.redLight, text: colorsRaw.gray, diff --git a/packages/decap-cms-widget-list/src/ListControl.js b/packages/decap-cms-widget-list/src/ListControl.js index 5a2ee6c1dec0..6237fe8a0cf5 100644 --- a/packages/decap-cms-widget-list/src/ListControl.js +++ b/packages/decap-cms-widget-list/src/ListControl.js @@ -6,7 +6,6 @@ import { css, ClassNames } from '@emotion/react'; import { List, Map, fromJS } from 'immutable'; import { partial, isEmpty, uniqueId } from 'lodash'; import { v4 as uuid } from 'uuid'; -import DecapCmsWidgetObject from 'decap-cms-widget-object'; import { DndContext, MouseSensor, @@ -34,8 +33,6 @@ import { getErrorMessageForTypedFieldAndValue, } from './typedListHelpers'; -const ObjectControl = DecapCmsWidgetObject.controlComponent; - const ListItem = styled.div(); const StyledListItemTopBar = styled(ListItemTopBar)` @@ -195,8 +192,6 @@ export default class ListControl extends React.Component { resolveWidget: PropTypes.func.isRequired, clearFieldErrors: PropTypes.func.isRequired, fieldsErrors: ImmutablePropTypes.map.isRequired, - isFieldUnused: PropTypes.func, - setFieldUnused: PropTypes.func, entry: ImmutablePropTypes.map.isRequired, t: PropTypes.func, }; @@ -414,7 +409,7 @@ export default class ListControl extends React.Component { */ getObjectValue = idx => this.props.value.get(idx) || Map(); - handleChangeFor(index) { + handleFieldChangeFor(index) { return (f, newValue, newMetadata) => { const { value, metadata, onChange, field } = this.props; const collectionName = field.get('name'); @@ -435,6 +430,41 @@ export default class ListControl extends React.Component { }; } + handleChangeFor(index) { + return (newValue, newMetadata) => { + const { value, metadata, onChange, field } = this.props; + const collectionName = field.get('name'); + const parsedMetadata = { + [collectionName]: Object.assign(metadata ? metadata.toJS() : {}, newMetadata || {}), + }; + onChange(value.set(index, newValue), parsedMetadata); + }; + } + + handleDuplicate = (index, event) => { + event.preventDefault(); + const { value, onChange } = this.props; + + const listValue = value.get(index); + if (!listValue) return + + const { itemsCollapsed } = this.state; + + // Create new arrays with the item inserted at index + 1 + const newItemsCollapsed = [...itemsCollapsed]; + const newKeys = [...this.state.keys]; + + newItemsCollapsed.splice(index + 1, 0, false); // Insert expanded state + newKeys.splice(index + 1, 0, uuid()); // Insert new key + + this.setState({ + itemsCollapsed: newItemsCollapsed, + keys: newKeys + }); + + onChange(value.insert(index + 1, listValue)); + }; + handleRemove = (index, event) => { event.preventDefault(); const { itemsCollapsed } = this.state; @@ -637,13 +667,12 @@ export default class ListControl extends React.Component { metadata, clearFieldErrors, fieldsErrors, - controlRef, resolveWidget, parentIds, forID, t, - isFieldUnused, - setFieldUnused, + collection, + collections, } = this.props; const { itemsCollapsed, keys } = this.state; @@ -659,6 +688,8 @@ export default class ListControl extends React.Component { } } + const ObjectControl = (this.props.getWidget('object')).control; + return ( @@ -698,7 +730,10 @@ export default class ListControl extends React.Component { })} value={item} field={field} - onChangeObject={this.handleChangeFor(index)} + collection={collection} + collections={collections} + onChange={this.handleChangeFor(index)} + onChangeObject={this.handleFieldChangeFor(index)} editorControl={editorControl} resolveWidget={resolveWidget} metadata={metadata} @@ -706,15 +741,12 @@ export default class ListControl extends React.Component { onValidateObject={onValidateObject} clearFieldErrors={clearFieldErrors} fieldsErrors={fieldsErrors} - ref={this.processControlRef} - controlRef={controlRef} + controlRef={this.processControlRef} validationKey={key} collapsed={collapsed} data-testid={`object-control-${key}`} hasError={hasError} parentIds={[...parentIds, forID, key]} - isFieldUnused={isFieldUnused} - setFieldUnused={setFieldUnused} /> )} @@ -734,7 +766,7 @@ export default class ListControl extends React.Component { > diff --git a/packages/decap-cms-widget-object/src/ObjectControl.js b/packages/decap-cms-widget-object/src/ObjectControl.js index 4a3dbf8492c5..5c29dee3f6a5 100644 --- a/packages/decap-cms-widget-object/src/ObjectControl.js +++ b/packages/decap-cms-widget-object/src/ObjectControl.js @@ -26,9 +26,13 @@ export default class ObjectControl extends React.Component { processControlRef = ref => { if (!ref) return; - const name = ref.props.field.get('name'); - this.childRefs[name] = ref; - this.props.controlRef?.(ref); + const parentId = ref.props.parentIds[ref.props.parentIds.length - 1]; + const belongsToDifferentParent = parentId && this.props.forID && parentId !== this.props.forID; + if (!belongsToDifferentParent) { + const name = ref.props.field.get('name'); + this.childRefs[name] = ref; + } + this.props.controlRef?.(this); }; static propTypes = { @@ -87,7 +91,9 @@ export default class ObjectControl extends React.Component { fields = List.isList(fields) ? fields : List([fields]); fields.forEach(field => { const widget = field.get('widget'); + if (widget === 'hidden' || (widget === 'object' && field.has('flat'))) return; + const parentName = field.get('parentName'); const name = field.get('name'); @@ -260,7 +266,9 @@ export default class ObjectControl extends React.Component { ?.map(field => field.set('parentName', fieldParentName)); const singleField = f.get('field')?.set('parentName', fieldParentName); - return mappedMultiFields.push(...this.renderFields(multiFields, singleField, f)); + const renderedFields = this.renderFields(multiFields, singleField, f); + if (Array.isArray(renderedFields)) return mappedMultiFields.push(...renderedFields); + return mappedMultiFields.push(renderedFields); } return mappedMultiFields.push(this.controlFor(f, idx)); }); diff --git a/packages/decap-cms-widget-relation/src/RelationControl.js b/packages/decap-cms-widget-relation/src/RelationControl.js index c17deca0a536..c9e5be261451 100644 --- a/packages/decap-cms-widget-relation/src/RelationControl.js +++ b/packages/decap-cms-widget-relation/src/RelationControl.js @@ -227,15 +227,12 @@ export default class RelationControl extends React.Component { return ( this.props.value !== nextProps.value || this.props.hasActiveStyle !== nextProps.hasActiveStyle || - this.props.queryHits !== nextProps.queryHits + this.props.queryHits !== nextProps.queryHits || + this.props.field.get('collection') !== nextProps.field.get('collection') ); } - async componentDidMount() { - this.mounted = true; - // if the field has a previous value perform an initial search based on the value field - // this is required since each search is limited by optionsLength so the selected value - // might not show up on the search + async loadInitialOptions() { const { forID, field, value, query, onChange } = this.props; const collection = field.get('collection'); const file = field.get('file'); @@ -272,6 +269,17 @@ export default class RelationControl extends React.Component { } } + async componentDidMount() { + this.mounted = true; + await this.loadInitialOptions(); + } + + async componentDidUpdate(prevProps) { + if (this.props.field.get('collection') !== prevProps.field.get('collection')) { + await this.loadInitialOptions(); + } + } + componentWillUnmount() { this.mounted = false; } @@ -424,7 +432,7 @@ export default class RelationControl extends React.Component { value={selectedValue} inputId={forID} cacheOptions - defaultOptions + defaultOptions={options?.length ? options : true} loadOptions={this.loadOptions} onChange={this.handleChange} className={classNameWrapper} diff --git a/packages/decap-cms-widget-string/src/StringControl.js b/packages/decap-cms-widget-string/src/StringControl.js index 339e9338058f..01910fc36174 100644 --- a/packages/decap-cms-widget-string/src/StringControl.js +++ b/packages/decap-cms-widget-string/src/StringControl.js @@ -29,9 +29,11 @@ export default class StringControl extends React.Component { // The input element ref _el = null; - shouldComponentUpdate(nextProps, nextState) { + shouldComponentUpdate(nextProps, nextState = {}) { return Boolean( - nextState && (this.state.value !== nextState.value || nextProps.value !== nextState.value), + this.props.classNameWrapper !== nextProps.classNameWrapper || + this.state.value !== nextState.value || + nextProps.value !== nextState.value ); } From 484269b842e685af876bdb2c65f09865bafa4d52 Mon Sep 17 00:00:00 2001 From: Dfdez Date: Fri, 30 May 2025 19:31:24 +0200 Subject: [PATCH 2/8] feat: duplicate workflow wip --- .../src/actions/editorialWorkflow.ts | 41 +++++++++++--- .../decap-cms-core/src/actions/entries.ts | 21 ++++--- packages/decap-cms-core/src/backend.ts | 12 +++- .../src/components/Editor/Editor.js | 56 ++++++++++++------- .../decap-cms-core/src/reducers/entries.ts | 2 +- 5 files changed, 89 insertions(+), 43 deletions(-) diff --git a/packages/decap-cms-core/src/actions/editorialWorkflow.ts b/packages/decap-cms-core/src/actions/editorialWorkflow.ts index 4841b72c9873..600886e86bc4 100644 --- a/packages/decap-cms-core/src/actions/editorialWorkflow.ts +++ b/packages/decap-cms-core/src/actions/editorialWorkflow.ts @@ -274,7 +274,7 @@ export function loadUnpublishedEntry(collection: Collection, slug: string) { dispatch(unpublishedEntryLoaded(collection, entry)); dispatch(createDraftFromEntry(entry)); } catch (error) { - if (error.name === EDITORIAL_WORKFLOW_DISMISS_ERROR) return + if (error.name === EDITORIAL_WORKFLOW_DISMISS_ERROR) return; if (error.name === EDITORIAL_WORKFLOW_ERROR && error.notUnderEditorialWorkflow) { dispatch(unpublishedEntryRedirected(collection, slug)); dispatch(loadEntry(collection, slug)); @@ -397,7 +397,11 @@ export function persistCustomUnpublishedEntry( }; } -export function persistUnpublishedEntry(collection: Collection, existingUnpublishedEntry: boolean, context: HookContext) { +export function persistUnpublishedEntry( + collection: Collection, + existingUnpublishedEntry: boolean, + context: HookContext, +) { return async (dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); const entryDraft = state.entryDraft; @@ -423,8 +427,12 @@ export function persistUnpublishedEntry(collection: Collection, existingUnpublis return Promise.reject(); } - const persistFunc = persistCustomUnpublishedEntry(collection, existingUnpublishedEntry, entryDraft, context); - return persistFunc(dispatch, getState); + return persistCustomUnpublishedEntry( + collection, + existingUnpublishedEntry, + entryDraft, + context, + )(dispatch, getState); }; } @@ -499,16 +507,16 @@ export function deleteUnpublishedEntry(collection: string, slug: string) { }; } -export function publishUnpublishedEntry( +export function publishCustomUnpublishedEntry( collectionName: string, slug: string, + entry: EntryMap, context: HookContext, ) { return async (dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); const collections = state.collections; const backend = currentBackend(state.config); - const entry = selectUnpublishedEntry(state, collectionName, slug); const isDeleteWorkflow = entry.get('isDeleteWorkflow'); dispatch(unpublishedEntryPublishRequest(collectionName, slug)); try { @@ -569,6 +577,18 @@ export function publishUnpublishedEntry( }; } +export function publishUnpublishedEntry( + collectionName: string, + slug: string, + context: HookContext, +) { + return (dispatch: ThunkDispatch, getState: () => State) => { + const state = getState(); + const entry = selectUnpublishedEntry(state, collectionName, slug); + return publishCustomUnpublishedEntry(collectionName, slug, entry, context)(dispatch, getState); + }; +} + export function unpublishCustomPublishedEntry( collection: Collection, slug: string, @@ -624,11 +644,14 @@ export function unpublishCustomPublishedEntry( }; } -export function unpublishPublishedEntry(collection: Collection, slug: string, context: HookContext) { +export function unpublishPublishedEntry( + collection: Collection, + slug: string, + context: HookContext, +) { return (dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); const entry = selectEntry(state, collection.get('name'), slug); - const unpublishFunc = unpublishCustomPublishedEntry(collection, slug, entry, context); - return unpublishFunc(dispatch, getState); + return unpublishCustomPublishedEntry(collection, slug, entry, context)(dispatch, getState); }; } diff --git a/packages/decap-cms-core/src/actions/entries.ts b/packages/decap-cms-core/src/actions/entries.ts index 3b569fbcb19d..fe862c54a555 100644 --- a/packages/decap-cms-core/src/actions/entries.ts +++ b/packages/decap-cms-core/src/actions/entries.ts @@ -36,7 +36,6 @@ import type { ViewGroup, Entry, EntryDraft, - Entries, } from '../types/redux'; import type { EntryValue } from '../valueObjects/Entry'; import type { Backend, HookContext } from '../backend'; @@ -892,15 +891,11 @@ export function persistCustomEntry( collection: Collection, entryDraft: EntryDraft, context: HookContext, - entries?: Entries, ) { return async (dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); - const usedSlugs = selectPublishedSlugs( - entries ? { ...state, entries: fromJS(entries) } : state, - collection.get('name'), - ); + const usedSlugs = selectPublishedSlugs(state, collection.get('name')); const backend = currentBackend(state.config); const entry = entryDraft.get('entry'); @@ -988,19 +983,23 @@ export function persistEntry(collection: Collection, context: HookContext) { return Promise.reject(); } - const persistFunc = persistCustomEntry(collection, entryDraft, context); - return persistFunc(dispatch, getState); + return persistCustomEntry(collection, entryDraft, context)(dispatch, getState); }; } -export function deleteEntry(collection: Collection, slug: string, context: HookContext) { +export function deleteEntry( + collection: Collection, + slug: string, + context: HookContext, + entry?: EntryMap, +) { return (dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); const backend = currentBackend(state.config); dispatch(entryDeleting(collection, slug)); return backend - .deleteEntry(state, collection, slug, context) + .deleteEntry(state, collection, slug, context, entry) .then(async () => { dispatch(entryDeleted(collection, slug)); dispatch( @@ -1016,7 +1015,7 @@ export function deleteEntry(collection: Collection, slug: string, context: HookC ); if (backend.implementation.deleteCollectionFiles) { dispatch(loadUnpublishedEntry(collection, slug)); - } else { + } else if (!entry) { navigateToCollection(collection.get('name')); } }) diff --git a/packages/decap-cms-core/src/backend.ts b/packages/decap-cms-core/src/backend.ts index 3c1f3f831703..ce1cdeb9696d 100644 --- a/packages/decap-cms-core/src/backend.ts +++ b/packages/decap-cms-core/src/backend.ts @@ -1227,7 +1227,7 @@ export class Backend { await this.invokePostSaveEvent(entryDraft.get('entry'), context); if (!useWorkflow) { - await this.invokePostPublishEvent(entryDraft.get('entry')); + await this.invokePostPublishEvent(entryDraft.get('entry'), context); } return slug; @@ -1282,10 +1282,17 @@ export class Backend { return this.implementation.persistMedia(file, options); } - async deleteEntry(state: State, collection: Collection, slug: string, context?: HookContext) { + async deleteEntry( + state: State, + collection: Collection, + slug: string, + context?: HookContext, + customEntry?: EntryMap, + ) { const config = state.config; const path = selectEntryPath(collection, slug) as string; const extension = selectFolderEntryExtension(collection) as string; + const entry = customEntry || selectEntry(state.entries, collection.get('name'), slug); if (!selectAllowDeletion(collection)) { throw new Error('Not allowed to delete entries in this collection'); @@ -1305,7 +1312,6 @@ export class Backend { user.useOpenAuthoring, ); - const entry = selectEntry(state.entries, collection.get('name'), slug); await this.invokePreUnpublishEvent(entry, context); let paths = [path]; if (hasI18n(collection)) { diff --git a/packages/decap-cms-core/src/components/Editor/Editor.js b/packages/decap-cms-core/src/components/Editor/Editor.js index c4f16c5743eb..e7bb5bac14b2 100644 --- a/packages/decap-cms-core/src/components/Editor/Editor.js +++ b/packages/decap-cms-core/src/components/Editor/Editor.js @@ -16,7 +16,6 @@ import { discardDraft, changeDraftField, changeDraftFieldValidation, - persistCustomEntry, persistEntry, deleteEntry, // persistLocalBackup, @@ -24,12 +23,16 @@ import { // retrieveLocalBackup, // deleteLocalBackup, removeDraftEntryMediaFiles, + persistCustomEntry, } from '../../actions/entries'; import { updateUnpublishedEntryStatus, publishUnpublishedEntry, unpublishPublishedEntry, deleteUnpublishedEntry, + persistCustomUnpublishedEntry, + unpublishCustomPublishedEntry, + publishCustomUnpublishedEntry, } from '../../actions/editorialWorkflow'; import { removeAssets } from '../../actions/media'; import { loadDeployPreview } from '../../actions/deploys'; @@ -51,7 +54,6 @@ export class Editor extends React.Component { entry: ImmutablePropTypes.map, entryDraft: ImmutablePropTypes.map.isRequired, loadEntry: PropTypes.func.isRequired, - persistCustomEntry: PropTypes.func.isRequired, persistEntry: PropTypes.func.isRequired, deleteEntry: PropTypes.func.isRequired, showDelete: PropTypes.bool.isRequired, @@ -81,6 +83,10 @@ export class Editor extends React.Component { }), hasChanged: PropTypes.bool, t: PropTypes.func.isRequired, + persistCustomEntry: PropTypes.func.isRequired, + persistCustomUnpublishedEntry: PropTypes.func.isRequired, + unpublishCustomPublishedEntry: PropTypes.func.isRequired, + publishCustomUnpublishedEntry: PropTypes.func.isRequired, // retrieveLocalBackup: PropTypes.func.isRequired, // localBackup: ImmutablePropTypes.map, // loadLocalBackup: PropTypes.func, @@ -232,46 +238,51 @@ export class Editor extends React.Component { // deleteLocalBackup(collection, !newEntry && slug); // } - createHookContext = (context) => { + createHookContext = context => { const defaultContext = { actions: { navigateToCollection, searchEntries: this.props.searchEntries, handleChangeStatus: this.handleChangeStatus, - getCollection: (name) => { + getCollection: name => { return this.props.collections.get(name); }, persistEntry: async (collection, entry, opts = {}) => { const context = this.createHookContext(opts); - const { entries } = (await this.props.searchEntries(null, [collection.get('name')])).payload; - return this.props.persistCustomEntry(collection, context, entry, entries); + const entryDraft = entry || createEmptyDraft(collection); + return this.props.persistCustomEntry(collection, entryDraft, context); }, persistUnpublishedEntry: async (collection, existingUnpublishedEntry, entry, opts = {}) => { const context = this.createHookContext(opts); return this.props.persistCustomUnpublishedEntry(collection, existingUnpublishedEntry, entry, context); }, - publishEntry: async (collection, slug, opts = {}) => { + publishEntry: async (collection, slug, entry, opts = {}) => { const context = this.createHookContext(opts); - return this.props.publishUnpublishedEntry(collection.get('name'), slug, context); + return this.props.publishCustomUnpublishedEntry( + collection.get('name'), + slug, + entry, + context, + ); }, - unpublishEntry: async (collection, slug, opts = {}) => { + unpublishEntry: async (collection, slug, entry, opts = {}) => { const context = this.createHookContext(opts); - return this.props.unpublishPublishedEntry(collection, slug, context); + return this.props.unpublishCustomPublishedEntry(collection, slug, entry, context); }, unpublishPublishedEntry: async (collection, slug, entry, opts = {}) => { const context = this.createHookContext(opts); return this.props.unpublishCustomPublishedEntry(collection, slug, entry, context); }, - deleteEntry: async (collection, slug, opts = {}) => { + deleteEntry: async (collection, slug, entry, opts = {}) => { const context = this.createHookContext(opts); - return this.props.deleteEntry(collection, slug, context); + return this.props.deleteEntry(collection, slug, context, entry); }, - } - } - if (!context) return defaultContext + }, + }; + if (!context) return defaultContext; - return Object.assign(defaultContext, context) - } + return Object.assign(defaultContext, context); + }; handlePersistEntry = async (opts = {}) => { const { createNew = false, duplicate = false, publishStack = false } = opts; @@ -328,7 +339,11 @@ export class Editor extends React.Component { return; } - await publishUnpublishedEntry(collection.get('name'), slug, this.createHookContext({ publishStack })); + await publishUnpublishedEntry( + collection.get('name'), + slug, + this.createHookContext({ publishStack }), + ); // this.deleteBackup(); @@ -581,7 +596,6 @@ const mapDispatchToProps = { createDraftDuplicateFromEntry, createEmptyDraft, discardDraft, - persistCustomEntry, persistEntry, deleteEntry, updateUnpublishedEntryStatus, @@ -592,6 +606,10 @@ const mapDispatchToProps = { removeDraftEntryMediaFiles, logoutUser, searchEntries, + persistCustomEntry, + persistCustomUnpublishedEntry, + unpublishCustomPublishedEntry, + publishCustomUnpublishedEntry, }; export default connect(mapStateToProps, mapDispatchToProps)(withWorkflow(translate()(Editor))); diff --git a/packages/decap-cms-core/src/reducers/entries.ts b/packages/decap-cms-core/src/reducers/entries.ts index 068d486a0e75..9a10e032bb67 100644 --- a/packages/decap-cms-core/src/reducers/entries.ts +++ b/packages/decap-cms-core/src/reducers/entries.ts @@ -230,7 +230,7 @@ function entries( const payload = action.payload as EntryDeletePayload; return state.withMutations(map => { map.deleteIn(['entities', `${payload.collectionName}.${payload.entrySlug}`]); - map.updateIn(['pages', payload.collectionName, 'ids'], (ids: string[]) => + map.updateIn(['pages', payload.collectionName, 'ids'], (ids: string[] = []) => ids.filter(id => id !== payload.entrySlug), ); }); From c98ea019fa8e1c7c35e617f77273585b38fdfa60 Mon Sep 17 00:00:00 2001 From: Dfdez Date: Sat, 31 May 2025 01:42:41 +0200 Subject: [PATCH 3/8] feat: duplicate workflow wip --- .../src/actions/editorialWorkflow.ts | 149 +++++++----------- .../decap-cms-core/src/actions/entries.ts | 84 +++++----- .../src/components/Editor/Editor.js | 72 ++++----- .../src/components/Editor/EditorToolbar.js | 69 +++++--- .../src/components/Editor/withWorkflow.js | 8 +- 5 files changed, 176 insertions(+), 206 deletions(-) diff --git a/packages/decap-cms-core/src/actions/editorialWorkflow.ts b/packages/decap-cms-core/src/actions/editorialWorkflow.ts index 600886e86bc4..faeda842292e 100644 --- a/packages/decap-cms-core/src/actions/editorialWorkflow.ts +++ b/packages/decap-cms-core/src/actions/editorialWorkflow.ts @@ -325,14 +325,16 @@ export function loadUnpublishedEntries(collections: Collections) { }; } -export function persistCustomUnpublishedEntry( +export function persistUnpublishedEntry( collection: Collection, existingUnpublishedEntry: boolean, - entryDraft: EntryDraft, context: HookContext, + customEntryDraft?: EntryDraft, ) { return async (dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); + const entryDraft = customEntryDraft || state.entryDraft; + const fieldsErrors = entryDraft.get('fieldsErrors'); const unpublishedSlugs = selectUnpublishedSlugs(state, collection.get('name')); const publishedSlugs = selectPublishedSlugs(state, collection.get('name')); const usedSlugs = publishedSlugs.concat(unpublishedSlugs) as List; @@ -340,6 +342,25 @@ export function persistCustomUnpublishedEntry( !entriesLoaded && dispatch(loadUnpublishedEntries(state.collections)); + if (fieldsErrors && !fieldsErrors.isEmpty()) { + const hasPresenceErrors = fieldsErrors.some(errors => + errors.some(error => error.type && error.type === ValidationErrorTypes.PRESENCE), + ); + + if (hasPresenceErrors) { + dispatch( + addNotification({ + message: { + key: 'ui.toast.missingRequiredField', + }, + type: 'error', + dismissAfter: 8000, + }), + ); + } + return Promise.reject(); + } + const backend = currentBackend(state.config); const entry = entryDraft.get('entry'); const assetProxies = getMediaAssets({ @@ -361,7 +382,7 @@ export function persistCustomUnpublishedEntry( entryDraft: serializedEntryDraft, assetProxies, usedSlugs, - context, + context }); dispatch( addNotification({ @@ -374,12 +395,15 @@ export function persistCustomUnpublishedEntry( ); dispatch(unpublishedEntryPersisted(collection, serializedEntry)); - if (entry.get('slug') !== newSlug) { - await dispatch(loadUnpublishedEntry(collection, newSlug)); - navigateToEntry(collection.get('name'), newSlug); + if (!customEntryDraft) { + if (entry.get('slug') !== newSlug) { + await dispatch(loadUnpublishedEntry(collection, newSlug)); + navigateToEntry(collection.get('name'), newSlug); + } } + + return newSlug; } catch (error) { - if (error.name === EDITORIAL_WORKFLOW_DISMISS_ERROR) return; dispatch( addNotification({ message: { @@ -397,45 +421,6 @@ export function persistCustomUnpublishedEntry( }; } -export function persistUnpublishedEntry( - collection: Collection, - existingUnpublishedEntry: boolean, - context: HookContext, -) { - return async (dispatch: ThunkDispatch, getState: () => State) => { - const state = getState(); - const entryDraft = state.entryDraft; - const fieldsErrors = entryDraft.get('fieldsErrors'); - - // Early return if draft contains validation errors - if (!fieldsErrors.isEmpty()) { - const hasPresenceErrors = fieldsErrors.some(errors => - errors.some(error => error.type && error.type === ValidationErrorTypes.PRESENCE), - ); - - if (hasPresenceErrors) { - dispatch( - addNotification({ - message: { - key: 'ui.toast.missingRequiredField', - }, - type: 'error', - dismissAfter: 8000, - }), - ); - } - return Promise.reject(); - } - - return persistCustomUnpublishedEntry( - collection, - existingUnpublishedEntry, - entryDraft, - context, - )(dispatch, getState); - }; -} - export function updateUnpublishedEntryStatus( collection: string, slug: string, @@ -507,16 +492,17 @@ export function deleteUnpublishedEntry(collection: string, slug: string) { }; } -export function publishCustomUnpublishedEntry( +export function publishUnpublishedEntry( collectionName: string, slug: string, - entry: EntryMap, context: HookContext, + customEntry?: EntryMap, ) { return async (dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); const collections = state.collections; const backend = currentBackend(state.config); + const entry = customEntry || selectUnpublishedEntry(state, collectionName, slug); const isDeleteWorkflow = entry.get('isDeleteWorkflow'); dispatch(unpublishedEntryPublishRequest(collectionName, slug)); try { @@ -549,19 +535,21 @@ export function publishCustomUnpublishedEntry( }), ); dispatch(unpublishedEntryPublished(collectionName, slug)); - const collection = collections.get(collectionName); - if (collection.has('nested')) { - dispatch(loadEntries(collection)); - const newSlug = slugFromCustomPath(collection, entry.get('path')); - loadEntry(collection, newSlug); - if (slug !== newSlug && selectEditingDraft(state.entryDraft)) { - navigateToEntry(collection.get('name'), newSlug); + if (!customEntry) { + const collection = collections.get(collectionName); + if (!collection.has('nested')) { + dispatch(loadEntries(collection)); + const newSlug = slugFromCustomPath(collection, entry.get('path')); + loadEntry(collection, newSlug); + if (slug !== newSlug && selectEditingDraft(state.entryDraft)) { + navigateToEntry(collection.get('name'), newSlug); + } + } else if (isDeleteWorkflow) { + dispatch(unpublishedEntryDeleted(collectionName, slug)); + return navigateToCollection(collectionName); + } else { + return dispatch(loadEntry(collection, slug)); } - } else if (isDeleteWorkflow) { - dispatch(unpublishedEntryDeleted(collectionName, slug)); - return navigateToCollection(collectionName); - } else { - return dispatch(loadEntry(collection, slug)); } } catch (error) { if (error.name === EDITORIAL_WORKFLOW_DISMISS_ERROR) return; @@ -577,31 +565,15 @@ export function publishCustomUnpublishedEntry( }; } -export function publishUnpublishedEntry( - collectionName: string, - slug: string, - context: HookContext, -) { - return (dispatch: ThunkDispatch, getState: () => State) => { - const state = getState(); - const entry = selectUnpublishedEntry(state, collectionName, slug); - return publishCustomUnpublishedEntry(collectionName, slug, entry, context)(dispatch, getState); - }; -} - -export function unpublishCustomPublishedEntry( - collection: Collection, - slug: string, - entry: EntryMap, - context: HookContext, -) { +export function unpublishPublishedEntry(collection: Collection, slug: string, customEntry?: EntryMap) { return (dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); const backend = currentBackend(state.config); + const entry = customEntry || selectEntry(state, collection.get('name'), slug); const entryDraft = Map().set('entry', entry) as unknown as EntryDraft; dispatch(unpublishedEntryPersisting(collection, slug)); return backend - .deleteEntry(state, collection, slug, context) + .deleteEntry(state, collection, slug) .then(() => { if (!backend.implementation.deleteCollectionFiles) { backend.persistEntry({ @@ -611,14 +583,15 @@ export function unpublishCustomPublishedEntry( assetProxies: [], usedSlugs: List(), status: status.get('PENDING_PUBLISH'), - context, }); } }) .then(() => { dispatch(unpublishedEntryPersisted(collection, entry)); dispatch(entryDeleted(collection, slug)); - dispatch(loadUnpublishedEntry(collection, slug)); + if (!customEntry) { + dispatch(loadUnpublishedEntry(collection, slug)); + } dispatch( addNotification({ message: { @@ -642,16 +615,4 @@ export function unpublishCustomPublishedEntry( dispatch(unpublishedEntryPersistedFail(error, collection, entry.get('slug'))); }); }; -} - -export function unpublishPublishedEntry( - collection: Collection, - slug: string, - context: HookContext, -) { - return (dispatch: ThunkDispatch, getState: () => State) => { - const state = getState(); - const entry = selectEntry(state, collection.get('name'), slug); - return unpublishCustomPublishedEntry(collection, slug, entry, context)(dispatch, getState); - }; -} +} \ No newline at end of file diff --git a/packages/decap-cms-core/src/actions/entries.ts b/packages/decap-cms-core/src/actions/entries.ts index fe862c54a555..f4bf23d9b70b 100644 --- a/packages/decap-cms-core/src/actions/entries.ts +++ b/packages/decap-cms-core/src/actions/entries.ts @@ -887,13 +887,36 @@ export function getSerializedEntry(collection: Collection, entry: Entry) { return serializedEntry; } -export function persistCustomEntry( +export function persistEntry( collection: Collection, - entryDraft: EntryDraft, context: HookContext, + customEntryDraft?: EntryDraft, ) { return async (dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); + const entryDraft = customEntryDraft || state.entryDraft; + + const fieldsErrors = entryDraft.get('fieldsErrors'); + + if (fieldsErrors && !fieldsErrors.isEmpty()) { + const hasPresenceErrors = fieldsErrors.some(errors => + errors.some(error => error.type && error.type === ValidationErrorTypes.PRESENCE), + ); + + if (hasPresenceErrors) { + dispatch( + addNotification({ + message: { + key: 'ui.toast.missingRequiredField', + }, + type: 'error', + dismissAfter: 8000, + }), + ); + } + + return Promise.reject(); + } const usedSlugs = selectPublishedSlugs(state, collection.get('name')); @@ -931,13 +954,17 @@ export function persistCustomEntry( await dispatch(loadMedia()); } dispatch(entryPersisted(collection, serializedEntry, newSlug)); - if (collection.has('nested')) { - await dispatch(loadEntries(collection)); - } - if (entry.get('slug') !== newSlug) { - await dispatch(loadEntry(collection, newSlug)); - navigateToEntry(collection.get('name'), newSlug); + if (!customEntryDraft) { + if (collection.has('nested')) { + await dispatch(loadEntries(collection)); + } + if (entry.get('slug') !== newSlug) { + await dispatch(loadEntry(collection, newSlug)); + navigateToEntry(collection.get('name'), newSlug); + } } + + return newSlug; }) .catch((error: Error) => { console.error(error); @@ -956,37 +983,6 @@ export function persistCustomEntry( }; } -export function persistEntry(collection: Collection, context: HookContext) { - return async (dispatch: ThunkDispatch, getState: () => State) => { - const state = getState(); - const entryDraft = state.entryDraft; - - const fieldsErrors = entryDraft.get('fieldsErrors'); - - // Early return if draft contains validation errors - if (!fieldsErrors.isEmpty()) { - const hasPresenceErrors = fieldsErrors.some(errors => - errors.some(error => error.type && error.type === ValidationErrorTypes.PRESENCE), - ); - - if (hasPresenceErrors) { - dispatch( - addNotification({ - message: { - key: 'ui.toast.missingRequiredField', - }, - type: 'error', - dismissAfter: 8000, - }), - ); - } - - return Promise.reject(); - } - return persistCustomEntry(collection, entryDraft, context)(dispatch, getState); - }; -} - export function deleteEntry( collection: Collection, slug: string, @@ -1013,10 +1009,12 @@ export function deleteEntry( dismissAfter: 4000, }), ); - if (backend.implementation.deleteCollectionFiles) { - dispatch(loadUnpublishedEntry(collection, slug)); - } else if (!entry) { - navigateToCollection(collection.get('name')); + if (!entry) { + if (backend.implementation.deleteCollectionFiles) { + dispatch(loadUnpublishedEntry(collection, slug)); + } else { + navigateToCollection(collection.get('name')); + } } }) .catch((error: Error) => { diff --git a/packages/decap-cms-core/src/components/Editor/Editor.js b/packages/decap-cms-core/src/components/Editor/Editor.js index e7bb5bac14b2..fbb6a0b8d2db 100644 --- a/packages/decap-cms-core/src/components/Editor/Editor.js +++ b/packages/decap-cms-core/src/components/Editor/Editor.js @@ -23,16 +23,13 @@ import { // retrieveLocalBackup, // deleteLocalBackup, removeDraftEntryMediaFiles, - persistCustomEntry, } from '../../actions/entries'; import { updateUnpublishedEntryStatus, publishUnpublishedEntry, unpublishPublishedEntry, deleteUnpublishedEntry, - persistCustomUnpublishedEntry, - unpublishCustomPublishedEntry, - publishCustomUnpublishedEntry, + persistUnpublishedEntry, } from '../../actions/editorialWorkflow'; import { removeAssets } from '../../actions/media'; import { loadDeployPreview } from '../../actions/deploys'; @@ -55,6 +52,7 @@ export class Editor extends React.Component { entryDraft: ImmutablePropTypes.map.isRequired, loadEntry: PropTypes.func.isRequired, persistEntry: PropTypes.func.isRequired, + persistUnpublishedEntry: PropTypes.func.isRequired, deleteEntry: PropTypes.func.isRequired, showDelete: PropTypes.bool.isRequired, fields: ImmutablePropTypes.list.isRequired, @@ -83,10 +81,6 @@ export class Editor extends React.Component { }), hasChanged: PropTypes.bool, t: PropTypes.func.isRequired, - persistCustomEntry: PropTypes.func.isRequired, - persistCustomUnpublishedEntry: PropTypes.func.isRequired, - unpublishCustomPublishedEntry: PropTypes.func.isRequired, - publishCustomUnpublishedEntry: PropTypes.func.isRequired, // retrieveLocalBackup: PropTypes.func.isRequired, // localBackup: ImmutablePropTypes.map, // loadLocalBackup: PropTypes.func, @@ -220,10 +214,6 @@ export class Editor extends React.Component { handleChangeStatus = newStatusName => { const { entryDraft, updateUnpublishedEntryStatus, collection, slug, currentStatus, t } = this.props; - if (currentStatus === status.get('PROCESSING')) { - window.alert(t('editor.editor.onProcessingUpdate')); - return; - } if (entryDraft.get('hasChanged')) { window.alert(t('editor.editor.onUpdatingWithUnsavedChanges')); return; @@ -244,34 +234,36 @@ export class Editor extends React.Component { navigateToCollection, searchEntries: this.props.searchEntries, handleChangeStatus: this.handleChangeStatus, + handleDeleteUnpublishedChanges: this.handleDeleteUnpublishedChanges, getCollection: name => { return this.props.collections.get(name); }, persistEntry: async (collection, entry, opts = {}) => { const context = this.createHookContext(opts); const entryDraft = entry || createEmptyDraft(collection); - return this.props.persistCustomEntry(collection, entryDraft, context); + return this.props.persistEntry(collection, context, entryDraft); }, persistUnpublishedEntry: async (collection, existingUnpublishedEntry, entry, opts = {}) => { const context = this.createHookContext(opts); - return this.props.persistCustomUnpublishedEntry(collection, existingUnpublishedEntry, entry, context); + const entryDraft = entry || createEmptyDraft(collection); + return this.props.persistUnpublishedEntry(collection, existingUnpublishedEntry, context, entryDraft); }, publishEntry: async (collection, slug, entry, opts = {}) => { const context = this.createHookContext(opts); - return this.props.publishCustomUnpublishedEntry( + const entryDraft = entry || createEmptyDraft(collection); + return this.props.publishUnpublishedEntry( collection.get('name'), slug, - entry, context, + entryDraft, ); }, - unpublishEntry: async (collection, slug, entry, opts = {}) => { - const context = this.createHookContext(opts); - return this.props.unpublishCustomPublishedEntry(collection, slug, entry, context); + deleteUnpublishedEntry: async (collection, slug) => { + return this.props.deleteUnpublishedEntry(collection, slug); }, unpublishPublishedEntry: async (collection, slug, entry, opts = {}) => { const context = this.createHookContext(opts); - return this.props.unpublishCustomPublishedEntry(collection, slug, entry, context); + return this.props.unpublishPublishedEntry(collection, slug, context, entry); }, deleteEntry: async (collection, slug, entry, opts = {}) => { const context = this.createHookContext(opts); @@ -371,11 +363,7 @@ export class Editor extends React.Component { }; handleDeleteEntry = () => { - const { entryDraft, newEntry, collection, deleteEntry, slug, currentStatus, t } = this.props; - if (currentStatus === status.get('PROCESSING')) { - window.alert(t('editor.editor.onProcessingUpdate')); - return; - } + const { entryDraft, newEntry, collection, deleteEntry, slug, t } = this.props; if (entryDraft.get('hasChanged')) { if (!window.confirm(t('editor.editor.onDeleteWithUnsavedChanges'))) { return; @@ -394,12 +382,12 @@ export class Editor extends React.Component { }, 0); }; - handleDeleteUnpublishedChanges = async () => { + handleDeleteUnpublishedChanges = async (opts = {}) => { + const { force = false } = opts; const { entryDraft, collection, slug, - currentStatus, removeAssets, removeDraftEntryMediaFiles, deleteUnpublishedEntry, @@ -408,22 +396,21 @@ export class Editor extends React.Component { isDeleteWorkflow, t, } = this.props; - if (currentStatus === status.get('PROCESSING')) { - window.alert(t('editor.editor.onProcessingUpdate')); - return; - } - if ( - entryDraft.get('hasChanged') && - !window.confirm( - t('editor.editor.onDeleteUnpublishedChangesWithUnsavedChanges') || isDeleteWorkflow, - ) - ) { - return; - } else if (!window.confirm(t('editor.editor.onDeleteUnpublishedChanges'))) { - return; + if (!force) { + if ( + entryDraft.get('hasChanged') && + !window.confirm( + t('editor.editor.onDeleteUnpublishedChangesWithUnsavedChanges') || isDeleteWorkflow, + ) + ) { + return; + } else if (!window.confirm(t('editor.editor.onDeleteUnpublishedChanges'))) { + return; + } } + await deleteUnpublishedEntry(collection.get('name'), slug); // this.deleteBackup(); @@ -597,6 +584,7 @@ const mapDispatchToProps = { createEmptyDraft, discardDraft, persistEntry, + persistUnpublishedEntry, deleteEntry, updateUnpublishedEntryStatus, publishUnpublishedEntry, @@ -606,10 +594,6 @@ const mapDispatchToProps = { removeDraftEntryMediaFiles, logoutUser, searchEntries, - persistCustomEntry, - persistCustomUnpublishedEntry, - unpublishCustomPublishedEntry, - publishCustomUnpublishedEntry, }; export default connect(mapStateToProps, mapDispatchToProps)(withWorkflow(translate()(Editor))); diff --git a/packages/decap-cms-core/src/components/Editor/EditorToolbar.js b/packages/decap-cms-core/src/components/Editor/EditorToolbar.js index 911f857d44db..1a1245535753 100644 --- a/packages/decap-cms-core/src/components/Editor/EditorToolbar.js +++ b/packages/decap-cms-core/src/components/Editor/EditorToolbar.js @@ -390,8 +390,36 @@ export class EditorToolbar extends React.Component { ); }; + handleStatusChange = (newStatusName) => { + const { + currentStatus, + onChangeStatus, + t + } = this.props; + if (currentStatus === status.get('PROCESSING')) { + window.alert(t('editor.editor.onProcessingUpdate')); + return; + } + onChangeStatus(newStatusName); + } + + handleDelete = () => { + const { + currentStatus, + hasUnpublishedChanges, + onDeleteUnpublishedChanges, + onDelete, + t + } = this.props; + if (currentStatus === status.get('PROCESSING')) { + window.alert(t('editor.editor.onProcessingUpdate')); + return; + } + return hasUnpublishedChanges ? onDeleteUnpublishedChanges() : onDelete(); + } + renderWorkflowStatusControls = () => { - const { isUpdatingStatus, onChangeStatus, currentStatus, t, useOpenAuthoring } = this.props; + const { isUpdatingStatus, currentStatus, t, useOpenAuthoring } = this.props; const statusToTranslation = { [status.get('DRAFT')]: t('editor.editorToolbar.draft'), @@ -414,12 +442,12 @@ export class EditorToolbar extends React.Component { > onChangeStatus('DRAFT')} + onClick={() => this.handleStatusChange('DRAFT')} icon={currentStatus === status.get('DRAFT') ? 'check' : null} /> onChangeStatus('PENDING_REVIEW')} + onClick={() => this.handleStatusChange('PENDING_REVIEW')} icon={currentStatus === status.get('PENDING_REVIEW') ? 'check' : null} /> {useOpenAuthoring ? ( @@ -427,7 +455,7 @@ export class EditorToolbar extends React.Component { ) : ( onChangeStatus('PENDING_PUBLISH')} + onClick={() => this.handleStatusChange('PENDING_PUBLISH')} icon={currentStatus === status.get('PENDING_PUBLISH') ? 'check' : null} /> )} @@ -605,8 +633,6 @@ export class EditorToolbar extends React.Component { renderWorkflowControls = () => { const { onPersist, - onDelete, - onDeleteUnpublishedChanges, // showDelete, hasChanged, hasUnpublishedChanges, @@ -634,7 +660,6 @@ export class EditorToolbar extends React.Component { // (isNewEntry || !isModification) && // t('editor.editorToolbar.deleteUnpublishedEntry')) || // (!hasUnpublishedChanges && !isModification && t('editor.editorToolbar.deletePublishedEntry')); - return [ , currentStatus ? [ - - {this.renderWorkflowStatusControls()} - {currentStatus === status.get('PENDING_PUBLISH') && - this.renderNewEntryWorkflowPublishControls({ canCreate, canPublish })} - , - ] + + {this.renderWorkflowStatusControls()} + {currentStatus === status.get('PENDING_PUBLISH') && + this.renderNewEntryWorkflowPublishControls({ canCreate, canPublish })} + , + ] : !isNewEntry && ( - - {this.renderExistingEntryWorkflowPublishControls({ - canCreate, - canPublish, - canDelete, - })} - - ), + + {this.renderExistingEntryWorkflowPublishControls({ + canCreate, + canPublish, + canDelete, + })} + + ), !hasUnpublishedChanges && !isModification ? null : ( {isDeleting ? t('editor.editorToolbar.discarding') : deleteLabel} diff --git a/packages/decap-cms-core/src/components/Editor/withWorkflow.js b/packages/decap-cms-core/src/components/Editor/withWorkflow.js index 6869f83c6c86..1766beefc08a 100644 --- a/packages/decap-cms-core/src/components/Editor/withWorkflow.js +++ b/packages/decap-cms-core/src/components/Editor/withWorkflow.js @@ -26,7 +26,7 @@ function mapStateToProps(state, ownProps) { } function mergeProps(stateProps, dispatchProps, ownProps) { - const { isEditorialWorkflow, unpublishedEntry } = stateProps; + const { isEditorialWorkflow } = stateProps; const { dispatch } = dispatchProps; const returnObj = {}; @@ -35,8 +35,10 @@ function mergeProps(stateProps, dispatchProps, ownProps) { returnObj.loadEntry = (collection, slug) => dispatch(loadUnpublishedEntry(collection, slug)); // Overwrite persistEntry to persistUnpublishedEntry - returnObj.persistEntry = (collection, context) => - dispatch(persistUnpublishedEntry(collection, unpublishedEntry, context)); + returnObj.persistEntry = (collection, context, entryDraft) => { + const { unpublished = stateProps.unpublishedEntry } = context; + return dispatch(persistUnpublishedEntry(collection, unpublished, context, entryDraft)); + } } return { From 8c1bc759cb5b5fd780899e43fc4c31820f99294b Mon Sep 17 00:00:00 2001 From: Dfdez Date: Mon, 2 Jun 2025 19:07:47 +0200 Subject: [PATCH 4/8] feat: added local empty draft --- .../src/actions/__tests__/entries.spec.js | 8 ++++---- packages/decap-cms-core/src/actions/entries.ts | 8 ++++++++ .../src/components/Editor/Editor.js | 16 ++++++++++------ 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/decap-cms-core/src/actions/__tests__/entries.spec.js b/packages/decap-cms-core/src/actions/__tests__/entries.spec.js index 40697cb5ff10..1827cc588590 100644 --- a/packages/decap-cms-core/src/actions/__tests__/entries.spec.js +++ b/packages/decap-cms-core/src/actions/__tests__/entries.spec.js @@ -3,7 +3,7 @@ import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { - createEmptyDraft, + createLocalEmptyDraft, createEmptyDraftData, retrieveLocalBackup, persistLocalBackup, @@ -40,7 +40,7 @@ describe('entries', () => { fields: [{ name: 'title' }], }); - return store.dispatch(createEmptyDraft(collection, '')).then(() => { + return store.dispatch(createLocalEmptyDraft(collection, '')).then(() => { const actions = store.getActions(); expect(actions).toHaveLength(1); @@ -73,7 +73,7 @@ describe('entries', () => { fields: [{ name: 'title' }, { name: 'boolean' }], }); - return store.dispatch(createEmptyDraft(collection, '?title=title&boolean=True')).then(() => { + return store.dispatch(createLocalEmptyDraft(collection, '?title=title&boolean=True')).then(() => { const actions = store.getActions(); expect(actions).toHaveLength(1); @@ -107,7 +107,7 @@ describe('entries', () => { }); return store - .dispatch(createEmptyDraft(collection, "?title=")) + .dispatch(createLocalEmptyDraft(collection, "?title=")) .then(() => { const actions = store.getActions(); expect(actions).toHaveLength(1); diff --git a/packages/decap-cms-core/src/actions/entries.ts b/packages/decap-cms-core/src/actions/entries.ts index f4bf23d9b70b..ba8f04229c18 100644 --- a/packages/decap-cms-core/src/actions/entries.ts +++ b/packages/decap-cms-core/src/actions/entries.ts @@ -768,6 +768,14 @@ export function createEmptyDraft(collection: Collection, search: string) { meta: meta as any, }); newEntry = await backend.processEntry(state, collection, newEntry); + + return newEntry; + }; +} + +export function createLocalEmptyDraft(collection: Collection, search: string) { + return async (dispatch: ThunkDispatch, getState: () => State) => { + const newEntry = await createEmptyDraft(collection, search)(dispatch, getState); dispatch(emptyDraftCreated(newEntry)); }; } diff --git a/packages/decap-cms-core/src/components/Editor/Editor.js b/packages/decap-cms-core/src/components/Editor/Editor.js index fbb6a0b8d2db..b1c6524e3f75 100644 --- a/packages/decap-cms-core/src/components/Editor/Editor.js +++ b/packages/decap-cms-core/src/components/Editor/Editor.js @@ -12,6 +12,7 @@ import { loadEntry, loadEntries, createDraftDuplicateFromEntry, + createLocalEmptyDraft, createEmptyDraft, discardDraft, changeDraftField, @@ -46,6 +47,7 @@ export class Editor extends React.Component { changeDraftFieldValidation: PropTypes.func.isRequired, collection: ImmutablePropTypes.map.isRequired, createDraftDuplicateFromEntry: PropTypes.func.isRequired, + createLocalEmptyDraft: PropTypes.func.isRequired, createEmptyDraft: PropTypes.func.isRequired, discardDraft: PropTypes.func.isRequired, entry: ImmutablePropTypes.map, @@ -94,7 +96,7 @@ export class Editor extends React.Component { collection, slug, loadEntry, - createEmptyDraft, + createLocalEmptyDraft, loadEntries, // retrieveLocalBackup, collectionEntriesLoaded, @@ -104,7 +106,7 @@ export class Editor extends React.Component { // retrieveLocalBackup(collection, slug); if (newEntry) { - createEmptyDraft(collection, this.props.location.search); + createLocalEmptyDraft(collection, this.props.location.search); } else { loadEntry(collection, slug); } @@ -189,7 +191,7 @@ export class Editor extends React.Component { const { newEntry, collection } = this.props; if (newEntry) { - prevProps.createEmptyDraft(collection, this.props.location.search); + prevProps.createLocalEmptyDraft(collection, this.props.location.search); } } @@ -235,22 +237,23 @@ export class Editor extends React.Component { searchEntries: this.props.searchEntries, handleChangeStatus: this.handleChangeStatus, handleDeleteUnpublishedChanges: this.handleDeleteUnpublishedChanges, + createEmptyDraft: this.props.createEmptyDraft, getCollection: name => { return this.props.collections.get(name); }, persistEntry: async (collection, entry, opts = {}) => { const context = this.createHookContext(opts); - const entryDraft = entry || createEmptyDraft(collection); + const entryDraft = entry || this.props.createEmptyDraft(collection); return this.props.persistEntry(collection, context, entryDraft); }, persistUnpublishedEntry: async (collection, existingUnpublishedEntry, entry, opts = {}) => { const context = this.createHookContext(opts); - const entryDraft = entry || createEmptyDraft(collection); + const entryDraft = entry || this.props.createEmptyDraft(collection); return this.props.persistUnpublishedEntry(collection, existingUnpublishedEntry, context, entryDraft); }, publishEntry: async (collection, slug, entry, opts = {}) => { const context = this.createHookContext(opts); - const entryDraft = entry || createEmptyDraft(collection); + const entryDraft = entry || this.props.createEmptyDraft(collection); return this.props.publishUnpublishedEntry( collection.get('name'), slug, @@ -581,6 +584,7 @@ const mapDispatchToProps = { // persistLocalBackup, // deleteLocalBackup, createDraftDuplicateFromEntry, + createLocalEmptyDraft, createEmptyDraft, discardDraft, persistEntry, From 492afb02e639a8b51fc5209e173dc7237c50fc4d Mon Sep 17 00:00:00 2001 From: Dfdez Date: Tue, 3 Jun 2025 13:21:34 +0200 Subject: [PATCH 5/8] feat: added isCustomEntry to edit state with data --- .../src/actions/editorialWorkflow.ts | 21 +++++++++++------- .../decap-cms-core/src/actions/entries.ts | 22 ++++++++++++------- .../decap-cms-core/src/reducers/entryDraft.js | 1 + packages/decap-cms-core/src/types/redux.ts | 1 + 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/packages/decap-cms-core/src/actions/editorialWorkflow.ts b/packages/decap-cms-core/src/actions/editorialWorkflow.ts index faeda842292e..02f00e932705 100644 --- a/packages/decap-cms-core/src/actions/editorialWorkflow.ts +++ b/packages/decap-cms-core/src/actions/editorialWorkflow.ts @@ -334,6 +334,8 @@ export function persistUnpublishedEntry( return async (dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); const entryDraft = customEntryDraft || state.entryDraft; + const isCustomEntry = customEntryDraft && entryDraft.getIn(['entry', 'isCustomEntry'], true); + const status = customEntryDraft && customEntryDraft.getIn(['entry', 'status']); const fieldsErrors = entryDraft.get('fieldsErrors'); const unpublishedSlugs = selectUnpublishedSlugs(state, collection.get('name')); const publishedSlugs = selectPublishedSlugs(state, collection.get('name')); @@ -382,7 +384,8 @@ export function persistUnpublishedEntry( entryDraft: serializedEntryDraft, assetProxies, usedSlugs, - context + context, + status }); dispatch( addNotification({ @@ -393,9 +396,9 @@ export function persistUnpublishedEntry( dismissAfter: 4000, }), ); - dispatch(unpublishedEntryPersisted(collection, serializedEntry)); - if (!customEntryDraft) { + if (!isCustomEntry) { + dispatch(unpublishedEntryPersisted(collection, serializedEntry)); if (entry.get('slug') !== newSlug) { await dispatch(loadUnpublishedEntry(collection, newSlug)); navigateToEntry(collection.get('name'), newSlug); @@ -503,6 +506,7 @@ export function publishUnpublishedEntry( const collections = state.collections; const backend = currentBackend(state.config); const entry = customEntry || selectUnpublishedEntry(state, collectionName, slug); + const isCustomEntry = customEntry && customEntry.get('isCustomEntry', true); const isDeleteWorkflow = entry.get('isDeleteWorkflow'); dispatch(unpublishedEntryPublishRequest(collectionName, slug)); try { @@ -535,7 +539,7 @@ export function publishUnpublishedEntry( }), ); dispatch(unpublishedEntryPublished(collectionName, slug)); - if (!customEntry) { + if (!isCustomEntry) { const collection = collections.get(collectionName); if (!collection.has('nested')) { dispatch(loadEntries(collection)); @@ -570,6 +574,7 @@ export function unpublishPublishedEntry(collection: Collection, slug: string, cu const state = getState(); const backend = currentBackend(state.config); const entry = customEntry || selectEntry(state, collection.get('name'), slug); + const isCustomEntry = customEntry && customEntry.get('isCustomEntry', true); const entryDraft = Map().set('entry', entry) as unknown as EntryDraft; dispatch(unpublishedEntryPersisting(collection, slug)); return backend @@ -582,14 +587,14 @@ export function unpublishPublishedEntry(collection: Collection, slug: string, cu entryDraft, assetProxies: [], usedSlugs: List(), - status: status.get('PENDING_PUBLISH'), + status: customEntry ? customEntry.get('status'): status.get('PENDING_PUBLISH'), }); } }) .then(() => { - dispatch(unpublishedEntryPersisted(collection, entry)); dispatch(entryDeleted(collection, slug)); - if (!customEntry) { + if (!isCustomEntry) { + dispatch(unpublishedEntryPersisted(collection, entry)); dispatch(loadUnpublishedEntry(collection, slug)); } dispatch( @@ -615,4 +620,4 @@ export function unpublishPublishedEntry(collection: Collection, slug: string, cu dispatch(unpublishedEntryPersistedFail(error, collection, entry.get('slug'))); }); }; -} \ No newline at end of file +} diff --git a/packages/decap-cms-core/src/actions/entries.ts b/packages/decap-cms-core/src/actions/entries.ts index ba8f04229c18..889bf9b981c2 100644 --- a/packages/decap-cms-core/src/actions/entries.ts +++ b/packages/decap-cms-core/src/actions/entries.ts @@ -930,13 +930,17 @@ export function persistEntry( const backend = currentBackend(state.config); const entry = entryDraft.get('entry'); + const isCustomEntry = customEntryDraft && entry.get('isCustomEntry', true); + const status = customEntryDraft && entry.get('status'); const assetProxies = getMediaAssets({ entry, }); const serializedEntry = getSerializedEntry(collection, entry); const serializedEntryDraft = entryDraft.set('entry', serializedEntry); - dispatch(entryPersisting(collection, serializedEntry)); + if (!isCustomEntry) { + dispatch(entryPersisting(collection, serializedEntry)); + } return backend .persistEntry({ config: state.config, @@ -945,6 +949,7 @@ export function persistEntry( assetProxies, usedSlugs, context, + status, }) .then(async (newSlug: string) => { dispatch( @@ -957,12 +962,12 @@ export function persistEntry( }), ); - // re-load media library if entry had media files - if (assetProxies.length > 0) { - await dispatch(loadMedia()); - } - dispatch(entryPersisted(collection, serializedEntry, newSlug)); - if (!customEntryDraft) { + if (!isCustomEntry) { + // re-load media library if entry had media files + if (assetProxies.length > 0) { + await dispatch(loadMedia()); + } + dispatch(entryPersisted(collection, serializedEntry, newSlug)); if (collection.has('nested')) { await dispatch(loadEntries(collection)); } @@ -1000,6 +1005,7 @@ export function deleteEntry( return (dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); const backend = currentBackend(state.config); + const isCustomEntry = entry && entry.get('isCustomEntry', true); dispatch(entryDeleting(collection, slug)); return backend @@ -1017,7 +1023,7 @@ export function deleteEntry( dismissAfter: 4000, }), ); - if (!entry) { + if (!isCustomEntry) { if (backend.implementation.deleteCollectionFiles) { dispatch(loadUnpublishedEntry(collection, slug)); } else { diff --git a/packages/decap-cms-core/src/reducers/entryDraft.js b/packages/decap-cms-core/src/reducers/entryDraft.js index 4c16435eb54c..b6ed4692f8a2 100644 --- a/packages/decap-cms-core/src/reducers/entryDraft.js +++ b/packages/decap-cms-core/src/reducers/entryDraft.js @@ -168,6 +168,7 @@ function entryDraftReducer(state = Map(), action) { case ENTRY_PERSIST_SUCCESS: case UNPUBLISHED_ENTRY_PERSIST_SUCCESS: return state.withMutations(state => { + state.setIn(['entry', 'data'], action.payload.entry.get('data')); state.deleteIn(['entry', 'isPersisting']); state.set('hasChanged', false); if (!state.getIn(['entry', 'slug'])) { diff --git a/packages/decap-cms-core/src/types/redux.ts b/packages/decap-cms-core/src/types/redux.ts index 3b00b312a5f8..c5309de7ec45 100644 --- a/packages/decap-cms-core/src/types/redux.ts +++ b/packages/decap-cms-core/src/types/redux.ts @@ -556,6 +556,7 @@ export type EntryObject = { mediaFiles: List; newRecord: boolean; isDeleteWorkflow: boolean; + isCustomEntry?: boolean; author?: string; updatedOn?: string; status: string; From e6e2d01045222ba3a9490a1bdd22a6732365d942 Mon Sep 17 00:00:00 2001 From: Dfdez Date: Tue, 3 Jun 2025 16:39:51 +0200 Subject: [PATCH 6/8] feat: added function to retrieve current unpublished entries --- .../src/actions/editorialWorkflow.ts | 29 ++++++++++++++++--- .../src/components/Editor/Editor.js | 4 +++ .../src/components/Editor/EditorToolbar.js | 19 +++++++++--- packages/decap-cms-locales/src/en/index.js | 7 ++--- 4 files changed, 47 insertions(+), 12 deletions(-) diff --git a/packages/decap-cms-core/src/actions/editorialWorkflow.ts b/packages/decap-cms-core/src/actions/editorialWorkflow.ts index 02f00e932705..42eb7bd1b4ff 100644 --- a/packages/decap-cms-core/src/actions/editorialWorkflow.ts +++ b/packages/decap-cms-core/src/actions/editorialWorkflow.ts @@ -305,7 +305,7 @@ export function loadUnpublishedEntries(collections: Collections) { } dispatch(unpublishedEntriesLoading()); - backend + return backend .unpublishedEntries(collections) .then(response => dispatch(unpublishedEntriesLoaded(response.entries, response.pagination))) .catch((error: Error) => { @@ -385,7 +385,7 @@ export function persistUnpublishedEntry( assetProxies, usedSlugs, context, - status + status, }); dispatch( addNotification({ @@ -569,7 +569,11 @@ export function publishUnpublishedEntry( }; } -export function unpublishPublishedEntry(collection: Collection, slug: string, customEntry?: EntryMap) { +export function unpublishPublishedEntry( + collection: Collection, + slug: string, + customEntry?: EntryMap, +) { return (dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); const backend = currentBackend(state.config); @@ -587,7 +591,7 @@ export function unpublishPublishedEntry(collection: Collection, slug: string, cu entryDraft, assetProxies: [], usedSlugs: List(), - status: customEntry ? customEntry.get('status'): status.get('PENDING_PUBLISH'), + status: customEntry ? customEntry.get('status') : status.get('PENDING_PUBLISH'), }); } }) @@ -621,3 +625,20 @@ export function unpublishPublishedEntry(collection: Collection, slug: string, cu }); }; } + +export function getUnpublishedEntries(collectionName?: string) { + return async (dispatch: ThunkDispatch, getState: () => State) => { + const state = getState(); + const entriesLoaded = get(state.editorialWorkflow.toJS(), 'pages.ids', false); + + if (!entriesLoaded) { + await dispatch(loadUnpublishedEntries(state.collections)); + } + + const unpublishedEntries = state.editorialWorkflow.get('entities'); + const entries = collectionName + ? unpublishedEntries.filter(entry => entry.get('collection') === collectionName) + : unpublishedEntries; + return List(entries.valueSeq()); + }; +} diff --git a/packages/decap-cms-core/src/components/Editor/Editor.js b/packages/decap-cms-core/src/components/Editor/Editor.js index b1c6524e3f75..435df1008281 100644 --- a/packages/decap-cms-core/src/components/Editor/Editor.js +++ b/packages/decap-cms-core/src/components/Editor/Editor.js @@ -26,6 +26,7 @@ import { removeDraftEntryMediaFiles, } from '../../actions/entries'; import { + getUnpublishedEntries, updateUnpublishedEntryStatus, publishUnpublishedEntry, unpublishPublishedEntry, @@ -52,6 +53,7 @@ export class Editor extends React.Component { discardDraft: PropTypes.func.isRequired, entry: ImmutablePropTypes.map, entryDraft: ImmutablePropTypes.map.isRequired, + getUnpublishedEntries: PropTypes.func.isRequired, loadEntry: PropTypes.func.isRequired, persistEntry: PropTypes.func.isRequired, persistUnpublishedEntry: PropTypes.func.isRequired, @@ -238,6 +240,7 @@ export class Editor extends React.Component { handleChangeStatus: this.handleChangeStatus, handleDeleteUnpublishedChanges: this.handleDeleteUnpublishedChanges, createEmptyDraft: this.props.createEmptyDraft, + getUnpublishedEntries: this.props.getUnpublishedEntries, getCollection: name => { return this.props.collections.get(name); }, @@ -576,6 +579,7 @@ function mapStateToProps(state, ownProps) { const mapDispatchToProps = { changeDraftField, changeDraftFieldValidation, + getUnpublishedEntries, loadEntry, loadEntries, loadDeployPreview, diff --git a/packages/decap-cms-core/src/components/Editor/EditorToolbar.js b/packages/decap-cms-core/src/components/Editor/EditorToolbar.js index 1a1245535753..13456f6045a0 100644 --- a/packages/decap-cms-core/src/components/Editor/EditorToolbar.js +++ b/packages/decap-cms-core/src/components/Editor/EditorToolbar.js @@ -396,10 +396,17 @@ export class EditorToolbar extends React.Component { onChangeStatus, t } = this.props; + if (currentStatus === status.get('PROCESSING')) { - window.alert(t('editor.editor.onProcessingUpdate')); - return; + const newStatusLabel = t(`editor.editorToolbar.${newStatusName.toLowerCase()}`); + + if (!window.confirm(t('editor.editor.onProcessingStatusChange', { + newStatus: newStatusLabel + }))) { + return; + } } + onChangeStatus(newStatusName); } @@ -412,8 +419,12 @@ export class EditorToolbar extends React.Component { t } = this.props; if (currentStatus === status.get('PROCESSING')) { - window.alert(t('editor.editor.onProcessingUpdate')); - return; + const translationKey = hasUnpublishedChanges + ? 'editor.editor.onProcessingDeleteUnpublishedChanges' + : 'editor.editor.onProcessingDeleteEntry'; + if (!window.confirm(t(translationKey))) { + return; + } } return hasUnpublishedChanges ? onDeleteUnpublishedChanges() : onDelete(); } diff --git a/packages/decap-cms-locales/src/en/index.js b/packages/decap-cms-locales/src/en/index.js index 025d1114a069..261f09fdf1b3 100644 --- a/packages/decap-cms-locales/src/en/index.js +++ b/packages/decap-cms-locales/src/en/index.js @@ -115,8 +115,9 @@ const en = { 'All changes to this entry will be deleted.\n\n Do you still want to delete?', loadingEntry: 'Loading entry...', confirmLoadBackup: 'A local backup was recovered for this entry, would you like to use it?', - onProcessingUpdate: - "Entry can't change the status while processing!\n\n Please wait until the process end.", + onProcessingStatusChange: "Are you sure you want to change the status to %{newStatus} while the entry is processing?", + onProcessingDeleteUnpublishedChanges: "Are you sure you want to delete unpublished changes while the entry is processing?", + onProcessingDeleteEntry: "Are you sure you want to delete this entry while it is processing?", onStackPublishing: 'Are you sure you want to publish all changes?', onStackClosing: 'Are you sure you want to discard all changes?', }, @@ -327,8 +328,6 @@ const en = { inReviewHeader: 'In Review', readyHeader: 'Ready', inProcessingHeader: 'Processing', - onProcessingUpdate: - "Entry can't change the status while processing!\n\n Please wait until the process end.", inStaleHeader: 'Stale', onStaleUpdate: "Entry can't be manually updated to stale status! Please discard changes insted of using stale status.", From ace56f02bae2e95f194aadeed152ae09cbb9983e Mon Sep 17 00:00:00 2001 From: Dfdez Date: Wed, 4 Jun 2025 15:59:26 +0200 Subject: [PATCH 7/8] feat: added enty and avoid problems if empty is missing --- packages/decap-cms-core/src/actions/entries.ts | 1 + packages/decap-cms-core/src/reducers/entryDraft.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/decap-cms-core/src/actions/entries.ts b/packages/decap-cms-core/src/actions/entries.ts index 889bf9b981c2..65c0d950ebb6 100644 --- a/packages/decap-cms-core/src/actions/entries.ts +++ b/packages/decap-cms-core/src/actions/entries.ts @@ -324,6 +324,7 @@ export function entryPersisted(collection: Collection, entry: EntryMap, slug: st * Pass slug from backend for newly created entries. */ slug, + entry, }, }; } diff --git a/packages/decap-cms-core/src/reducers/entryDraft.js b/packages/decap-cms-core/src/reducers/entryDraft.js index b6ed4692f8a2..d85374feab3a 100644 --- a/packages/decap-cms-core/src/reducers/entryDraft.js +++ b/packages/decap-cms-core/src/reducers/entryDraft.js @@ -168,7 +168,7 @@ function entryDraftReducer(state = Map(), action) { case ENTRY_PERSIST_SUCCESS: case UNPUBLISHED_ENTRY_PERSIST_SUCCESS: return state.withMutations(state => { - state.setIn(['entry', 'data'], action.payload.entry.get('data')); + if (action.payload.entry) state.setIn(['entry', 'data'], action.payload.entry.get('data')); state.deleteIn(['entry', 'isPersisting']); state.set('hasChanged', false); if (!state.getIn(['entry', 'slug'])) { From 9f92374f88a3ab98a54c49369a44da469e74db7d Mon Sep 17 00:00:00 2001 From: Dfdez Date: Thu, 5 Jun 2025 14:14:08 +0200 Subject: [PATCH 8/8] feat: refactor context structure and error name with integration --- .../src/actions/editorialWorkflow.ts | 6 +-- .../decap-cms-core/src/actions/entries.ts | 52 +++++++++++-------- .../src/components/Editor/Editor.js | 23 ++++---- 3 files changed, 44 insertions(+), 37 deletions(-) diff --git a/packages/decap-cms-core/src/actions/editorialWorkflow.ts b/packages/decap-cms-core/src/actions/editorialWorkflow.ts index 42eb7bd1b4ff..51b9ed2b7893 100644 --- a/packages/decap-cms-core/src/actions/editorialWorkflow.ts +++ b/packages/decap-cms-core/src/actions/editorialWorkflow.ts @@ -68,7 +68,7 @@ export const UNPUBLISHED_ENTRY_DELETE_REQUEST = 'UNPUBLISHED_ENTRY_DELETE_REQUES export const UNPUBLISHED_ENTRY_DELETE_SUCCESS = 'UNPUBLISHED_ENTRY_DELETE_SUCCESS'; export const UNPUBLISHED_ENTRY_DELETE_FAILURE = 'UNPUBLISHED_ENTRY_DELETE_FAILURE'; -export const EDITORIAL_WORKFLOW_DISMISS_ERROR = 'EDITORIAL_WORKFLOW_DISMISS_ERROR'; +export const UNPUBLISHED_ENTRY_DISMISS_ERROR = 'UNPUBLISHED_ENTRY_DISMISS_ERROR'; /* * Simple Action Creators (Internal) @@ -274,7 +274,7 @@ export function loadUnpublishedEntry(collection: Collection, slug: string) { dispatch(unpublishedEntryLoaded(collection, entry)); dispatch(createDraftFromEntry(entry)); } catch (error) { - if (error.name === EDITORIAL_WORKFLOW_DISMISS_ERROR) return; + if (error.name === UNPUBLISHED_ENTRY_DISMISS_ERROR) return; if (error.name === EDITORIAL_WORKFLOW_ERROR && error.notUnderEditorialWorkflow) { dispatch(unpublishedEntryRedirected(collection, slug)); dispatch(loadEntry(collection, slug)); @@ -556,7 +556,7 @@ export function publishUnpublishedEntry( } } } catch (error) { - if (error.name === EDITORIAL_WORKFLOW_DISMISS_ERROR) return; + if (error.name === UNPUBLISHED_ENTRY_DISMISS_ERROR) return; dispatch( addNotification({ message: { key: 'ui.toast.onFailToPublishEntry', details: error }, diff --git a/packages/decap-cms-core/src/actions/entries.ts b/packages/decap-cms-core/src/actions/entries.ts index 65c0d950ebb6..1346f9735613 100644 --- a/packages/decap-cms-core/src/actions/entries.ts +++ b/packages/decap-cms-core/src/actions/entries.ts @@ -20,7 +20,7 @@ import { selectCustomPath } from '../reducers/entryDraft'; import { navigateToCollection, navigateToEntry } from '../routing/history'; import { getProcessSegment } from '../lib/formatters'; import { hasI18n, duplicateDefaultI18nFields, serializeI18n, I18N, I18N_FIELD } from '../lib/i18n'; -import { loadUnpublishedEntry } from './editorialWorkflow'; +import { loadUnpublishedEntry, UNPUBLISHED_ENTRY_DISMISS_ERROR } from './editorialWorkflow'; import { addNotification } from './notifications'; import type { ImplementationMediaFile } from 'decap-cms-lib-util'; @@ -981,17 +981,20 @@ export function persistEntry( return newSlug; }) .catch((error: Error) => { - console.error(error); - dispatch( - addNotification({ - message: { - details: error, - key: 'ui.toast.onFailToPersist', - }, - type: 'error', - dismissAfter: 8000, - }), - ); + if (error.name !== UNPUBLISHED_ENTRY_DISMISS_ERROR) { + console.error(error); + dispatch( + addNotification({ + message: { + details: error, + key: 'ui.toast.onFailToPersist', + }, + type: 'error', + dismissAfter: 8000, + }), + ); + } + return Promise.reject(dispatch(entryPersistFail(collection, serializedEntry, error))); }); }; @@ -1033,17 +1036,20 @@ export function deleteEntry( } }) .catch((error: Error) => { - dispatch( - addNotification({ - message: { - details: error, - key: 'ui.toast.onFailToDelete', - }, - type: 'error', - dismissAfter: 8000, - }), - ); - console.error(error); + if (error.name !== UNPUBLISHED_ENTRY_DISMISS_ERROR) { + console.error(error); + dispatch( + addNotification({ + message: { + details: error, + key: 'ui.toast.onFailToDelete', + }, + type: 'error', + dismissAfter: 8000, + }), + ); + } + return Promise.reject(dispatch(entryDeleteFail(collection, slug, error))); }); }; diff --git a/packages/decap-cms-core/src/components/Editor/Editor.js b/packages/decap-cms-core/src/components/Editor/Editor.js index 435df1008281..7e6a39ab8f2b 100644 --- a/packages/decap-cms-core/src/components/Editor/Editor.js +++ b/packages/decap-cms-core/src/components/Editor/Editor.js @@ -232,18 +232,19 @@ export class Editor extends React.Component { // deleteLocalBackup(collection, !newEntry && slug); // } - createHookContext = context => { + createHookContext = (context) => { const defaultContext = { - actions: { - navigateToCollection, - searchEntries: this.props.searchEntries, - handleChangeStatus: this.handleChangeStatus, + editor: { + props: this.props, + handlePersistEntry: this.handlePersistEntry, + handlePublishEntry: this.handlePublishEntry, + handleUnpublishEntry: this.handleUnpublishEntry, + handleDeleteEntry: this.handleDeleteEntry, handleDeleteUnpublishedChanges: this.handleDeleteUnpublishedChanges, - createEmptyDraft: this.props.createEmptyDraft, - getUnpublishedEntries: this.props.getUnpublishedEntries, - getCollection: name => { - return this.props.collections.get(name); - }, + handleDuplicateEntry: this.handleDuplicateEntry, + handleChangeStatus: this.handleChangeStatus, + }, + actions: { persistEntry: async (collection, entry, opts = {}) => { const context = this.createHookContext(opts); const entryDraft = entry || this.props.createEmptyDraft(collection); @@ -254,7 +255,7 @@ export class Editor extends React.Component { const entryDraft = entry || this.props.createEmptyDraft(collection); return this.props.persistUnpublishedEntry(collection, existingUnpublishedEntry, context, entryDraft); }, - publishEntry: async (collection, slug, entry, opts = {}) => { + publishUnpublishedEntry: async (collection, slug, entry, opts = {}) => { const context = this.createHookContext(opts); const entryDraft = entry || this.props.createEmptyDraft(collection); return this.props.publishUnpublishedEntry(