Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@
:text="`Email ${$formatNumber(count)} ${count === 1 ? 'user' : 'users'}`"
@click="showMassEmailDialog = true"
/>
<IconButton
icon="download"
class="ma-0"
:color="$themeTokens.primary"
text="Download CSV"
data-test="csv"
:disabled="!count"
@click="onDownloadCSV"
/>
</h1>
<EmailUsersDialog
v-model="showMassEmailDialog"
Expand Down Expand Up @@ -72,6 +81,65 @@
/>
</VFlex>
</VLayout>
<VLayout
wrap
class="mb-2"
>
<VFlex
xs12
sm6
md3
class="px-3"
>
<VSelect
v-model="joinedWithinFilter"
:items="joinedWithinOptions"
item-text="label"
item-value="value"
label="Joined within"
box
:menu-props="{ offsetY: true }"
/>
</VFlex>
<VFlex
xs12
sm6
md3
class="px-3"
>
<VSelect
v-model="activeWithinFilter"
:items="activeWithinOptions"
item-text="label"
item-value="value"
label="Active within"
box
:menu-props="{ offsetY: true }"
/>
</VFlex>
<VFlex
xs12
sm6
md3
class="align-center d-flex px-3"
>
<Checkbox
v-model="hasPublishedFilter"
label="Has published a channel"
/>
</VFlex>
<VFlex
xs12
sm6
md3
class="align-center d-flex px-3"
>
<Checkbox
v-model="hasEditsFilter"
label="Has Studio edits"
/>
</VFlex>
</VLayout>
<VDataTable
v-model="selected"
:headers="headers"
Expand Down Expand Up @@ -141,10 +209,12 @@
import { ref, onMounted, computed, getCurrentInstance } from 'vue';
import { mapGetters } from 'vuex';
import transform from 'lodash/transform';
import { saveAs } from 'file-saver';
import { useTable } from '../../composables/useTable';
import { RouteNames, rowsPerPageItems } from '../../constants';
import EmailUsersDialog from './EmailUsersDialog';
import UserItem from './UserItem';
import client from 'shared/client';
import { useFilter } from 'shared/composables/useFilter';
import { useKeywordSearch } from 'shared/composables/useKeywordSearch';
import { routerMixin } from 'shared/mixins';
Expand All @@ -160,6 +230,64 @@
sushichef: { label: 'Sushi chef', params: { chef: true } },
};

const DATE_WINDOWS = [
{ key: 'any', label: 'Any time', months: null },
{ key: '1mo', label: 'Last month', months: 1 },
{ key: '3mo', label: 'Last 3 months', months: 3 },
{ key: '6mo', label: 'Last 6 months', months: 6 },
{ key: '1yr', label: 'Last year', months: 12 },
];

function buildDateWindowFilterMap(paramName) {
const map = {};
for (const window of DATE_WINDOWS) {
if (window.months === null) {
map[window.key] = { label: window.label, params: {} };
} else {
const cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() - window.months);
const iso = cutoff.toISOString().slice(0, 10);
map[window.key] = { label: window.label, params: { [paramName]: iso } };
}
}
return map;
}

function useDateWindowFilter({ name, paramName }) {
const { filter, options, fetchQueryParams } = useFilter({
name,
filterMap: buildDateWindowFilterMap(paramName),
defaultValue: 'any',
});
const wrapped = computed({
get: () => filter.value.value || 'any',
set: value => {
filter.value = options.value.find(o => o.value === value) || {};
},
});
return { filter: wrapped, options, fetchQueryParams };
}

function useBooleanFilter({ name, label, paramName }) {
const filterMap = {
no: { label: 'Any', params: {} },
yes: { label, params: { [paramName]: true } },
};
const { filter, options, fetchQueryParams } = useFilter({
name,
filterMap,
defaultValue: 'no',
});
const wrapped = computed({
get: () => filter.value.value === 'yes',
set: value => {
const targetKey = value ? 'yes' : 'no';
filter.value = options.value.find(o => o.value === targetKey) || {};
},
});
return { filter: wrapped, fetchQueryParams };
}

export default {
name: 'UserTable',
components: {
Expand Down Expand Up @@ -218,6 +346,32 @@
},
});

const {
filter: joinedWithinFilter,
options: joinedWithinOptions,
fetchQueryParams: joinedWithinFetchQueryParams,
} = useDateWindowFilter({ name: 'joinedWithin', paramName: 'joined_since' });

const {
filter: activeWithinFilter,
options: activeWithinOptions,
fetchQueryParams: activeWithinFetchQueryParams,
} = useDateWindowFilter({ name: 'activeWithin', paramName: 'active_since' });

const { filter: hasPublishedFilter, fetchQueryParams: hasPublishedFetchQueryParams } =
useBooleanFilter({
name: 'hasPublished',
label: 'Has published a channel',
paramName: 'published_channel',
});

