Skip to content

Commit 30a0bf1

Browse files
authored
Merge pull request #5922 from rtibbles/get_laura_the_deets_stat
Add Kolibri-usage filters and CSV export to admin Users page
2 parents 350fcf1 + e0102a7 commit 30a0bf1

4 files changed

Lines changed: 649 additions & 1 deletion

File tree

contentcuration/contentcuration/frontend/administration/pages/Users/UserTable.vue

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@
1111
:text="`Email ${$formatNumber(count)} ${count === 1 ? 'user' : 'users'}`"
1212
@click="showMassEmailDialog = true"
1313
/>
14+
<IconButton
15+
icon="download"
16+
class="ma-0"
17+
:color="$themeTokens.primary"
18+
text="Download CSV"
19+
data-test="csv"
20+
:disabled="!count"
21+
@click="onDownloadCSV"
22+
/>
1423
</h1>
1524
<EmailUsersDialog
1625
v-model="showMassEmailDialog"
@@ -72,6 +81,65 @@
7281
/>
7382
</VFlex>
7483
</VLayout>
84+
<VLayout
85+
wrap
86+
class="mb-2"
87+
>
88+
<VFlex
89+
xs12
90+
sm6
91+
md3
92+
class="px-3"
93+
>
94+
<VSelect
95+
v-model="joinedWithinFilter"
96+
:items="joinedWithinOptions"
97+
item-text="label"
98+
item-value="value"
99+
label="Joined within"
100+
box
101+
:menu-props="{ offsetY: true }"
102+
/>
103+
</VFlex>
104+
<VFlex
105+
xs12
106+
sm6
107+
md3
108+
class="px-3"
109+
>
110+
<VSelect
111+
v-model="activeWithinFilter"
112+
:items="activeWithinOptions"
113+
item-text="label"
114+
item-value="value"
115+
label="Active within"
116+
box
117+
:menu-props="{ offsetY: true }"
118+
/>
119+
</VFlex>
120+
<VFlex
121+
xs12
122+
sm6
123+
md3
124+
class="align-center d-flex px-3"
125+
>
126+
<Checkbox
127+
v-model="hasPublishedFilter"
128+
label="Has published a channel"
129+
/>
130+
</VFlex>
131+
<VFlex
132+
xs12
133+
sm6
134+
md3
135+
class="align-center d-flex px-3"
136+
>
137+
<Checkbox
138+
v-model="hasEditsFilter"
139+
label="Has Studio edits"
140+
/>
141+
</VFlex>
142+
</VLayout>
75143
<VDataTable
76144
v-model="selected"
77145
:headers="headers"
@@ -141,10 +209,12 @@
141209
import { ref, onMounted, computed, getCurrentInstance } from 'vue';
142210
import { mapGetters } from 'vuex';
143211
import transform from 'lodash/transform';
212+
import { saveAs } from 'file-saver';
144213
import { useTable } from '../../composables/useTable';
145214
import { RouteNames, rowsPerPageItems } from '../../constants';
146215
import EmailUsersDialog from './EmailUsersDialog';
147216
import UserItem from './UserItem';
217+
import client from 'shared/client';
148218
import { useFilter } from 'shared/composables/useFilter';
149219
import { useKeywordSearch } from 'shared/composables/useKeywordSearch';
150220
import { routerMixin } from 'shared/mixins';
@@ -160,6 +230,64 @@
160230
sushichef: { label: 'Sushi chef', params: { chef: true } },
161231
};
162232
233+
const DATE_WINDOWS = [
234+
{ key: 'any', label: 'Any time', months: null },
235+
{ key: '1mo', label: 'Last month', months: 1 },
236+
{ key: '3mo', label: 'Last 3 months', months: 3 },
237+
{ key: '6mo', label: 'Last 6 months', months: 6 },
238+
{ key: '1yr', label: 'Last year', months: 12 },
239+
];
240+
241+
function buildDateWindowFilterMap(paramName) {
242+
const map = {};
243+
for (const window of DATE_WINDOWS) {
244+
if (window.months === null) {
245+
map[window.key] = { label: window.label, params: {} };
246+
} else {
247+
const cutoff = new Date();
248+
cutoff.setMonth(cutoff.getMonth() - window.months);
249+
const iso = cutoff.toISOString().slice(0, 10);
250+
map[window.key] = { label: window.label, params: { [paramName]: iso } };
251+
}
252+
}
253+
return map;
254+
}
255+
256+
function useDateWindowFilter({ name, paramName }) {
257+
const { filter, options, fetchQueryParams } = useFilter({
258+
name,
259+
filterMap: buildDateWindowFilterMap(paramName),
260+
defaultValue: 'any',
261+
});
262+
const wrapped = computed({
263+
get: () => filter.value.value || 'any',
264+
set: value => {
265+
filter.value = options.value.find(o => o.value === value) || {};
266+
},
267+
});
268+
return { filter: wrapped, options, fetchQueryParams };
269+
}
270+
271+
function useBooleanFilter({ name, label, paramName }) {
272+
const filterMap = {
273+
no: { label: 'Any', params: {} },
274+
yes: { label, params: { [paramName]: true } },
275+
};
276+
const { filter, options, fetchQueryParams } = useFilter({
277+
name,
278+
filterMap,
279+
defaultValue: 'no',
280+
});
281+
const wrapped = computed({
282+
get: () => filter.value.value === 'yes',
283+
set: value => {
284+
const targetKey = value ? 'yes' : 'no';
285+
filter.value = options.value.find(o => o.value === targetKey) || {};
286+
},
287+
});
288+
return { filter: wrapped, fetchQueryParams };
289+
}
290+
163291
export default {
164292
name: 'UserTable',
165293
components: {
@@ -218,6 +346,32 @@
218346
},
219347
});
220348
349+
const {
350+
filter: joinedWithinFilter,
351+
options: joinedWithinOptions,
352+
fetchQueryParams: joinedWithinFetchQueryParams,
353+
} = useDateWindowFilter({ name: 'joinedWithin', paramName: 'joined_since' });
354+
355+
const {
356+
filter: activeWithinFilter,
357+
options: activeWithinOptions,
358+
fetchQueryParams: activeWithinFetchQueryParams,
359+
} = useDateWindowFilter({ name: 'activeWithin', paramName: 'active_since' });
360+
361+
const { filter: hasPublishedFilter, fetchQueryParams: hasPublishedFetchQueryParams } =
362+
useBooleanFilter({
363+
name: 'hasPublished',
364+
label: 'Has published a channel',
365+
paramName: 'published_channel',
366+
});
367+
368+
const { filter: hasEditsFilter, fetchQueryParams: hasEditsFetchQueryParams } =
369+
useBooleanFilter({
370+
name: 'hasEdits',
371+
label: 'Has Studio edits',
372+
paramName: 'has_edits',
373+
});
374+
221375
onMounted(() => {
222376
// The locationFilterMap is built from the options in the CountryField component,
223377
// so we need to wait until it's mounted to access them.
@@ -240,6 +394,10 @@
240394
...userTypeFetchQueryParams.value,
241395
...locationFetchQueryParams.value,
242396
...keywordSearchFetchQueryParams.value,
397+
...joinedWithinFetchQueryParams.value,
398+
...activeWithinFetchQueryParams.value,
399+
...hasPublishedFetchQueryParams.value,
400+
...hasEditsFetchQueryParams.value,
243401
};
244402
});
245403
@@ -260,6 +418,12 @@
260418
keywordInput,
261419
setKeywords,
262420
clearSearch,
421+
joinedWithinFilter,
422+
joinedWithinOptions,
423+
activeWithinFilter,
424+
activeWithinOptions,
425+
hasPublishedFilter,
426+
hasEditsFilter,
263427
pagination,
264428
loading,
265429
loadItems,
@@ -333,6 +497,29 @@
333497
mounted() {
334498
this.updateTabTitle('Users - Administration');
335499
},
500+
methods: {
501+
async onDownloadCSV() {
502+
this.$store.dispatch('showSnackbarSimple', 'Generating CSV...');
503+
try {
504+
const response = await client.get(window.Urls.admin_users_download_csv(), {
505+
params: this.filterFetchQueryParams,
506+
responseType: 'blob',
507+
});
508+
const filename = `studio_users_${new Date().toISOString().slice(0, 10)}.csv`;
509+
saveAs(response.data, filename);
510+
} catch (error) {
511+
const status = error.response && error.response.status;
512+
if (status === 412) {
513+
this.$store.dispatch(
514+
'showSnackbarSimple',
515+
'No filters applied. Pick at least one filter and try again.',
516+
);
517+
} else {
518+
this.$store.dispatch('showSnackbarSimple', 'CSV download failed. Try again.');
519+
}
520+
}
521+
},
522+
},
336523
};
337524
338525
</script>

