Skip to content

Commit a65992e

Browse files
committed
Export subscribers panel
1 parent 10480a9 commit a65992e

8 files changed

Lines changed: 297 additions & 122 deletions

File tree

assets/vue/api.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {Client, ListClient, SubscribersClient, SubscriptionClient} from '@tatevikgr/rest-api-client';
1+
import {Client, ListClient, SubscribersClient, SubscriptionClient, SubscriberAttributesClient} from '@tatevikgr/rest-api-client';
22

33
const appElement = document.getElementById('vue-app');
44
const apiToken = appElement?.dataset.apiToken;
@@ -17,5 +17,6 @@ if (apiToken) {
1717
export const subscribersClient = new SubscribersClient(client);
1818
export const listClient = new ListClient(client);
1919
export const subscriptionClient = new SubscriptionClient(client);
20+
export const subscriberAttributesClient = new SubscriberAttributesClient(client);
2021

2122
export default client;
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
<template>
2+
<div class="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
3+
<div class="p-4 sm:p-6 space-y-5">
4+
<!-- Date type -->
5+
<div class="space-y-2">
6+
<p class="text-sm font-medium text-slate-800">What date needs to be used:</p>
7+
8+
<div class="flex flex-wrap gap-x-5 gap-y-2">
9+
<label
10+
v-for="option in dateTypeOptions"
11+
:key="option.value"
12+
class="inline-flex items-center gap-2 text-sm text-slate-700 cursor-pointer mr-2"
13+
>
14+
<input
15+
v-model="form.dateType"
16+
type="radio"
17+
name="list-export-date-type"
18+
class="h-4 w-4 border-slate-300 text-ext-wf1 focus:ring-ext-wf1"
19+
:value="option.value"
20+
>
21+
<span>{{ option.label }}</span>
22+
</label>
23+
</div>
24+
</div>
25+
26+
<!-- Date range -->
27+
<div class="grid grid-cols-1 lg:grid-cols-[1fr_auto_1fr] gap-3 items-end">
28+
<div>
29+
<label class="block text-sm font-medium text-slate-700 mb-1" for="list-export-date-from">
30+
Date From
31+
</label>
32+
<input
33+
id="list-export-date-from"
34+
v-model="form.dateFrom"
35+
type="date"
36+
class="block w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 disabled:bg-slate-100 disabled:text-slate-400"
37+
:disabled="usesAnyDate"
38+
>
39+
</div>
40+
41+
<div class="hidden lg:flex items-center justify-center pb-2 text-slate-400">
42+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
43+
<path
44+
fill-rule="evenodd"
45+
d="M3 10a1 1 0 011-1h9.586l-2.293-2.293a1 1 0 111.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L13.586 11H4a1 1 0 01-1-1z"
46+
clip-rule="evenodd"
47+
/>
48+
</svg>
49+
</div>
50+
51+
<div>
52+
<label class="block text-sm font-medium text-slate-700 mb-1" for="list-export-date-to">
53+
Date To
54+
</label>
55+
<input
56+
id="list-export-date-to"
57+
v-model="form.dateTo"
58+
type="date"
59+
class="block w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 disabled:bg-slate-100 disabled:text-slate-400"
60+
:disabled="usesAnyDate"
61+
>
62+
</div>
63+
</div>
64+
65+
<!-- Columns -->
66+
<div class="space-y-3">
67+
<div class="flex flex-wrap items-center justify-between gap-3">
68+
<p class="text-sm font-medium text-slate-800">Columns</p>
69+
70+
<label class="inline-flex items-center gap-2 text-sm font-medium text-slate-900 cursor-pointer">
71+
<input
72+
ref="selectAllColumnsCheckbox"
73+
type="checkbox"
74+
class="h-4 w-4 rounded border-slate-300 text-ext-wf1 focus:ring-ext-wf1"
75+
:checked="allColumnsSelected"
76+
@change="toggleAllColumns"
77+
>
78+
<span>Select all</span>
79+
</label>
80+
</div>
81+
82+
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-x-8 gap-y-3">
83+
<label
84+
v-for="column in columnOptions"
85+
:key="column.value"
86+
class="inline-flex items-center gap-2 text-sm text-slate-700 cursor-pointer min-w-0"
87+
>
88+
<input
89+
v-model="form.columns"
90+
type="checkbox"
91+
class="h-4 w-4 rounded border-slate-300 text-ext-wf1 focus:ring-ext-wf1 shrink-0"
92+
:value="column.value"
93+
>
94+
<span class="truncate">{{ column.label }}</span>
95+
</label>
96+
</div>
97+
</div>
98+
99+
<p v-if="exportError" class="text-sm text-red-600">{{ exportError }}</p>
100+
</div>
101+
102+
<!-- Footer -->
103+
<div class="px-4 sm:px-6 py-4 border-t border-slate-200 flex items-center justify-between gap-3">
104+
<button
105+
type="button"
106+
class="inline-flex items-center justify-center rounded-md border border-transparent bg-ext-wf1 px-5 py-2 text-sm font-medium text-white shadow-sm hover:bg-ext-wf3 disabled:opacity-50 disabled:cursor-not-allowed"
107+
@click="exportSubscribers"
108+
>
109+
Export
110+
</button>
111+
</div>
112+
</div>
113+
</template>
114+
115+
<script setup>
116+
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
117+
// todo: check why subscriberAttributesClient was not working
118+
import client, { subscriberAttributesClient } from '../../api'
119+
120+
onMounted(async () => {
121+
try {
122+
const queryParams = { limit: 5 };
123+
const data = await client.get('attributes', queryParams)
124+
const dynamicColumns = data.items.map(attr => ({
125+
value: attr.name,
126+
label: capitalizeFirst(attr.name)
127+
}))
128+
129+
columnOptions.value = [
130+
...columnOptions.value,
131+
...dynamicColumns
132+
]
133+
134+
form.value.columns = columnOptions.value.map(c => c.value)
135+
} catch (err) {
136+
console.error('Failed to load attribute definitions', err)
137+
}
138+
})
139+
const capitalizeFirst = (s) => s ? s[0].toUpperCase() + s.slice(1) : ''
140+
141+
const props = defineProps({
142+
listId: {
143+
type: Number,
144+
},
145+
listName: {
146+
type: String,
147+
default: ''
148+
}
149+
})
150+
151+
const dateTypeOptions = [
152+
{ value: 'any', label: 'Any date (Export all subscribers)' },
153+
{ value: 'signup', label: 'When they signed up' },
154+
{ value: 'changed', label: 'When the record was changed' },
155+
{ value: 'changelog', label: 'Based on changelog' },
156+
{ value: 'subscribed', label: 'When they subscribed to new list' }
157+
]
158+
159+
const columnOptions = ref([
160+
{ value: 'id', label: 'ID' },
161+
{ value: 'email', label: 'Email' },
162+
{ value: 'confirmed', label: 'Is this subscriber confirmed' },
163+
{ value: 'blacklisted', label: 'Is this subscriber blacklisted' },
164+
// { value: 'manualConfirm', label: 'Did this subscriber manually confirm' },
165+
{ value: 'bounceCount', label: 'Number of bounces' },
166+
{ value: 'createdAt', label: 'Entered' },
167+
{ value: 'updatedAt', label: 'Last Modified' },
168+
{ value: 'uniqueId', label: 'Unique ID' },
169+
{ value: 'htmlEmail', label: 'Send this subscriber HTML emails' },
170+
{ value: 'rssFrequency', label: 'RSS Frequency' },
171+
{ value: 'disabled', label: 'Is this account disabled?' },
172+
{ value: 'extraData', label: 'Additional data' },
173+
{ value: 'foreignKey', label: 'Foreign Key' },
174+
])
175+
176+
const form = ref({
177+
dateType: 'any',
178+
dateFrom: '',
179+
dateTo: '',
180+
columns: columnOptions.value.map((column) => column.value)
181+
})
182+
183+
const exportError = ref('')
184+
const selectAllColumnsCheckbox = ref(null)
185+
186+
const usesAnyDate = computed(() => form.value.dateType === 'any')
187+
188+
const allColumnsSelected = computed(() => {
189+
return form.value.columns.length === columnOptions.length
190+
})
191+
192+
const someColumnsSelected = computed(() => {
193+
return form.value.columns.length > 0 && !allColumnsSelected.value
194+
})
195+
196+
watchEffect(() => {
197+
if (!selectAllColumnsCheckbox.value) return
198+
selectAllColumnsCheckbox.value.indeterminate = someColumnsSelected.value
199+
})
200+
201+
watch(() => form.value.dateType, (dateType) => {
202+
exportError.value = ''
203+
if (dateType === 'any') {
204+
form.value.dateFrom = ''
205+
form.value.dateTo = ''
206+
}
207+
})
208+
209+
watch(() => [form.value.dateFrom, form.value.dateTo], () => {
210+
exportError.value = ''
211+
})
212+
213+
const toggleAllColumns = (event) => {
214+
if (event.target.checked) {
215+
form.value.columns = columnOptions.value.map((column) => column.value)
216+
return
217+
}
218+
219+
form.value.columns = []
220+
}
221+
222+
const exportSubscribers = () => {
223+
if (!usesAnyDate.value && form.value.dateFrom && form.value.dateTo && form.value.dateFrom > form.value.dateTo) {
224+
exportError.value = 'Date From cannot be after Date To.'
225+
return
226+
}
227+
228+
const params = new URLSearchParams()
229+
if (props.listId) {
230+
params.set('list_id', String(props.listId))
231+
}
232+
params.set('date_type', form.value.dateType)
233+
234+
if (!usesAnyDate.value) {
235+
if (form.value.dateFrom) {
236+
params.set('date_from', form.value.dateFrom)
237+
}
238+
239+
if (form.value.dateTo) {
240+
params.set('date_to', form.value.dateTo)
241+
}
242+
}
243+
244+
form.value.columns.forEach((column) => {
245+
params.append('columns[]', column)
246+
})
247+
248+
window.location.href = `/subscribers/export?${params.toString()}`
249+
}
250+
</script>

assets/vue/components/subscribers/SubscriberDirectory.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@
8383
</div>
8484
</div>
8585
</div>
86+
87+
<ListSubscribersExportPanel/>
8688
</template>
8789

8890
<script setup>
@@ -94,6 +96,7 @@ import ImportResult from './ImportResult.vue'
9496
import {inject, onMounted, ref} from 'vue'
9597
import {subscriberFilters} from './subscriberFilters'
9698
import {subscribersClient} from '../../api'
99+
import ListSubscribersExportPanel from "../lists/ListSubscribersExportPanel.vue";
97100
98101
const initialSubscribers = inject('subscribers', [])
99102
const initialPagination = inject('pagination', {

assets/vue/components/subscribers/SubscribersTable.vue

Lines changed: 0 additions & 64 deletions
This file was deleted.

assets/vue/views/ListSubscribersView.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,9 @@
245245
</div>
246246
</div>
247247
</div>
248+
249+
<ListSubscribersExportPanel :list-id="listId" :list-name="listName" />
250+
248251
</div>
249252
</AdminLayout>
250253
</template>
@@ -253,6 +256,7 @@
253256
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
254257
import { useRoute } from 'vue-router'
255258
import AdminLayout from '../layouts/AdminLayout.vue'
259+
import ListSubscribersExportPanel from '../components/lists/ListSubscribersExportPanel.vue'
256260
import client, { subscriptionClient } from '../api'
257261
258262
const route = useRoute()

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"webpack-notifier": "^1.15.0"
2020
},
2121
"dependencies": {
22-
"@tatevikgr/rest-api-client": "^1.2.0",
22+
"@tatevikgr/rest-api-client": "^1.3.0",
2323
"apexcharts": "^5.10.4",
2424
"vue": "^3.5.16",
2525
"vue-router": "4",

0 commit comments

Comments
 (0)