WIP: Add bulk campaign deletion.

This commit is contained in:
Kailash Nadh
2025-04-07 23:53:39 +05:30
parent b3e6b09929
commit a0f08d36f5
9 changed files with 131 additions and 42 deletions

View File

@@ -360,7 +360,28 @@ func (a *App) DeleteCampaign(c echo.Context) error {
}
// Delete the campaign from the DB.
if err := a.core.DeleteCampaign(id); err != nil {
if err := a.core.DeleteCampaigns([]int{id}); err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{true})
}
// DeleteCampaigns handles bulk deletion of one or more campaigns.
func (a *App) DeleteCampaigns(c echo.Context) error {
// Multiple IDs.
ids, err := parseStringIDs(c.Request().URL.Query()["id"])
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
a.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error()))
}
if len(ids) == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
a.i18n.Ts("globals.messages.errorInvalidIDs", "error", "ids"))
}
// Delete the subscribers from the DB.
if err := a.core.DeleteCampaigns(ids); err != nil {
return err
}

View File

@@ -159,6 +159,7 @@ func initHTTPHandlers(e *echo.Echo, a *App) {
g.PUT("/api/campaigns/:id/status", pm(hasID(a.UpdateCampaignStatus), "campaigns:manage_all", "campaigns:manage"))
g.PUT("/api/campaigns/:id/archive", pm(hasID(a.UpdateCampaignArchive), "campaigns:manage_all", "campaigns:manage"))
g.DELETE("/api/campaigns/:id", pm(hasID(a.DeleteCampaign), "campaigns:manage_all", "campaigns:manage"))
g.DELETE("/api/campaigns", pm(a.DeleteCampaigns, "campaigns:manage_all", "campaigns:manage"))
g.GET("/api/media", pm(a.GetAllMedia, "media:get"))
g.GET("/api/media/:id", pm(hasID(a.GetMedia), "media:get"))

View File

@@ -343,6 +343,11 @@ export const deleteCampaign = async (id) => http.delete(
{ loading: models.campaigns },
);
export const deleteCampaigns = async (params) => http.delete(
'/api/campaigns',
{ params, loading: models.campaigns },
);
// Media.
export const getMedia = async (params) => http.get(
'/api/media',

View File

@@ -17,17 +17,12 @@
</div>
</header>
<b-table :data="campaigns.results" :loading="loading.campaigns" :row-class="highlightedRow" paginated
backend-pagination pagination-position="both" @page-change="onPageChange" :current-page="queryParams.page"
:per-page="campaigns.perPage" :total="campaigns.total" hoverable backend-sorting @sort="onSort">
<template #top-left>
<div class="columns">
<div class="column is-6">
<form @submit.prevent="getCampaigns">
<form @submit.prevent="getCampaigns" class="is-half column">
<div>
<b-field>
<b-input v-model="queryParams.query" name="query" expanded
:placeholder="$t('campaigns.queryPlaceholder')" icon="magnify" ref="query" />
<b-input v-model="queryParams.query" name="query" expanded :placeholder="$t('campaigns.queryPlaceholder')"
icon="magnify" ref="query" />
<p class="controls">
<b-button native-type="submit" type="is-primary" icon-left="magnify" />
</p>
@@ -35,6 +30,24 @@
</div>
</form>
</div>
<b-table :data="campaigns.results" :loading="loading.campaigns" :row-class="highlightedRow"
@check-all="onTableCheck" @check="onTableCheck" :checked-rows.sync="bulk.checked" checkable paginated
backend-pagination pagination-position="both" @page-change="onPageChange" :current-page="queryParams.page"
:per-page="campaigns.perPage" :total="campaigns.total" hoverable backend-sorting @sort="onSort">
<template #top-left>
<div class="actions">
<template v-if="bulk.checked.length > 0">
<a class="a" href="#" @click.prevent="onDeleteCampaigns" data-cy="btn-delete-campaigns">
<b-icon icon="trash-can-outline" size="is-small" /> Delete
</a>
<span class="a">
{{ $t('globals.messages.numSelected', {
num: numSelected,
name: $tc('globals.terms.campaign', numSelected).toLowerCase(),
}) }}
</span>
</template>
</div>
</template>
@@ -242,7 +255,7 @@
</b-tooltip>
</router-link>
<a v-if="$can('campaigns:manage')" href="#"
@click.prevent="$utils.confirm($t('campaigns.confirmDelete', { name: props.row.name }), () => deleteCampaign(props.row))"
@click.prevent="$utils.confirm($t('campaigns.confirmDelete', { name: props.row.name }), () => onDeleteCampaign(props.row))"
data-cy="btn-delete" :aria-label="$t('globals.buttons.delete')">
<b-icon icon="trash-can-outline" size="is-small" />
</a>
@@ -283,6 +296,12 @@ export default Vue.extend({
},
pollID: null,
campaignStatsData: {},
// Table bulk row selection states.
bulk: {
checked: [],
all: false,
},
};
},
@@ -324,6 +343,13 @@ export default Vue.extend({
return '';
},
onTableCheck() {
// Disable bulk.all selection if there are no rows checked in the table.
if (this.bulk.checked.length !== this.campaigns.total) {
this.bulk.all = false;
}
},
onPageChange(p) {
this.queryParams.page = p;
this.getCampaigns();
@@ -351,6 +377,7 @@ export default Vue.extend({
order_by: this.queryParams.orderBy,
order: this.queryParams.order,
});
this.bulk.checked = [];
},
// Stats returns the campaign object with stats (sent, toSend etc.)
@@ -438,7 +465,26 @@ export default Vue.extend({
});
},
deleteCampaign(c) {
onDeleteCampaigns() {
const langName = this.$tc('globals.terms.campaign', this.numSelected).toLowerCase();
const ids = this.bulk.checked.map((s) => s.id);
const fn = () => {
this.$api.deleteCampaigns({ id: ids })
.then(() => {
this.getCampaigns();
this.$utils.toast(this.$t(
'globals.messages.numDeleted',
{ num: this.numSelected, name: langName },
));
});
};
this.$utils.confirm(this.$t('globals.messages.confirmNumDelete', { num: this.numSelected, name: langName }), fn);
},
onDeleteCampaign(c) {
this.$api.deleteCampaign(c.id).then(() => {
this.getCampaigns();
this.$utils.toast(this.$t('globals.messages.deleted', { name: c.name }));
@@ -448,6 +494,14 @@ export default Vue.extend({
computed: {
...mapState(['campaigns', 'loading']),
numSelected() {
if (this.bulk.all) {
return this.campaigns.total;
}
return this.bulk.checked.length;
},
},
mounted() {

View File

@@ -82,20 +82,26 @@
</a>
<template v-if="bulk.checked.length > 0">
<a class="a" href="#" @click.prevent="showBulkListForm" data-cy="btn-manage-lists">
<b-icon icon="format-list-bulleted-square" size="is-small" /> Manage lists
<b-icon icon="format-list-bulleted-square" size="is-small" /> {{ $t('subscribers.manageLists') }}
</a>
<a class="a" href="#" @click.prevent="deleteSubscribers" data-cy="btn-delete-subscribers">
<b-icon icon="trash-can-outline" size="is-small" /> Delete
<b-icon icon="trash-can-outline" size="is-small" /> {{ $t('globals.buttons.delete') }}
</a>
<a class="a" href="#" @click.prevent="blocklistSubscribers" data-cy="btn-manage-blocklist">
<b-icon icon="account-off-outline" size="is-small" /> Blocklist
<b-icon icon="account-off-outline" size="is-small" /> {{ $t('import.blocklist') }}
</a>
<span class="a">
{{ $t('subscribers.numSelected', { num: numSelectedSubscribers }) }}
{{ $t('globals.messages.numSelected', {
num: numSelected,
name: $tc('globals.terms.subscriber', numSelected).toLowerCase(),
}) }}
<span v-if="!bulk.all && subscribers.total > subscribers.perPage">
&mdash;
<a href="#" @click.prevent="selectAllSubscribers">
{{ $t('subscribers.selectAll', { num: subscribers.total }) }}
<a href="#" @click.prevent="onSelectAll">
{{ $t('globals.buttons.selectAll', {
num: subscribers.total,
name: $t('globals.terms.subscribers').toLowerCase(),
}) }}
</a>
</span>
</span>
@@ -177,7 +183,7 @@
<!-- Manage list modal -->
<b-modal scroll="keep" :aria-modal="true" :active.sync="isBulkListFormVisible" :width="500" class="has-overflow">
<subscriber-bulk-list :num-subscribers="this.numSelectedSubscribers" @finished="bulkChangeLists" />
<subscriber-bulk-list :num-subscribers="this.numSelected" @finished="bulkChangeLists" />
</b-modal>
<!-- Add / edit form modal -->
@@ -260,7 +266,7 @@ export default Vue.extend({
},
// Mark all subscribers in the query as selected.
selectAllSubscribers() {
onSelectAll() {
this.bulk.all = true;
},
@@ -378,7 +384,7 @@ export default Vue.extend({
};
}
this.$utils.confirm(this.$t('subscribers.confirmBlocklist', { num: this.numSelectedSubscribers }), fn);
this.$utils.confirm(this.$t('subscribers.confirmBlocklist', { num: this.numSelected }), fn);
},
exportSubscribers() {
@@ -407,6 +413,7 @@ export default Vue.extend({
},
deleteSubscribers() {
const langName = this.$tc('globals.terms.subscriber', this.numSelected).toLowerCase();
let fn = null;
if (!this.bulk.all && this.bulk.checked.length > 0) {
// If 'all' is not selected, delete subscribers by IDs.
@@ -416,7 +423,7 @@ export default Vue.extend({
.then(() => {
this.querySubscribers();
this.$utils.toast(this.$t('subscribers.subscribersDeleted', { num: this.numSelectedSubscribers }));
this.$utils.toast(this.$t('globals.messages.numDeleted', { num: this.numSelected, name: langName }));
});
};
} else {
@@ -433,14 +440,14 @@ export default Vue.extend({
this.querySubscribers();
this.$utils.toast(this.$t(
'subscribers.subscribersDeleted',
{ num: this.numSelectedSubscribers },
'globals.messages.numDeleted',
{ num: this.numSelected, name: langName },
));
});
};
}
this.$utils.confirm(this.$t('subscribers.confirmDelete', { num: this.numSelectedSubscribers }), fn);
this.$utils.confirm(this.$t('globals.messages.confirmNumDelete', { num: this.numSelected, name: langName }), fn);
},
bulkChangeLists(action, preconfirm, lists) {
@@ -477,7 +484,7 @@ export default Vue.extend({
computed: {
...mapState(['subscribers', 'lists', 'loading']),
numSelectedSubscribers() {
numSelected() {
if (this.bulk.all) {
return this.subscribers.total;
}

View File

@@ -154,6 +154,7 @@
"globals.buttons.saveChanges": "Save changes",
"globals.buttons.toggleSelect": "Toggle selection",
"globals.buttons.view": "View",
"globals.buttons.selectAll": "Select all {num} {name}",
"globals.days.0": "Sun",
"globals.days.1": "Sun",
"globals.days.2": "Mon",
@@ -170,6 +171,8 @@
"globals.fields.type": "Type",
"globals.fields.updatedAt": "Updated",
"globals.fields.uuid": "UUID",
"globals.messages.confirmNumDelete": "Delete {num} {name}?",
"globals.messages.numSelected": "{num} {name} selected",
"globals.messages.confirm": "Are you sure?",
"globals.messages.confirmDiscard": "Discard changes?",
"globals.messages.copied": "Copied",
@@ -196,6 +199,7 @@
"globals.messages.permissionDenied": "Permission denied: {name}",
"globals.messages.slowQueriesCached": "Slow queries are being cached. Some numbers on this page will not be up-to-date.",
"globals.messages.updated": "\"{name}\" updated",
"globals.messages.numDeleted": "{num} {name} deleted",
"globals.months.1": "Jan",
"globals.months.10": "Oct",
"globals.months.11": "Nov",
@@ -575,14 +579,12 @@
"subscribers.manageLists": "Manage lists",
"subscribers.markUnsubscribed": "Mark as unsubscribed",
"subscribers.newSubscriber": "New subscriber",
"subscribers.numSelected": "{num} subscriber(s) selected",
"subscribers.optinSubject": "Confirm subscription",
"subscribers.preconfirm": "Preconfirm subscriptions",
"subscribers.preconfirmHelp": "Don't send opt-in e-mails and mark all list subscriptions as 'subscribed'.",
"subscribers.query": "Query",
"subscribers.queryPlaceholder": "E-mail or name",
"subscribers.reset": "Reset",
"subscribers.selectAll": "Select all {num}",
"subscribers.sendOptinConfirm": "Send opt-in confirmation",
"subscribers.sentOptinConfirm": "Opt-in confirmation sent",
"subscribers.status.blocklisted": "Blocklisted",
@@ -591,7 +593,6 @@
"subscribers.status.subscribed": "Subscribed",
"subscribers.status.unconfirmed": "Unconfirmed",
"subscribers.status.unsubscribed": "Unsubscribed",
"subscribers.subscribersDeleted": "{num} subscriber(s) deleted",
"templates.cantDeleteDefault": "Cannot delete non-existent or default template",
"templates.default": "Default",
"templates.dummyName": "Dummy campaign",

View File

@@ -42,7 +42,7 @@ func (c *Core) QueryCampaigns(searchStr string, statuses, tags []string, orderBy
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
for i := 0; i < len(out); i++ {
for i := range out {
// Replace null tags.
if out[i].Tags == nil {
out[i].Tags = []string{}
@@ -309,9 +309,9 @@ func (c *Core) UpdateCampaignArchive(id int, enabled bool, tplID int, meta model
return nil
}
// DeleteCampaign deletes a campaign.
func (c *Core) DeleteCampaign(id int) error {
res, err := c.q.DeleteCampaign.Exec(id)
// DeleteCampaigns deletes a campaign.
func (c *Core) DeleteCampaigns(ids []int) error {
res, err := c.q.DeleteCampaigns.Exec(pq.Array(ids))
if err != nil {
c.log.Printf("error deleting campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,

View File

@@ -86,7 +86,7 @@ type Queries struct {
UpdateCampaignCounts *sqlx.Stmt `query:"update-campaign-counts"`
UpdateCampaignArchive *sqlx.Stmt `query:"update-campaign-archive"`
RegisterCampaignView *sqlx.Stmt `query:"register-campaign-view"`
DeleteCampaign *sqlx.Stmt `query:"delete-campaign"`
DeleteCampaigns *sqlx.Stmt `query:"delete-campaigns"`
InsertMedia *sqlx.Stmt `query:"insert-media"`
GetMedia *sqlx.Stmt `query:"get-media"`

View File

@@ -909,8 +909,8 @@ UPDATE campaigns SET
updated_at=NOW()
WHERE id=$1;
-- name: delete-campaign
DELETE FROM campaigns WHERE id=$1;
-- name: delete-campaigns
DELETE FROM campaigns WHERE id = ANY($1);
-- name: register-campaign-view
WITH view AS (