const { filter: hasEditsFilter, fetchQueryParams: hasEditsFetchQueryParams } =
useBooleanFilter({
name: 'hasEdits',
label: 'Has Studio edits',
paramName: 'has_edits',
});

onMounted(() => {
// The locationFilterMap is built from the options in the CountryField component,
// so we need to wait until it's mounted to access them.
Expand All @@ -240,6 +394,10 @@
...userTypeFetchQueryParams.value,
...locationFetchQueryParams.value,
...keywordSearchFetchQueryParams.value,
...joinedWithinFetchQueryParams.value,
...activeWithinFetchQueryParams.value,
...hasPublishedFetchQueryParams.value,
...hasEditsFetchQueryParams.value,
};
});

Expand All @@ -260,6 +418,12 @@
keywordInput,
setKeywords,
clearSearch,
joinedWithinFilter,
joinedWithinOptions,
activeWithinFilter,
activeWithinOptions,
hasPublishedFilter,
hasEditsFilter,
pagination,
loading,
loadItems,
Expand Down Expand Up @@ -333,6 +497,29 @@
mounted() {
this.updateTabTitle('Users - Administration');
},
methods: {
async onDownloadCSV() {
this.$store.dispatch('showSnackbarSimple', 'Generating CSV...');
try {
const response = await client.get(window.Urls.admin_users_download_csv(), {
params: this.filterFetchQueryParams,
responseType: 'blob',
});
const filename = `studio_users_${new Date().toISOString().slice(0, 10)}.csv`;
saveAs(response.data, filename);
} catch (error) {
const status = error.response && error.response.status;
if (status === 412) {
this.$store.dispatch(
'showSnackbarSimple',
'No filters applied. Pick at least one filter and try again.',
);
} else {
this.$store.dispatch('showSnackbarSimple', 'CSV download failed. Try again.');
}
}
},
},
};

</script>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import router from '../../../router';
import { RouteNames } from '../../../constants';
import UserTable from '../UserTable';

jest.mock('shared/client', () => ({
__esModule: true,
default: { get: jest.fn() },
}));
jest.mock('file-saver', () => ({ saveAs: jest.fn() }));

const localVue = createLocalVue();

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

expect(router.currentRoute.query.keywords).toBe('keyword test');
});

it('changing joined-within filter sets joined_since query param to an ISO date', () => {
wrapper.vm.joinedWithinFilter = '3mo';
const params = wrapper.vm.filterFetchQueryParams;
expect(params.joined_since).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});

it('changing active-within filter sets active_since query param to an ISO date', () => {
wrapper.vm.activeWithinFilter = '1mo';
const params = wrapper.vm.filterFetchQueryParams;
expect(params.active_since).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});

it('toggling has-published filter sets published_channel=true', () => {
wrapper.vm.hasPublishedFilter = true;
const params = wrapper.vm.filterFetchQueryParams;
expect(params.published_channel).toBe(true);
});

it('toggling has-edits filter sets has_edits=true', () => {
wrapper.vm.hasEditsFilter = true;
const params = wrapper.vm.filterFetchQueryParams;
expect(params.has_edits).toBe(true);
});
});

describe('selection', () => {
Expand Down Expand Up @@ -125,4 +155,56 @@ describe('userTable', () => {
expect(wrapper.vm.showEmailDialog).toBe(true);
});
});

describe('csv download', () => {
beforeEach(() => {
const client = require('shared/client').default;
const { saveAs } = require('file-saver');
client.get.mockReset();
client.get.mockResolvedValue({
data: new Blob(['col1,col2\n1,2'], { type: 'text/csv' }),
});
saveAs.mockClear();
});

it('renders the Download CSV button when count > 0', () => {
expect(wrapper.find('[data-test="csv"]').exists()).toBe(true);
});

it('clicking Download CSV calls the API with the current filter params', async () => {
await wrapper.findComponent('[data-test="csv"]').trigger('click');
// Flush the microtask queue so the chained .then() runs.
await new Promise(resolve => setImmediate(resolve));

const client = require('shared/client').default;
const { saveAs } = require('file-saver');
expect(client.get).toHaveBeenCalled();
const [, options] = client.get.mock.calls[0];
expect(options.responseType).toBe('blob');
expect(saveAs).toHaveBeenCalled();
const [savedBlob, savedName] = saveAs.mock.calls[0];
expect(savedBlob).toBeInstanceOf(Blob);
expect(savedName).toMatch(/^studio_users_\d{4}-\d{2}-\d{2}\.csv$/);
});
});

describe('csv download disabled state', () => {
it('disables Download CSV when count is zero', () => {
const emptyStore = new Store({
modules: {
userAdmin: {
namespaced: true,
actions: { loadUsers },
getters: {
users: () => [],
count: () => 0,
},
},
},
});
const emptyWrapper = makeWrapper(emptyStore);
const button = emptyWrapper.find('[data-test="csv"]');
expect(button.attributes('disabled') !== undefined || button.props().disabled).toBe(true);
});
});
});
Loading
Loading