diff --git a/core/server/api/canary/members.js b/core/server/api/canary/members.js index 8a1aee964b..8d8ef83eb0 100644 --- a/core/server/api/canary/members.js +++ b/core/server/api/canary/members.js @@ -104,7 +104,8 @@ const members = { 'filter', 'order', 'debug', - 'page' + 'page', + 'search' ], permissions: true, validation: {}, diff --git a/core/server/models/base/index.js b/core/server/models/base/index.js index fa19f3154b..3c59daef59 100644 --- a/core/server/models/base/index.js +++ b/core/server/models/base/index.js @@ -38,6 +38,9 @@ ghostBookshelf.plugin(plugins.transactionEvents); // Load the Ghost filter plugin, which handles applying a 'filter' to findPage requests ghostBookshelf.plugin(plugins.filter); +// Load the Ghost search plugin, which handles applying a search query to findPage requests +ghostBookshelf.plugin(plugins.search); + // Load the Ghost include count plugin, which allows for the inclusion of cross-table counts ghostBookshelf.plugin(plugins.includeCount); @@ -885,6 +888,9 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({ // Add Filter behaviour itemCollection.applyDefaultAndCustomFilters(options); + // Apply model-specific search behaviour + itemCollection.applySearchQuery(options); + // Ensure only valid fields/columns are added to query // and append default columns to fetch if (options.columns) { diff --git a/core/server/models/member.js b/core/server/models/member.js index 66ac35cc99..47f98e6ca8 100644 --- a/core/server/models/member.js +++ b/core/server/models/member.js @@ -154,6 +154,11 @@ const Member = ghostBookshelf.Model.extend({ return options; }, + searchQuery: function searchQuery(queryBuilder, query) { + queryBuilder.where('name', 'like', `%${query}%`); + queryBuilder.orWhere('email', 'like', `%${query}%`); + }, + toJSON(unfilteredOptions) { const options = Member.filterOptions(unfilteredOptions, 'toJSON'); const attrs = ghostBookshelf.Model.prototype.toJSON.call(this, options); @@ -169,6 +174,21 @@ const Member = ghostBookshelf.Model.extend({ return attrs; } +}, { + /** + * Returns an array of keys permitted in a method's `options` hash, depending on the current method. + * @param {String} methodName The name of the method to check valid options for. + * @return {Array} Keys allowed in the `options` hash of the model's method. + */ + permittedOptions: function permittedOptions(methodName) { + let options = ghostBookshelf.Model.permittedOptions.call(this, methodName); + + if (['findPage', 'findAll'].includes(methodName)) { + options = options.concat(['search']); + } + + return options; + } }); const Members = ghostBookshelf.Collection.extend({ diff --git a/core/server/models/plugins/index.js b/core/server/models/plugins/index.js index 8bed75e95f..aba6cf9137 100644 --- a/core/server/models/plugins/index.js +++ b/core/server/models/plugins/index.js @@ -1,5 +1,6 @@ module.exports = { filter: require('./filter'), + search: require('./search'), includeCount: require('./include-count'), pagination: require('./pagination'), collision: require('./collision'), diff --git a/core/server/models/plugins/search.js b/core/server/models/plugins/search.js new file mode 100644 index 0000000000..2014286298 --- /dev/null +++ b/core/server/models/plugins/search.js @@ -0,0 +1,18 @@ +const searchPlugin = function searchPlugin(Bookshelf) { + const Model = Bookshelf.Model.extend({ + // override this on the model itself + searchQuery() {}, + + applySearchQuery: function applySearchQuery(options) { + if (options.search) { + this.query((qb) => { + this.searchQuery(qb, options.search); + }); + } + } + }); + + Bookshelf.Model = Model; +}; + +module.exports = searchPlugin; diff --git a/test/api-acceptance/admin/members_spec.js b/test/api-acceptance/admin/members_spec.js index 21b88f8f16..a6cbd3ac2d 100644 --- a/test/api-acceptance/admin/members_spec.js +++ b/test/api-acceptance/admin/members_spec.js @@ -76,6 +76,26 @@ describe('Members API', function () { }); }); + it('Can browse with search', function () { + return request + .get(localUtils.API.getApiQuery('members/?search=member1')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse); + should.exist(jsonResponse.members); + jsonResponse.members.should.have.length(1); + jsonResponse.members[0].email.should.equal('member1@test.com'); + localUtils.API.checkResponse(jsonResponse, 'members'); + localUtils.API.checkResponse(jsonResponse.members[0], 'member', 'stripe'); + localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination'); + }); + }); + it('Can read', function () { return request .get(localUtils.API.getApiQuery(`members/${testUtils.DataGenerator.Content.members[0].id}/`)) diff --git a/test/regression/api/canary/admin/members_spec.js b/test/regression/api/canary/admin/members_spec.js index 10b7806030..02ee9aee00 100644 --- a/test/regression/api/canary/admin/members_spec.js +++ b/test/regression/api/canary/admin/members_spec.js @@ -30,6 +30,46 @@ describe('Members API', function () { }); }); + it('Can search by case-insensitive name', function () { + return request + .get(localUtils.API.getApiQuery('members/?search=egg')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse); + should.exist(jsonResponse.members); + jsonResponse.members.should.have.length(1); + jsonResponse.members[0].email.should.equal('member1@test.com'); + localUtils.API.checkResponse(jsonResponse, 'members'); + localUtils.API.checkResponse(jsonResponse.members[0], 'member', 'stripe'); + localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination'); + }); + }); + + it('Can search by case-insensitive email', function () { + return request + .get(localUtils.API.getApiQuery('members/?search=MEMBER2')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse); + should.exist(jsonResponse.members); + jsonResponse.members.should.have.length(1); + jsonResponse.members[0].email.should.equal('member2@test.com'); + localUtils.API.checkResponse(jsonResponse, 'members'); + localUtils.API.checkResponse(jsonResponse.members[0], 'member', 'stripe'); + localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination'); + }); + }); + it('Add should fail when passing incorrect email_type query parameter', function () { const member = { name: 'test', diff --git a/test/regression/api/canary/admin/utils.js b/test/regression/api/canary/admin/utils.js index fd4fcd9395..a0f271133a 100644 --- a/test/regression/api/canary/admin/utils.js +++ b/test/regression/api/canary/admin/utils.js @@ -17,6 +17,7 @@ const expectedProperties = { slug: ['slug'], invites: ['invites', 'meta'], themes: ['themes'], + members: ['members', 'meta'], post: _(schema.posts) .keys()