Subscribers.vue 14 KB

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