contentcuration/contentcuration/frontend/administration/pages/Users/__tests__/userTable.spec.js

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ import router from '../../../router';
44
import { RouteNames } from '../../../constants';
55
import UserTable from '../UserTable';
66

7+
jest.mock('shared/client', () => ({
8+
__esModule: true,
9+
default: { get: jest.fn() },
10+
}));
11+
jest.mock('file-saver', () => ({ saveAs: jest.fn() }));
12+
713
const localVue = createLocalVue();
814

915
localVue.use(Vuex);
@@ -72,6 +78,30 @@ describe('userTable', () => {
7278

7379
expect(router.currentRoute.query.keywords).toBe('keyword test');
7480
});
81+
82+
it('changing joined-within filter sets joined_since query param to an ISO date', () => {
83+
wrapper.vm.joinedWithinFilter = '3mo';
84+
const params = wrapper.vm.filterFetchQueryParams;
85+
expect(params.joined_since).toMatch(/^\d{4}-\d{2}-\d{2}$/);
86+
});
87+
88+
it('changing active-within filter sets active_since query param to an ISO date', () => {
89+
wrapper.vm.activeWithinFilter = '1mo';
90+
const params = wrapper.vm.filterFetchQueryParams;
91+
expect(params.active_since).toMatch(/^\d{4}-\d{2}-\d{2}$/);
92+
});
93+
94+
it('toggling has-published filter sets published_channel=true', () => {
95+
wrapper.vm.hasPublishedFilter = true;
96+
const params = wrapper.vm.filterFetchQueryParams;
97+
expect(params.published_channel).toBe(true);
98+
});
99+
100+
it('toggling has-edits filter sets has_edits=true', () => {
101+
wrapper.vm.hasEditsFilter = true;
102+
const params = wrapper.vm.filterFetchQueryParams;
103+
expect(params.has_edits).toBe(true);
104+
});
75105
});
76106

