Subscribers.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  1. <template>
  2. <section class="subscribers">
  3. <header class="columns">
  4. <div class="column is-half">
  5. <h1 class="title is-4">{{ $t('globals.terms.subscribers') }}
  6. <span v-if="!isNaN(subscribers.total)">
  7. (<span data-cy="count">{{ subscribers.total }}</span>)
  8. </span>
  9. <span v-if="currentList">
  10. &raquo; {{ currentList.name }}
  11. </span>
  12. </h1>
  13. </div>
  14. <div class="column has-text-right">
  15. <b-button type="is-primary" icon-left="plus" @click="showNewForm" data-cy="btn-new">
  16. {{ $t('globals.buttons.new') }}
  17. </b-button>
  18. </div>
  19. </header>
  20. <section class="subscribers-controls columns">
  21. <div class="column is-4">
  22. <form @submit.prevent="onSubmit">
  23. <div>
  24. <b-field grouped>
  25. <b-input @input="onSimpleQueryInput" v-model="queryInput"
  26. :placeholder="$t('subscribers.queryPlaceholder')" icon="magnify" ref="query"
  27. :disabled="isSearchAdvanced" data-cy="search"></b-input>
  28. <b-button native-type="submit" type="is-primary" icon-left="magnify"
  29. :disabled="isSearchAdvanced" data-cy="btn-search"></b-button>
  30. </b-field>
  31. <p>
  32. <a href="#" @click.prevent="toggleAdvancedSearch" data-cy="btn-advanced-search">
  33. <b-icon icon="cog-outline" size="is-small" />
  34. {{ $t('subscribers.advancedQuery') }}
  35. </a>
  36. </p>
  37. <div v-if="isSearchAdvanced">
  38. <b-field>
  39. <b-input v-model="queryParams.queryExp"
  40. @keydown.native.enter="onAdvancedQueryEnter"
  41. type="textarea" ref="queryExp"
  42. placeholder="subscribers.name LIKE '%user%' or subscribers.status='blocklisted'"
  43. data-cy="query">
  44. </b-input>
  45. </b-field>
  46. <b-field>
  47. <span class="is-size-6 has-text-grey">
  48. {{ $t('subscribers.advancedQueryHelp') }}.{{ ' ' }}
  49. <a href="https://listmonk.app/docs/querying-and-segmentation"
  50. target="_blank" rel="noopener noreferrer">
  51. {{ $t('globals.buttons.learnMore') }}.
  52. </a>
  53. </span>
  54. </b-field>
  55. <div class="buttons">
  56. <b-button native-type="submit" type="is-primary"
  57. icon-left="magnify" data-cy="btn-query">{{ $t('subscribers.query') }}</b-button>
  58. <b-button @click.prevent="toggleAdvancedSearch" icon-left="cancel"
  59. data-cy="btn-query-reset">
  60. {{ $t('subscribers.reset') }}
  61. </b-button>
  62. </div>
  63. </div><!-- advanced query -->
  64. </div>
  65. </form>
  66. </div><!-- search -->
  67. <div class="column is-4 subscribers-bulk" v-if="bulk.checked.length > 0">
  68. <div>
  69. <p>
  70. <span class="is-size-5 has-text-weight-semibold">
  71. {{ $t('subscribers.numSelected', { num: numSelectedSubscribers }) }}
  72. </span>
  73. <span v-if="!bulk.all && subscribers.total > subscribers.perPage">
  74. &mdash;
  75. <a href="" @click.prevent="selectAllSubscribers">
  76. {{ $t('subscribers.selectAll', { num: subscribers.total }) }}
  77. </a>
  78. </span>
  79. </p>
  80. <p class="actions">
  81. <a href='' @click.prevent="showBulkListForm" data-cy="btn-manage-lists">
  82. <b-icon icon="format-list-bulleted-square" size="is-small" /> Manage lists
  83. </a>
  84. <a href='' @click.prevent="deleteSubscribers" data-cy="btn-delete-subscribers">
  85. <b-icon icon="trash-can-outline" size="is-small" /> Delete
  86. </a>
  87. <a href='' @click.prevent="blocklistSubscribers" data-cy="btn-manage-blocklist">
  88. <b-icon icon="account-off-outline" size="is-small" /> Blocklist
  89. </a>
  90. </p><!-- selection actions //-->
  91. </div>
  92. </div>
  93. </section><!-- control -->
  94. <b-table
  95. :data="subscribers.results"
  96. :loading="loading.subscribers"
  97. @check-all="onTableCheck" @check="onTableCheck"
  98. :checked-rows.sync="bulk.checked"
  99. paginated backend-pagination pagination-position="both" @page-change="onPageChange"
  100. :current-page="queryParams.page" :per-page="subscribers.perPage" :total="subscribers.total"
  101. hoverable checkable backend-sorting @sort="onSort">
  102. <template #top-left>
  103. <a href='' @click.prevent="exportSubscribers">
  104. <b-icon icon="cloud-download-outline" size="is-small" /> {{ $t('subscribers.export') }}
  105. </a>
  106. </template>
  107. <b-table-column v-slot="props" field="status" :label="$t('globals.fields.status')"
  108. header-class="cy-status" :td-attrs="$utils.tdID" sortable>
  109. <a :href="`/subscribers/${props.row.id}`"
  110. @click.prevent="showEditForm(props.row)">
  111. <b-tag :class="props.row.status">
  112. {{ $t(`subscribers.status.${props.row.status}`) }}
  113. </b-tag>
  114. </a>
  115. </b-table-column>
  116. <b-table-column v-slot="props" field="email" :label="$t('subscribers.email')"
  117. header-class="cy-email" sortable>
  118. <a :href="`/subscribers/${props.row.id}`"
  119. @click.prevent="showEditForm(props.row)">
  120. {{ props.row.email }}
  121. </a>
  122. <b-taglist>
  123. <template v-for="l in props.row.lists">
  124. <router-link :to="`/subscribers/lists/${l.id}`"
  125. v-bind:key="l.id" style="padding-right:0.5em;">
  126. <b-tag :class="l.subscriptionStatus" size="is-small" :key="l.id">
  127. {{ l.name }}
  128. <sup>{{ $t('subscribers.status.'+ l.subscriptionStatus) }}</sup>
  129. </b-tag>
  130. </router-link>
  131. </template>
  132. </b-taglist>
  133. </b-table-column>
  134. <b-table-column v-slot="props" field="name" :label="$t('globals.fields.name')"
  135. header-class="cy-name" sortable>
  136. <a :href="`/subscribers/${props.row.id}`"
  137. @click.prevent="showEditForm(props.row)">
  138. {{ props.row.name }}
  139. </a>
  140. </b-table-column>
  141. <b-table-column v-slot="props" field="lists" :label="$t('globals.terms.lists')"
  142. header-class="cy-lists" centered>
  143. {{ listCount(props.row.lists) }}
  144. </b-table-column>
  145. <b-table-column v-slot="props" field="created_at" :label="$t('globals.fields.createdAt')"
  146. header-class="cy-created_at" sortable>
  147. {{ $utils.niceDate(props.row.createdAt) }}
  148. </b-table-column>
  149. <b-table-column v-slot="props" field="updated_at" :label="$t('globals.fields.updatedAt')"
  150. header-class="cy-updated_at" sortable>
  151. {{ $utils.niceDate(props.row.updatedAt) }}
  152. </b-table-column>
  153. <b-table-column v-slot="props" label="Actions" cell-class="actions" align="right">
  154. <div>
  155. <a :href="`/api/subscribers/${props.row.id}/export`" data-cy="btn-download">
  156. <b-tooltip :label="$t('subscribers.downloadData')" type="is-dark">
  157. <b-icon icon="cloud-download-outline" size="is-small" />
  158. </b-tooltip>
  159. </a>
  160. <a :href="`/subscribers/${props.row.id}`"
  161. @click.prevent="showEditForm(props.row)" data-cy="btn-edit">
  162. <b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
  163. <b-icon icon="pencil-outline" size="is-small" />
  164. </b-tooltip>
  165. </a>
  166. <a href='' @click.prevent="deleteSubscriber(props.row)" data-cy="btn-delete">
  167. <b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
  168. <b-icon icon="trash-can-outline" size="is-small" />
  169. </b-tooltip>
  170. </a>
  171. </div>
  172. </b-table-column>
  173. <template #empty v-if="!loading.subscribers">
  174. <empty-placeholder />
  175. </template>
  176. </b-table>
  177. <!-- Manage list modal -->
  178. <b-modal scroll="keep" :aria-modal="true" :active.sync="isBulkListFormVisible" :width="450">
  179. <subscriber-bulk-list :numSubscribers="this.numSelectedSubscribers"
  180. @finished="bulkChangeLists" />
  181. </b-modal>
  182. <!-- Add / edit form modal -->
  183. <b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="600"
  184. @close="onFormClose">
  185. <subscriber-form :data="curItem" :isEditing="isEditing"
  186. @finished="querySubscribers"></subscriber-form>
  187. </b-modal>
  188. </section>
  189. </template>
  190. <script>
  191. import Vue from 'vue';
  192. import { mapState } from 'vuex';
  193. import SubscriberForm from './SubscriberForm.vue';
  194. import SubscriberBulkList from './SubscriberBulkList.vue';
  195. import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
  196. import { uris } from '../constants';
  197. export default Vue.extend({
  198. components: {
  199. SubscriberForm,
  200. SubscriberBulkList,
  201. EmptyPlaceholder,
  202. },
  203. data() {
  204. return {
  205. // Current subscriber item being edited.
  206. curItem: null,
  207. isSearchAdvanced: false,
  208. isEditing: false,
  209. isFormVisible: false,
  210. isBulkListFormVisible: false,
  211. // Table bulk row selection states.
  212. bulk: {
  213. checked: [],
  214. all: false,
  215. },
  216. queryInput: '',
  217. // Query params to filter the getSubscribers() API call.
  218. queryParams: {
  219. // Search query expression.
  220. queryExp: '',
  221. // ID of the list the current subscriber view is filtered by.
  222. listID: null,
  223. page: 1,
  224. orderBy: 'id',
  225. order: 'desc',
  226. },
  227. };
  228. },
  229. methods: {
  230. // Count the lists from which a subscriber has not unsubscribed.
  231. listCount(lists) {
  232. return lists.reduce((defVal, item) => (defVal + (item.subscriptionStatus !== 'unsubscribed' ? 1 : 0)), 0);
  233. },
  234. toggleAdvancedSearch() {
  235. this.isSearchAdvanced = !this.isSearchAdvanced;
  236. // Toggling to simple search.
  237. if (!this.isSearchAdvanced) {
  238. this.$nextTick(() => {
  239. this.queryInput = '';
  240. this.queryParams.queryExp = '';
  241. this.queryParams.page = 1;
  242. this.$refs.query.focus();
  243. this.querySubscribers();
  244. });
  245. return;
  246. }
  247. // Toggling to advanced search.
  248. this.$nextTick(() => {
  249. this.$refs.queryExp.focus();
  250. });
  251. },
  252. // Mark all subscribers in the query as selected.
  253. selectAllSubscribers() {
  254. this.bulk.all = true;
  255. },
  256. onTableCheck() {
  257. // Disable bulk.all selection if there are no rows checked in the table.
  258. if (this.bulk.checked.length !== this.subscribers.total) {
  259. this.bulk.all = false;
  260. }
  261. },
  262. // Show the edit list form.
  263. showEditForm(sub) {
  264. this.curItem = sub;
  265. this.isFormVisible = true;
  266. this.isEditing = true;
  267. },
  268. // Show the new list form.
  269. showNewForm() {
  270. this.curItem = {};
  271. this.isFormVisible = true;
  272. this.isEditing = false;
  273. },
  274. showBulkListForm() {
  275. this.isBulkListFormVisible = true;
  276. },
  277. onFormClose() {
  278. if (this.$route.params.id) {
  279. this.$router.push({ name: 'subscribers' });
  280. }
  281. },
  282. onPageChange(p) {
  283. this.querySubscribers({ page: p });
  284. },
  285. onSort(field, direction) {
  286. this.querySubscribers({ orderBy: field, order: direction });
  287. },
  288. // Prepares an SQL expression for simple name search inputs and saves it
  289. // in this.queryExp.
  290. onSimpleQueryInput(v) {
  291. const q = v.replace(/'/, "''").trim();
  292. this.queryParams.page = 1;
  293. if (this.$utils.validateEmail(q)) {
  294. this.queryParams.queryExp = `email = '${q}'`;
  295. } else {
  296. this.queryParams.queryExp = `(name ~* '${q}' OR email ~* '${q}')`;
  297. }
  298. },
  299. // Ctrl + Enter on the advanced query searches.
  300. onAdvancedQueryEnter(e) {
  301. if (e.ctrlKey) {
  302. this.onSubmit();
  303. }
  304. },
  305. onSubmit() {
  306. this.querySubscribers({ page: 1 });
  307. },
  308. // Search / query subscribers.
  309. querySubscribers(params) {
  310. this.queryParams = { ...this.queryParams, ...params };
  311. this.$nextTick(() => {
  312. this.$api.getSubscribers({
  313. list_id: this.queryParams.listID,
  314. query: this.queryParams.queryExp,
  315. page: this.queryParams.page,
  316. order_by: this.queryParams.orderBy,
  317. order: this.queryParams.order,
  318. }).then(() => {
  319. this.bulk.checked = [];
  320. });
  321. });
  322. },
  323. deleteSubscriber(sub) {
  324. this.$utils.confirm(
  325. null,
  326. () => {
  327. this.$api.deleteSubscriber(sub.id).then(() => {
  328. this.querySubscribers();
  329. this.$utils.toast(this.$t('globals.messages.deleted', { name: sub.name }));
  330. });
  331. },
  332. );
  333. },
  334. blocklistSubscribers() {
  335. let fn = null;
  336. if (!this.bulk.all && this.bulk.checked.length > 0) {
  337. // If 'all' is not selected, blocklist subscribers by IDs.
  338. fn = () => {
  339. const ids = this.bulk.checked.map((s) => s.id);
  340. this.$api.blocklistSubscribers({ ids })
  341. .then(() => this.querySubscribers());
  342. };
  343. } else {
  344. // 'All' is selected, blocklist by query.
  345. fn = () => {
  346. this.$api.blocklistSubscribersByQuery({
  347. query: this.queryParams.queryExp,
  348. list_ids: this.queryParams.listID ? [this.queryParams.listID] : null,
  349. }).then(() => this.querySubscribers());
  350. };
  351. }
  352. this.$utils.confirm(this.$t('subscribers.confirmBlocklist', { num: this.numSelectedSubscribers }), fn);
  353. },
  354. exportSubscribers() {
  355. this.$utils.confirm(this.$t('subscribers.confirmExport', { num: this.subscribers.total }), () => {
  356. const q = new URLSearchParams();
  357. q.append('query', this.queryParams.queryExp);
  358. q.append('list_id', this.queryParams.listID);
  359. document.location.href = `${uris.exportSubscribers}?${q.toString()}`;
  360. });
  361. },
  362. deleteSubscribers() {
  363. let fn = null;
  364. if (!this.bulk.all && this.bulk.checked.length > 0) {
  365. // If 'all' is not selected, delete subscribers by IDs.
  366. fn = () => {
  367. const ids = this.bulk.checked.map((s) => s.id);
  368. this.$api.deleteSubscribers({ id: ids })
  369. .then(() => {
  370. this.querySubscribers();
  371. this.$utils.toast(this.$t('subscribers.subscribersDeleted', { num: this.numSelectedSubscribers }));
  372. });
  373. };
  374. } else {
  375. // 'All' is selected, delete by query.
  376. fn = () => {
  377. this.$api.deleteSubscribersByQuery({
  378. query: this.queryParams.queryExp,
  379. list_ids: this.queryParams.listID ? [this.queryParams.listID] : null,
  380. }).then(() => {
  381. this.querySubscribers();
  382. this.$utils.toast(this.$t('subscribers.subscribersDeleted',
  383. { num: this.numSelectedSubscribers }));
  384. });
  385. };
  386. }
  387. this.$utils.confirm(this.$t('subscribers.confirmDelete', { num: this.numSelectedSubscribers }), fn);
  388. },
  389. bulkChangeLists(action, lists) {
  390. const data = {
  391. action,
  392. query: this.fullQueryExp,
  393. target_list_ids: lists.map((l) => l.id),
  394. };
  395. let fn = null;
  396. if (!this.bulk.all && this.bulk.checked.length > 0) {
  397. // If 'all' is not selected, perform by IDs.
  398. fn = this.$api.addSubscribersToLists;
  399. data.ids = this.bulk.checked.map((s) => s.id);
  400. } else {
  401. // 'All' is selected, perform by query.
  402. data.query = this.queryParams.queryExp;
  403. fn = this.$api.addSubscribersToListsByQuery;
  404. }
  405. fn(data).then(() => {
  406. this.querySubscribers();
  407. this.$utils.toast(this.$t('subscribers.listChangeApplied'));
  408. });
  409. },
  410. },
  411. computed: {
  412. ...mapState(['subscribers', 'lists', 'loading']),
  413. numSelectedSubscribers() {
  414. if (this.bulk.all) {
  415. return this.subscribers.total;
  416. }
  417. return this.bulk.checked.length;
  418. },
  419. // Returns the list that the subscribers are being filtered by in.
  420. currentList() {
  421. if (!this.queryParams.listID || !this.lists.results) {
  422. return null;
  423. }
  424. return this.lists.results.find((l) => l.id === this.queryParams.listID);
  425. },
  426. },
  427. mounted() {
  428. if (this.$route.params.listID) {
  429. this.queryParams.listID = parseInt(this.$route.params.listID, 10);
  430. }
  431. if (this.$route.params.id) {
  432. this.$api.getSubscriber(parseInt(this.$route.params.id, 10)).then((data) => {
  433. this.showEditForm(data);
  434. });
  435. } else {
  436. // Get subscribers on load.
  437. this.querySubscribers();
  438. }
  439. },
  440. });
  441. </script>