Subscribers.vue 16 KB

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