Skip to content

Commit f3585b5

Browse files
fix(FTM): save and publish buttons should behave the same way when a piece of content is published in the future dotCMS#31238 (dotCMS#32091)
### Proposed Changes - **New Feature: Notify Users of Future Publish Dates** Extracted logic for handling future publish date messages into a new utility class: `ContentPublishDateUtil`. - **Refactoring for Content Type Handling** Replaced direct use of `Structure` with `ContentType` in the live/expire logic to align with modern content-type APIs and centralize date-field access. - **Integration into Workflow** Updated `SaveContentActionlet.executeAction` to invoke `notifyIfFuturePublishDate` during “Save” operations on contentlets, ensuring the same future-publish warning appears whether the user clicks Save or Publish. - **Test Coverage** Added `ContentPublishDateUtilTest` with unit tests covering all major cases. Modified `SaveContentActionletTest` to verify that the utility is called when appropriate, using static mocking. ### Checklist - [x] Tests - [x] Translations - [x] Security Implications Contemplated (add notes if applicable) ### Additional Info This enhancement closes the UX gap: content scheduled for future publication will now trigger the same warning when saving as it does when publishing. ### Screenshot https://github.com/user-attachments/assets/0e1ee405-c4ca-45eb-a5e4-861b785745fc
1 parent 16a813e commit f3585b5

File tree

7 files changed

+317
-47
lines changed

7 files changed

+317
-47
lines changed

dotCMS/src/main/java/com/dotmarketing/business/VersionableAPI.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.dotmarketing.business;
22

3+
import com.dotcms.contenttype.model.type.ContentType;
34
import com.dotcms.variant.model.Variant;
45
import java.util.Date;
56
import java.util.List;
@@ -11,6 +12,7 @@
1112
import com.dotmarketing.exception.DotSecurityException;
1213
import com.dotmarketing.portlets.contentlet.model.Contentlet;
1314
import com.dotmarketing.portlets.contentlet.model.ContentletVersionInfo;
15+
import com.dotmarketing.portlets.structure.model.Structure;
1416
import com.liferay.portal.model.User;
1517