77107
describe('selection', () => {
@@ -125,4 +155,56 @@ describe('userTable', () => {
125155
expect(wrapper.vm.showEmailDialog).toBe(true);
126156
});
127157
});
158+
159+
describe('csv download', () => {
160+
beforeEach(() => {
161+
const client = require('shared/client').default;
162+
const { saveAs } = require('file-saver');
163+
client.get.mockReset();
164+
client.get.mockResolvedValue({
165+
data: new Blob(['col1,col2\n1,2'], { type: 'text/csv' }),
166+
});
167+
saveAs.mockClear();
168+
});
169+
170+
it('renders the Download CSV button when count > 0', () => {
171+
expect(wrapper.find('[data-test="csv"]').exists()).toBe(true);
172+
});
173+
174+
it('clicking Download CSV calls the API with the current filter params', async () => {
175+
await wrapper.findComponent('[data-test="csv"]').trigger('click');
176+
// Flush the microtask queue so the chained .then() runs.
177+
await new Promise(resolve => setImmediate(resolve));
178+
179+
const client = require('shared/client').default;
180+
const { saveAs } = require('file-saver');
181+
expect(client.get).toHaveBeenCalled();
182+
const [, options] = client.get.mock.calls[0];
183+
expect(options.responseType).toBe('blob');
184+
expect(saveAs).toHaveBeenCalled();
185+
const [savedBlob, savedName] = saveAs.mock.calls[0];
186+
expect(savedBlob).toBeInstanceOf(Blob);
187+
expect(savedName).toMatch(/^studio_users_\d{4}-\d{2}-\d{2}\.csv$/);
188+
});
189+
});
190+
191+
describe('csv download disabled state', () => {
192+
it('disables Download CSV when count is zero', () => {
193+
const emptyStore = new Store({
194+
modules: {
195+
userAdmin: {
196+
namespaced: true,
197+
actions: { loadUsers },
198+
getters: {
199+
users: () => [],
200+
count: () => 0,
201+
},
202+
},
203+
},
204+
});
205+
const emptyWrapper = makeWrapper(emptyStore);
206+
const button = emptyWrapper.find('[data-test="csv"]');
207+
expect(button.attributes('disabled') !== undefined || button.props().disabled).toBe(true);
208+
});
209+
});
128210
});

0 commit comments

Comments
 (0)