Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions api/v4/source/definitions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4732,6 +4732,14 @@ components:
error_code:
type: string
description: Explains the error behind why a scheduled post could not have been sent
repeat_type:
type: string
description: >
Empty for a one-time schedule, or `weekly` for a recurring weekly schedule in `repeat_timezone`.
repeat_timezone:
type: string
description: >
IANA timezone name (for example `America/New_York`) used to interpret weekly recurrence; required when `repeat_type` is `weekly`.
metadata:
$ref: "#/components/schemas/PostMetadata"
AccessControlFieldsAutocompleteResponse:
Expand Down
12 changes: 12 additions & 0 deletions api/v4/source/scheduled_post.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@
props:
description: A general JSON property bag to attach to the post
type: object
repeat_type:
type: string
description: Set to `weekly` for a recurring weekly schedule, or omit for a one-time schedule
repeat_timezone:
type: string
description: IANA timezone for weekly recurrence; required when `repeat_type` is `weekly`
responses:
"200":
description: Created scheduled post
Expand Down Expand Up @@ -150,6 +156,12 @@
message:
type: string
description: The message contents, can be formatted with Markdown
repeat_type:
type: string
description: Set to `weekly` for a recurring weekly schedule, or empty for a one-time schedule
repeat_timezone:
type: string
description: IANA timezone for weekly recurrence; required when `repeat_type` is `weekly`
responses:
"200":
description: Updated scheduled post
Expand Down
1 change: 1 addition & 0 deletions e2e-tests/.ci/server.generate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ services:
MM_FEATUREFLAGS_PROPERTYFIELDRANK: "true"
MM_FEATUREFLAGS_ATTRIBUTEVALUEMASKING: "true"
MM_FEATUREFLAGS_WYSIWYGEDITOR: "true"
MM_FEATUREFLAGS_RECURRINGSCHEDULEDPOSTS: "true"
MM_LOGSETTINGS_ENABLEDIAGNOSTICS: "false"
MM_LOGSETTINGS_CONSOLELEVEL: "DEBUG"
network_mode: host
Expand Down
1 change: 1 addition & 0 deletions e2e-tests/playwright/lib/src/containers/env_baseline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const SERVER_ENV_BASELINE: Record<string, string> = {
MM_FEATUREFLAGS_MOVETHREADSENABLED: 'true',
MM_FEATUREFLAGS_PERMISSIONPOLICIES: 'true',
MM_FEATUREFLAGS_PROPERTYFIELDRANK: 'true',
MM_FEATUREFLAGS_RECURRINGSCHEDULEDPOSTS: 'true',
MM_FEATUREFLAGS_TEAMMEMBERSHIPACCESSCONTROL: 'true',
MM_FEATUREFLAGS_WYSIWYGEDITOR: 'true',
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export default class ScheduleMessageModal {
readonly dateButton: Locator;
readonly timeButton: Locator;
readonly timeOptionDropdown: Locator;
readonly repeatWeeklyCheckbox: Locator;
readonly closeButton: Locator;
readonly scheduleButton: Locator;
readonly cancelButton: Locator;
Expand All @@ -18,6 +19,7 @@ export default class ScheduleMessageModal {
this.dateButton = container.getByRole('button', {name: /Date/});
this.timeButton = container.getByTestId('time_button');
this.timeOptionDropdown = container.getByLabel('Choose a time');
this.repeatWeeklyCheckbox = container.getByRole('checkbox', {name: 'Repeat weekly'});
this.closeButton = container.getByRole('button', {name: 'Close'});
this.scheduleButton = container.getByRole('button', {name: 'Schedule'});
this.cancelButton = container.getByRole('button', {name: 'Cancel'});
Expand Down Expand Up @@ -98,9 +100,21 @@ export default class ScheduleMessageModal {
return text;
}

async scheduleMessage(dayFromToday: number = 0, timeOptionIndex: number = 0) {
async setRepeatWeekly(enabled: boolean) {
const isChecked = await this.repeatWeeklyCheckbox.isChecked();

if (isChecked !== enabled) {
await this.repeatWeeklyCheckbox.click();
}
}

async scheduleMessage(dayFromToday: number = 0, timeOptionIndex: number = 0, repeatWeekly?: boolean) {
await this.toBeVisible();

if (typeof repeatWeekly === 'boolean') {
await this.setRepeatWeekly(repeatWeekly);
}

const selectedDate = await this.selectDate(dayFromToday);

const fromDateButtonText = (await this.dateButton.textContent()) ?? '';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export default class ScheduledPost {

readonly panelHeader;
readonly panelBody;
readonly repeatsWeeklyTag;

readonly postBody;
readonly postHeader;
Expand All @@ -29,6 +30,7 @@ export default class ScheduledPost {

this.panelHeader = container.getByTestId('draft-panel-header');
this.panelBody = container.getByTestId('draft-panel-body');
this.repeatsWeeklyTag = container.getByText('Repeats weekly', {exact: true});

this.postBody = container.getByTestId('draft-post-body');
this.postHeader = container.getByTestId('draft-post-header');
Expand Down
18 changes: 14 additions & 4 deletions e2e-tests/playwright/lib/src/ui/pages/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,12 @@ export default class ChannelsPage {
return popover;
}

async scheduleMessage(message: string, dayFromToday: number = 0, timeOptionIndex: number = 0) {
async scheduleMessage(
message: string,
dayFromToday: number = 0,
timeOptionIndex: number = 0,
repeatWeekly?: boolean,
) {
await this.centerView.postCreate.writeMessage(message);

await expect(this.centerView.postCreate.scheduleMessageButton).toBeVisible();
Expand All @@ -452,10 +457,15 @@ export default class ChannelsPage {
await this.scheduleMessageMenu.toBeVisible();
await this.scheduleMessageMenu.selectCustomTime();

return this.scheduleMessageModal.scheduleMessage(dayFromToday, timeOptionIndex);
return this.scheduleMessageModal.scheduleMessage(dayFromToday, timeOptionIndex, repeatWeekly);
}

async scheduleMessageFromThread(message: string, dayFromToday: number = 0, timeOptionIndex: number = 0) {
async scheduleMessageFromThread(
message: string,
dayFromToday: number = 0,
timeOptionIndex: number = 0,
repeatWeekly?: boolean,
) {
await this.sidebarRight.postCreate.writeMessage(message);

await expect(this.sidebarRight.postCreate.scheduleMessageButton).toBeVisible();
Expand All @@ -464,7 +474,7 @@ export default class ChannelsPage {
await this.scheduleMessageMenu.toBeVisible();
await this.scheduleMessageMenu.selectCustomTime();

return this.scheduleMessageModal.scheduleMessage(dayFromToday, timeOptionIndex);
return this.scheduleMessageModal.scheduleMessage(dayFromToday, timeOptionIndex, repeatWeekly);
}

async getFlaggedPostViewDetailButton(flaggedPostId: string) {
Expand Down
10 changes: 8 additions & 2 deletions e2e-tests/playwright/lib/src/ui/pages/scheduled_posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,18 @@ export default class ScheduledPostsPage {
return new components.ScheduledPost(nthPost);
}

async rescheduleMessage(post: ScheduledPost, dayFromToday: number = 0, timeOptionIndex: number = 0) {
async openRescheduleMessageModal(post: ScheduledPost) {
await post.hover();
await expect(post.rescheduleButton).toBeVisible();
await post.rescheduleButton.click();
await this.scheduleMessageModal.toBeVisible();

return this.scheduleMessageModal;
}

return this.scheduleMessageModal.scheduleMessage(dayFromToday, timeOptionIndex);
async rescheduleMessage(post: ScheduledPost, dayFromToday: number = 0, timeOptionIndex: number = 0) {
const scheduleMessageModal = await this.openRescheduleMessageModal(post);
return scheduleMessageModal.scheduleMessage(dayFromToday, timeOptionIndex);
}

async goto(teamName: string) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,52 @@ test.fixme(
},
);

/**
* @objective Verify that a weekly recurring scheduled message does not pin the channel indicator above the composer.
*
* @precondition
* A test server with valid license and the RecurringScheduledPosts feature flag enabled
*/
test(
'creates weekly recurring scheduled message from channel without showing the channel indicator',
{tag: '@scheduled_messages'},
async ({pw}) => {
await pw.skipIfFeatureFlagNotSet('RecurringScheduledPosts', true);

const draftMessage = `Weekly Scheduled Draft ${pw.random.id()}`;

// # Initialize test user, login and navigate to a channel
const {user, team} = await pw.initSetup();
const {channelsPage, scheduledPostsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();

// # Create a weekly recurring scheduled message for tomorrow
const {selectedDate, selectedTime} = await channelsPage.scheduleMessage(draftMessage, 1, 0, true);

// * Verify scheduled post badge appears with count of 1
await verifyScheduledPostBadgeOnLeftSidebar(channelsPage, 1);

// * Verify the channel indicator stays hidden since every scheduled post is recurring
await channelsPage.centerView.scheduledPostIndicator.toBeNotVisible();

// # Navigate to scheduled posts page
await scheduledPostsPage.goto(team.name);

// * Verify scheduled post appears with recurring weekly details
const scheduledPost = await verifyScheduledPost(scheduledPostsPage, {
draftMessage,
selectedDate,
selectedTime,
badgeCountOnTab: 1,
repeatWeekly: true,
});

// * Verify one-time send now control is not available for recurring scheduled posts
await expect(scheduledPost.sendNowButton).toHaveCount(0);
},
);

/**
* @objective Verify the ability to create a scheduled message in a thread.
*
Expand Down Expand Up @@ -182,6 +228,70 @@ test(
},
);

/**
* @objective Verify rescheduling a weekly recurring scheduled message keeps its weekly recurrence.
*
* @precondition
* A test server with valid license and the RecurringScheduledPosts feature flag enabled
*/
test(
'reschedules weekly recurring scheduled message from scheduled posts page and keeps weekly recurrence',
{tag: '@scheduled_messages'},
async ({pw}) => {
await pw.skipIfFeatureFlagNotSet('RecurringScheduledPosts', true);

const draftMessage = `Weekly Scheduled Draft ${pw.random.id()}`;

// # Initialize test user, login and navigate to a channel
const {user, team} = await pw.initSetup();
const {channelsPage, scheduledPostsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();

// # Create a weekly recurring scheduled message for tomorrow
const {selectedDate, selectedTime} = await channelsPage.scheduleMessage(draftMessage, 1, 0, true);

// # Navigate to scheduled posts page
await scheduledPostsPage.goto(team.name);

// * Verify scheduled post appears with recurring weekly details
let scheduledPost = await verifyScheduledPost(scheduledPostsPage, {
draftMessage,
selectedDate,
selectedTime,
badgeCountOnTab: 1,
repeatWeekly: true,
});

// # Open the reschedule modal for the recurring scheduled message
const scheduleMessageModal = await scheduledPostsPage.openRescheduleMessageModal(scheduledPost);

// * Verify the weekly recurrence option remains selected in the reschedule modal
await expect(scheduleMessageModal.repeatWeeklyCheckbox).toBeChecked();

// # Reschedule the recurring message to a different future date
const {selectedDate: newSelectedDate, selectedTime: newSelectedTime} =
await scheduleMessageModal.scheduleMessage(2);

// * Verify the rescheduled post still appears as a weekly recurring message
scheduledPost = await verifyScheduledPost(scheduledPostsPage, {
draftMessage,
selectedDate: newSelectedDate,
selectedTime: newSelectedTime,
badgeCountOnTab: 1,
repeatWeekly: true,
});
await expect(scheduledPost.sendNowButton).toHaveCount(0);

// # Return to channel page
await channelsPage.goto();

// * Verify the channel indicator stays hidden since every scheduled post is recurring
await verifyScheduledPostBadgeOnLeftSidebar(channelsPage, 1);
await channelsPage.centerView.scheduledPostIndicator.toBeNotVisible();
},
);

/**
* @objective Verify the ability to delete a scheduled message.
*
Expand Down Expand Up @@ -577,11 +687,23 @@ async function verifyScheduledPost(
selectedDate,
selectedTime,
badgeCountOnTab,
}: {draftMessage: string; selectedDate: string; selectedTime: string | null; badgeCountOnTab: number},
repeatWeekly = false,
}: {
draftMessage: string;
selectedDate: string;
selectedTime: string | null;
badgeCountOnTab: number;
repeatWeekly?: boolean;
},
) {
// * Verify scheduled posts page is visible
await scheduledPostsPage.toBeVisible();

// Clear hover and focus (e.g. left behind by the reschedule modal); the panel hides its
// timestamp/tag info section while hovered or focused.
await scheduledPostsPage.page.mouse.move(0, 0);
await scheduledPostsPage.page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());

// * Verify scheduled post badge on tab has correct count
expect(await scheduledPostsPage.getBadgeCountOnTab()).toBe(badgeCountOnTab.toString());

Expand Down Expand Up @@ -610,5 +732,9 @@ async function verifyScheduledPost(
);
}

if (repeatWeekly) {
await expect(scheduledPost.repeatsWeeklyTag).toBeVisible();
}

return scheduledPost;
}
28 changes: 26 additions & 2 deletions server/channels/api4/scheduled_post.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package api4

import (
"encoding/json"
"io"
"net/http"

"github.com/gorilla/mux"
Expand Down Expand Up @@ -177,12 +178,27 @@ func updateScheduledPost(c *Context, w http.ResponseWriter, r *http.Request) {
return
}

var scheduledPost model.ScheduledPost
if err := json.NewDecoder(r.Body).Decode(&scheduledPost); err != nil {
body, err := io.ReadAll(r.Body)
if err != nil {
c.SetInvalidParamWithErr("schedule_post", err)
return
}

var scheduledPost model.ScheduledPost
if unmarshalErr := json.Unmarshal(body, &scheduledPost); unmarshalErr != nil {
c.SetInvalidParamWithErr("schedule_post", unmarshalErr)
return
}

// Detect whether the payload included repeat_type at all.
var rawPayload struct {
RepeatType json.RawMessage `json:"repeat_type"`
}
if unmarshalErr := json.Unmarshal(body, &rawPayload); unmarshalErr != nil {
c.SetInvalidParamWithErr("schedule_post", unmarshalErr)
return
}

if scheduledPost.Id != scheduledPostId {
c.SetInvalidURLParam("scheduled_post_id")
return
Expand All @@ -207,6 +223,14 @@ func updateScheduledPost(c *Context, w http.ResponseWriter, r *http.Request) {
return
}

// Clients that predate recurring scheduled posts omit the repeat fields entirely, so an
// absent repeat_type preserves the existing recurrence rather than ending the series.
// Sending an explicit, empty repeat_type remains the way to stop repeating.
if rawPayload.RepeatType == nil {
scheduledPost.RepeatType = existingScheduledPost.RepeatType
scheduledPost.RepeatTimezone = existingScheduledPost.RepeatTimezone
}

if len(scheduledPost.FileIds) > 0 {
originalPost, err := existingScheduledPost.ToPost()
if err != nil {
Expand Down
Loading
Loading