mirror of
https://github.com/knadh/listmonk.git
synced 2025-12-05 16:00:03 +01:00
WIP: Add bulk campaign deletion.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -17,24 +17,37 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<b-table :data="campaigns.results" :loading="loading.campaigns" :row-class="highlightedRow" paginated
|
||||
<div class="columns">
|
||||
<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" />
|
||||
<p class="controls">
|
||||
<b-button native-type="submit" type="is-primary" icon-left="magnify" />
|
||||
</p>
|
||||
</b-field>
|
||||
</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="columns">
|
||||
<div class="column is-6">
|
||||
<form @submit.prevent="getCampaigns">
|
||||
<div>
|
||||
<b-field>
|
||||
<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>
|
||||
</b-field>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<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() {
|
||||
|
||||
@@ -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">
|
||||
—
|
||||
<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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user