1618
public interface VersionableAPI {

dotCMS/src/main/java/com/dotmarketing/business/VersionableAPIImpl.java

Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import com.dotcms.cdi.CDIUtils;
1313
import com.dotcms.concurrent.Debouncer;
1414
import com.dotcms.contenttype.business.uniquefields.UniqueFieldValidationStrategyResolver;
15+
import com.dotcms.contenttype.model.type.ContentType;
1516
import com.dotcms.variant.model.Variant;
1617
import com.dotmarketing.beans.Identifier;
1718
import com.dotmarketing.beans.VersionInfo;
@@ -22,6 +23,7 @@
2223
import com.dotmarketing.portlets.contentlet.model.Contentlet;
2324
import com.dotmarketing.portlets.contentlet.model.ContentletVersionInfo;
2425
import com.dotmarketing.portlets.structure.model.Structure;
26+
import com.dotmarketing.util.ContentPublishDateUtil;
2527
import com.dotmarketing.util.InodeUtils;
2628
import com.dotmarketing.util.Logger;
2729
import com.dotmarketing.util.UtilMethods;
@@ -47,7 +49,6 @@ public class VersionableAPIImpl implements VersionableAPI {
4749

4850
private final VersionableFactory versionableFactory;
4951
private final PermissionAPI permissionAPI;
50-
final Debouncer debouncer = new Debouncer();
5152
final UniqueFieldValidationStrategyResolver uniqueFieldValidationStrategyResolver;
5253

5354
public VersionableAPIImpl() {
@@ -526,17 +527,11 @@ public void setLive ( final Versionable versionable ) throws DotDataException, D
526527
contentlet.getIdentifier(), contentlet.getLanguageId(), contentlet.getVariantId());
527528

528529
//Get the structure for this contentlet
529-
final Structure structure = CacheLocator.getContentTypeCache().getStructureByInode( contentlet.getStructureInode() );
530-
531-
if ( UtilMethods.isSet( structure.getPublishDateVar() ) ) {//Verify if the structure have a Publish Date Field set
532-
if ( UtilMethods.isSet( identifier.getSysPublishDate() ) && identifier.getSysPublishDate().after( new Date() ) ) {
533-
final Runnable futurePublishDateRunnable = ()->
534-
{futurePublishDateMessage(versionable.getModUser());};
535-
debouncer.debounce("contentPublishDateError"+versionable.getModUser(),futurePublishDateRunnable,5000,TimeUnit.MILLISECONDS);
536-
return;
537-
}
530+
final ContentType contentType = contentlet.getContentType();
531+
if (ContentPublishDateUtil.notifyIfFuturePublishDate(contentType, identifier, versionable.getModUser())) {
532+
return;
538533
}
539-
if ( UtilMethods.isSet( structure.getExpireDateVar() ) ) {//Verify if the structure have a Expire Date Field set
534+
if ( UtilMethods.isSet( contentType.expireDateVar() ) ) {//Verify if the structure have a Expire Date Field set
540535
if ( UtilMethods.isSet( identifier.getSysExpireDate() ) && identifier.getSysExpireDate().before( new Date() ) ) {
541536
throw new ExpiredContentletPublishStateException( contentlet );
542537
}
@@ -559,22 +554,6 @@ public void setLive ( final Versionable versionable ) throws DotDataException, D
559554
}
560555
}
561556

562-
/**
563-
* Method to encapsulate the logic of a growl message when content has a future publish date
564-
* @param user user to show the growl
565-
*/
566-
private void futurePublishDateMessage(final String user){
567-
final String message = Try.of(() -> LanguageUtil.get("message.contentlet.publish.future.date"))
568-
.getOrElse("The content was saved successfully but cannot be published because"
569-
+ " it is scheduled to be published on future date.");
570-
final SystemMessageBuilder systemMessageBuilder = new SystemMessageBuilder()
571-
.setMessage(message).setType(MessageType.SIMPLE_MESSAGE)
572-
.setSeverity(MessageSeverity.SUCCESS).setLife(5000);
573-
574-
SystemMessageEventUtil.getInstance().pushMessage(systemMessageBuilder.create(),
575-
ImmutableList.of(user));
576-
Logger.debug(this,message);
577-
}
578557

579558
@WrapInTransaction
580559
@Override

dotCMS/src/main/java/com/dotmarketing/portlets/workflows/actionlet/SaveContentActionlet.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package com.dotmarketing.portlets.workflows.actionlet;
22

33
import com.dotcms.business.WrapInTransaction;
4+
import com.dotcms.contenttype.model.type.ContentType;
45
import com.dotcms.exception.ExceptionUtil;
6+
import com.dotmarketing.beans.Identifier;
57
import com.dotmarketing.business.APILocator;
68
import com.dotmarketing.exception.DotDataException;
79
import com.dotmarketing.exception.DotSecurityException;
@@ -14,6 +16,7 @@
1416
import com.dotmarketing.portlets.workflows.model.WorkflowActionletParameter;
1517
import com.dotmarketing.portlets.workflows.model.WorkflowProcessor;
1618
import com.dotmarketing.portlets.workflows.model.WorkflowStep;
19+
import com.dotmarketing.util.ContentPublishDateUtil;
1720
import com.dotmarketing.util.Logger;
1821
import com.dotmarketing.util.UtilMethods;
1922
import com.google.common.annotations.VisibleForTesting;
@@ -85,6 +88,13 @@ public void executeAction(final WorkflowProcessor processor,
8588
this.contentletAPI.checkin(checkoutContentlet, contentletDependencies):
8689
this.contentletAPI.checkin(checkoutContentlet, processor.getUser(), respectFrontendPermission);
8790

91+
final Identifier identifier = APILocator.getIdentifierAPI().find( contentletNew );
92+
if ( identifier.getAssetType().equals( "contentlet" ) ) {
93+
//Get the structure for this contentlet
94+
final ContentType contentType = contentlet.getContentType();
95+
ContentPublishDateUtil.notifyIfFuturePublishDate(contentType, identifier, contentletNew.getModUser());
96+
}
97+
8898
this.setIndexPolicy(contentlet, contentletNew);
8999
this.setSpecialVariables(contentlet, contentletNew);
90100

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package com.dotmarketing.util;
2+
3+
import com.dotcms.api.system.event.message.MessageSeverity;
4+
import com.dotcms.api.system.event.message.MessageType;
5+
import com.dotcms.api.system.event.message.SystemMessageEventUtil;
6+
import com.dotcms.api.system.event.message.builder.SystemMessageBuilder;
7+
import com.dotcms.concurrent.Debouncer;
8+
import com.dotcms.contenttype.model.type.ContentType;
9+
import com.dotcms.repackage.com.google.common.annotations.VisibleForTesting;
10+
import com.dotmarketing.beans.Identifier;
11+
import com.liferay.portal.language.LanguageUtil;
12+
13+
import io.vavr.control.Try;
14+
15+
import java.util.Date;
16+
import java.util.concurrent.TimeUnit;
17+
18+
/**
19+
* Utility class for handling content publish date related operations
20+
*/
21+
public class ContentPublishDateUtil {
22+
23+
private static Debouncer debouncer = new Debouncer();
24+
25+
@VisibleForTesting
26+
static void setDebouncer(Debouncer testDebouncer) {
27+
debouncer = testDebouncer;
28+
}
29+
30+
@VisibleForTesting
31+
static Debouncer getDebouncer() {
32+
return debouncer;
33+
}
34+
35+
/**
36+
* Checks whether the given versionable has a publish date set in the future and, if so,
37+
* sends a user notification message indicating that the content cannot be published yet.
38+
*
39+
* <p>This method verifies that the contentType defines a publish date field and that the
40+
* identifier's publish date is later than the current time. If both conditions are met,
41+
* a success-type system message is sent to the user, and {@code true} is returned.</p>
42+
*
43+
* @param contentType the contentType, used to determine if a publish date field exists
44+
* @param identifier the identifier containing publish date metadata
45+
* @param modUser the user who last modified the version
46+
* @return {@code true} if a future publish date was detected and a message was sent;
47+
* {@code false} otherwise
48+
*/
49+
public static boolean notifyIfFuturePublishDate(final ContentType contentType, final Identifier identifier, final String modUser) {
50+
if (UtilMethods.isSet(contentType.publishDateVar()) &&
51+
UtilMethods.isSet(identifier.getSysPublishDate()) &&
52+
identifier.getSysPublishDate().after(new Date())) {
53+
54+
final Runnable futurePublishDateRunnable = () -> futurePublishDateMessage(modUser);
55+
debouncer.debounce("contentPublishDateError" + modUser, futurePublishDateRunnable, 5000, TimeUnit.MILLISECONDS);
56+
return true;
57+
}
58+
return false;
59+
}
60+
61+
/**
62+
* Method to encapsulate the logic of a growl message when content has a future publish date
63+
* @param user user to show the growl
64+
*/
65+
private static void futurePublishDateMessage(final String user) {
66+
final String message = Try.of(() -> LanguageUtil.get("message.contentlet.publish.future.date"))
67+
.getOrElse("The content was saved successfully but cannot be published because"
68+
+ " it is scheduled to be published on future date.");
69+
final SystemMessageBuilder systemMessageBuilder = new SystemMessageBuilder()
70+
.setMessage(message).setType(MessageType.SIMPLE_MESSAGE)
71+
.setSeverity(MessageSeverity.SUCCESS).setLife(5000);
72+
73+
SystemMessageEventUtil.getInstance().pushMessage(systemMessageBuilder.create(),
74+
java.util.List.of(user));
75+
Logger.debug(ContentPublishDateUtil.class, message);
76+
}
77+
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
package com.dotmarketing.util;
2+
3+
import com.dotcms.api.system.event.message.SystemMessageEventUtil;
4+
import com.dotcms.concurrent.Debouncer;
5+
import com.dotcms.contenttype.model.type.ContentType;
6+
import com.dotmarketing.beans.Identifier;
7+
import com.liferay.portal.language.LanguageUtil;
8+
import org.junit.jupiter.api.AfterEach;
9+
import org.junit.jupiter.api.BeforeEach;
10+
import org.junit.jupiter.api.Test;
11+
import org.mockito.MockedStatic;
12+
import org.mockito.Mockito;
13+
14+
import java.util.Calendar;
15+
import java.util.concurrent.TimeUnit;
16+
17+
import static org.junit.jupiter.api.Assertions.*;
18+
import static org.mockito.Mockito.*;
19+
20+
/**
21+
* Unit tests for {@link ContentPublishDateUtil}.
22+
* <p>
23+
* Verifies behavior related to future publish date checks, system message handling,
24+
* and the use of debouncing for user notifications.
25+
*/
26+
class ContentPublishDateUtilTest {
27+
28+
private ContentType contentType;
29+
private Identifier identifier;
30+
private Debouncer originalDebouncer;
31+
private Debouncer debouncerMock;
32+
33+
/**
34+
* Initializes mocks and injects a mock Debouncer into the utility class before each test.
35+
*/
36+
@BeforeEach
37+
void setUp() {
38+
contentType = mock(ContentType.class);
39+
identifier = mock(Identifier.class);
40+
originalDebouncer = ContentPublishDateUtil.getDebouncer();
41+
debouncerMock = mock(Debouncer.class);
42+
ContentPublishDateUtil.setDebouncer(debouncerMock);
43+
}
44+
45+
@AfterEach
46+
void tearDown() {
47+
ContentPublishDateUtil.setDebouncer(originalDebouncer);
48+
}
49+
50+
/**
51+
* Verifies that when the publish date is in the future and the content type has a publish field,
52+
* the method returns true and attempts to send a user message.
53+
*/
54+
@Test
55+
void test_notifyIfFuturePublishDate_shouldReturnTrueAndSendMessage() {
56+
when(contentType.publishDateVar()).thenReturn("publishDate");
57+
58+
Calendar future = Calendar.getInstance();
59+
future.add(Calendar.HOUR, 1);
60+
when(identifier.getSysPublishDate()).thenReturn(future.getTime());
61+
62+
try (
63+
MockedStatic<LanguageUtil> langMock = Mockito.mockStatic(LanguageUtil.class);
64+
MockedStatic<SystemMessageEventUtil> msgMock = Mockito.mockStatic(SystemMessageEventUtil.class);
65+
MockedStatic<Logger> loggerMock = Mockito.mockStatic(Logger.class)
66+
) {
67+
langMock.when(() -> LanguageUtil.get("message.contentlet.publish.future.date"))
68+
.thenReturn("Future publish message");
69+
70+
SystemMessageEventUtil mockUtil = mock(SystemMessageEventUtil.class);
71+
msgMock.when(SystemMessageEventUtil::getInstance).thenReturn(mockUtil);
72+
73+
boolean result = ContentPublishDateUtil.notifyIfFuturePublishDate(contentType, identifier, "user123");
74+
75+
assertTrue(result);
76+
}
77+
}
78+
79+
/**
80+
* Ensures that the method returns false when the content type does not define a publish date field.
81+
*/
82+
@Test
83+
void test_notifyIfFuturePublishDate_shouldReturnFalseIfNoPublishDate() {
84+
when(contentType.publishDateVar()).thenReturn(null);
85+
86+
boolean result = ContentPublishDateUtil.notifyIfFuturePublishDate(contentType, identifier, "user123");
87+
88+
assertFalse(result);
89+
}
90+
91+
/**
92+
* Ensures that the method returns false when the publish date is in the past.
93+
*/
94+
@Test
95+
void test_notifyIfFuturePublishDate_shouldReturnFalseIfPublishDateNotInFuture() {
96+
when(contentType.publishDateVar()).thenReturn("publishDate");
97+
98+
Calendar past = Calendar.getInstance();
99+
past.add(Calendar.HOUR, -1);
100+
when(identifier.getSysPublishDate()).thenReturn(past.getTime());
101+
102+
boolean result = ContentPublishDateUtil.notifyIfFuturePublishDate(contentType, identifier, "user123");
103+
104+
assertFalse(result);
105+
}
106+
107+
/**
108+
* Verifies that the debouncer is invoked when a future publish date is detected.
109+
*/
110+
@Test
111+
void test_debouncerCalled_whenFutureDate() {
112+
when(contentType.publishDateVar()).thenReturn("publishDate");
113+
114+
Calendar future = Calendar.getInstance();
115+
future.add(Calendar.MINUTE, 10);
116+
when(identifier.getSysPublishDate()).thenReturn(future.getTime());
117+
118+
boolean result = ContentPublishDateUtil.notifyIfFuturePublishDate(contentType, identifier, "userX");
119+
120+
assertTrue(result);
121+
122+
verify(debouncerMock).debounce(
123+
eq("contentPublishDateErroruserX"),
124+
any(Runnable.class),
125+
eq(5000L),
126+
eq(TimeUnit.MILLISECONDS)
127+
);
128+
}
129+
130+
/**
131+
* Verifies that the debouncer is not invoked when the publish date is not in the future.
132+
*/
133+
@Test
134+
void test_debouncerNotCalled_whenPublishDateIsPast() {
135+
when(contentType.publishDateVar()).thenReturn("publishDate");
136+
137+
Calendar past = Calendar.getInstance();
138+
past.add(Calendar.MINUTE, -10);
139+
when(identifier.getSysPublishDate()).thenReturn(past.getTime());
140+
141+
boolean result = ContentPublishDateUtil.notifyIfFuturePublishDate(contentType, identifier, "userX");
142+
143+
assertFalse(result);
144+
verifyNoInteractions(debouncerMock);
145+
}
146+
147+
/**
148+
* Verifies that the Runnable passed to the debouncer sends the correct user message
149+
* when manually executed.
150+
*/
151+
@Test
152+
void test_debouncerRunnable_executesSystemMessage() {
153+
Runnable[] capturedRunnable = new Runnable[1];
154+
155+
when(contentType.publishDateVar()).thenReturn("publishDate");
156+
157+
Calendar future = Calendar.getInstance();
158+
future.add(Calendar.MINUTE, 5);
159+
when(identifier.getSysPublishDate()).thenReturn(future.getTime());
160+
161+
doAnswer(invocation -> {
162+
capturedRunnable[0] = invocation.getArgument(1);
163+
return null;
164+
}).when(debouncerMock).debounce(any(), any(), anyLong(), any());
165+
166+
try (
167+
MockedStatic<LanguageUtil> langMock = Mockito.mockStatic(LanguageUtil.class);
168+
MockedStatic<SystemMessageEventUtil> msgMock = Mockito.mockStatic(SystemMessageEventUtil.class);
169+
MockedStatic<Logger> loggerMock = Mockito.mockStatic(Logger.class)
170+
) {
171+
langMock.when(() -> LanguageUtil.get("message.contentlet.publish.future.date"))
172+
.thenReturn("Scheduled in future");
173+
174+
SystemMessageEventUtil mockUtil = mock(SystemMessageEventUtil.class);
175+
msgMock.when(SystemMessageEventUtil::getInstance).thenReturn(mockUtil);
176+
177+
ContentPublishDateUtil.notifyIfFuturePublishDate(contentType, identifier, "user123");
178+
179+
assertNotNull(capturedRunnable[0]);
180+
capturedRunnable[0].run();
181+
182+
verify(mockUtil).pushMessage(any(), eq(java.util.List.of("user123")));
183+
loggerMock.verify(() -> Logger.debug(eq(ContentPublishDateUtil.class), anyString()));
184+
}
185+
}
186+
}

0 commit comments

Comments
 (0)