Documents: Use single-transaction create/update-and-publish endpoints (FE) - #23031
Conversation
… (FE) Replace the separate save+publish calls on the document save-and-publish path with the new create-and-publish / update-and-publish endpoints, and keep unpublished but edited variants dirty after reload via a current-state re-merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-take-three' into v17/feature/68071-save-and-publish-fe
|
Claude finished @iOvergaard's task in 5m 24s —— View job PR ReviewTarget: Wires the document backoffice to new combined create/update-and-publish endpoints, replacing the previous two-step save-then-publish flow with a single atomic server call; adds logic to re-apply unpublished variant edits after the reload so they remain dirty.
Suggestions
Approved with Suggestions for improvementGood to go, but please carefully consider the importance of the suggestions. Labels applied: |
There was a problem hiding this comment.
Pull request overview
This PR updates the Documents backoffice save-and-publish flow to use the new single-transaction Management API endpoints (create-and-publish / update-and-publish), and adds client-side state handling to keep unpublished edits “dirty” after a publish-triggered reload.
Changes:
- Add
createAndPublish/updateAndPublishto the document detail server data source (plusculturesToPublishmapping + re-read). - Wire the publishing workspace to call the combined operation (instead of save + publish) and restore unpublished edits after reload.
- Add unit tests covering the save-and-publish data-state behavior and the single combined call path, plus MSW/mock support for the new endpoints.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/context/document-workspace.context.ts | Adds combined create/update-and-publish orchestration and post-reload “dirty variant” restoration logic. |
| src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/context/document-workspace-save-and-publish.context.test.ts | New unit tests verifying dirty-state behavior after save-and-publish and ensuring only one combined call is used. |
| src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail.server.data-source.ts | Adds createAndPublish / updateAndPublish methods and culturesToPublish mapping with re-read. |
| src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail.server.data-source.test.ts | New unit tests for update-and-publish behavior (requested cultures + invariant case). |
| src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail.repository.ts | Exposes repository pass-throughs for the new server data source methods. |
| src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/document-publishing.workspace-context.ts | Switches save-and-publish flow to the combined endpoint and reapplies unpublished edits after reload. |
| src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts | Regenerates OpenAPI types to include new request models and routes. |
| src/Umbraco.Web.UI.Client/src/packages/core/backend-api/sdk.gen.ts | Regenerates OpenAPI SDK methods for the new endpoints. |
| src/Umbraco.Web.UI.Client/mocks/msw-handlers/document/detail.handlers.ts | Adds MSW handlers for create-and-publish and update-and-publish endpoints. |
| src/Umbraco.Web.UI.Client/mocks/db/document-publishing.manager.ts | Adds mock DB publishing support for combined create/update-and-publish operations. |
- Deduplicate request-body mapping in the document data source (shared #mapCreateRequestBody / #mapUpdateRequestBody) to remove create vs create-and-publish duplication (CodeScene). - Split performCreateOrUpdateAndPublish into #createAndPublish / #updateAndPublish to reduce method complexity (CodeScene), and clarify its post-condition in JSDoc. - Default parentUnique to null on the repository createAndPublish overload (Claude). - De-duplicate segments before expanding variant lists (Copilot). - Add a createAndPublish data-source unit test (Copilot). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The create/update-and-publish data-source methods re-read the full document after the mutation, but the publishing workspace context immediately calls reload() (which re-fetches and refreshes state) and discards that result. Remove the data-source re-read so save-and-publish fetches the document once (the reload) instead of twice. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Hi, Is it possible to maintain the current domain split, with all publishing-related methods located in the publishing repository and publishing context? This would mean that methods like createAndPublish would be moved from the DocumentDetailRepository to the UmbDocumentPublishingRepository. I believe this separation of responsibilities aligns better with how package authors may want to extend a workspace with additional functionality, giving us as much flexibility as possible for workspace extensions. Additionally, it will be beneficial if other entities require publishing functionality, as we would only need to "borrow" the Publishing domain across. |
|
Reviewing purely from a testing perspective, I can see the endpoints being called as expected for "create and publish" and "update and publish". There's an issue with "create and publish" in that after the operation completes there's an immediate and unnecessary display of the "Discard unsaved changes" dialog. If I click "Stay" and then navigate away, there's no secondary display of this dialog, which is correct, so seems there's an issue where this is incorrectly triggered immediately after the create and publish, presumably as it navigates to the "edit" view for the newly created document. |
|
@madsrasmussen Great question! Agreed on keeping the domain split. One heads-up: moving the The catch is the |
|
@AndyButland Interesting finding. I didn't see that myself before, but I will test it out. I think it may be caused by the fact that we now don't overwrite the whole dataset after create/update, because you may not have save-published all variants. Did you have any variants on your page, or any of the known problematic property editors, such as Tiptap with blocks on the document? |
|
I checked - I see this on invariant documents, but it does seem to be property editor related, I've tested with an invariant document that does have an RTE on it, but I haven't populated it with anything. |
|
@iOvergaard I don’t fully remember the entire ceremony around create/update, but it is probably quite complicated. I think there is something interesting in exposing “just enough” so that additional workspace contexts will be able to “prepare” data for a create/save. So maybe we need to introduce new public methods to support this 🤔 I could imagine that other actions might be interested in doing a save/create before their own action and might introduce combined endpoints too. |
After create-and-publish the workspace flipped isNew=false before its data state was reconciled (that happens in the subsequent reload + transfer). The flip schedules the new->edit redirect, whose navigation guard then saw a transient dirty state and popped a "Discard unsaved changes" dialog — most visible on an invariant document with an empty RTE (reported by Andy Butland). Reconcile persisted to the just-saved data before flipping isNew. Using saveData (not the full current data) keeps published variants clean while edited-but-unpublished variants stay dirty, so their edits remain preserved and navigation-guarded during the brief pre-reload window. Update-and-publish is unaffected (no redirect). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Following review feedback (Mads Rasmussen), keep all publishing-related HTTP in the publishing repository/data source rather than the document detail layer: - Move createAndPublish/updateAndPublish from UmbDocumentServerDataSource + UmbDocumentDetailRepository to UmbDocumentPublishingServerDataSource + UmbDocumentPublishingRepository. Extract the shared create/update request-body mapping to document-detail-request.mappers.ts so it is not duplicated. - The publishing workspace context orchestrates the combined call and asks the document workspace context to apply the create/update lifecycle via new public finalizeCreate/finalizeUpdate methods. This keeps setIsNew and _workspaceEventUnique private — no shared workspace base-class changes. - finalizeCreate carries the create-and-publish fix (reconcile persisted to the saved data before the new->edit redirect) so an edited-but-unpublished variant stays dirty while published variants are clean. Move the and-publish data-source test to the publishing folder; rework the save-and-publish/create-and-publish context tests to drive the publishing data source + finalize methods. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#createOrUpdateAndPublish re-checked #documentWorkspaceContext although its only caller (#performSaveAndPublish) already guards it; pass the narrowed reference in. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
I was still seeing the same issue @iOvergaard after pulling down the latest - but for one particular document type and not others if I tried to recreate. I found evidence of the problem though:
You see the elements in the I'll give you the diff that looks to fix this in - * Persisted is reconciled to `savedData` (not the full current data) BEFORE flipping isNew: that flip
- * triggers the new->edit redirect, whose navigation guard would otherwise see a transient dirty state
- * (the caller's reload reconciles only afterwards) and pop a spurious "Discard unsaved changes" dialog
- * (reported by Andy Butland). Using `savedData` keeps published variants clean while edited-but-
- * unpublished variants correctly stay dirty, so their edits remain guarded during the pre-reload window.
+ * Both persisted and current are reconciled to `savedData` BEFORE flipping isNew, so the new->edit
+ * redirect that flip triggers can never see a dirty state and pop a spurious "Discard unsaved changes"
+ * dialog. Edited-but-unpublished variants are temporarily absent from the current state until the
+ * caller's reload + transferPublishedVariantsToCurrent restores them (dirty again).
* @param {UmbDocumentDetailModel} savedData - The data that was sent to the server (constructSaveData)
* @returns {Promise<void>}
* @memberof UmbDocumentWorkspaceContext
*/
public async finalizeCreate(savedData: UmbDocumentDetailModel): Promise<void> {
const parent = this._internal_getCreateUnderParent();
if (!parent) throw new Error('Parent is not set');
+ // Set persisted AND current to the same data BEFORE flipping isNew: that flip triggers the
+ // new->edit redirect, whose navigation guard compares the two states with an order-sensitive
+ // JSON.stringify. `savedData` is a merge-processed projection of the draft (its values array can
+ // be ordered differently, and resolver-backed editors may rewrite values), so reconciling only
+ // persisted still leaves a spurious mismatch -> "Discard unsaved changes" dialog.
+ // Edited-but-unpublished variants are restored (and become dirty again) by the
+ // caller's reload + transferPublishedVariantsToCurrent.
this._data.setPersisted(savedData);
+ this._data.setCurrent(savedData);
this.setIsNew(false);The test
|
Andy Butland found the create-and-publish "Discard unsaved changes" dialog still appeared for some document types. The dirty guard's jsonStringComparison is order-sensitive, and savedData (a merge-processed projection of the draft) can order its values array differently from current — so reconciling only persisted still left a spurious mismatch. Set both persisted and current to savedData before flipping isNew, so the new->edit redirect can never observe a dirty state. Edited-but-unpublished variants are restored (dirty again) by the caller's reload + transferPublishedVariantsToCurrent. Update the create-and-publish "no data loss" test to assert the end state (after reload + transfer) instead of the intermediate redirect window, since the unpublished variant's edit is briefly absent during it by design. Diagnosis and fix proposed by Andy Butland. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks Andy, that's a good find — the Confirmed it's what you said: the dirty check is an order-sensitive
You were right about the test as well — it was asserting the intermediate state. Moved it to check the end state after reload + transfer, and renamed it. Green here. Give it another go on the doc type that was reproducing for you when you get a chance? Want to confirm it's gone on your side too. |
|
Yes, this is working as expected now @iOvergaard. I'll mark as approved from a testing perspective - but if you are still in discussions with @madsrasmussen and @nielslyngsoe on the code/architecture side of things, obviously please wait for merging until you have resolved everything there. |
|
Thanks, @AndyButland. @madsrasmussen, I implemented your suggestions. Would you check it, please, and let me know what you think? |
…ion failure
The validation-failure path already notifies and re-rejects; the shared
top-level .catch then fired a second, contradictory toast ("saved for you"
followed by "could not be saved"). Scope the failure notification to the
publish path so each outcome shows a single message.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@claude please review this PR again after having merged in frontend changes |
Review: Documents — Single-transaction create/update-and-publish (FE)This is a well-executed PR. The problem is well-understood, the fix is targeted, and the test coverage is thorough. Navigation-guard fix The old Replacing
Pulling this into a shared private method removes the duplication between create and update paths and makes the contract explicit: merge server data into persisted + current for only the selected variants. The
Clean escape hatch. Using it inside the custom persist closure keeps the sequencing right: combined endpoint fires, read-back happens inside Request body mappers Extracting Test coverage The three new test files reproduce the original bugs directly: the spurious Discard dialog on create-and-publish, and the edited-but-unpublished variant losing its edits after save-and-publish. Tests fail on pre-fix code and pass after — good. Two non-blocking observations (inline comments above):
No breaking changes, scheduling path unchanged, validation-failure fallback preserved. This is a solid implementation. ✅ |
…fails After a successful create/update-and-publish the workspace re-reads the document (the endpoints return key-only). If that read-back fails, the publish has already succeeded server-side, so falling through to the publish-failed toast is misleading. Fall back to the submitted data so the workspace lifecycle completes, and surface a soft warning that the editor could not be refreshed. Also widen the loadWithoutPersist() error message from "document" to "entity" since the method lives on the shared entity-detail base. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Both addressed in bba87c7:
Browser-tested the EN-only Save & Publish on a variant doc: single success toast, the unsaved Danish draft is preserved, and the discard guard still fires on navigate-away. Thanks for spotting the read-back edge case 👍 |
5ed6da6
into
v17/improvement/save-and-publish-take-three
…22812) * Reintroduce create/save-and-publish as a single operation * Added a truckload on tests. * add default implementation * PR review fixes * Fix XML doc formatting. * Move name length check for content in ContentService, now duplicated 4 times, into a single helper method. * Provide a more comprehensive default implementation for save and publish, that checks the save result before publishing. * Improved XML doc on the new SaveAndPublish method on IContentService. * Align validation on Publish and SaveAndPublish. * Removed region from ContentServiceTests (it's the only one, and test names already provide details of the method under test). * Add authorization checks for create+publish and update+publish on the combined operation controllers. * Added authorization integration tests. * Documents: Use single-transaction create/update-and-publish endpoints (FE) (#23031) * Documents: Use single-transaction create/update-and-publish endpoints (FE) Replace the separate save+publish calls on the document save-and-publish path with the new create-and-publish / update-and-publish endpoints, and keep unpublished but edited variants dirty after reload via a current-state re-merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Documents: Address review feedback on save-and-publish FE - Deduplicate request-body mapping in the document data source (shared #mapCreateRequestBody / #mapUpdateRequestBody) to remove create vs create-and-publish duplication (CodeScene). - Split performCreateOrUpdateAndPublish into #createAndPublish / #updateAndPublish to reduce method complexity (CodeScene), and clarify its post-condition in JSDoc. - Default parentUnique to null on the repository createAndPublish overload (Claude). - De-duplicate segments before expanding variant lists (Copilot). - Add a createAndPublish data-source unit test (Copilot). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Documents: Drop redundant document re-read on save-and-publish The create/update-and-publish data-source methods re-read the full document after the mutation, but the publishing workspace context immediately calls reload() (which re-fetches and refreshes state) and discards that result. Remove the data-source re-read so save-and-publish fetches the document once (the reload) instead of twice. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Documents: Fix spurious discard dialog after create-and-publish After create-and-publish the workspace flipped isNew=false before its data state was reconciled (that happens in the subsequent reload + transfer). The flip schedules the new->edit redirect, whose navigation guard then saw a transient dirty state and popped a "Discard unsaved changes" dialog — most visible on an invariant document with an empty RTE (reported by Andy Butland). Reconcile persisted to the just-saved data before flipping isNew. Using saveData (not the full current data) keeps published variants clean while edited-but-unpublished variants stay dirty, so their edits remain preserved and navigation-guarded during the brief pre-reload window. Update-and-publish is unaffected (no redirect). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Documents: Move create/update-and-publish into the publishing domain Following review feedback (Mads Rasmussen), keep all publishing-related HTTP in the publishing repository/data source rather than the document detail layer: - Move createAndPublish/updateAndPublish from UmbDocumentServerDataSource + UmbDocumentDetailRepository to UmbDocumentPublishingServerDataSource + UmbDocumentPublishingRepository. Extract the shared create/update request-body mapping to document-detail-request.mappers.ts so it is not duplicated. - The publishing workspace context orchestrates the combined call and asks the document workspace context to apply the create/update lifecycle via new public finalizeCreate/finalizeUpdate methods. This keeps setIsNew and _workspaceEventUnique private — no shared workspace base-class changes. - finalizeCreate carries the create-and-publish fix (reconcile persisted to the saved data before the new->edit redirect) so an edited-but-unpublished variant stays dirty while published variants are clean. Move the and-publish data-source test to the publishing folder; rework the save-and-publish/create-and-publish context tests to drive the publishing data source + finalize methods. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Documents: Drop redundant context guard in save-and-publish orchestrator #createOrUpdateAndPublish re-checked #documentWorkspaceContext although its only caller (#performSaveAndPublish) already guards it; pass the narrowed reference in. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Documents: Reconcile current as well as persisted in finalizeCreate Andy Butland found the create-and-publish "Discard unsaved changes" dialog still appeared for some document types. The dirty guard's jsonStringComparison is order-sensitive, and savedData (a merge-processed projection of the draft) can order its values array differently from current — so reconciling only persisted still left a spurious mismatch. Set both persisted and current to savedData before flipping isNew, so the new->edit redirect can never observe a dirty state. Edited-but-unpublished variants are restored (dirty again) by the caller's reload + transferPublishedVariantsToCurrent. Update the create-and-publish "no data loss" test to assert the end state (after reload + transfer) instead of the intermediate redirect window, since the unpublished variant's edit is briefly absent during it by design. Diagnosis and fix proposed by Andy Butland. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Documents: Align finalizeUpdate with finalizeCreate and tighten method docs Review feedback from Niels: finalizeUpdate now takes the saved data and reconciles persisted/current like finalizeCreate (mirroring the base _update), the finalize*/transfer JSDocs describe only the method's own responsibility, and the transfer parameter is renamed to currentData to match the method name. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * initial correction * second round of refactor * url-pattern-to-string tests * url-pattern-to-string jsdocs * avoid discard changes dialog when navigating between create and edit * keep track of the absolute route as well * check absolute path as part of dirty check * check navigation util * added TODOs * clean up * error handling * error handling for schedule * remove unused import * rename to performDefault... * rename * rename and export interface * simplify #applyPersistedData * fix(documents): avoid double notification on publish/schedule validation failure The validation-failure path already notifies and re-rejects; the shared top-level .catch then fired a second, contradictory toast ("saved for you" followed by "could not be saved"). Scope the failure notification to the publish path so each outcome shows a single message. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(documents): don't report publish failure when only the read-back fails After a successful create/update-and-publish the workspace re-reads the document (the endpoints return key-only). If that read-back fails, the publish has already succeeded server-side, so falling through to the publish-failed toast is misleading. Fall back to the submitted data so the workspace lifecycle completes, and surface a soft warning that the editor could not be refreshed. Also widen the loadWithoutPersist() error message from "document" to "entity" since the method lives on the shared entity-detail base. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * simplify comment --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Niels Lyngsø <niels.lyngso@gmail.com> --------- Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Co-authored-by: Sven Geusens <geusens@gmail.com> Co-authored-by: Andy Butland <abutland73@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Niels Lyngsø <niels.lyngso@gmail.com>

Description
Frontend half of the single-transaction save-and-publish work (ADO 68071, under PBI 68081). The backend endpoints come from #22812, and this targets that feature branch so the whole thing lands on
v17/devtogether.Save-and-publish used to be two server calls —
PUT /document/{id}(save) thenPUT /document/{id}/publish— plus a reload. This wires the backoffice to the new combined endpoints so it's a single transaction.Where it lives
All the publish-related HTTP sits in the publishing domain rather than the detail layer (kept there on review feedback, so other entities/actions can reuse it if they grow combined endpoints of their own):
UmbDocumentPublishingServerDataSource/UmbDocumentPublishingRepository— newcreateAndPublish/updateAndPublish, plus a#mapCulturesToPublishhelper that emits only real culture codes (invariant content types send an empty array, matching the server contract).umbMapDocumentCreateRequestBody/umbMapDocumentUpdateRequestBody) build the bodies and are used by both the plaincreate/updateand the and-publish calls, so there's no duplication.UmbDocumentWorkspaceContext— publicfinalizeCreate/finalizeUpdateapply the same create/update workspace lifecycle the base save flow would (mark no-longer-new, tree events, reconcile the data state) after the publishing context has made the call. This keepssetIsNewand the workspace event discriminator private, so there are no shared base-class changes. PlustransferPublishedVariantsToCurrent(below).UmbDocumentPublishingWorkspaceContext.#performSaveAndPublishorchestrates the flow: one combined call → reload → transfer.Keeping unpublished edits dirty (ADO 68071)
After save-and-publish, the reload resets both persisted and current state to the full server document.
transferPublishedVariantsToCurrentre-applies the still-unpublished, edited variants from the pre-publish draft onto the current state (viaUmbMergeContentVariantDataController), so a variant you changed but did not publish stays dirty and the Discard-Changes guard still fires. Verified: edit English + Danish, publish English only → Danish stays dirty with its edit intact.No spurious "Discard unsaved changes" dialog on create
Creating and publishing a brand-new document flips
isNew, which triggers the new→edit redirect, and that redirect's navigation guard was catching the workspace mid-transition and popping a Discard dialog.finalizeCreatenow reconciles both persisted and current to the saved data before the flip, so the guard sees a clean workspace whatever the doc type — the dirty check is an order-sensitive JSON comparison, and the saved data can order itsvaluesdifferently from the draft, so reconciling only one side wasn't enough. The unpublished variants are restored (dirty again) by the reload + transfer that follow, so nothing is lost. Thanks to @AndyButland for spotting this and pinning down the ordering cause.Not changed
publish()methods are still used by scheduling, the publish entity action, and the publish bulk action.Testing
culturesToPublishcorrectly; the 68071 data-state behaviour (published variant clean, unpublished edit stays dirty — fails on the pre-fix code, passes after); a single combined call (no separate publish); and the create-and-publish path staying clean through the redirect window then restoring the unpublished edit after reload + transfer.OpenApi.json.Loading / fetch behaviour
Progress during save & publish shows only on the action button (the in-progress
setExecutingstate from #22551). The full-workspace loader is intentionally not raised — what's on screen is what was just published, so there's no reason to blank and reload the editor.reload()refreshes the workspace state in place without toggling theloadingflag.Post-publish requests are one
GET /document/{id}(reload),GET /document/{id}/published(recomputes the published-vs-draft "pending changes" indicator — pre-existing behaviour, its result is used), and a backgroundGET /tree/document/{id}/siblings. The redundant secondGET /document/{id}the data source used to issue after mutating has been removed, so the document is fetched once post-publish.Related to #21152
🤖 Generated with Claude Code