Fix inconsistent behaviour in campaign scheduling on the UI.

- Fix status/button state management issues when `Send at` was toggled
  under various scenarios.
- Allow paused campaigns to be edited and turned into scheduled campaigns.
- Add Cypress UI tests for unscheduling.
This commit is contained in:
Kailash Nadh 2025-03-31 13:00:51 +05:30
parent fbc27ae4b2
commit a5f8b28cb1
7 changed files with 56 additions and 25 deletions

View file

@ -114,6 +114,18 @@ describe('Campaigns', () => {
cy.get('tbody td[data-label=Status] .tag.scheduled');
});
it('Unschedules campaign', () => {
cy.get('td[data-label=Status] a').eq(1).click();
cy.wait(250);
cy.get('button[data-cy=btn-unschedule]').click();
cy.get('.modal button.is-primary:eq(0)').click();
cy.wait(250);
cy.visit('/admin/campaigns');
// Check if the status label has the inner text `Draft`.
cy.get('td[data-label=Status] .tag.draft').should('have.length', 1);
});
it('Switches formats', () => {
cy.resetDB();
cy.loginAndVisit('/admin/campaigns');

View file

@ -126,6 +126,10 @@ section {
background-color: $primary;
}
.has-text-primary {
color: $primary !important;
}
.box {
background: $white;
box-shadow: 2px 2px 0 #f3f3f3;

View file

@ -44,8 +44,8 @@
</b-button>
</b-field>
<b-field expanded v-if="canUnSchedule">
<b-button expanded @click="unscheduleCampaign" :loading="loading.campaigns" type="is-primary"
icon-left="clock-start" data-cy="btn-unschedule">
<b-button expanded @click="$utils.confirm(null, unscheduleCampaign)" :loading="loading.campaigns"
type="is-primary" icon-left="clock-start" data-cy="btn-unschedule">
{{ $t('campaigns.unSchedule') }}
</b-button>
</b-field>
@ -127,8 +127,8 @@
<br />
<b-field v-if="form.sendLater" data-cy="send_at"
:message="form.sendAtDate ? $utils.duration(Date(), form.sendAtDate) : ''">
<b-datetimepicker v-model="form.sendAtDate" :disabled="!canEdit"
:placeholder="$t('campaigns.dateAndTime')" icon="calendar-clock"
<b-datetimepicker v-model="form.sendAtDate" :disabled="!canEdit" required editable mobile-native
position="is-top-right" :placeholder="$t('campaigns.dateAndTime')" icon="calendar-clock"
:timepicker="{ hourFormat: '24' }" :datetime-formatter="formatDateTime"
horizontal-time-picker />
</b-field>
@ -469,11 +469,6 @@ export default Vue.extend({
}
return f;
});
if (data.sendAt !== null) {
this.form.sendLater = true;
this.form.sendAtDate = dayjs(data.sendAt).toDate();
}
});
},
@ -553,11 +548,16 @@ export default Vue.extend({
typMsg = 'campaigns.started';
}
if (!this.form.sendAtDate) {
this.form.sendLater = false;
}
// This promise is used by startCampaign to first save before starting.
return new Promise((resolve) => {
this.$api.updateCampaign(this.data.id, data).then((d) => {
this.data = d;
this.form.archiveSlug = d.archiveSlug;
this.$utils.toast(this.$t(typMsg, { name: d.name }));
resolve();
});
@ -613,7 +613,6 @@ export default Vue.extend({
unscheduleCampaign() {
this.$api.changeCampaignStatus(this.data.id, 'draft').then((d) => {
this.data = d;
this.form.archiveSlug = d.archiveSlug;
});
},
},
@ -627,15 +626,15 @@ export default Vue.extend({
},
canSchedule() {
return this.data.status === 'draft' && this.data.sendAt;
return (this.data.status === 'draft' || this.data.status === 'paused') && (this.form.sendLater && this.form.sendAtDate);
},
canUnSchedule() {
return this.data.status === 'scheduled' && this.data.sendAt;
return this.data.status === 'scheduled';
},
canStart() {
return this.data.status === 'draft' || this.data.status === 'paused';
return (this.data.status === 'draft' || this.data.status === 'paused') && !this.form.sendLater;
},
canArchive() {
@ -671,6 +670,16 @@ export default Vue.extend({
selectedLists() {
this.form.lists = this.selectedLists;
},
'data.sendAt': function () {
if (this.data.sendAt !== null) {
this.form.sendLater = true;
this.form.sendAtDate = dayjs(this.data.sendAt).toDate();
} else {
this.form.sendLater = false;
this.form.sendAtDate = null;
}
},
},
mounted() {

View file

@ -52,16 +52,14 @@
</router-link>
</p>
<p v-if="isSheduled(props.row)">
<b-tooltip :label="$t('scheduled')" type="is-dark">
<span class="is-size-7 has-text-grey scheduled">
<b-icon icon="alarm" size="is-small" />
<span v-if="!isDone(props.row) && !isRunning(props.row)">
{{ $utils.duration(new Date(), props.row.sendAt, true) }}
<br />
</span>
{{ $utils.niceDate(props.row.sendAt, true) }}
<span class="is-size-7 has-text-grey scheduled">
<b-icon icon="alarm" size="is-small" />
<span v-if="!isDone(props.row) && !isRunning(props.row)">
{{ $utils.duration(new Date(), props.row.sendAt, true) }}
<br />
</span>
</b-tooltip>
{{ $utils.niceDate(props.row.sendAt, true) }}
</span>
</p>
</div>
</b-table-column>

View file

@ -61,7 +61,7 @@
"campaigns.notFound": "Campaign not found.",
"campaigns.onlyActiveCancel": "Only active campaigns can be cancelled.",
"campaigns.onlyActivePause": "Only active campaigns can be paused.",
"campaigns.onlyDraftAsScheduled": "Only draft campaigns can be scheduled.",
"campaigns.onlyDraftAsScheduled": "Only draft or paused campaigns can be scheduled.",
"campaigns.onlyPausedDraft": "Only paused campaigns and drafts can be started.",
"campaigns.onlyScheduledAsDraft": "Only scheduled campaigns can be saved as drafts.",
"campaigns.pause": "Pause",

View file

@ -255,7 +255,7 @@ func (c *Core) UpdateCampaignStatus(id int, status string) (models.Campaign, err
errMsg = c.i18n.T("campaigns.onlyScheduledAsDraft")
}
case models.CampaignStatusScheduled:
if cm.Status != models.CampaignStatusDraft {
if cm.Status != models.CampaignStatusDraft && cm.Status != models.CampaignStatusPaused {
errMsg = c.i18n.T("campaigns.onlyDraftAsScheduled")
}
if !cm.SendAt.Valid {

View file

@ -880,7 +880,15 @@ UPDATE campaigns SET
WHERE id=$1;
-- name: update-campaign-status
UPDATE campaigns SET status=$2, updated_at=NOW() WHERE id = $1;
UPDATE campaigns SET
status=(
CASE
WHEN send_at IS NOT NULL AND $2 = 'running' THEN 'scheduled'
ELSE $2::campaign_status
END
),
updated_at=NOW()
WHERE id = $1;
-- name: update-campaign-archive
UPDATE campaigns SET