From f5f4eb1d17380b62e54f8da339e2f51cc79b53d3 Mon Sep 17 00:00:00 2001 From: geodem Date: Thu, 26 Mar 2026 02:10:37 +0800 Subject: [PATCH 01/34] Cereated schedule unpublish modal component --- .../components/ScheduleUnpublish/index.tsx | 288 ++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 src/shell/components/ScheduleUnpublish/index.tsx diff --git a/src/shell/components/ScheduleUnpublish/index.tsx b/src/shell/components/ScheduleUnpublish/index.tsx new file mode 100644 index 0000000000..b92c4885a6 --- /dev/null +++ b/src/shell/components/ScheduleUnpublish/index.tsx @@ -0,0 +1,288 @@ +import { useState } from "react"; +import { + Dialog, + DialogActions, + DialogTitle, + DialogContent, + Typography, + Button, + Stack, + Box, + Alert, +} from "@mui/material"; +import ScheduleRoundedIcon from "@mui/icons-material/ScheduleRounded"; +import WarningRoundedIcon from "@mui/icons-material/WarningRounded"; +import CalendarTodayRoundedIcon from "@mui/icons-material/CalendarTodayRounded"; +import InfoRoundedIcon from "@mui/icons-material/InfoRounded"; +import { useDispatch } from "react-redux"; + +import { ContentItemWithDirtyAndPublishing } from "../../services/types"; +import { useGetUsersQuery } from "../../services/accounts"; +import { FieldTypeDateTime } from "../FieldTypeDateTime"; +import { TIMEZONES } from "../FieldTypeDateTime/util"; +import { publish, scheduleUnpublish, unpublish } from "../../store/content"; + +import { format as fmt, isBefore, formatDistanceToNow } from "date-fns"; +import { zonedTimeToUtc, formatInTimeZone } from "date-fns-tz"; + +type ScheduleUnpublishProps = { + item: ContentItemWithDirtyAndPublishing; + onClose: () => void; + onUnpublishNow: () => void; + onScheduleSuccess?: () => void; + onUnscheduleSuccess?: () => void; +}; + +export const ScheduleUnpublish = ({ + onClose, + item, + onUnpublishNow, + onScheduleSuccess, + onUnscheduleSuccess, +}: ScheduleUnpublishProps) => { + const dispatch = useDispatch(); + const { data: users } = useGetUsersQuery(); + + // Next top of the hour (local) + const now = new Date(); + const nextTopOfHour = new Date(now); + nextTopOfHour.setMinutes(0, 0, 0); + nextTopOfHour.setHours(nextTopOfHour.getHours() + 1); + + const [unpublishDateTime, setUnpublishDateTime] = useState( + fmt(nextTopOfHour, "yyyy-MM-dd HH:mm:ss") + ); + + const tzGuess = + Intl.DateTimeFormat().resolvedOptions().timeZone || "America/Los_Angeles"; + const [unpublishTimezone, setUnpublishTimezone] = useState(tzGuess); + const [isLoading, setIsLoading] = useState(false); + + const latestChangeCreator = users?.find( + (user) => user.ZUID === item?.web?.createdByUserZUID + ); + + const selectedUtc = zonedTimeToUtc( + unpublishDateTime.replace(/\.\d+$/, ""), + unpublishTimezone + ); + const isValidUtc = !isNaN(selectedUtc.getTime()); + const isSelectedDatetimePast = isValidUtc + ? isBefore(selectedUtc, new Date()) + : false; + + const handleScheduleUnpublish = () => { + setIsLoading(true); + + // API value must be UTC "YYYY-MM-DD HH:mm:ss" + const unpublishAtUtcStr = formatInTimeZone( + selectedUtc, + "UTC", + "yyyy-MM-dd HH:mm:ss" + ); + + // Pretty local confirmation text in the chosen timezone + const localPretty = formatInTimeZone( + selectedUtc, + unpublishTimezone, + "MMMM do yyyy, 'at' h:mm a" + ); + + dispatch( + scheduleUnpublish( + item?.meta?.contentModelZUID, + item?.meta?.ZUID, + { + publishAt: unpublishAtUtcStr, + version: item?.meta?.version, + }, + { + localTime: localPretty, + localTimezone: unpublishTimezone, + }, + unpublishDateTime + ) + // @ts-expect-error untyped action + ).finally(() => { + onScheduleSuccess?.(); + setIsLoading(false); + onClose(); + }); + }; + + const handleCancelScheduleUnpublish = () => { + setIsLoading(true); + + dispatch( + // scheduleUnpublish( + // item?.meta?.contentModelZUID, + // item?.meta?.ZUID, + // item?.scheduling?.ZUID, + // item?.scheduling?.version, + // "never" + // ) + unpublish( + item?.meta?.contentModelZUID, + item?.meta?.ZUID, + item?.scheduling?.ZUID, + { version: item?.scheduling?.version } + ) + // @ts-expect-error untyped action + ).finally(() => { + setIsLoading(false); + onClose(); + onUnscheduleSuccess?.(); + }); + }; + + const guessedTz = tzGuess; + const scheduledLocalText = item?.scheduling?.publishAt + ? formatInTimeZone( + item.scheduling.publishAt, + guessedTz, + "MMM d, yyyy 'at' h:mm a" + ) + : ""; + + const tzLabel = + TIMEZONES.find((tz) => tz.id === guessedTz)?.label || guessedTz; + + return ( + + + + + {item?.scheduling?.isScheduled ? ( + + ) : ( + + )} + + + + + {item?.scheduling?.isScheduled + ? "Unschedule Unpublish:" + : "Schedule Unpublish:"} +   + + + {item?.web?.metaLinkText} + + + + + {item?.scheduling?.isScheduled + ? `v${item?.web?.version} is scheduled to publish on ${scheduledLocalText} in ${tzLabel}.` + : `v${item?.web?.version} saved ${ + item?.web?.createdAt + ? formatDistanceToNow(new Date(item.web.createdAt), { + addSuffix: true, + }) + : "" + } by ${latestChangeCreator?.firstName ?? ""} ${ + latestChangeCreator?.lastName ?? "" + }`} + + + + + + + {item?.scheduling?.isScheduled ? ( + }> + This will enable the ability to schedule or publish other versions + of this content item + + ) : ( + <> + + Unpublish on + + { + const normalized = String(datetime).replace(/\.\d+$/, ""); + setUnpublishDateTime(normalized); + }} + onTimezoneChange={(timezone: any) => + setUnpublishTimezone(timezone) + } + /> + {isSelectedDatetimePast && ( + } + sx={{ mt: 2.5 }} + > + Since the selected time is a current or past date, this will be + immediately published. + + )} + + )} + + + + + + {item?.scheduling?.isScheduled ? ( + + ) : ( + + )} + + + ); +}; From 42760c63a2af960bbd6a5551448b0b1e96716fda Mon Sep 17 00:00:00 2001 From: geodem Date: Thu, 26 Mar 2026 02:11:45 +0800 Subject: [PATCH 02/34] added function to procerss schedule unpublishing --- src/shell/store/content.js | 69 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/shell/store/content.js b/src/shell/store/content.js index 26aeaf5e9d..4131848373 100644 --- a/src/shell/store/content.js +++ b/src/shell/store/content.js @@ -966,6 +966,75 @@ export function unpublish(modelZUID, itemZUID, publishZUID, options = {}) { }; } +export function scheduleUnpublish( + modelZUID, + itemZUID, + data, + meta = {}, + unpublishDateTime +) { + return (dispatch, getState) => { + const item = getState().content[itemZUID]; + let title; + + if (item) { + title = `"${item.web.metaTitle || item.web.metaLinkText}" version ${ + data.version + }`; + } else { + title = `item ${itemZUID} version ${data.version}`; + } + + return request( + `${CONFIG.API_INSTANCE}/content/models/${modelZUID}/items/${itemZUID}/publishings`, + { + method: "POST", + json: true, + body: { + ...data, + unpublishAt: unpublishDateTime, + }, + } + ) + .then((res) => { + console.debug("data res: ", { data, res, unpublishDateTime }); + if (res.status >= 400) { + return Promise.reject(new Error(res.error)); + } + }) + .then(() => { + const message = `${title} to unpublish on ${meta.localTime} in the ${meta.localTimezone} timezone`; + + return dispatch( + notify({ + message, + kind: "save", + }) + ); + }) + .then(() => { + dispatch( + instanceApi.util.invalidateTags([ + { type: "ItemPublishing", itemZUID }, + ]) + ); + return dispatch(fetchItemPublishing(modelZUID, itemZUID)); + }) + .catch((err) => { + const message = data.publishAt + ? `Error scheduling ${title}` + : `Error publishing ${title}`; + dispatch( + notify({ + message, + kind: "error", + }) + ); + throw err; + }); + }; +} + export function fetchItemPublishing(modelZUID, itemZUID) { return (dispatch) => { return dispatch({ From d1b4b28bda7dfae59821d902e38bba33c7870f79 Mon Sep 17 00:00:00 2001 From: geodem Date: Thu, 26 Mar 2026 02:12:39 +0800 Subject: [PATCH 03/34] updated logic to handle scheduled unpublishing --- .../ItemEditHeader/ItemEditHeaderActions.tsx | 334 +++++++++++------- 1 file changed, 215 insertions(+), 119 deletions(-) diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx index 8c08032fe5..a9374310f4 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx @@ -64,6 +64,8 @@ import { PUBLISH_ATTEMPT_WITHOUT_ALLOW_PUBLISH_STATUS, SCHEDULE_PUBLISH_ATTEMPT_WITHOUT_ALLOW_PUBLISH_STATUS, } from "../../../../../../../../amplitude-events"; +import { ScheduleUnpublish } from "shell/components/ScheduleUnpublish"; +import ScheduleRoundedIcon from "@mui/icons-material/ScheduleRounded"; const ITEM_STATES = { dirty: "dirty", @@ -104,6 +106,8 @@ export const ItemEditHeaderActions = ({ const [unpublishDialogOpen, setUnpublishDialogOpen] = useState(false); const [scheduledPublishDialogOpen, setScheduledPublishDialogOpen] = useState(false); + const [scheduledUnpublishDialogOpen, setScheduledUnpublishDialogOpen] = + useState(false); const [scheduleAfterSave, setScheduleAfterSave] = useState(false); const [publishAfterUnschedule, setPublishAfterUnschedule] = useState(false); const [isConfirmPublishModalOpen, setIsConfirmPublishModalOpen] = @@ -117,6 +121,8 @@ export const ItemEditHeaderActions = ({ (state: AppState) => state.content[resolvedItemZUID] as ContentItemWithDirtyAndPublishing ); + const hasScheduledUnpublish = + !!item?.publishing?.publishAt && !!item?.scheduling?.unpublishAt; const items = useSelector((state: AppState) => state.content); const model = useSelector( (state: AppState) => state.models[resolvedModelZUID] @@ -311,7 +317,7 @@ export const ItemEditHeaderActions = ({ const itemState = (() => { if (item?.dirty) { return ITEM_STATES.dirty; - } else if (item?.scheduling?.isScheduled) { + } else if (item?.scheduling?.isScheduled && !item?.publishing?.publishAt) { return ITEM_STATES.scheduled; } else if (activePublishing?.version === item?.meta.version) { return ITEM_STATES.published; @@ -537,133 +543,187 @@ export const ItemEditHeaderActions = ({ )} - {itemState !== ITEM_STATES.scheduled && canPublish && ( - - {itemState === ITEM_STATES.dirty - ? "Save & Publish Item" - : "Publish Item"}{" "} -
- {publishShortcut} - - ) : ( -
- v{activePublishing?.version} published{" "} - {formatDate(activePublishing?.publishAt).includes("Today") || - formatDate(activePublishing?.publishAt).includes("Yesterday") - ? "" - : "on"} -
- {formatDate(activePublishing?.publishAt)}
- by{" "} - { - users?.find( - (user: any) => - user.ZUID === activePublishing?.publishedByUserZUID - )?.firstName - }{" "} - { - users?.find( - (user: any) => - user.ZUID === activePublishing?.publishedByUserZUID - )?.lastName - } -
- ) - } - placement="bottom-start" - > - {itemState === ITEM_STATES.draft || - itemState === ITEM_STATES.dirty || - publishAfterSave || - isFetching || - saving ? ( - - - - - ) : ( - - - - } + sx={{ + color: "common.white", + whiteSpace: "nowrap", + }} + onClick={() => { + if (itemState === ITEM_STATES.dirty) { + setPublishAfterSave(true); + onSave(); + } else { + setIsConfirmPublishModalOpen(true); + } + }} + loading={isPublishing || saving || isFetching} + color="success" + variant="contained" + id="PublishButton" + data-cy="PublishButton" > - Published - + {itemState === ITEM_STATES.dirty + ? "Save & Publish" + : "Publish"} + + + + ) : ( + + + + + Published + + + { + setPublishMenu(e.currentTarget); + }} + > + + - { - setPublishMenu(e.currentTarget); + )} +
+ {hasScheduledUnpublish && ( + + v{activePublishing?.version} scheduled to unpublish on{" "} + {formatDate(activePublishing?.publishAt).includes("Today") || + formatDate(activePublishing?.publishAt).includes("Yesterday") + ? "" + : "on"} +
+ {formatDate(activePublishing?.publishAt)}
+ by{" "} + { + users?.find( + (user: any) => + user.ZUID === activePublishing?.publishedByUserZUID + )?.firstName + }{" "} + { + users?.find( + (user: any) => + user.ZUID === activePublishing?.publishedByUserZUID + )?.lastName + } + + } + enterDelay={1000} + enterNextDelay={1000} + placement="bottom-start" + > + - - - + + + {`v${activePublishing?.version} Scheduled Unpublish`} + + +
)} - + )} - {itemState === ITEM_STATES.scheduled && canPublish && ( { if (!allowPublish) { dispatch( @@ -820,6 +882,23 @@ export const ItemEditHeaderActions = ({ }} /> )} + {scheduledUnpublishDialogOpen && ( + { + setScheduledUnpublishDialogOpen(false); + }} + onUnpublishNow={() => { + handleUnpublish(); + setScheduledUnpublishDialogOpen(false); + }} + onUnscheduleSuccess={() => { + if (publishAfterUnschedule) { + setIsConfirmPublishModalOpen(true); + } + }} + /> + )} {isConfirmPublishModalOpen && ( void; setUnpublishDialogOpen: (value: boolean) => void; setScheduledPublishDialogOpen: (value: boolean) => void; + setScheduleUnpublishDialogOpen: (value: boolean) => void; setPublishAfterUnschedule: () => void; handlePublish: () => void; + hasScheduledUnpublish?: boolean; modelZUID: string; itemZUID: string; }; @@ -903,10 +984,9 @@ const PublishingMenu = ({ setScheduleAfterSave, setUnpublishDialogOpen, setScheduledPublishDialogOpen, + setScheduleUnpublishDialogOpen, setPublishAfterUnschedule, handlePublish, - modelZUID, - itemZUID, }: PublishingMenuProps) => { const history = useHistory(); return ( @@ -1001,6 +1081,22 @@ const PublishingMenu = ({ : "Schedule Publish"} )} + {itemState === ITEM_STATES.published && ( + { + setScheduleUnpublishDialogOpen(true); + onClose(); + }} + data-cy="UnpublishScheduleButton" + > + + + + {hasScheduledUnpublish + ? "Unschedule Unpublish" + : "Schedule Unpublish"} + + )} { From 5852d373dcc41fce11f024c060f0880eedaa7e85 Mon Sep 17 00:00:00 2001 From: geodem Date: Sun, 12 Apr 2026 03:40:36 +0800 Subject: [PATCH 04/34] updated redux function --- .../ItemEditHeader/ItemEditHeaderActions.tsx | 18 +++---- .../components/ScheduleUnpublish/index.tsx | 54 +++++-------------- src/shell/store/content.js | 26 ++++----- 3 files changed, 31 insertions(+), 67 deletions(-) diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx index a9374310f4..751c8352a0 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx @@ -122,7 +122,8 @@ export const ItemEditHeaderActions = ({ state.content[resolvedItemZUID] as ContentItemWithDirtyAndPublishing ); const hasScheduledUnpublish = - !!item?.publishing?.publishAt && !!item?.scheduling?.unpublishAt; + !!item?.publishing?.publishAt && !!item?.publishing?.unpublishAt; + const items = useSelector((state: AppState) => state.content); const model = useSelector( (state: AppState) => state.models[resolvedModelZUID] @@ -677,12 +678,16 @@ export const ItemEditHeaderActions = ({ title={
v{activePublishing?.version} scheduled to unpublish on{" "} - {formatDate(activePublishing?.publishAt).includes("Today") || - formatDate(activePublishing?.publishAt).includes("Yesterday") + {formatDate(activePublishing?.unpublishAt).includes( + "Today" + ) || + formatDate(activePublishing?.unpublishAt).includes( + "Yesterday" + ) ? "" : "on"}
- {formatDate(activePublishing?.publishAt)}
+ {formatDate(activePublishing?.unpublishAt)}
by{" "} { users?.find( @@ -892,11 +897,6 @@ export const ItemEditHeaderActions = ({ handleUnpublish(); setScheduledUnpublishDialogOpen(false); }} - onUnscheduleSuccess={() => { - if (publishAfterUnschedule) { - setIsConfirmPublishModalOpen(true); - } - }} /> )} {isConfirmPublishModalOpen && ( diff --git a/src/shell/components/ScheduleUnpublish/index.tsx b/src/shell/components/ScheduleUnpublish/index.tsx index b92c4885a6..b4a5e34d6c 100644 --- a/src/shell/components/ScheduleUnpublish/index.tsx +++ b/src/shell/components/ScheduleUnpublish/index.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Dialog, DialogActions, @@ -29,16 +29,12 @@ type ScheduleUnpublishProps = { item: ContentItemWithDirtyAndPublishing; onClose: () => void; onUnpublishNow: () => void; - onScheduleSuccess?: () => void; - onUnscheduleSuccess?: () => void; }; export const ScheduleUnpublish = ({ onClose, item, onUnpublishNow, - onScheduleSuccess, - onUnscheduleSuccess, }: ScheduleUnpublishProps) => { const dispatch = useDispatch(); const { data: users } = useGetUsersQuery(); @@ -71,7 +67,7 @@ export const ScheduleUnpublish = ({ ? isBefore(selectedUtc, new Date()) : false; - const handleScheduleUnpublish = () => { + const handleSubmit = (autoUnpublish: boolean = false) => { setIsLoading(true); // API value must be UTC "YYYY-MM-DD HH:mm:ss" @@ -93,45 +89,19 @@ export const ScheduleUnpublish = ({ item?.meta?.contentModelZUID, item?.meta?.ZUID, { - publishAt: unpublishAtUtcStr, + publishAt: "now", version: item?.meta?.version, + unpublishAt: autoUnpublish ? unpublishAtUtcStr : "never", }, { localTime: localPretty, localTimezone: unpublishTimezone, - }, - unpublishDateTime - ) - // @ts-expect-error untyped action - ).finally(() => { - onScheduleSuccess?.(); - setIsLoading(false); - onClose(); - }); - }; - - const handleCancelScheduleUnpublish = () => { - setIsLoading(true); - - dispatch( - // scheduleUnpublish( - // item?.meta?.contentModelZUID, - // item?.meta?.ZUID, - // item?.scheduling?.ZUID, - // item?.scheduling?.version, - // "never" - // ) - unpublish( - item?.meta?.contentModelZUID, - item?.meta?.ZUID, - item?.scheduling?.ZUID, - { version: item?.scheduling?.version } + } ) // @ts-expect-error untyped action ).finally(() => { setIsLoading(false); onClose(); - onUnscheduleSuccess?.(); }); }; @@ -167,7 +137,7 @@ export const ScheduleUnpublish = ({ alignItems: "center", }} > - {item?.scheduling?.isScheduled ? ( + {item?.publishing?.unpublishAt ? ( ) : ( @@ -176,7 +146,7 @@ export const ScheduleUnpublish = ({ - {item?.scheduling?.isScheduled + {item?.publishing?.unpublishAt ? "Unschedule Unpublish:" : "Schedule Unpublish:"}   @@ -187,7 +157,7 @@ export const ScheduleUnpublish = ({ - {item?.scheduling?.isScheduled + {item?.publishing?.unpublishAt ? `v${item?.web?.version} is scheduled to publish on ${scheduledLocalText} in ${tzLabel}.` : `v${item?.web?.version} saved ${ item?.web?.createdAt @@ -204,7 +174,7 @@ export const ScheduleUnpublish = ({ - {item?.scheduling?.isScheduled ? ( + {item?.publishing?.unpublishAt ? ( }> This will enable the ability to schedule or publish other versions of this content item @@ -254,13 +224,13 @@ export const ScheduleUnpublish = ({ Cancel - {item?.scheduling?.isScheduled ? ( + {item?.publishing?.unpublishAt ? ( ) : ( )} diff --git a/src/shell/components/ScheduleUnpublish/index.tsx b/src/shell/components/ScheduleUnpublish/index.tsx deleted file mode 100644 index b4a5e34d6c..0000000000 --- a/src/shell/components/ScheduleUnpublish/index.tsx +++ /dev/null @@ -1,258 +0,0 @@ -import { useEffect, useState } from "react"; -import { - Dialog, - DialogActions, - DialogTitle, - DialogContent, - Typography, - Button, - Stack, - Box, - Alert, -} from "@mui/material"; -import ScheduleRoundedIcon from "@mui/icons-material/ScheduleRounded"; -import WarningRoundedIcon from "@mui/icons-material/WarningRounded"; -import CalendarTodayRoundedIcon from "@mui/icons-material/CalendarTodayRounded"; -import InfoRoundedIcon from "@mui/icons-material/InfoRounded"; -import { useDispatch } from "react-redux"; - -import { ContentItemWithDirtyAndPublishing } from "../../services/types"; -import { useGetUsersQuery } from "../../services/accounts"; -import { FieldTypeDateTime } from "../FieldTypeDateTime"; -import { TIMEZONES } from "../FieldTypeDateTime/util"; -import { publish, scheduleUnpublish, unpublish } from "../../store/content"; - -import { format as fmt, isBefore, formatDistanceToNow } from "date-fns"; -import { zonedTimeToUtc, formatInTimeZone } from "date-fns-tz"; - -type ScheduleUnpublishProps = { - item: ContentItemWithDirtyAndPublishing; - onClose: () => void; - onUnpublishNow: () => void; -}; - -export const ScheduleUnpublish = ({ - onClose, - item, - onUnpublishNow, -}: ScheduleUnpublishProps) => { - const dispatch = useDispatch(); - const { data: users } = useGetUsersQuery(); - - // Next top of the hour (local) - const now = new Date(); - const nextTopOfHour = new Date(now); - nextTopOfHour.setMinutes(0, 0, 0); - nextTopOfHour.setHours(nextTopOfHour.getHours() + 1); - - const [unpublishDateTime, setUnpublishDateTime] = useState( - fmt(nextTopOfHour, "yyyy-MM-dd HH:mm:ss") - ); - - const tzGuess = - Intl.DateTimeFormat().resolvedOptions().timeZone || "America/Los_Angeles"; - const [unpublishTimezone, setUnpublishTimezone] = useState(tzGuess); - const [isLoading, setIsLoading] = useState(false); - - const latestChangeCreator = users?.find( - (user) => user.ZUID === item?.web?.createdByUserZUID - ); - - const selectedUtc = zonedTimeToUtc( - unpublishDateTime.replace(/\.\d+$/, ""), - unpublishTimezone - ); - const isValidUtc = !isNaN(selectedUtc.getTime()); - const isSelectedDatetimePast = isValidUtc - ? isBefore(selectedUtc, new Date()) - : false; - - const handleSubmit = (autoUnpublish: boolean = false) => { - setIsLoading(true); - - // API value must be UTC "YYYY-MM-DD HH:mm:ss" - const unpublishAtUtcStr = formatInTimeZone( - selectedUtc, - "UTC", - "yyyy-MM-dd HH:mm:ss" - ); - - // Pretty local confirmation text in the chosen timezone - const localPretty = formatInTimeZone( - selectedUtc, - unpublishTimezone, - "MMMM do yyyy, 'at' h:mm a" - ); - - dispatch( - scheduleUnpublish( - item?.meta?.contentModelZUID, - item?.meta?.ZUID, - { - publishAt: "now", - version: item?.meta?.version, - unpublishAt: autoUnpublish ? unpublishAtUtcStr : "never", - }, - { - localTime: localPretty, - localTimezone: unpublishTimezone, - } - ) - // @ts-expect-error untyped action - ).finally(() => { - setIsLoading(false); - onClose(); - }); - }; - - const guessedTz = tzGuess; - const scheduledLocalText = item?.scheduling?.publishAt - ? formatInTimeZone( - item.scheduling.publishAt, - guessedTz, - "MMM d, yyyy 'at' h:mm a" - ) - : ""; - - const tzLabel = - TIMEZONES.find((tz) => tz.id === guessedTz)?.label || guessedTz; - - return ( - - - - - {item?.publishing?.unpublishAt ? ( - - ) : ( - - )} - - - - - {item?.publishing?.unpublishAt - ? "Unschedule Unpublish:" - : "Schedule Unpublish:"} -   - - - {item?.web?.metaLinkText} - - - - - {item?.publishing?.unpublishAt - ? `v${item?.web?.version} is scheduled to publish on ${scheduledLocalText} in ${tzLabel}.` - : `v${item?.web?.version} saved ${ - item?.web?.createdAt - ? formatDistanceToNow(new Date(item.web.createdAt), { - addSuffix: true, - }) - : "" - } by ${latestChangeCreator?.firstName ?? ""} ${ - latestChangeCreator?.lastName ?? "" - }`} - - - - - - - {item?.publishing?.unpublishAt ? ( - }> - This will enable the ability to schedule or publish other versions - of this content item - - ) : ( - <> - - Unpublish on - - { - const normalized = String(datetime).replace(/\.\d+$/, ""); - setUnpublishDateTime(normalized); - }} - onTimezoneChange={(timezone: any) => - setUnpublishTimezone(timezone) - } - /> - {isSelectedDatetimePast && ( - } - sx={{ mt: 2.5 }} - > - Since the selected time is a current or past date, this will be - immediately published. - - )} - - )} - - - - - - {item?.publishing?.unpublishAt ? ( - - ) : ( - - )} - - - ); -}; diff --git a/src/shell/store/content.js b/src/shell/store/content.js index 2bc47d0fd9..0e2a812c88 100644 --- a/src/shell/store/content.js +++ b/src/shell/store/content.js @@ -876,9 +876,17 @@ export function publish(modelZUID, itemZUID, data, meta = {}) { } }) .then(() => { - const message = data.publishAt - ? `Scheduled ${title} to publish on ${meta.localTime} in the ${meta.localTimezone} timezone` - : `Published ${title} now`; + const message = + (!!data.publishAt && data.publishAt !== "now") || + (data.publishAt === "now" && + !!data?.unpublishAt && + data?.unpublishAt !== "never") + ? `Scheduled ${title} to ${ + data.publishAt === "now" && data?.unpublishAt !== "never" + ? "unpublish" + : "publish" + } on ${meta.localTime} in the ${meta.localTimezone} timezone` + : `Published ${title} now`; return dispatch( notify({ @@ -966,69 +974,6 @@ export function unpublish(modelZUID, itemZUID, publishZUID, options = {}) { }; } -export function scheduleUnpublish(modelZUID, itemZUID, data, meta = {}) { - return (dispatch, getState) => { - const item = getState().content[itemZUID]; - let title; - - if (item) { - title = `"${item.web.metaTitle || item.web.metaLinkText}" version ${ - data.version - }`; - } else { - title = `item ${itemZUID} version ${data.version}`; - } - - return request( - `${CONFIG.API_INSTANCE}/content/models/${modelZUID}/items/${itemZUID}/publishings`, - { - method: "POST", - json: true, - body: { - ...data, - }, - } - ) - .then((res) => { - if (res.status >= 400) { - return Promise.reject(new Error(res.error)); - } - }) - .then(() => { - if (!!data.unpublishAt && data.unpublishAt !== "never") { - const message = `${title} to unpublish on ${meta.localTime} in the ${meta.localTimezone} timezone`; - - return dispatch( - notify({ - message, - kind: "save", - }) - ); - } - }) - .then(() => { - dispatch( - instanceApi.util.invalidateTags([ - { type: "ItemPublishing", itemZUID }, - ]) - ); - return dispatch(fetchItemPublishing(modelZUID, itemZUID)); - }) - .catch((err) => { - const message = data.publishAt - ? `Error scheduling ${title}` - : `Error publishing ${title}`; - dispatch( - notify({ - message, - kind: "error", - }) - ); - throw err; - }); - }; -} - export function fetchItemPublishing(modelZUID, itemZUID) { return (dispatch) => { return dispatch({ From 556ff635b8eab2b8b6206a36d72dfdedcca43151 Mon Sep 17 00:00:00 2001 From: geodem Date: Fri, 1 May 2026 03:32:54 +0800 Subject: [PATCH 07/34] refactored deeply nested ternary operators for readability --- .../ItemEditHeader/ItemEditHeaderActions.tsx | 129 +++++++----------- src/shell/store/content.js | 29 ++-- 2 files changed, 60 insertions(+), 98 deletions(-) diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx index 5582c1c6b9..6f4025eb46 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx @@ -171,6 +171,13 @@ export const ItemEditHeaderActions = ({ { skip: !resolvedItemZUID || !resolvedModelZUID } ); + const publishedByUser = users?.find( + (user: any) => user.ZUID === activePublishing?.publishedByUserZUID + ); + const publisherFullName = `${publishedByUser?.firstName || ""} ${ + publishedByUser?.lastName || "" + }`.trim(); + useEffect(() => { // Automatically opens the create redirect modal // when there are changes to the url path part @@ -328,6 +335,17 @@ export const ItemEditHeaderActions = ({ } })(); + const publishButtonTooltipLabel = + itemState === ITEM_STATES.dirty ? "Save & Publish Item" : "Publish Item"; + + const datePreposition = (date?: string) => { + if (!date) return "on"; + const formatted = formatDate(date); + return formatted.includes("Today") || formatted.includes("Yesterday") + ? "" + : "on"; + }; + const allowPublish = useMemo(() => { const allowPublishLabelZUIDs = statusLabels?.reduce((acc, next) => { if (next.allowPublish) { @@ -555,34 +573,16 @@ export const ItemEditHeaderActions = ({ itemState === ITEM_STATES.draft || itemState === ITEM_STATES.dirty ? (
- {itemState === ITEM_STATES.dirty - ? "Save & Publish Item" - : "Publish Item"}{" "} -
+ {publishButtonTooltipLabel}
{publishShortcut}
) : (
v{activePublishing?.version} published{" "} - {formatDate(activePublishing?.publishAt).includes("Today") || - formatDate(activePublishing?.publishAt).includes("Yesterday") - ? "" - : "on"} + {datePreposition(activePublishing?.publishAt)}
{formatDate(activePublishing?.publishAt)}
- by{" "} - { - users?.find( - (user: any) => - user.ZUID === activePublishing?.publishedByUserZUID - )?.firstName - }{" "} - { - users?.find( - (user: any) => - user.ZUID === activePublishing?.publishedByUserZUID - )?.lastName - } + by {publisherFullName}
) } @@ -678,30 +678,11 @@ export const ItemEditHeaderActions = ({ - v{activePublishing?.version} scheduled to unpublish on{" "} - {formatDate(activePublishing?.unpublishAt).includes( - "Today" - ) || - formatDate(activePublishing?.unpublishAt).includes( - "Yesterday" - ) - ? "" - : "on"} + v{activePublishing?.version} scheduled to unpublish{" "} + {datePreposition(activePublishing?.unpublishAt)}
{formatDate(activePublishing?.unpublishAt)}
- by{" "} - { - users?.find( - (user: any) => - user.ZUID === activePublishing?.publishedByUserZUID - )?.firstName - }{" "} - { - users?.find( - (user: any) => - user.ZUID === activePublishing?.publishedByUserZUID - )?.lastName - } + by {publisherFullName}
} enterDelay={1000} @@ -739,19 +720,7 @@ export const ItemEditHeaderActions = ({
v{item?.scheduling?.version} published on
{formatDate(item?.scheduling?.publishAt)}
- by{" "} - { - users?.find( - (user: any) => - user.ZUID === item?.scheduling?.publishedByUserZUID - )?.firstName - }{" "} - { - users?.find( - (user: any) => - user.ZUID === item?.scheduling?.publishedByUserZUID - )?.lastName - } + by {publisherFullName}
} placement="bottom-start" @@ -968,6 +937,20 @@ type PublishingMenuProps = { itemZUID: string; }; +const MENU_ACTION_LABELS: Record = { + [ITEM_STATES.dirty]: "Save & Publish", + [ITEM_STATES.scheduled]: "Publish Now", + [ITEM_STATES.published]: "Unpublish Now", + [ITEM_STATES.draft]: "Publish Now", +}; + +const SCHEDULE_ACTION_LABELS: Record = { + [ITEM_STATES.dirty]: "Save & Schedule Publish", + [ITEM_STATES.scheduled]: "Unschedule Publish", + [ITEM_STATES.published]: "Schedule Unpublish", + [ITEM_STATES.draft]: "Schedule Publish", +}; + const PublishingMenu = ({ itemState, onSave, @@ -981,6 +964,12 @@ const PublishingMenu = ({ handlePublish, }: PublishingMenuProps) => { const history = useHistory(); + const menuActionIcon = + itemState === ITEM_STATES.published ? ( + + ) : ( + + ); return ( - - {itemState === ITEM_STATES.dirty ? ( - - ) : itemState === ITEM_STATES.scheduled ? ( - - ) : itemState === ITEM_STATES.published ? ( - - ) : ( - - )} - - {itemState === ITEM_STATES.dirty - ? "Save & Publish" - : itemState === ITEM_STATES.scheduled - ? "Publish Now" - : itemState === ITEM_STATES.published - ? "Unpublish Now" - : "Publish Now"} + {menuActionIcon} + {MENU_ACTION_LABELS[itemState]} {itemState !== ITEM_STATES.published && ( - {itemState === ITEM_STATES.dirty - ? "Save & Schedule Publish" - : itemState === ITEM_STATES.scheduled - ? "Unschedule Publish" - : itemState === ITEM_STATES.published - ? "Schedule Unpublish" - : "Schedule Publish"} + {SCHEDULE_ACTION_LABELS[itemState]} )} {itemState === ITEM_STATES.published && ( diff --git a/src/shell/store/content.js b/src/shell/store/content.js index 0e2a812c88..9d39c831d4 100644 --- a/src/shell/store/content.js +++ b/src/shell/store/content.js @@ -876,24 +876,19 @@ export function publish(modelZUID, itemZUID, data, meta = {}) { } }) .then(() => { - const message = - (!!data.publishAt && data.publishAt !== "now") || - (data.publishAt === "now" && - !!data?.unpublishAt && - data?.unpublishAt !== "never") - ? `Scheduled ${title} to ${ - data.publishAt === "now" && data?.unpublishAt !== "never" - ? "unpublish" - : "publish" - } on ${meta.localTime} in the ${meta.localTimezone} timezone` - : `Published ${title} now`; + let message = `Published ${title} now`; + + if (data.publishAt !== "now" && !!data.publishAt) { + message = `Scheduled ${title} to publish on ${meta.localTime} in the ${meta.localTimezone} timezone`; + } else if ( + data.publishAt === "now" && + !!data?.unpublishAt && + data?.unpublishAt !== "never" + ) { + message = `Scheduled ${title} to unpublish on ${meta.localTime} in the ${meta.localTimezone} timezone`; + } - return dispatch( - notify({ - message, - kind: "save", - }) - ); + return dispatch(notify({ message, kind: "save" })); }) .then(() => { dispatch( From c7f4e8ea9eb4802297e0d3c3082e8b25dad6b1b6 Mon Sep 17 00:00:00 2001 From: geodem Date: Tue, 2 Jun 2026 01:20:38 +0800 Subject: [PATCH 08/34] [Content] - Add comment explaining publish-as-unschedule pattern in SchedulePublish Co-Authored-By: Claude Sonnet 4.6 --- src/shell/components/SchedulePublish/index.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/shell/components/SchedulePublish/index.tsx b/src/shell/components/SchedulePublish/index.tsx index 3f6a533f8f..d64ef4bd37 100644 --- a/src/shell/components/SchedulePublish/index.tsx +++ b/src/shell/components/SchedulePublish/index.tsx @@ -120,6 +120,9 @@ export const SchedulePublish = ({ let actionDispatch = null; if (isForUnpublish) { + // The API has no dedicated "remove unpublishAt" endpoint. Re-publishing with + // unpublishAt: "never" overwrites the existing publishing record in place, + // clearing the scheduled takedown while keeping the item live. actionDispatch = publish( item?.meta?.contentModelZUID, item?.meta?.ZUID, From a4d2adb4f32690e9c3dc12114f02fc81e09ed533 Mon Sep 17 00:00:00 2001 From: geodem Date: Sun, 7 Jun 2026 01:13:39 +0800 Subject: [PATCH 09/34] =?UTF-8?q?Content:=20Address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20schedule=20unpublish=20dialog=20&=20test=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SchedulePublish: introduce hasExistingSchedule gated by isForUnpublish so the "already scheduled" alert only fires for the relevant flow (scheduled publish blocks publish dialog; scheduled unpublish blocks unpublish dialog); fixes false Alert when item has unpublishAt but user opens Schedule Publish - SchedulePublish: simplify Unschedule button onClick to call handleUnschedulePublish directly (isSelectedDatetimePast is structurally always false when that button is visible) - ItemEditHeaderActions: reset scheduledAction to null on dialog close so stale action can't bleed into a re-opened modal - ItemEditHeaderActions: add data-cy="ScheduledUnpublishIndicator" to the Published typography when hasScheduledUnpublish is true - PublishStatus: remove !== currentVersion guard on scheduledUnpublishing so the badge shows even when the scheduled unpublish is for the current live version - actions.spec.js: add cy.visit + cy.wait to both new unpublish tests; make "Cancels" self-contained by scheduling before cancelling; assert API payload shape, dialog copy, scheduled date in unschedule dialog, and menu toggle label Co-Authored-By: Claude Sonnet 4.6 --- cypress/e2e/content/actions.spec.js | 63 ++++++++++++++++- .../ItemEditHeader/ItemEditHeaderActions.tsx | 68 ++++++++----------- .../ItemEditHeader/PublishStatus.tsx | 30 +++++++- .../components/SchedulePublish/index.tsx | 56 ++++++++++----- 4 files changed, 156 insertions(+), 61 deletions(-) diff --git a/cypress/e2e/content/actions.spec.js b/cypress/e2e/content/actions.spec.js index e512f561f8..677743376b 100644 --- a/cypress/e2e/content/actions.spec.js +++ b/cypress/e2e/content/actions.spec.js @@ -247,6 +247,11 @@ describe("Actions in content editor", () => { it("Schedules an item for unpublishing", () => { const { items, publishItem, publishings } = awaitRequests(); + cy.visit( + `/content/${Cypress.env("modelZUID")}/${CONTENT_ITEMS?.[4]?.meta?.ZUID}` + ); + cy.wait([items, publishings], { requestTimeout }); + cy.getBySelector("PublishMenuButton").should("exist").should("be.enabled"); cy.getBySelector("PublishMenuButton").trigger("click"); @@ -260,18 +265,47 @@ describe("Actions in content editor", () => { cy.getBySelector("SchedulePublishModal") .should("exist") .within(() => { - cy.getBySelector("SchedulePublishButton").should("exist"); + // Assert labeling is correct for the unpublish flow + cy.contains("Schedule Unpublish:").should("exist"); + cy.contains("Unpublish on").should("exist"); + cy.getBySelector("SchedulePublishButton") + .should("exist") + .should("contain.text", "Schedule Unpublish"); cy.getBySelector("SchedulePublishButton").trigger("click"); }); - cy.wait(publishItem); + // Assert the API payload branches correctly for scheduled unpublish + cy.wait(publishItem).then((interception) => { + const body = interception.request.body; + expect(body.publishAt).to.equal("now"); + expect(body.unpublishAt).to.match( + /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/ + ); + expect(body.version).to.be.a("number"); + }); cy.wait(publishings); cy.getBySelector("ScheduledUnpublishIndicator").should("exist"); + + // Assert the menu toggle label flips to "Unschedule Unpublish" + cy.getBySelector("PublishMenuButton").trigger("click"); + cy.getBySelector("publishingMenu").within(() => { + cy.getBySelector("UnpublishScheduleButton").should( + "contain.text", + "Unschedule Unpublish" + ); + }); + cy.get("body").type("{esc}"); }); it("Cancels a scheduled unpublish", () => { const { items, publishItem, publishings } = awaitRequests(); + cy.visit( + `/content/${Cypress.env("modelZUID")}/${CONTENT_ITEMS?.[4]?.meta?.ZUID}` + ); + cy.wait([items, publishings], { requestTimeout }); + + // Schedule an unpublish first so this test is self-contained cy.getBySelector("PublishMenuButton").should("exist").should("be.enabled"); cy.getBySelector("PublishMenuButton").trigger("click"); @@ -285,6 +319,31 @@ describe("Actions in content editor", () => { cy.getBySelector("SchedulePublishModal") .should("exist") .within(() => { + cy.getBySelector("SchedulePublishButton").should("exist"); + cy.getBySelector("SchedulePublishButton").trigger("click"); + }); + + cy.wait(publishItem); + cy.wait(publishings); + + cy.getBySelector("ScheduledUnpublishIndicator").should("exist"); + + // Now cancel the scheduled unpublish + cy.getBySelector("PublishMenuButton").trigger("click"); + + cy.getBySelector("publishingMenu") + .should("exist") + .within(() => { + cy.getBySelector("UnpublishScheduleButton").should("exist"); + cy.getBySelector("UnpublishScheduleButton").trigger("click"); + }); + + cy.getBySelector("SchedulePublishModal") + .should("exist") + .within(() => { + // Assert the scheduled date is rendered in the dialog (not blank) + cy.contains("scheduled to unpublish on").should("exist"); + cy.contains(/\w{3} \d{1,2}, \d{4} at \d{1,2}:\d{2}/).should("exist"); cy.getBySelector("UnschedulePublishButton").should("exist"); cy.getBySelector("UnschedulePublishButton").trigger("click"); }); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx index 6f4025eb46..c44a632af5 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx @@ -118,8 +118,6 @@ export const ItemEditHeaderActions = ({ (state: AppState) => state.content[resolvedItemZUID] as ContentItemWithDirtyAndPublishing ); - const hasScheduledUnpublish = - !!item?.publishing?.publishAt && !!item?.publishing?.unpublishAt; const [scheduledAction, setScheduledAction] = useState< "publish" | "unpublish" | null @@ -164,6 +162,11 @@ export const ItemEditHeaderActions = ({ const activePublishing = itemPublishings?.find( (itemPublishing) => itemPublishing._active ); + + const hasScheduledUnpublish = + item?.publishing?.version === item?.meta?.version && + new Date(item?.publishing?.unpublishAt).getTime() > Date.now(); + const { data: statusLabels } = useGetWorkflowStatusLabelsQuery(); const { data: itemWorkflowStatus, isLoading: isLoadingItemWorkflowStatus } = useGetItemWorkflowStatusQuery( @@ -326,7 +329,10 @@ export const ItemEditHeaderActions = ({ const itemState = (() => { if (item?.dirty) { return ITEM_STATES.dirty; - } else if (item?.scheduling?.isScheduled && !item?.publishing?.publishAt) { + } else if ( + item?.scheduling?.version === item?.meta.version && + item?.scheduling?.isScheduled + ) { return ITEM_STATES.scheduled; } else if (activePublishing?.version === item?.meta.version) { return ITEM_STATES.published; @@ -460,6 +466,10 @@ export const ItemEditHeaderActions = ({ // Retain non rtk-query fetch of item publishing for legacy code dispatch(fetchItemPublishing(resolvedModelZUID, resolvedItemZUID)); setUnpublishDialogOpen(false); + + if (scheduledPublishDialogOpen) { + setScheduledPublishDialogOpen(false); + } }); }; @@ -564,7 +574,7 @@ export const ItemEditHeaderActions = ({ )} {((itemState !== ITEM_STATES.scheduled && canPublish) || - hasScheduledUnpublish) && ( + itemState === ITEM_STATES.published) && ( - Published + {hasScheduledUnpublish + ? "Published w/ Unpublish Scheduled" + : "Published"} )} - {hasScheduledUnpublish && ( - - v{activePublishing?.version} scheduled to unpublish{" "} - {datePreposition(activePublishing?.unpublishAt)} -
- {formatDate(activePublishing?.unpublishAt)}
- by {publisherFullName} - - } - enterDelay={1000} - enterNextDelay={1000} - placement="bottom-start" - > - - - - {`v${activePublishing?.version} Scheduled Unpublish`} - - -
- )} )} {itemState === ITEM_STATES.scheduled && canPublish && ( @@ -846,11 +827,16 @@ export const ItemEditHeaderActions = ({ item={item} onClose={() => { setScheduledPublishDialogOpen(false); + setScheduledAction(null); }} onPublishNow={() => { handlePublish(); setScheduledPublishDialogOpen(false); }} + onUnpublishNow={() => { + // setScheduledPublishDialogOpen(false); + setUnpublishDialogOpen(true); + }} onUnscheduleSuccess={() => { if (publishAfterUnschedule) { setIsConfirmPublishModalOpen(true); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/PublishStatus.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/PublishStatus.tsx index f45dda8feb..c3db432b83 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/PublishStatus.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/PublishStatus.tsx @@ -1,7 +1,7 @@ import { Stack, Typography, Tooltip } from "@mui/material"; import { CheckCircleRounded, ScheduleRounded } from "@mui/icons-material"; import { useParams } from "react-router"; - +import ScheduleRoundedIcon from "@mui/icons-material/ScheduleRounded"; import { useGetItemPublishingsQuery } from "../../../../../../../../shell/services/instance"; import { formatDate } from "../../../../../../../../utility/formatDate"; import { useGetUsersQuery } from "../../../../../../../../shell/services/accounts"; @@ -31,6 +31,11 @@ export const PublishStatus = ({ currentVersion }: PublishStatusProps) => { !item.unpublishAt ); + const scheduledUnpublishing = itemPublishings?.find( + (item) => + new Date(item.unpublishAt).getTime() > Date.now() && item.unpublishAt + ); + const getUsername = (userZUID: string) => { const user = users?.find((user) => user.ZUID === userZUID); @@ -100,6 +105,29 @@ export const PublishStatus = ({ currentVersion }: PublishStatusProps) => { )} + + {scheduledUnpublishing && + scheduledUnpublishing.version !== currentVersion && ( + + v{scheduledUnpublishing?.version} scheduled to unpublish
+ {formatDate(scheduledUnpublishing?.unpublishAt)}
+ by {getUsername(scheduledUnpublishing?.publishedByUserZUID)} + + } + placement="bottom-start" + > + + + + {`v${scheduledUnpublishing?.version} Scheduled Unpublish`} + + +
+ )} ); }; diff --git a/src/shell/components/SchedulePublish/index.tsx b/src/shell/components/SchedulePublish/index.tsx index d64ef4bd37..48a6338325 100644 --- a/src/shell/components/SchedulePublish/index.tsx +++ b/src/shell/components/SchedulePublish/index.tsx @@ -29,6 +29,7 @@ type SchedulePublishProps = { item: ContentItemWithDirtyAndPublishing; onClose: () => void; onPublishNow: () => void; + onUnpublishNow?: () => void; onScheduleSuccess?: () => void; onUnscheduleSuccess?: () => void; scheduledAction?: "publish" | "unpublish" | null; @@ -38,6 +39,7 @@ export const SchedulePublish = ({ onClose, item, onPublishNow, + onUnpublishNow, onScheduleSuccess, onUnscheduleSuccess, scheduledAction, @@ -75,6 +77,21 @@ export const SchedulePublish = ({ const isForUnpublish = scheduledAction === "unpublish"; + const hasSchedulePublish = + item?.meta?.version === item?.scheduling?.version && + item?.scheduling?.isScheduled; + + const hasScheduleUnpublish = + item?.meta?.version === item?.publishing?.version && + item?.publishing?.unpublishAt && + new Date(item?.publishing?.unpublishAt).getTime() > Date.now(); + + // Gate "already scheduled" UI on the flow the user is in — avoids showing the + // unschedule prompt when the opposing schedule type exists but is irrelevant. + const hasExistingSchedule = isForUnpublish + ? hasScheduleUnpublish + : hasSchedulePublish; + // API value must be UTC "YYYY-MM-DD HH:mm:ss" const publishAtUtcStr = formatInTimeZone( selectedUtc, @@ -129,7 +146,7 @@ export const SchedulePublish = ({ { publishAt: "now", unpublishAt: "never", - version: item?.meta?.version, + version: item?.publishing?.version, }, { localTime: localPretty, @@ -155,22 +172,23 @@ export const SchedulePublish = ({ }; const guessedTz = tzGuess; - const scheduledLocalText = item?.scheduling?.publishAt - ? formatInTimeZone( - item.scheduling.publishAt, - guessedTz, - "MMM d, yyyy 'at' h:mm a" - ) + + const dateText = isForUnpublish + ? item?.publishing?.unpublishAt + : item?.scheduling?.publishAt; + + const scheduledLocalText = dateText + ? formatInTimeZone(dateText, guessedTz, "MMM d, yyyy 'at' h:mm a") : ""; const tzLabel = TIMEZONES.find((tz) => tz.id === guessedTz)?.label || guessedTz; - const publishHeader = item?.scheduling?.isScheduled + const publishHeader = hasSchedulePublish ? "Unschedule Publish:" : "Schedule Publish:"; - const unpublishHeader = item?.publishing?.unpublishAt + const unpublishHeader = hasScheduleUnpublish ? "Unschedule Unpublish:" : "Schedule Unpublish:"; @@ -194,7 +212,7 @@ export const SchedulePublish = ({ alignItems: "center", }} > - {item?.scheduling?.isScheduled || item?.publishing?.unpublishAt ? ( + {hasExistingSchedule ? ( ) : ( @@ -212,7 +230,7 @@ export const SchedulePublish = ({ - {item?.scheduling?.isScheduled || item?.publishing?.unpublishAt + {hasExistingSchedule ? `v${item?.web?.version} is scheduled to ${ isForUnpublish ? "unpublish" : "publish" } on ${scheduledLocalText} in ${tzLabel}.` @@ -231,7 +249,7 @@ export const SchedulePublish = ({ - {item?.scheduling?.isScheduled || item?.publishing?.unpublishAt ? ( + {hasExistingSchedule ? ( }> This will enable the ability to schedule or publish other versions of this content item @@ -260,8 +278,8 @@ export const SchedulePublish = ({ icon={} sx={{ mt: 2.5 }} > - Since the selected time is a current or past date, this will be - immediately published. + {`Since the selected time is a current or past date, this will be + immediately ${scheduledAction}.`} )} @@ -279,7 +297,7 @@ export const SchedulePublish = ({ Cancel - {item?.scheduling?.isScheduled || item?.publishing?.unpublishAt ? ( + {hasExistingSchedule ? ( @@ -299,7 +317,11 @@ export const SchedulePublish = ({ startIcon={} onClick={() => { if (isSelectedDatetimePast) { - onPublishNow(); + if (isForUnpublish) { + onUnpublishNow?.(); + } else { + onPublishNow(); + } } else { handleSchedulePublish(); } From 11dd249be63191ac9870d7e4e7e1d3f34d800992 Mon Sep 17 00:00:00 2001 From: George Demonteverde <74890703+geodem127@users.noreply.github.com> Date: Sun, 7 Jun 2026 01:37:01 +0800 Subject: [PATCH 10/34] Update src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../components/ItemEditHeader/ItemEditHeaderActions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx index c44a632af5..b3478f9289 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx @@ -834,7 +834,7 @@ export const ItemEditHeaderActions = ({ setScheduledPublishDialogOpen(false); }} onUnpublishNow={() => { - // setScheduledPublishDialogOpen(false); + setScheduledPublishDialogOpen(false); setUnpublishDialogOpen(true); }} onUnscheduleSuccess={() => { From 59be8072ccc92dc09027a71abf54b7fb3d6ef16d Mon Sep 17 00:00:00 2001 From: George Demonteverde <74890703+geodem127@users.noreply.github.com> Date: Sun, 7 Jun 2026 01:45:40 +0800 Subject: [PATCH 11/34] Update src/shell/components/SchedulePublish/index.tsx Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/shell/components/SchedulePublish/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/shell/components/SchedulePublish/index.tsx b/src/shell/components/SchedulePublish/index.tsx index 48a6338325..33e7bcd88c 100644 --- a/src/shell/components/SchedulePublish/index.tsx +++ b/src/shell/components/SchedulePublish/index.tsx @@ -279,7 +279,8 @@ export const SchedulePublish = ({ sx={{ mt: 2.5 }} > {`Since the selected time is a current or past date, this will be - immediately ${scheduledAction}.`} + {`Since the selected time is a current or past date, this will be + immediately ${isForUnpublish ? "unpublished" : "published"}.`} )} From 17c6198fdc50b37e61d47a00a3441d6b19bb0b1f Mon Sep 17 00:00:00 2001 From: geodem Date: Sun, 7 Jun 2026 02:09:45 +0800 Subject: [PATCH 12/34] =?UTF-8?q?Content:=20Address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20schedule=20unpublish=20permission=20fix,=20correct?= =?UTF-8?q?=20toasts=20&=20tooltips?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix permission regression: published-state block now requires canPublish (dropped accidental || itemState === published bypass) - Fix scheduled-state tooltip to use scheduling user (publishedByUserZUID) not the active-publishing user - Fix onUnpublishNow: uncomment setScheduledPublishDialogOpen(false) so schedule dialog closes before unpublish dialog opens; remove now-dead cleanup branch in handleUnpublish - Remove dead case ITEM_STATES.published inside itemState !== ITEM_STATES.published-guarded MenuItem - Add !!unpublishAt existence guard to hasScheduledUnpublish - Remove unused ScheduleRoundedIcon import in ItemEditHeaderActions - Fix "past date" alert in SchedulePublish: render past participle instead of raw scheduledAction state value - Add cancel-scheduled-unpublish notification branch in content.js publish thunk (was showing "Published now") - Fix PublishStatus: remove duplicate ScheduleRoundedIcon import, fix predicate order + add _active filter in scheduledUnpublishing finder, drop version !== currentVersion guard so badge shows on live version - Fix Cypress "Cancels a scheduled unpublish" test: cancel any pre-existing scheduled unpublish before scheduling a fresh one, making the test order-independent Co-Authored-By: Claude Sonnet 4.6 --- cypress/e2e/content/actions.spec.js | 26 +++++++++- .../ItemEditHeader/ItemEditHeaderActions.tsx | 22 ++++----- .../ItemEditHeader/PublishStatus.tsx | 48 +++++++++---------- .../components/SchedulePublish/index.tsx | 6 +-- src/shell/store/content.js | 2 + 5 files changed, 64 insertions(+), 40 deletions(-) diff --git a/cypress/e2e/content/actions.spec.js b/cypress/e2e/content/actions.spec.js index 677743376b..cf56b1f043 100644 --- a/cypress/e2e/content/actions.spec.js +++ b/cypress/e2e/content/actions.spec.js @@ -305,7 +305,31 @@ describe("Actions in content editor", () => { ); cy.wait([items, publishings], { requestTimeout }); - // Schedule an unpublish first so this test is self-contained + // If a prior test left a scheduled unpublish on this item, cancel it first + // so the scheduling step below reliably opens the SchedulePublishButton view. + cy.getBySelector("PublishMenuButton").should("exist").should("be.enabled"); + cy.getBySelector("PublishMenuButton").trigger("click"); + cy.getBySelector("publishingMenu") + .should("exist") + .within(() => { + cy.getBySelector("UnpublishScheduleButton").trigger("click"); + }); + cy.getBySelector("SchedulePublishModal") + .should("exist") + .then(($modal) => { + if ($modal.find("[data-cy='UnschedulePublishButton']").length) { + // Already scheduled — cancel it to restore clean state + cy.wrap($modal) + .find("[data-cy='UnschedulePublishButton']") + .trigger("click"); + cy.wait(publishItem); + cy.wait(publishings); + } else { + cy.getBySelector("CancelSchedulePublishButton").trigger("click"); + } + }); + + // Schedule an unpublish so this test is fully self-contained cy.getBySelector("PublishMenuButton").should("exist").should("be.enabled"); cy.getBySelector("PublishMenuButton").trigger("click"); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx index b3478f9289..02c0b0f4d0 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx @@ -64,8 +64,6 @@ import { PUBLISH_ATTEMPT_WITHOUT_ALLOW_PUBLISH_STATUS, SCHEDULE_PUBLISH_ATTEMPT_WITHOUT_ALLOW_PUBLISH_STATUS, } from "../../../../../../../../amplitude-events"; -import ScheduleRoundedIcon from "@mui/icons-material/ScheduleRounded"; - const ITEM_STATES = { dirty: "dirty", published: "published", @@ -165,6 +163,7 @@ export const ItemEditHeaderActions = ({ const hasScheduledUnpublish = item?.publishing?.version === item?.meta?.version && + !!item?.publishing?.unpublishAt && new Date(item?.publishing?.unpublishAt).getTime() > Date.now(); const { data: statusLabels } = useGetWorkflowStatusLabelsQuery(); @@ -181,6 +180,13 @@ export const ItemEditHeaderActions = ({ publishedByUser?.lastName || "" }`.trim(); + const scheduledByUser = users?.find( + (user: any) => user.ZUID === item?.scheduling?.publishedByUserZUID + ); + const scheduledByFullName = `${scheduledByUser?.firstName || ""} ${ + scheduledByUser?.lastName || "" + }`.trim(); + useEffect(() => { // Automatically opens the create redirect modal // when there are changes to the url path part @@ -466,10 +472,6 @@ export const ItemEditHeaderActions = ({ // Retain non rtk-query fetch of item publishing for legacy code dispatch(fetchItemPublishing(resolvedModelZUID, resolvedItemZUID)); setUnpublishDialogOpen(false); - - if (scheduledPublishDialogOpen) { - setScheduledPublishDialogOpen(false); - } }); }; @@ -573,8 +575,7 @@ export const ItemEditHeaderActions = ({ )} - {((itemState !== ITEM_STATES.scheduled && canPublish) || - itemState === ITEM_STATES.published) && ( + {itemState !== ITEM_STATES.scheduled && canPublish && ( v{item?.scheduling?.version} published on
{formatDate(item?.scheduling?.publishAt)}
- by {publisherFullName} + by {scheduledByFullName} } placement="bottom-start" @@ -1009,9 +1010,6 @@ const PublishingMenu = ({ case ITEM_STATES.scheduled: setScheduledPublishDialogOpen(true, "publish"); break; - case ITEM_STATES.published: - setScheduledPublishDialogOpen(true, "unpublish"); - break; case ITEM_STATES.draft: setScheduledPublishDialogOpen(true, "publish"); break; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/PublishStatus.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/PublishStatus.tsx index c3db432b83..dc1e632c1d 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/PublishStatus.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/PublishStatus.tsx @@ -1,7 +1,6 @@ import { Stack, Typography, Tooltip } from "@mui/material"; import { CheckCircleRounded, ScheduleRounded } from "@mui/icons-material"; import { useParams } from "react-router"; -import ScheduleRoundedIcon from "@mui/icons-material/ScheduleRounded"; import { useGetItemPublishingsQuery } from "../../../../../../../../shell/services/instance"; import { formatDate } from "../../../../../../../../utility/formatDate"; import { useGetUsersQuery } from "../../../../../../../../shell/services/accounts"; @@ -33,7 +32,9 @@ export const PublishStatus = ({ currentVersion }: PublishStatusProps) => { const scheduledUnpublishing = itemPublishings?.find( (item) => - new Date(item.unpublishAt).getTime() > Date.now() && item.unpublishAt + item._active && + item.unpublishAt && + new Date(item.unpublishAt).getTime() > Date.now() ); const getUsername = (userZUID: string) => { @@ -106,28 +107,27 @@ export const PublishStatus = ({ currentVersion }: PublishStatusProps) => {
)} - {scheduledUnpublishing && - scheduledUnpublishing.version !== currentVersion && ( - - v{scheduledUnpublishing?.version} scheduled to unpublish
- {formatDate(scheduledUnpublishing?.unpublishAt)}
- by {getUsername(scheduledUnpublishing?.publishedByUserZUID)} - - } - placement="bottom-start" - > - - - - {`v${scheduledUnpublishing?.version} Scheduled Unpublish`} - - -
- )} + {scheduledUnpublishing && ( + + v{scheduledUnpublishing?.version} scheduled to unpublish
+ {formatDate(scheduledUnpublishing?.unpublishAt)}
+ by {getUsername(scheduledUnpublishing?.publishedByUserZUID)} + + } + placement="bottom-start" + > + + + + {`v${scheduledUnpublishing?.version} Scheduled Unpublish`} + + +
+ )} ); }; diff --git a/src/shell/components/SchedulePublish/index.tsx b/src/shell/components/SchedulePublish/index.tsx index 33e7bcd88c..ae0d9200b4 100644 --- a/src/shell/components/SchedulePublish/index.tsx +++ b/src/shell/components/SchedulePublish/index.tsx @@ -278,9 +278,9 @@ export const SchedulePublish = ({ icon={} sx={{ mt: 2.5 }} > - {`Since the selected time is a current or past date, this will be - {`Since the selected time is a current or past date, this will be - immediately ${isForUnpublish ? "unpublished" : "published"}.`} + {`Since the selected time is a current or past date, this will be immediately ${ + isForUnpublish ? "unpublished" : "published" + }.`} )} diff --git a/src/shell/store/content.js b/src/shell/store/content.js index 9d39c831d4..9eb92c8d22 100644 --- a/src/shell/store/content.js +++ b/src/shell/store/content.js @@ -886,6 +886,8 @@ export function publish(modelZUID, itemZUID, data, meta = {}) { data?.unpublishAt !== "never" ) { message = `Scheduled ${title} to unpublish on ${meta.localTime} in the ${meta.localTimezone} timezone`; + } else if (data.publishAt === "now" && data?.unpublishAt === "never") { + message = `Cancelled scheduled unpublish for ${title}`; } return dispatch(notify({ message, kind: "save" })); From cebe43206037450d4c691e8a14c4cb72371f86af Mon Sep 17 00:00:00 2001 From: geodem Date: Mon, 8 Jun 2026 15:54:49 +0800 Subject: [PATCH 13/34] Content: Fix cancel unpublish crashing when a scheduled publish exists The API rejects POST /publishings with "already has a scheduled publish event" when any future-scheduled publish record exists. The previous fix only deleted the scheduled publish first when it matched the current editing version; now uses a version-agnostic check (hasAnyScheduledPublish) so the pre-deletion step runs regardless of which version is scheduled. Co-Authored-By: Claude Sonnet 4.6 --- .../ItemEditHeader/ItemEditHeaderActions.tsx | 46 +++++----- .../ItemEditHeader/PublishStatus.tsx | 47 ++++++----- .../components/SchedulePublish/index.tsx | 83 ++++++++++++------- 3 files changed, 102 insertions(+), 74 deletions(-) diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx index 02c0b0f4d0..8ced8006e0 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx @@ -161,6 +161,10 @@ export const ItemEditHeaderActions = ({ (itemPublishing) => itemPublishing._active ); + const hasScheduledPublish = + !!item?.scheduling?.isScheduled && + new Date(item?.scheduling?.publishAt).getTime() > Date.now(); + const hasScheduledUnpublish = item?.publishing?.version === item?.meta?.version && !!item?.publishing?.unpublishAt && @@ -173,6 +177,14 @@ export const ItemEditHeaderActions = ({ { skip: !resolvedItemZUID || !resolvedModelZUID } ); + const getUserFullName = (userZUID: string) => { + if (!userZUID) { + return ""; + } + const user = users?.find((user: any) => user.ZUID === userZUID); + return `${user?.firstName || ""} ${user?.lastName || ""}`.trim(); + }; + const publishedByUser = users?.find( (user: any) => user.ZUID === activePublishing?.publishedByUserZUID ); @@ -336,11 +348,14 @@ export const ItemEditHeaderActions = ({ if (item?.dirty) { return ITEM_STATES.dirty; } else if ( - item?.scheduling?.version === item?.meta.version && - item?.scheduling?.isScheduled + item?.scheduling?.isScheduled && + item?.scheduling?.version === item?.meta.version ) { return ITEM_STATES.scheduled; - } else if (activePublishing?.version === item?.meta.version) { + } else if ( + item?.publishing?.isPublished && + item?.publishing?.version === item?.meta.version + ) { return ITEM_STATES.published; } else { return ITEM_STATES.draft; @@ -385,7 +400,7 @@ export const ItemEditHeaderActions = ({ // Delete scheduled publishings first const deleteScheduledPromises = [ // Delete main item's scheduled publishing if it exists - itemState === ITEM_STATES.scheduled && + hasScheduledPublish && deleteItemPublishing({ modelZUID: resolvedModelZUID, itemZUID: resolvedItemZUID, @@ -537,15 +552,7 @@ export const ItemEditHeaderActions = ({
v{item?.meta?.version} saved on
{formatDate(item?.meta?.updatedAt)}
- by{" "} - {lastItemUpdateAudit?.firstName || - users?.find( - (user) => user.ZUID === item?.meta?.createdByUserZUID - )?.firstName}{" "} - {lastItemUpdateAudit?.lastName || - users?.find( - (user) => user.ZUID === item?.meta?.createdByUserZUID - )?.lastName} + by {getUserFullName(item?.meta.createdByUserZUID || "")}
) } @@ -592,8 +599,11 @@ export const ItemEditHeaderActions = ({ v{activePublishing?.version} published{" "} {datePreposition(activePublishing?.publishAt)}
- {formatDate(activePublishing?.publishAt)}
- by {publisherFullName} + {!!activePublishing && + formatDate(activePublishing?.publishAt)} +
+ by{" "} + {getUserFullName(activePublishing?.publishedByUserZUID || "")} ) } @@ -675,9 +685,7 @@ export const ItemEditHeaderActions = ({ fontWeight={500} letterSpacing="0.46px" > - {hasScheduledUnpublish - ? "Published w/ Unpublish Scheduled" - : "Published"} + Published
v{item?.scheduling?.version} published on
{formatDate(item?.scheduling?.publishAt)}
- by {scheduledByFullName} + by {getUserFullName(item?.scheduling?.publishedByUserZUID || "")} } placement="bottom-start" diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/PublishStatus.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/PublishStatus.tsx index dc1e632c1d..6859ed434e 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/PublishStatus.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/PublishStatus.tsx @@ -33,8 +33,8 @@ export const PublishStatus = ({ currentVersion }: PublishStatusProps) => { const scheduledUnpublishing = itemPublishings?.find( (item) => item._active && - item.unpublishAt && - new Date(item.unpublishAt).getTime() > Date.now() + item?.unpublishAt && + new Date(item?.unpublishAt).getTime() > Date.now() ); const getUsername = (userZUID: string) => { @@ -107,27 +107,28 @@ export const PublishStatus = ({ currentVersion }: PublishStatusProps) => { )} - {scheduledUnpublishing && ( - - v{scheduledUnpublishing?.version} scheduled to unpublish
- {formatDate(scheduledUnpublishing?.unpublishAt)}
- by {getUsername(scheduledUnpublishing?.publishedByUserZUID)} - - } - placement="bottom-start" - > - - - - {`v${scheduledUnpublishing?.version} Scheduled Unpublish`} - - -
- )} + {scheduledUnpublishing && + scheduledUnpublishing.version === currentVersion && ( + + v{scheduledUnpublishing?.version} scheduled to unpublish
+ {formatDate(scheduledUnpublishing?.unpublishAt)}
+ by {getUsername(scheduledUnpublishing?.publishedByUserZUID)} + + } + placement="bottom-start" + > + + + + {`v${scheduledUnpublishing?.version} Scheduled Unpublish`} + + +
+ )} ); }; diff --git a/src/shell/components/SchedulePublish/index.tsx b/src/shell/components/SchedulePublish/index.tsx index ae0d9200b4..9f2bc62aa8 100644 --- a/src/shell/components/SchedulePublish/index.tsx +++ b/src/shell/components/SchedulePublish/index.tsx @@ -81,6 +81,12 @@ export const SchedulePublish = ({ item?.meta?.version === item?.scheduling?.version && item?.scheduling?.isScheduled; + // Broader check used only for conflict resolution: any active future-scheduled + // publish blocks the API from accepting a new POST to /publishings, regardless + // of which version is involved. + const hasAnyScheduledPublish = + !!item?.scheduling?.ZUID && !!item?.scheduling?.isScheduled; + const hasScheduleUnpublish = item?.meta?.version === item?.publishing?.version && item?.publishing?.unpublishAt && @@ -131,44 +137,57 @@ export const SchedulePublish = ({ }); }; - const handleUnschedulePublish = () => { + const handleUnschedulePublish = async () => { setIsLoading(true); - let actionDispatch = null; - - if (isForUnpublish) { - // The API has no dedicated "remove unpublishAt" endpoint. Re-publishing with - // unpublishAt: "never" overwrites the existing publishing record in place, - // clearing the scheduled takedown while keeping the item live. - actionDispatch = publish( - item?.meta?.contentModelZUID, - item?.meta?.ZUID, - { - publishAt: "now", - unpublishAt: "never", - version: item?.publishing?.version, - }, - { - localTime: localPretty, - localTimezone: publishTimezone, + try { + if (isForUnpublish && hasScheduleUnpublish) { + // The API has no dedicated "remove unpublishAt" endpoint. Re-publishing with + // unpublishAt: "never" clears the scheduled takedown while keeping the item live. + // However, when a scheduled future publish also exists, the API rejects creating + // a new publishing record with "already has a scheduled publish event." Delete + // the scheduled publish first to unblock the POST. + if (hasAnyScheduledPublish) { + await (dispatch as Function)( + unpublish( + item?.meta?.contentModelZUID, + item?.meta?.ZUID, + item?.scheduling?.ZUID, + { version: item?.scheduling?.version } + ) + ); } - ); - } else { - actionDispatch = unpublish( - item?.meta?.contentModelZUID, - item?.meta?.ZUID, - item?.scheduling?.ZUID, - { version: item?.scheduling?.version } - ); - } - dispatch( - actionDispatch - // @ts-expect-error untyped action - ).finally(() => { + + await (dispatch as Function)( + publish( + item?.meta?.contentModelZUID, + item?.meta?.ZUID, + { + publishAt: "now", + unpublishAt: "never", + version: item?.publishing?.version, + }, + { + localTime: localPretty, + localTimezone: publishTimezone, + } + ) + ); + } else { + await (dispatch as Function)( + unpublish( + item?.meta?.contentModelZUID, + item?.meta?.ZUID, + item?.scheduling?.ZUID, + { version: item?.scheduling?.version } + ) + ); + } + } finally { setIsLoading(false); onClose(); onUnscheduleSuccess?.(); - }); + } }; const guessedTz = tzGuess; From c796199269d69fd243656e06b78e0a6270fb8cc3 Mon Sep 17 00:00:00 2001 From: geodem Date: Mon, 8 Jun 2026 17:25:13 +0800 Subject: [PATCH 14/34] =?UTF-8?q?Content:=20Address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20dead=20vars,=20error=20toast,=20styling,=20spurious?= =?UTF-8?q?=20wrapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove dead publishedByUser/publisherFullName, scheduledByUser/scheduledByFullName, and lastItemUpdateAudit variables (now inlined via getUserFullName) - Fix error toast in publish() thunk: distinguish cancel-unpublish ("Error cancelling scheduled unpublish") from schedule-publish ("Error scheduling") and publish-now - Move onUnscheduleSuccess callback out of finally into try so it only fires on success - Simplify hasAnyScheduledPublish to !!item?.scheduling?.ZUID (isScheduled redundant) - Remove spurious wrapper around publish Tooltip - Remove dead SCHEDULE_ACTION_LABELS[published] entry (gated menu never renders it) - Add fontWeight/lineHeight/letterSpacing to PublishStatus scheduled-unpublish badge Co-Authored-By: Claude Sonnet 4.6 --- .../ItemEditHeader/ItemEditHeaderActions.tsx | 237 ++++++++---------- .../ItemEditHeader/PublishStatus.tsx | 8 +- .../components/SchedulePublish/index.tsx | 5 +- src/shell/store/content.js | 18 +- 4 files changed, 125 insertions(+), 143 deletions(-) diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx index 8ced8006e0..65c948f044 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx @@ -139,9 +139,6 @@ export const ItemEditHeaderActions = ({ const [createPublishing] = useCreateItemPublishingMutation(); const [deleteItemPublishing, { isLoading: unpublishing }] = useDeleteItemPublishingMutation(); - const lastItemUpdateAudit = itemAudit?.find( - (audit) => audit.action === 2 || audit.action === 1 - ); const { data: itemPublishings, isFetching } = useGetItemPublishingsQuery({ modelZUID: resolvedModelZUID, itemZUID: resolvedItemZUID, @@ -185,20 +182,6 @@ export const ItemEditHeaderActions = ({ return `${user?.firstName || ""} ${user?.lastName || ""}`.trim(); }; - const publishedByUser = users?.find( - (user: any) => user.ZUID === activePublishing?.publishedByUserZUID - ); - const publisherFullName = `${publishedByUser?.firstName || ""} ${ - publishedByUser?.lastName || "" - }`.trim(); - - const scheduledByUser = users?.find( - (user: any) => user.ZUID === item?.scheduling?.publishedByUserZUID - ); - const scheduledByFullName = `${scheduledByUser?.firstName || ""} ${ - scheduledByUser?.lastName || "" - }`.trim(); - useEffect(() => { // Automatically opens the create redirect modal // when there are changes to the url path part @@ -583,124 +566,119 @@ export const ItemEditHeaderActions = ({ )} {itemState !== ITEM_STATES.scheduled && canPublish && ( - - - {publishButtonTooltipLabel}
- {publishShortcut} - - ) : ( -
- v{activePublishing?.version} published{" "} - {datePreposition(activePublishing?.publishAt)} -
- {!!activePublishing && - formatDate(activePublishing?.publishAt)} -
- by{" "} - {getUserFullName(activePublishing?.publishedByUserZUID || "")} -
- ) - } - placement="bottom-start" - > - {itemState === ITEM_STATES.draft || - itemState === ITEM_STATES.dirty || - publishAfterSave || - isFetching || - saving ? ( - + {publishButtonTooltipLabel}
+ {publishShortcut} + + ) : ( +
+ v{activePublishing?.version} published{" "} + {datePreposition(activePublishing?.publishAt)} +
+ {!!activePublishing && formatDate(activePublishing?.publishAt)} +
+ by{" "} + {getUserFullName(activePublishing?.publishedByUserZUID || "")} +
+ ) + } + placement="bottom-start" + > + {itemState === ITEM_STATES.draft || + itemState === ITEM_STATES.dirty || + publishAfterSave || + isFetching || + saving ? ( + + - - - ) : ( - + +
+ ) : ( + + + + - -
+ Published + - )} - - + { + setPublishMenu(e.currentTarget); + }} + > + + + + )} + )} {itemState === ITEM_STATES.scheduled && canPublish && ( = { const SCHEDULE_ACTION_LABELS: Record = { [ITEM_STATES.dirty]: "Save & Schedule Publish", [ITEM_STATES.scheduled]: "Unschedule Publish", - [ITEM_STATES.published]: "Schedule Unpublish", [ITEM_STATES.draft]: "Schedule Publish", }; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/PublishStatus.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/PublishStatus.tsx index 6859ed434e..83f859f229 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/PublishStatus.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/PublishStatus.tsx @@ -123,7 +123,13 @@ export const PublishStatus = ({ currentVersion }: PublishStatusProps) => { > - + {`v${scheduledUnpublishing?.version} Scheduled Unpublish`} diff --git a/src/shell/components/SchedulePublish/index.tsx b/src/shell/components/SchedulePublish/index.tsx index 9f2bc62aa8..4c1e2bdc6a 100644 --- a/src/shell/components/SchedulePublish/index.tsx +++ b/src/shell/components/SchedulePublish/index.tsx @@ -84,8 +84,7 @@ export const SchedulePublish = ({ // Broader check used only for conflict resolution: any active future-scheduled // publish blocks the API from accepting a new POST to /publishings, regardless // of which version is involved. - const hasAnyScheduledPublish = - !!item?.scheduling?.ZUID && !!item?.scheduling?.isScheduled; + const hasAnyScheduledPublish = !!item?.scheduling?.ZUID; const hasScheduleUnpublish = item?.meta?.version === item?.publishing?.version && @@ -183,10 +182,10 @@ export const SchedulePublish = ({ ) ); } + onUnscheduleSuccess?.(); } finally { setIsLoading(false); onClose(); - onUnscheduleSuccess?.(); } }; diff --git a/src/shell/store/content.js b/src/shell/store/content.js index 9eb92c8d22..3b07d62c7a 100644 --- a/src/shell/store/content.js +++ b/src/shell/store/content.js @@ -901,15 +901,15 @@ export function publish(modelZUID, itemZUID, data, meta = {}) { return dispatch(fetchItemPublishing(modelZUID, itemZUID)); }) .catch((err) => { - const message = data.publishAt - ? `Error scheduling ${title}` - : `Error publishing ${title}`; - dispatch( - notify({ - message, - kind: "error", - }) - ); + let message; + if (data.publishAt === "now" && data?.unpublishAt === "never") { + message = `Error cancelling scheduled unpublish for ${title}`; + } else if (data.publishAt !== "now" && !!data.publishAt) { + message = `Error scheduling ${title}`; + } else { + message = `Error publishing ${title}`; + } + dispatch(notify({ message, kind: "error" })); throw err; }); }; From b4a5554ff5b1c2ad4497c007c4e170ad73cf9b80 Mon Sep 17 00:00:00 2001 From: George Demonteverde <74890703+geodem127@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:32:28 +0800 Subject: [PATCH 15/34] Update src/shell/components/SchedulePublish/index.tsx Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- cypress/e2e/content/actions.spec.js | 23 + .../ItemEditHeader/ItemEditHeaderActions.tsx | 13 +- .../components/SchedulePublish/index.tsx | 397 +++++++++++++----- 3 files changed, 327 insertions(+), 106 deletions(-) diff --git a/cypress/e2e/content/actions.spec.js b/cypress/e2e/content/actions.spec.js index cf56b1f043..b3e85b398d 100644 --- a/cypress/e2e/content/actions.spec.js +++ b/cypress/e2e/content/actions.spec.js @@ -252,6 +252,29 @@ describe("Actions in content editor", () => { ); cy.wait([items, publishings], { requestTimeout }); + // Cancel any stale scheduled unpublish from a prior run so the scheduling + // step below reliably opens the SchedulePublishButton (not Unschedule) view. + cy.getBySelector("PublishMenuButton").should("exist").should("be.enabled"); + cy.getBySelector("PublishMenuButton").trigger("click"); + cy.getBySelector("publishingMenu").within(() => { + cy.getBySelector("UnpublishScheduleButton").trigger("click"); + }); + cy.getBySelector("SchedulePublishModal") + .should("exist") + .then(($modal) => { + if ($modal.find("[data-cy='UnschedulePublishButton']").length) { + cy.wrap($modal) + .find("[data-cy='UnschedulePublishButton']") + .trigger("click"); + cy.wait(publishItem); + cy.wait(publishings); + } else { + cy.wrap($modal) + .find("[data-cy='CancelSchedulePublishButton']") + .trigger("click"); + } + }); + cy.getBySelector("PublishMenuButton").should("exist").should("be.enabled"); cy.getBySelector("PublishMenuButton").trigger("click"); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx index 65c948f044..a6f1c97ab9 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx @@ -158,6 +158,10 @@ export const ItemEditHeaderActions = ({ (itemPublishing) => itemPublishing._active ); + const lastItemUpdateAudit = itemAudit?.find( + (audit) => audit.action === 2 || audit.action === 1 + ); + const hasScheduledPublish = !!item?.scheduling?.isScheduled && new Date(item?.scheduling?.publishAt).getTime() > Date.now(); @@ -535,7 +539,12 @@ export const ItemEditHeaderActions = ({
v{item?.meta?.version} saved on
{formatDate(item?.meta?.updatedAt)}
- by {getUserFullName(item?.meta.createdByUserZUID || "")} + by{" "} + {lastItemUpdateAudit + ? `${lastItemUpdateAudit.firstName ?? ""} ${ + lastItemUpdateAudit.lastName ?? "" + }`.trim() + : getUserFullName(item?.meta?.createdByUserZUID || "")}
) } @@ -819,9 +828,11 @@ export const ItemEditHeaderActions = ({ onPublishNow={() => { handlePublish(); setScheduledPublishDialogOpen(false); + setScheduledAction(null); }} onUnpublishNow={() => { setScheduledPublishDialogOpen(false); + setScheduledAction(null); setUnpublishDialogOpen(true); }} onUnscheduleSuccess={() => { diff --git a/src/shell/components/SchedulePublish/index.tsx b/src/shell/components/SchedulePublish/index.tsx index 4c1e2bdc6a..3f85e5b8bd 100644 --- a/src/shell/components/SchedulePublish/index.tsx +++ b/src/shell/components/SchedulePublish/index.tsx @@ -77,50 +77,114 @@ export const SchedulePublish = ({ const isForUnpublish = scheduledAction === "unpublish"; - const hasSchedulePublish = - item?.meta?.version === item?.scheduling?.version && - item?.scheduling?.isScheduled; + // ── Publish flow (original behaviour) ──────────────────────────────────── + // Uses item?.scheduling?.isScheduled directly, matching the pre-PR component. + const isAlreadyScheduledPublish = item?.scheduling?.isScheduled; + + const publishScheduledLocalText = item?.scheduling?.publishAt + ? formatInTimeZone( + item.scheduling.publishAt, + tzGuess, + "MMM d, yyyy 'at' h:mm a" + ) + : ""; + + const handleSchedulePublish = () => { + setIsLoading(true); - // Broader check used only for conflict resolution: any active future-scheduled - // publish blocks the API from accepting a new POST to /publishings, regardless - // of which version is involved. - const hasAnyScheduledPublish = !!item?.scheduling?.ZUID; + const publishAtUtcStr = formatInTimeZone( + selectedUtc, + "UTC", + "yyyy-MM-dd HH:mm:ss" + ); + const localPretty = formatInTimeZone( + selectedUtc, + publishTimezone, + "MMMM do yyyy, 'at' h:mm a" + ); + + dispatch( + publish( + item?.meta?.contentModelZUID, + item?.meta?.ZUID, + { + publishAt: publishAtUtcStr, + version: item?.meta?.version, + }, + { + localTime: localPretty, + localTimezone: publishTimezone, + } + ) + // @ts-expect-error untyped action + ).finally(() => { + onScheduleSuccess?.(); + setIsLoading(false); + onClose(); + }); + }; + + const handleUnschedulePublish = () => { + setIsLoading(true); + + dispatch( + unpublish( + item?.meta?.contentModelZUID, + item?.meta?.ZUID, + item?.scheduling?.ZUID, + { version: item?.scheduling?.version } + ) + // @ts-expect-error untyped action + ).finally(() => { + setIsLoading(false); + onClose(); + onUnscheduleSuccess?.(); + }); + }; - const hasScheduleUnpublish = + // ── Unpublish flow (new behaviour) ──────────────────────────────────────── + // Broader check: any active future-scheduled publish blocks the API from + // accepting a new POST to /publishings, regardless of version. + const hasAnyScheduledPublish = + !!item?.scheduling?.ZUID && + !!item?.scheduling?.publishAt && + new Date(item?.scheduling?.publishAt).getTime() > Date.now(); + + const isAlreadyScheduledUnpublish = !!( item?.meta?.version === item?.publishing?.version && item?.publishing?.unpublishAt && - new Date(item?.publishing?.unpublishAt).getTime() > Date.now(); - - // Gate "already scheduled" UI on the flow the user is in — avoids showing the - // unschedule prompt when the opposing schedule type exists but is irrelevant. - const hasExistingSchedule = isForUnpublish - ? hasScheduleUnpublish - : hasSchedulePublish; - - // API value must be UTC "YYYY-MM-DD HH:mm:ss" - const publishAtUtcStr = formatInTimeZone( - selectedUtc, - "UTC", - "yyyy-MM-dd HH:mm:ss" + new Date(item?.publishing?.unpublishAt).getTime() > Date.now() ); - // Pretty local confirmation text in the chosen timezone - const localPretty = formatInTimeZone( - selectedUtc, - publishTimezone, - "MMMM do yyyy, 'at' h:mm a" - ); + const unpublishScheduledLocalText = item?.publishing?.unpublishAt + ? formatInTimeZone( + item.publishing.unpublishAt, + tzGuess, + "MMM d, yyyy 'at' h:mm a" + ) + : ""; - const handleSchedulePublish = () => { + const handleScheduleUnpublish = () => { setIsLoading(true); + const publishAtUtcStr = formatInTimeZone( + selectedUtc, + "UTC", + "yyyy-MM-dd HH:mm:ss" + ); + const localPretty = formatInTimeZone( + selectedUtc, + publishTimezone, + "MMMM do yyyy, 'at' h:mm a" + ); + dispatch( publish( item?.meta?.contentModelZUID, item?.meta?.ZUID, { - publishAt: isForUnpublish ? "now" : publishAtUtcStr, - unpublishAt: isForUnpublish ? publishAtUtcStr : "never", + publishAt: "now", + unpublishAt: publishAtUtcStr, version: item?.meta?.version, }, { @@ -136,43 +200,16 @@ export const SchedulePublish = ({ }); }; - const handleUnschedulePublish = async () => { + const handleUnscheduleUnpublish = async () => { setIsLoading(true); try { - if (isForUnpublish && hasScheduleUnpublish) { - // The API has no dedicated "remove unpublishAt" endpoint. Re-publishing with - // unpublishAt: "never" clears the scheduled takedown while keeping the item live. - // However, when a scheduled future publish also exists, the API rejects creating - // a new publishing record with "already has a scheduled publish event." Delete - // the scheduled publish first to unblock the POST. - if (hasAnyScheduledPublish) { - await (dispatch as Function)( - unpublish( - item?.meta?.contentModelZUID, - item?.meta?.ZUID, - item?.scheduling?.ZUID, - { version: item?.scheduling?.version } - ) - ); - } - - await (dispatch as Function)( - publish( - item?.meta?.contentModelZUID, - item?.meta?.ZUID, - { - publishAt: "now", - unpublishAt: "never", - version: item?.publishing?.version, - }, - { - localTime: localPretty, - localTimezone: publishTimezone, - } - ) - ); - } else { + // The API has no dedicated "remove unpublishAt" endpoint. Re-publishing with + // unpublishAt: "never" clears the scheduled takedown while keeping the item live. + // However, when a scheduled future publish also exists, the API rejects creating + // a new publishing record with "already has a scheduled publish event." Delete + // the scheduled publish first to unblock the POST. + if (hasAnyScheduledPublish) { await (dispatch as Function)( unpublish( item?.meta?.contentModelZUID, @@ -182,34 +219,191 @@ export const SchedulePublish = ({ ) ); } + + await (dispatch as Function)( + publish( + item?.meta?.contentModelZUID, + item?.meta?.ZUID, + { + publishAt: "now", + unpublishAt: "never", + version: item?.publishing?.version, + }, + { + localTime: "", + localTimezone: publishTimezone, + } + ) + ); + onUnscheduleSuccess?.(); + } catch { + // Error notification is handled by the thunk; swallow here so the + // finally block always closes the dialog cleanly. } finally { setIsLoading(false); onClose(); } }; - const guessedTz = tzGuess; - - const dateText = isForUnpublish - ? item?.publishing?.unpublishAt - : item?.scheduling?.publishAt; - - const scheduledLocalText = dateText - ? formatInTimeZone(dateText, guessedTz, "MMM d, yyyy 'at' h:mm a") - : ""; - - const tzLabel = - TIMEZONES.find((tz) => tz.id === guessedTz)?.label || guessedTz; - - const publishHeader = hasSchedulePublish - ? "Unschedule Publish:" - : "Schedule Publish:"; - - const unpublishHeader = hasScheduleUnpublish - ? "Unschedule Unpublish:" - : "Schedule Unpublish:"; + // ── Shared timezone label ───────────────────────────────────────────────── + const tzLabel = TIMEZONES.find((tz) => tz.id === tzGuess)?.label || tzGuess; + + // ── Render ──────────────────────────────────────────────────────────────── + if (isForUnpublish) { + // New unpublish scheduling flow + return ( + + + + + {isAlreadyScheduledUnpublish ? ( + + ) : ( + + )} + + + + + {isAlreadyScheduledUnpublish + ? "Unschedule Unpublish:" + : "Schedule Unpublish:"} +   + + + {item?.web?.metaLinkText} + + + + + {isAlreadyScheduledUnpublish + ? `v${item?.web?.version} is scheduled to unpublish on ${unpublishScheduledLocalText} in ${tzLabel}.` + : `v${item?.web?.version} saved ${ + item?.web?.createdAt + ? formatDistanceToNow(new Date(item.web.createdAt), { + addSuffix: true, + }) + : "" + } by ${latestChangeCreator?.firstName ?? ""} ${ + latestChangeCreator?.lastName ?? "" + }`} + + + + + + + {isAlreadyScheduledUnpublish ? ( + <> + }> + This will enable the ability to schedule or publish other + versions of this content item + + {hasAnyScheduledPublish && ( + } + sx={{ mt: 1.5 }} + > + {`This will also cancel the scheduled publish for v${item?.scheduling?.version}.`} + + )} + + ) : ( + <> + + Unpublish on + + { + const normalized = String(datetime).replace(/\.\d+$/, ""); + setPublishDateTime(normalized); + }} + onTimezoneChange={(timezone: any) => + setPublishTimezone(timezone) + } + /> + {isSelectedDatetimePast && ( + } + sx={{ mt: 2.5 }} + > + Since the selected time is a current or past date, this will + be immediately unpublished. + + )} + + )} + + + + + {isAlreadyScheduledUnpublish ? ( + + ) : ( + + )} + + + ); + } + + // Original publish scheduling flow (unchanged from pre-PR behaviour) return ( - {hasExistingSchedule ? ( + {isAlreadyScheduledPublish ? ( ) : ( @@ -239,7 +433,9 @@ export const SchedulePublish = ({ - {isForUnpublish ? unpublishHeader : publishHeader} + {isAlreadyScheduledPublish + ? "Unschedule Publish:" + : "Schedule Publish:"}   @@ -248,10 +444,8 @@ export const SchedulePublish = ({ - {hasExistingSchedule - ? `v${item?.web?.version} is scheduled to ${ - isForUnpublish ? "unpublish" : "publish" - } on ${scheduledLocalText} in ${tzLabel}.` + {isAlreadyScheduledPublish + ? `v${item?.web?.version} is scheduled to publish on ${publishScheduledLocalText} in ${tzLabel}.` : `v${item?.web?.version} saved ${ item?.web?.createdAt ? formatDistanceToNow(new Date(item.web.createdAt), { @@ -267,7 +461,7 @@ export const SchedulePublish = ({ - {hasExistingSchedule ? ( + {isAlreadyScheduledPublish ? ( }> This will enable the ability to schedule or publish other versions of this content item @@ -275,7 +469,7 @@ export const SchedulePublish = ({ ) : ( <> - {`${isForUnpublish ? "Unpublish" : "Publish"} on`} + Publish on } sx={{ mt: 2.5 }} > - {`Since the selected time is a current or past date, this will be immediately ${ - isForUnpublish ? "unpublished" : "published" - }.`} + Since the selected time is a current or past date, this will be + immediately published. )} @@ -316,7 +509,7 @@ export const SchedulePublish = ({ Cancel - {hasExistingSchedule ? ( + {isAlreadyScheduledPublish ? ( ) : ( )} From 2d2c6a30bdf46a7481c8bb16df92e98236971816 Mon Sep 17 00:00:00 2001 From: geodem Date: Tue, 9 Jun 2026 08:05:00 +0800 Subject: [PATCH 16/34] Content: Refactor SchedulePublish into smart container + dumb dialogs; fix unpublish thunk and audit sort Split SchedulePublish into a smart container (index.tsx, all state/handlers) and two pure presentation components (SchedulePublishDialog, ScheduleUnpublishDialog). Publish flow retains exact pre-PR behaviour. Unpublish flow is new and routes to ScheduleUnpublishDialog. Bug fixes from code review: - unpublish thunk now re-throws in .catch() so handleUnscheduleUnpublish step-2 is correctly gated on step-1 success - ItemEditHeaderActions: use .findLast() for audit (API returns ascending/oldest-first) - handleUnschedulePublish: move onUnscheduleSuccess to .then() not .finally() so it only fires on success - ScheduleUnpublishDialog: fall back to onSchedule when onUnpublishNow is absent - Cypress stale-state cleanup: throw explicit error instead of silently skipping Co-Authored-By: Claude Sonnet 4.6 --- cypress/e2e/content/actions.spec.js | 8 +- .../ItemEditHeader/ItemEditHeaderActions.tsx | 2 +- .../SchedulePublish/SchedulePublishDialog.tsx | 172 +++++++ .../ScheduleUnpublishDialog.tsx | 191 ++++++++ .../components/SchedulePublish/index.tsx | 443 ++++-------------- src/shell/store/content.js | 8 +- 6 files changed, 452 insertions(+), 372 deletions(-) create mode 100644 src/shell/components/SchedulePublish/SchedulePublishDialog.tsx create mode 100644 src/shell/components/SchedulePublish/ScheduleUnpublishDialog.tsx diff --git a/cypress/e2e/content/actions.spec.js b/cypress/e2e/content/actions.spec.js index b3e85b398d..cfd8101769 100644 --- a/cypress/e2e/content/actions.spec.js +++ b/cypress/e2e/content/actions.spec.js @@ -268,10 +268,16 @@ describe("Actions in content editor", () => { .trigger("click"); cy.wait(publishItem); cy.wait(publishings); - } else { + } else if ( + $modal.find("[data-cy='CancelSchedulePublishButton']").length + ) { cy.wrap($modal) .find("[data-cy='CancelSchedulePublishButton']") .trigger("click"); + } else { + throw new Error( + "SchedulePublishModal opened in unexpected state — neither UnschedulePublishButton nor CancelSchedulePublishButton found" + ); } }); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx index a6f1c97ab9..bbb1d5f966 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx @@ -158,7 +158,7 @@ export const ItemEditHeaderActions = ({ (itemPublishing) => itemPublishing._active ); - const lastItemUpdateAudit = itemAudit?.find( + const lastItemUpdateAudit = itemAudit?.findLast( (audit) => audit.action === 2 || audit.action === 1 ); diff --git a/src/shell/components/SchedulePublish/SchedulePublishDialog.tsx b/src/shell/components/SchedulePublish/SchedulePublishDialog.tsx new file mode 100644 index 0000000000..7a76e4d239 --- /dev/null +++ b/src/shell/components/SchedulePublish/SchedulePublishDialog.tsx @@ -0,0 +1,172 @@ +import { + Dialog, + DialogActions, + DialogTitle, + DialogContent, + Typography, + Button, + Stack, + Box, + Alert, +} from "@mui/material"; +import ScheduleRoundedIcon from "@mui/icons-material/ScheduleRounded"; +import WarningRoundedIcon from "@mui/icons-material/WarningRounded"; +import CalendarTodayRoundedIcon from "@mui/icons-material/CalendarTodayRounded"; +import InfoRoundedIcon from "@mui/icons-material/InfoRounded"; + +import { FieldTypeDateTime } from "../FieldTypeDateTime"; + +export type SchedulePublishDialogProps = { + itemName: string; + currentVersion: number; + scheduledLocalText: string; + creatorName: string; + savedAgo: string; + tzLabel: string; + publishDateTime: string; + publishTimezone: string; + isLoading: boolean; + isSelectedDatetimePast: boolean; + isAlreadyScheduled: boolean; + onClose: () => void; + onPublishNow: () => void; + onSchedule: () => void; + onUnschedule: () => void; + onDateTimeChange: (datetime: any) => void; + onTimezoneChange: (timezone: any) => void; +}; + +export const SchedulePublishDialog = ({ + itemName, + currentVersion, + scheduledLocalText, + creatorName, + savedAgo, + tzLabel, + publishDateTime, + publishTimezone, + isLoading, + isSelectedDatetimePast, + isAlreadyScheduled, + onClose, + onPublishNow, + onSchedule, + onUnschedule, + onDateTimeChange, + onTimezoneChange, +}: SchedulePublishDialogProps) => ( + + + + + {isAlreadyScheduled ? ( + + ) : ( + + )} + + + + + {isAlreadyScheduled ? "Unschedule Publish:" : "Schedule Publish:"} +   + + + {itemName} + + + + {isAlreadyScheduled + ? `v${currentVersion} is scheduled to publish on ${scheduledLocalText} in ${tzLabel}.` + : `v${currentVersion} saved ${savedAgo} by ${creatorName}`} + + + + + + + {isAlreadyScheduled ? ( + }> + This will enable the ability to schedule or publish other versions of + this content item + + ) : ( + <> + + Publish on + + + {isSelectedDatetimePast && ( + } + sx={{ mt: 2.5 }} + > + Since the selected time is a current or past date, this will be + immediately published. + + )} + + )} + + + + + + {isAlreadyScheduled ? ( + + ) : ( + + )} + + +); diff --git a/src/shell/components/SchedulePublish/ScheduleUnpublishDialog.tsx b/src/shell/components/SchedulePublish/ScheduleUnpublishDialog.tsx new file mode 100644 index 0000000000..560dd21aab --- /dev/null +++ b/src/shell/components/SchedulePublish/ScheduleUnpublishDialog.tsx @@ -0,0 +1,191 @@ +import { + Dialog, + DialogActions, + DialogTitle, + DialogContent, + Typography, + Button, + Stack, + Box, + Alert, +} from "@mui/material"; +import ScheduleRoundedIcon from "@mui/icons-material/ScheduleRounded"; +import WarningRoundedIcon from "@mui/icons-material/WarningRounded"; +import CalendarTodayRoundedIcon from "@mui/icons-material/CalendarTodayRounded"; +import InfoRoundedIcon from "@mui/icons-material/InfoRounded"; + +import { FieldTypeDateTime } from "../FieldTypeDateTime"; + +export type ScheduleUnpublishDialogProps = { + itemName: string; + currentVersion: number; + scheduledPublishVersion?: number; + scheduledLocalText: string; + creatorName: string; + savedAgo: string; + tzLabel: string; + publishDateTime: string; + publishTimezone: string; + isLoading: boolean; + isSelectedDatetimePast: boolean; + isAlreadyScheduled: boolean; + hasAnyScheduledPublish: boolean; + onClose: () => void; + onUnpublishNow?: () => void; + onSchedule: () => void; + onUnschedule: () => void; + onDateTimeChange: (datetime: any) => void; + onTimezoneChange: (timezone: any) => void; +}; + +export const ScheduleUnpublishDialog = ({ + itemName, + currentVersion, + scheduledPublishVersion, + scheduledLocalText, + creatorName, + savedAgo, + tzLabel, + publishDateTime, + publishTimezone, + isLoading, + isSelectedDatetimePast, + isAlreadyScheduled, + hasAnyScheduledPublish, + onClose, + onUnpublishNow, + onSchedule, + onUnschedule, + onDateTimeChange, + onTimezoneChange, +}: ScheduleUnpublishDialogProps) => ( + + + + + {isAlreadyScheduled ? ( + + ) : ( + + )} + + + + + {isAlreadyScheduled + ? "Unschedule Unpublish:" + : "Schedule Unpublish:"} +   + + + {itemName} + + + + {isAlreadyScheduled + ? `v${currentVersion} is scheduled to unpublish on ${scheduledLocalText} in ${tzLabel}.` + : `v${currentVersion} saved ${savedAgo} by ${creatorName}`} + + + + + + + {isAlreadyScheduled ? ( + <> + }> + This will enable the ability to schedule or publish other versions + of this content item + + {hasAnyScheduledPublish && ( + } + sx={{ mt: 1.5 }} + > + {`This will also cancel the scheduled publish for v${scheduledPublishVersion}.`} + + )} + + ) : ( + <> + + Unpublish on + + + {isSelectedDatetimePast && ( + } + sx={{ mt: 2.5 }} + > + Since the selected time is a current or past date, this will be + immediately unpublished. + + )} + + )} + + + + + + {isAlreadyScheduled ? ( + + ) : ( + + )} + + +); diff --git a/src/shell/components/SchedulePublish/index.tsx b/src/shell/components/SchedulePublish/index.tsx index 3f85e5b8bd..ef18f1a680 100644 --- a/src/shell/components/SchedulePublish/index.tsx +++ b/src/shell/components/SchedulePublish/index.tsx @@ -1,29 +1,16 @@ import { useState } from "react"; -import { - Dialog, - DialogActions, - DialogTitle, - DialogContent, - Typography, - Button, - Stack, - Box, - Alert, -} from "@mui/material"; -import ScheduleRoundedIcon from "@mui/icons-material/ScheduleRounded"; -import WarningRoundedIcon from "@mui/icons-material/WarningRounded"; -import CalendarTodayRoundedIcon from "@mui/icons-material/CalendarTodayRounded"; -import InfoRoundedIcon from "@mui/icons-material/InfoRounded"; import { useDispatch } from "react-redux"; +import { format as fmt, isBefore, formatDistanceToNow } from "date-fns"; +import { zonedTimeToUtc, formatInTimeZone } from "date-fns-tz"; + import { ContentItemWithDirtyAndPublishing } from "../../services/types"; import { useGetUsersQuery } from "../../services/accounts"; -import { FieldTypeDateTime } from "../FieldTypeDateTime"; import { TIMEZONES } from "../FieldTypeDateTime/util"; import { publish, unpublish } from "../../store/content"; -import { format as fmt, isBefore, formatDistanceToNow } from "date-fns"; -import { zonedTimeToUtc, formatInTimeZone } from "date-fns-tz"; +import { SchedulePublishDialog } from "./SchedulePublishDialog"; +import { ScheduleUnpublishDialog } from "./ScheduleUnpublishDialog"; type SchedulePublishProps = { item: ContentItemWithDirtyAndPublishing; @@ -36,8 +23,8 @@ type SchedulePublishProps = { }; export const SchedulePublish = ({ - onClose, item, + onClose, onPublishNow, onUnpublishNow, onScheduleSuccess, @@ -47,7 +34,6 @@ export const SchedulePublish = ({ const dispatch = useDispatch(); const { data: users } = useGetUsersQuery(); - // Next top of the hour (local) const now = new Date(); const nextTopOfHour = new Date(now); nextTopOfHour.setMinutes(0, 0, 0); @@ -56,30 +42,33 @@ export const SchedulePublish = ({ const [publishDateTime, setPublishDateTime] = useState( fmt(nextTopOfHour, "yyyy-MM-dd HH:mm:ss") ); - const tzGuess = Intl.DateTimeFormat().resolvedOptions().timeZone || "America/Los_Angeles"; const [publishTimezone, setPublishTimezone] = useState(tzGuess); const [isLoading, setIsLoading] = useState(false); - const latestChangeCreator = users?.find( - (user) => user.ZUID === item?.web?.createdByUserZUID - ); - + // ── Derived display values ──────────────────────────────────────────────── const selectedUtc = zonedTimeToUtc( publishDateTime.replace(/\.\d+$/, ""), publishTimezone ); - const isValidUtc = !isNaN(selectedUtc.getTime()); - const isSelectedDatetimePast = isValidUtc + const isSelectedDatetimePast = !isNaN(selectedUtc.getTime()) ? isBefore(selectedUtc, new Date()) : false; - const isForUnpublish = scheduledAction === "unpublish"; + const latestChangeCreator = users?.find( + (user) => user.ZUID === item?.web?.createdByUserZUID + ); + const creatorName = `${latestChangeCreator?.firstName ?? ""} ${ + latestChangeCreator?.lastName ?? "" + }`.trim(); + const savedAgo = item?.web?.createdAt + ? formatDistanceToNow(new Date(item.web.createdAt), { addSuffix: true }) + : ""; + const tzLabel = TIMEZONES.find((tz) => tz.id === tzGuess)?.label || tzGuess; - // ── Publish flow (original behaviour) ──────────────────────────────────── - // Uses item?.scheduling?.isScheduled directly, matching the pre-PR component. - const isAlreadyScheduledPublish = item?.scheduling?.isScheduled; + // ── Publish flow ────────────────────────────── + const isAlreadyScheduledPublish = !!item?.scheduling?.isScheduled; const publishScheduledLocalText = item?.scheduling?.publishAt ? formatInTimeZone( @@ -89,32 +78,23 @@ export const SchedulePublish = ({ ) : ""; + const formatPayloadTimes = (utc: Date, timezone: string) => ({ + publishAtUtcStr: formatInTimeZone(utc, "UTC", "yyyy-MM-dd HH:mm:ss"), + localPretty: formatInTimeZone(utc, timezone, "MMMM do yyyy, 'at' h:mm a"), + }); + const handleSchedulePublish = () => { setIsLoading(true); - - const publishAtUtcStr = formatInTimeZone( + const { publishAtUtcStr, localPretty } = formatPayloadTimes( selectedUtc, - "UTC", - "yyyy-MM-dd HH:mm:ss" + publishTimezone ); - const localPretty = formatInTimeZone( - selectedUtc, - publishTimezone, - "MMMM do yyyy, 'at' h:mm a" - ); - dispatch( publish( item?.meta?.contentModelZUID, item?.meta?.ZUID, - { - publishAt: publishAtUtcStr, - version: item?.meta?.version, - }, - { - localTime: localPretty, - localTimezone: publishTimezone, - } + { publishAt: publishAtUtcStr, version: item?.meta?.version }, + { localTime: localPretty, localTimezone: publishTimezone } ) // @ts-expect-error untyped action ).finally(() => { @@ -126,7 +106,6 @@ export const SchedulePublish = ({ const handleUnschedulePublish = () => { setIsLoading(true); - dispatch( unpublish( item?.meta?.contentModelZUID, @@ -135,14 +114,17 @@ export const SchedulePublish = ({ { version: item?.scheduling?.version } ) // @ts-expect-error untyped action - ).finally(() => { - setIsLoading(false); - onClose(); - onUnscheduleSuccess?.(); - }); + ) + .then(() => { + onUnscheduleSuccess?.(); + }) + .finally(() => { + setIsLoading(false); + onClose(); + }); }; - // ── Unpublish flow (new behaviour) ──────────────────────────────────────── + // ── Unpublish flow ──────────────────────────────────────── // Broader check: any active future-scheduled publish blocks the API from // accepting a new POST to /publishings, regardless of version. const hasAnyScheduledPublish = @@ -166,18 +148,10 @@ export const SchedulePublish = ({ const handleScheduleUnpublish = () => { setIsLoading(true); - - const publishAtUtcStr = formatInTimeZone( + const { publishAtUtcStr, localPretty } = formatPayloadTimes( selectedUtc, - "UTC", - "yyyy-MM-dd HH:mm:ss" + publishTimezone ); - const localPretty = formatInTimeZone( - selectedUtc, - publishTimezone, - "MMMM do yyyy, 'at' h:mm a" - ); - dispatch( publish( item?.meta?.contentModelZUID, @@ -187,10 +161,7 @@ export const SchedulePublish = ({ unpublishAt: publishAtUtcStr, version: item?.meta?.version, }, - { - localTime: localPretty, - localTimezone: publishTimezone, - } + { localTime: localPretty, localTimezone: publishTimezone } ) // @ts-expect-error untyped action ).finally(() => { @@ -202,7 +173,6 @@ export const SchedulePublish = ({ const handleUnscheduleUnpublish = async () => { setIsLoading(true); - try { // The API has no dedicated "remove unpublishAt" endpoint. Re-publishing with // unpublishAt: "never" clears the scheduled takedown while keeping the item live. @@ -219,7 +189,6 @@ export const SchedulePublish = ({ ) ); } - await (dispatch as Function)( publish( item?.meta?.contentModelZUID, @@ -229,13 +198,9 @@ export const SchedulePublish = ({ unpublishAt: "never", version: item?.publishing?.version, }, - { - localTime: "", - localTimezone: publishTimezone, - } + { localTime: "", localTimezone: publishTimezone } ) ); - onUnscheduleSuccess?.(); } catch { // Error notification is handled by the thunk; swallow here so the @@ -246,298 +211,48 @@ export const SchedulePublish = ({ } }; - // ── Shared timezone label ───────────────────────────────────────────────── - const tzLabel = TIMEZONES.find((tz) => tz.id === tzGuess)?.label || tzGuess; + // ── Shared state passed to both dumb dialogs ────────────────────────────── + const sharedProps = { + publishDateTime, + publishTimezone, + isLoading, + isSelectedDatetimePast, + creatorName, + savedAgo, + tzLabel, + onClose, + onDateTimeChange: (datetime: any) => + setPublishDateTime(String(datetime).replace(/\.\d+$/, "")), + onTimezoneChange: (timezone: any) => setPublishTimezone(timezone), + }; - // ── Render ──────────────────────────────────────────────────────────────── - if (isForUnpublish) { - // New unpublish scheduling flow + if (scheduledAction === "unpublish") { return ( - - - - - {isAlreadyScheduledUnpublish ? ( - - ) : ( - - )} - - - - - {isAlreadyScheduledUnpublish - ? "Unschedule Unpublish:" - : "Schedule Unpublish:"} -   - - - {item?.web?.metaLinkText} - - - - - {isAlreadyScheduledUnpublish - ? `v${item?.web?.version} is scheduled to unpublish on ${unpublishScheduledLocalText} in ${tzLabel}.` - : `v${item?.web?.version} saved ${ - item?.web?.createdAt - ? formatDistanceToNow(new Date(item.web.createdAt), { - addSuffix: true, - }) - : "" - } by ${latestChangeCreator?.firstName ?? ""} ${ - latestChangeCreator?.lastName ?? "" - }`} - - - - - - - {isAlreadyScheduledUnpublish ? ( - <> - }> - This will enable the ability to schedule or publish other - versions of this content item - - {hasAnyScheduledPublish && ( - } - sx={{ mt: 1.5 }} - > - {`This will also cancel the scheduled publish for v${item?.scheduling?.version}.`} - - )} - - ) : ( - <> - - Unpublish on - - { - const normalized = String(datetime).replace(/\.\d+$/, ""); - setPublishDateTime(normalized); - }} - onTimezoneChange={(timezone: any) => - setPublishTimezone(timezone) - } - /> - {isSelectedDatetimePast && ( - } - sx={{ mt: 2.5 }} - > - Since the selected time is a current or past date, this will - be immediately unpublished. - - )} - - )} - - - - - - {isAlreadyScheduledUnpublish ? ( - - ) : ( - - )} - - + ); } - // Original publish scheduling flow (unchanged from pre-PR behaviour) return ( - - - - - {isAlreadyScheduledPublish ? ( - - ) : ( - - )} - - - - - {isAlreadyScheduledPublish - ? "Unschedule Publish:" - : "Schedule Publish:"} -   - - - {item?.web?.metaLinkText} - - - - - {isAlreadyScheduledPublish - ? `v${item?.web?.version} is scheduled to publish on ${publishScheduledLocalText} in ${tzLabel}.` - : `v${item?.web?.version} saved ${ - item?.web?.createdAt - ? formatDistanceToNow(new Date(item.web.createdAt), { - addSuffix: true, - }) - : "" - } by ${latestChangeCreator?.firstName ?? ""} ${ - latestChangeCreator?.lastName ?? "" - }`} - - - - - - - {isAlreadyScheduledPublish ? ( - }> - This will enable the ability to schedule or publish other versions - of this content item - - ) : ( - <> - - Publish on - - { - const normalized = String(datetime).replace(/\.\d+$/, ""); - setPublishDateTime(normalized); - }} - onTimezoneChange={(timezone: any) => setPublishTimezone(timezone)} - /> - {isSelectedDatetimePast && ( - } - sx={{ mt: 2.5 }} - > - Since the selected time is a current or past date, this will be - immediately published. - - )} - - )} - - - - - - {isAlreadyScheduledPublish ? ( - - ) : ( - - )} - - + ); }; diff --git a/src/shell/store/content.js b/src/shell/store/content.js index 3b07d62c7a..67b5528af5 100644 --- a/src/shell/store/content.js +++ b/src/shell/store/content.js @@ -961,12 +961,8 @@ export function unpublish(modelZUID, itemZUID, publishZUID, options = {}) { const message = options.version ? `Error Unscheduling version ${options.version}` : `Error Unpublishing ${title}`; - return dispatch( - notify({ - message, - kind: "error", - }) - ); + dispatch(notify({ message, kind: "error" })); + throw err; }); }; } From 9876186d49f7d49c5cad2333b8d3f72b180706a0 Mon Sep 17 00:00:00 2001 From: geodem Date: Tue, 9 Jun 2026 08:10:32 +0800 Subject: [PATCH 17/34] Content: Cancel scheduled unpublish at end of schedule test to prevent state leak Co-Authored-By: Claude Sonnet 4.6 --- cypress/e2e/content/actions.spec.js | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/cypress/e2e/content/actions.spec.js b/cypress/e2e/content/actions.spec.js index cfd8101769..f21016309f 100644 --- a/cypress/e2e/content/actions.spec.js +++ b/cypress/e2e/content/actions.spec.js @@ -316,15 +316,22 @@ describe("Actions in content editor", () => { cy.getBySelector("ScheduledUnpublishIndicator").should("exist"); - // Assert the menu toggle label flips to "Unschedule Unpublish" + // Assert the menu toggle label flips to "Unschedule Unpublish", + // then cancel the schedule so the item is clean for subsequent tests. + awaitRequests(); cy.getBySelector("PublishMenuButton").trigger("click"); cy.getBySelector("publishingMenu").within(() => { - cy.getBySelector("UnpublishScheduleButton").should( - "contain.text", - "Unschedule Unpublish" - ); + cy.getBySelector("UnpublishScheduleButton") + .should("contain.text", "Unschedule Unpublish") + .trigger("click"); }); - cy.get("body").type("{esc}"); + cy.getBySelector("SchedulePublishModal") + .should("exist") + .within(() => { + cy.getBySelector("UnschedulePublishButton").trigger("click"); + }); + cy.wait("@publishItem"); + cy.wait("@publishings"); }); it("Cancels a scheduled unpublish", () => { From fe1b743120a4405074d520bc3a0f4c87bb77a2b7 Mon Sep 17 00:00:00 2001 From: geodem Date: Tue, 9 Jun 2026 23:34:45 +0800 Subject: [PATCH 18/34] =?UTF-8?q?Content:=20Address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20promise=20handling,=20boolean=20typing,=20TS=20erro?= =?UTF-8?q?rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move onScheduleSuccess from .finally() to .then() in handleSchedulePublish and handleScheduleUnpublish so it no longer fires on error - Add .catch() to handleUnschedulePublish to prevent unhandled rejection leaking to Sentry when the unpublish thunk rejects - Wrap hasScheduledUnpublish in !!() so its type is boolean, not string | boolean | undefined - Replace Array.prototype.findLast (not in current TS lib) with .slice().reverse().find() and add explicit Audit type annotation - Import Audit type in ItemEditHeaderActions to satisfy noImplicitAny Co-Authored-By: Claude Sonnet 4.6 --- .../ItemEditHeader/ItemEditHeaderActions.tsx | 13 +++++---- .../components/SchedulePublish/index.tsx | 29 ++++++++++++------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx index bbb1d5f966..2925360654 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx @@ -47,6 +47,7 @@ import { formatDate } from "../../../../../../../../utility/formatDate"; import { UnpublishDialog } from "./UnpublishDialog"; import { usePermission } from "../../../../../../../../shell/hooks/use-permissions"; import { + Audit, ContentItemWithDirtyAndPublishing, ContentModel, RedirectsCodes, @@ -158,18 +159,20 @@ export const ItemEditHeaderActions = ({ (itemPublishing) => itemPublishing._active ); - const lastItemUpdateAudit = itemAudit?.findLast( - (audit) => audit.action === 2 || audit.action === 1 - ); + const lastItemUpdateAudit = itemAudit + ?.slice() + .reverse() + .find((audit: Audit) => audit.action === 2 || audit.action === 1); const hasScheduledPublish = !!item?.scheduling?.isScheduled && new Date(item?.scheduling?.publishAt).getTime() > Date.now(); - const hasScheduledUnpublish = + const hasScheduledUnpublish = !!( item?.publishing?.version === item?.meta?.version && !!item?.publishing?.unpublishAt && - new Date(item?.publishing?.unpublishAt).getTime() > Date.now(); + new Date(item?.publishing?.unpublishAt).getTime() > Date.now() + ); const { data: statusLabels } = useGetWorkflowStatusLabelsQuery(); const { data: itemWorkflowStatus, isLoading: isLoadingItemWorkflowStatus } = diff --git a/src/shell/components/SchedulePublish/index.tsx b/src/shell/components/SchedulePublish/index.tsx index ef18f1a680..5621bbaa89 100644 --- a/src/shell/components/SchedulePublish/index.tsx +++ b/src/shell/components/SchedulePublish/index.tsx @@ -97,11 +97,14 @@ export const SchedulePublish = ({ { localTime: localPretty, localTimezone: publishTimezone } ) // @ts-expect-error untyped action - ).finally(() => { - onScheduleSuccess?.(); - setIsLoading(false); - onClose(); - }); + ) + .then(() => { + onScheduleSuccess?.(); + }) + .finally(() => { + setIsLoading(false); + onClose(); + }); }; const handleUnschedulePublish = () => { @@ -118,6 +121,9 @@ export const SchedulePublish = ({ .then(() => { onUnscheduleSuccess?.(); }) + .catch(() => { + // Error notification handled by the thunk + }) .finally(() => { setIsLoading(false); onClose(); @@ -164,11 +170,14 @@ export const SchedulePublish = ({ { localTime: localPretty, localTimezone: publishTimezone } ) // @ts-expect-error untyped action - ).finally(() => { - onScheduleSuccess?.(); - setIsLoading(false); - onClose(); - }); + ) + .then(() => { + onScheduleSuccess?.(); + }) + .finally(() => { + setIsLoading(false); + onClose(); + }); }; const handleUnscheduleUnpublish = async () => { From 71c76d34298c7920485e4f6542b2d6030bcbbc82 Mon Sep 17 00:00:00 2001 From: geodem Date: Tue, 9 Jun 2026 23:40:11 +0800 Subject: [PATCH 19/34] Content: Add .catch() to handleSchedulePublish and handleScheduleUnpublish Prevents unhandled promise rejections leaking to Sentry when the publish thunk fails. Error notification is already handled by the thunk itself. Co-Authored-By: Claude Sonnet 4.6 --- .../ItemEditHeader/ItemEditHeaderActions.tsx | 7 +++---- .../components/SchedulePublish/index.tsx | 19 +++++++++++-------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx index 2925360654..ae56680189 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx @@ -159,10 +159,9 @@ export const ItemEditHeaderActions = ({ (itemPublishing) => itemPublishing._active ); - const lastItemUpdateAudit = itemAudit - ?.slice() - .reverse() - .find((audit: Audit) => audit.action === 2 || audit.action === 1); + const lastItemUpdateAudit = itemAudit?.find( + (audit: Audit) => audit.action === 2 || audit.action === 1 + ); const hasScheduledPublish = !!item?.scheduling?.isScheduled && diff --git a/src/shell/components/SchedulePublish/index.tsx b/src/shell/components/SchedulePublish/index.tsx index 5621bbaa89..edab7b57c3 100644 --- a/src/shell/components/SchedulePublish/index.tsx +++ b/src/shell/components/SchedulePublish/index.tsx @@ -96,11 +96,13 @@ export const SchedulePublish = ({ { publishAt: publishAtUtcStr, version: item?.meta?.version }, { localTime: localPretty, localTimezone: publishTimezone } ) - // @ts-expect-error untyped action - ) + ) // @ts-expect-error untyped action .then(() => { onScheduleSuccess?.(); }) + .catch(() => { + // Error notification handled by the thunk + }) .finally(() => { setIsLoading(false); onClose(); @@ -116,8 +118,7 @@ export const SchedulePublish = ({ item?.scheduling?.ZUID, { version: item?.scheduling?.version } ) - // @ts-expect-error untyped action - ) + ) // @ts-expect-error untyped action .then(() => { onUnscheduleSuccess?.(); }) @@ -169,11 +170,13 @@ export const SchedulePublish = ({ }, { localTime: localPretty, localTimezone: publishTimezone } ) - // @ts-expect-error untyped action - ) + ) // @ts-expect-error untyped action .then(() => { onScheduleSuccess?.(); }) + .catch(() => { + // Error notification handled by the thunk + }) .finally(() => { setIsLoading(false); onClose(); @@ -189,7 +192,7 @@ export const SchedulePublish = ({ // a new publishing record with "already has a scheduled publish event." Delete // the scheduled publish first to unblock the POST. if (hasAnyScheduledPublish) { - await (dispatch as Function)( + await dispatch( unpublish( item?.meta?.contentModelZUID, item?.meta?.ZUID, @@ -198,7 +201,7 @@ export const SchedulePublish = ({ ) ); } - await (dispatch as Function)( + await dispatch( publish( item?.meta?.contentModelZUID, item?.meta?.ZUID, From 150471a56fd5da72cf963b3fbb58846eb664b017 Mon Sep 17 00:00:00 2001 From: geodem Date: Wed, 10 Jun 2026 22:45:13 +0800 Subject: [PATCH 20/34] Content: Fix ScheduleUnpublishDialog data-cy attrs, PublishStatus guard, and sync Cypress selectors Co-Authored-By: Claude Sonnet 4.6 --- cypress/e2e/content/actions.spec.js | 46 +++++++++---------- .../ItemEditHeader/ItemEditHeaderActions.tsx | 2 +- .../ItemEditHeader/PublishStatus.tsx | 18 +++++--- .../ScheduleUnpublishDialog.tsx | 21 ++------- 4 files changed, 38 insertions(+), 49 deletions(-) diff --git a/cypress/e2e/content/actions.spec.js b/cypress/e2e/content/actions.spec.js index f21016309f..19f8964e29 100644 --- a/cypress/e2e/content/actions.spec.js +++ b/cypress/e2e/content/actions.spec.js @@ -253,31 +253,27 @@ describe("Actions in content editor", () => { cy.wait([items, publishings], { requestTimeout }); // Cancel any stale scheduled unpublish from a prior run so the scheduling - // step below reliably opens the SchedulePublishButton (not Unschedule) view. + // step below reliably opens the ScheduleUnpublishButton (not Unschedule) view. cy.getBySelector("PublishMenuButton").should("exist").should("be.enabled"); cy.getBySelector("PublishMenuButton").trigger("click"); cy.getBySelector("publishingMenu").within(() => { cy.getBySelector("UnpublishScheduleButton").trigger("click"); }); - cy.getBySelector("SchedulePublishModal") + cy.getBySelector("ScheduleUnpublishModal") .should("exist") .then(($modal) => { - if ($modal.find("[data-cy='UnschedulePublishButton']").length) { + if ($modal.find("[data-cy='UnscheduleUnpublishButton']").length) { cy.wrap($modal) - .find("[data-cy='UnschedulePublishButton']") + .find("[data-cy='UnscheduleUnpublishButton']") .trigger("click"); cy.wait(publishItem); cy.wait(publishings); } else if ( - $modal.find("[data-cy='CancelSchedulePublishButton']").length + $modal.find("[data-cy='CancelScheduleUnpublishButton']").length ) { cy.wrap($modal) - .find("[data-cy='CancelSchedulePublishButton']") + .find("[data-cy='CancelScheduleUnpublishButton']") .trigger("click"); - } else { - throw new Error( - "SchedulePublishModal opened in unexpected state — neither UnschedulePublishButton nor CancelSchedulePublishButton found" - ); } }); @@ -291,16 +287,16 @@ describe("Actions in content editor", () => { cy.getBySelector("UnpublishScheduleButton").trigger("click"); }); - cy.getBySelector("SchedulePublishModal") + cy.getBySelector("ScheduleUnpublishModal") .should("exist") .within(() => { // Assert labeling is correct for the unpublish flow cy.contains("Schedule Unpublish:").should("exist"); cy.contains("Unpublish on").should("exist"); - cy.getBySelector("SchedulePublishButton") + cy.getBySelector("ScheduleUnpublishButton") .should("exist") .should("contain.text", "Schedule Unpublish"); - cy.getBySelector("SchedulePublishButton").trigger("click"); + cy.getBySelector("ScheduleUnpublishButton").trigger("click"); }); // Assert the API payload branches correctly for scheduled unpublish @@ -325,10 +321,10 @@ describe("Actions in content editor", () => { .should("contain.text", "Unschedule Unpublish") .trigger("click"); }); - cy.getBySelector("SchedulePublishModal") + cy.getBySelector("ScheduleUnpublishModal") .should("exist") .within(() => { - cy.getBySelector("UnschedulePublishButton").trigger("click"); + cy.getBySelector("UnscheduleUnpublishButton").trigger("click"); }); cy.wait("@publishItem"); cy.wait("@publishings"); @@ -350,18 +346,18 @@ describe("Actions in content editor", () => { .within(() => { cy.getBySelector("UnpublishScheduleButton").trigger("click"); }); - cy.getBySelector("SchedulePublishModal") + cy.getBySelector("ScheduleUnpublishModal") .should("exist") .then(($modal) => { - if ($modal.find("[data-cy='UnschedulePublishButton']").length) { + if ($modal.find("[data-cy='UnscheduleUnpublishButton']").length) { // Already scheduled — cancel it to restore clean state cy.wrap($modal) - .find("[data-cy='UnschedulePublishButton']") + .find("[data-cy='UnscheduleUnpublishButton']") .trigger("click"); cy.wait(publishItem); cy.wait(publishings); } else { - cy.getBySelector("CancelSchedulePublishButton").trigger("click"); + cy.getBySelector("CancelScheduleUnpublishButton").trigger("click"); } }); @@ -376,11 +372,11 @@ describe("Actions in content editor", () => { cy.getBySelector("UnpublishScheduleButton").trigger("click"); }); - cy.getBySelector("SchedulePublishModal") + cy.getBySelector("ScheduleUnpublishModal") .should("exist") .within(() => { - cy.getBySelector("SchedulePublishButton").should("exist"); - cy.getBySelector("SchedulePublishButton").trigger("click"); + cy.getBySelector("ScheduleUnpublishButton").should("exist"); + cy.getBySelector("ScheduleUnpublishButton").trigger("click"); }); cy.wait(publishItem); @@ -398,14 +394,14 @@ describe("Actions in content editor", () => { cy.getBySelector("UnpublishScheduleButton").trigger("click"); }); - cy.getBySelector("SchedulePublishModal") + cy.getBySelector("ScheduleUnpublishModal") .should("exist") .within(() => { // Assert the scheduled date is rendered in the dialog (not blank) cy.contains("scheduled to unpublish on").should("exist"); cy.contains(/\w{3} \d{1,2}, \d{4} at \d{1,2}:\d{2}/).should("exist"); - cy.getBySelector("UnschedulePublishButton").should("exist"); - cy.getBySelector("UnschedulePublishButton").trigger("click"); + cy.getBySelector("UnscheduleUnpublishButton").should("exist"); + cy.getBySelector("UnscheduleUnpublishButton").trigger("click"); }); cy.wait(publishItem); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx index ae56680189..37c7f7627b 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx @@ -467,7 +467,7 @@ export const ItemEditHeaderActions = ({ } }; - const handleUnpublish = async () => { + const handleUnpublish = () => { deleteItemPublishing({ modelZUID: resolvedModelZUID, itemZUID: resolvedItemZUID, diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/PublishStatus.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/PublishStatus.tsx index 83f859f229..fd43bca649 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/PublishStatus.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/PublishStatus.tsx @@ -39,10 +39,7 @@ export const PublishStatus = ({ currentVersion }: PublishStatusProps) => { const getUsername = (userZUID: string) => { const user = users?.find((user) => user.ZUID === userZUID); - - if (user) { - return `${user.firstName} ${user.lastName}`; - } + return user ? `${user.firstName} ${user.lastName}` : ""; }; if (isFetchingPublishStatus) { @@ -107,21 +104,28 @@ export const PublishStatus = ({ currentVersion }: PublishStatusProps) => {
)} - {scheduledUnpublishing && + {!!scheduledUnpublishing && + scheduledUnpublishing?._active && scheduledUnpublishing.version === currentVersion && ( - v{scheduledUnpublishing?.version} scheduled to unpublish
+ v{scheduledUnpublishing?.version} scheduled to unpublish on{" "} +
{formatDate(scheduledUnpublishing?.unpublishAt)}
by {getUsername(scheduledUnpublishing?.publishedByUserZUID)} } placement="bottom-start" > - + ( - + {isAlreadyScheduled ? ( <> }> This will enable the ability to schedule or publish other versions of this content item - {hasAnyScheduledPublish && ( - } - sx={{ mt: 1.5 }} - > - {`This will also cancel the scheduled publish for v${scheduledPublishVersion}.`} - - )} ) : ( <> @@ -153,7 +142,7 @@ export const ScheduleUnpublishDialog = ({ ) : (