Ghost/ghost/admin/mirage/config/members.js
Kevin Ansfield 03defc274e 🐛 Fixed Admin crash when member filters were focused+blurred without entering a filter value
closes https://github.com/TryGhost/Team/issues/1309
closes https://github.com/TryGhost/Team/issues/1336

- the error occurred because the `<Members::FilterValue>` component detaches it's value from the passed in value it was initialized with. Due to the detached handling, after changing the filter type away from the default label filter to a text input based filter, the internal value stayed as `[]` even though the filter type's value was changed. When the blur event was triggered in that state the internal `[]` value was used to update the filter resulting in an invalid filter string
  - added a quick-fix of assigning the input's value in the blur event handler meaning we get the expected `''` value
  - allows for passing tests to be created ready for a deeper fix/refactor later
- added `nql` dependency and used it in the `GET /members` API mock to match members against the filter param so behaviour matches the real API
  - tested increase in code size - dev build increased by ~180KB, no difference in prod
- added acceptance tests for all current filters and search
2022-02-15 21:38:57 +00:00

219 lines
6.6 KiB
JavaScript

import faker from 'faker';
import moment from 'moment';
import nql from '@nexes/nql';
import {Response} from 'ember-cli-mirage';
import {extractFilterParam, paginateModelCollection} from '../utils';
import {underscore} from '@ember/string';
export function mockMembersStats(server) {
server.get('/members/stats/count', function (db, {queryParams}) {
let {days} = queryParams;
let firstSubscriberDays = faker.datatype.number({min: 30, max: 600});
if (days === 'all-time') {
days = firstSubscriberDays;
} else {
days = Number(days);
}
let total = 0;
if (firstSubscriberDays > days) {
total += faker.datatype.number({max: 1000});
}
// simulate sql GROUP BY where days with 0 subscribers are missing
let dateCounts = {};
let i = 0;
while (i < days) {
let date = moment().subtract(i, 'days').format('YYYY-MM-DD');
let count = faker.datatype.number({min: 0, max: 30});
if (count !== 0) {
dateCounts[date] = count;
}
i += 1;
}
// similar to what we'll need to do on the server
let totalOnDate = {};
let j = days - 1;
while (j >= 0) {
let date = moment().subtract(j, 'days').format('YYYY-MM-DD');
totalOnDate[date] = total + (dateCounts[date] || 0);
total += (dateCounts[date] || 0);
j -= 1;
}
return {
total,
resource: 'members',
data: Object.keys(totalOnDate).map((key, idx, arr) => {
return {
date: key,
free: arr[key],
paid: 0,
comped: 0
};
})
};
});
}
export default function mockMembers(server) {
server.post('/members/', function ({members}) {
let attrs = this.normalizedRequestAttrs();
return members.create(Object.assign({}, attrs, {id: 99}));
});
server.get('/members/', function ({members}, {queryParams}) {
let {filter, search, page, limit} = queryParams;
page = +page || 1;
limit = +limit || 15;
let collection = members.all();
if (filter) {
const nqlFilter = nql(filter, {
expansions: [
{
key: 'label',
replacement: 'labels.slug'
}
]
});
console.log(nqlFilter.toJSON());
collection = collection.filter((member) => {
const serializedMember = {};
// mirage model keys match our main model keys so we need to transform
// camelCase to underscore to match the filter format
Object.keys(member.attrs).forEach((key) => {
serializedMember[underscore(key)] = member.attrs[key];
});
// similar deal for associated label models
serializedMember.labels = [];
member.labels.models.forEach((label) => {
const serializedLabel = {};
Object.keys(label.attrs).forEach((key) => {
serializedLabel[underscore(key)] = label.attrs[key];
});
serializedMember.labels.push(serializedLabel);
});
console.log({serializedMember});
return nqlFilter.queryJSON(serializedMember);
});
}
if (search) {
const query = search.toLowerCase();
collection = collection.filter((member) => {
return member.name.toLowerCase().indexOf(query) !== -1
|| member.email.toLowerCase().indexOf(query) !== -1;
});
}
return paginateModelCollection('members', collection, page, limit);
});
server.del('/members/', function ({members}, {queryParams}) {
if (!queryParams.filter && !queryParams.search && queryParams.all !== 'true') {
return new Response(422, {}, {errors: [{
type: 'IncorrectUsageError',
message: 'DELETE /members/ must be used with a filter, search, or all=true query parameter'
}]});
}
let membersToDelete = members.all();
if (queryParams.filter) {
let labelFilter = extractFilterParam('label', queryParams.filter);
membersToDelete = membersToDelete.filter((member) => {
let matches = false;
labelFilter.forEach((slug) => {
if (member.labels.models.find(l => l.slug === slug)) {
matches = true;
}
});
return matches;
});
}
let count = membersToDelete.length;
membersToDelete.destroy();
return {
meta: {
stats: {
successful: count
}
}
};
});
server.get('/members/:id/', function ({members}, {params}) {
let {id} = params;
let member = members.find(id);
return member || new Response(404, {}, {
errors: [{
type: 'NotFoundError',
message: 'Member not found.'
}]
});
});
server.put('/members/:id/');
server.del('/members/:id/');
server.get('/members/upload/', function () {
return new Response(200, {
'Content-Disposition': 'attachment',
filename: `members.${moment().format('YYYY-MM-DD')}.csv`,
'Content-Type': 'text/csv'
}, '');
});
server.post('/members/upload/', function ({labels}, request) {
const label = labels.create();
// TODO: parse CSV and create member records
for (const kvPair of request.requestBody.entries()) {
const [key, value] = kvPair;
console.log({key, value}); // eslint-disable-line
}
return new Response(201, {}, {
meta: {
import_label: label,
stats: {imported: 1, invalid: []}
}
});
});
server.get('/members/events/', function ({memberActivityEvents}, {queryParams}) {
let {limit} = queryParams;
limit = +limit || 15;
let collection = memberActivityEvents.all().sort((a, b) => {
return (new Date(a.createdAt)) - (new Date(b.createdAt));
}).slice(0, limit);
return collection;
});
mockMembersStats(server);
}