diff --git a/ghost/admin/app/components/gh-psm-authors-input.hbs b/ghost/admin/app/components/gh-psm-authors-input.hbs index 54c1bba9955..5f4454a7171 100644 --- a/ghost/admin/app/components/gh-psm-authors-input.hbs +++ b/ghost/admin/app/components/gh-psm-authors-input.hbs @@ -2,6 +2,12 @@ @options={{this.availableAuthors}} @selected={{this.selectedAuthors}} @onChange={{action "updateAuthors"}} + @search={{if this.useServerSideSearch (perform this.searchAuthorsTask) null}} + {{!-- null searchField passes the whole author to matchAuthor so client-side + filtering can match name, slug, and email (not just the name field) --}} + @searchField={{null}} + @matcher={{this.matchAuthor}} + @loadingMessage="Loading authors..." @allowCreation={{false}} @renderInPlace={{true}} @triggerId={{this.triggerId}} diff --git a/ghost/admin/app/components/gh-psm-authors-input.js b/ghost/admin/app/components/gh-psm-authors-input.js index be3fdc3dad8..8fd847481c2 100644 --- a/ghost/admin/app/components/gh-psm-authors-input.js +++ b/ghost/admin/app/components/gh-psm-authors-input.js @@ -1,6 +1,13 @@ import Component from '@ember/component'; -import {computed} from '@ember/object'; +import {escapeNqlString} from '../helpers/escape-nql-string'; +import {not} from '@ember/object/computed'; import {inject as service} from '@ember/service'; +import {task, timeout} from 'ember-concurrency'; + +const PAGE_SIZE = 100; +const SEARCH_DEBOUNCE_MS = 250; +const AUTHORS_INCLUDE = 'count.posts'; +const AUTHORS_ORDER = 'count.posts desc, name asc'; export default Component.extend({ @@ -13,27 +20,140 @@ export default Component.extend({ // internal attrs availableAuthors: null, + _hasLoadedAllAuthors: false, // closure actions updateAuthors() {}, - availableAuthorNames: computed('availableAuthors.@each.name', function () { - return this.availableAuthors.map(author => author.get('name').toLowerCase()); - }), + // Search the API while the background all-authors query is still loading. + // Once all authors are in Ember Data we can fall back to local filtering. + useServerSideSearch: not('_hasLoadedAllAuthors'), init() { this._super(...arguments); - // perform a background query to fetch all users and set `availableAuthors` - // to a live-query that will be immediately populated with what's in the - // store and be updated when the above query returns - this.store.query('user', {limit: 'all'}); - this.set('availableAuthors', this.store.peekAll('user')); + this.set('availableAuthors', this._sortAuthors(this.store.peekAll('user').toArray())); + this.loadAllAuthorsTask.perform(); }, actions: { updateAuthors(newAuthors) { this.updateAuthors(newAuthors); } + }, + + // Load every author in the background, page by page, so Ember Data and the + // dropdown are updated after each response instead of waiting for `limit=all`. + loadAllAuthorsTask: task(function* () { + let page = 1; + let hasMorePages = true; + + while (hasMorePages && !this.isDestroying && !this.isDestroyed) { + const authors = yield this.store.query('user', { + include: AUTHORS_INCLUDE, + order: AUTHORS_ORDER, + limit: PAGE_SIZE, + page + }); + + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.set('availableAuthors', this._sortAuthors(this.store.peekAll('user').toArray())); + + const pagination = authors.meta?.pagination; + if (pagination?.pages) { + hasMorePages = pagination.page < pagination.pages; + } else { + hasMorePages = authors.length >= PAGE_SIZE; + } + + page += 1; + } + + if (!this.isDestroying && !this.isDestroyed) { + this.set('_hasLoadedAllAuthors', true); + } + }).drop(), + + // wired to GhTokenInput's @search only when `useServerSideSearch` is true. + // restartable + a 250ms timeout means the API is queried at most once per + // 250ms of typing. + searchAuthorsTask: task(function* (term) { + yield timeout(SEARCH_DEBOUNCE_MS); + + const localAuthors = this._localAuthorMatches(term); + const authors = yield this._fetchRemoteAuthors(term); + return this._mergeAuthors(localAuthors, authors.toArray()); + }).restartable(), + + _fetchRemoteAuthors(term) { + // match name, slug, or email. The OR group is parenthesised so it's + // combined as a unit with the endpoint's default status filter. + const nqlTerm = escapeNqlString(term); + return this.store.query('user', { + include: AUTHORS_INCLUDE, + filter: `(name:~${nqlTerm},slug:~${nqlTerm},email:~${nqlTerm})`, + order: AUTHORS_ORDER, + limit: PAGE_SIZE + }); + }, + + _filterSelectedAuthors(authors) { + const selectedIds = new Set((this.selectedAuthors || []).map(author => author.id)); + return this._sortAuthors(authors.filter(author => !selectedIds.has(author.id))); + }, + + _mergeAuthors(...authorLists) { + const seenIds = new Set(); + const authors = []; + + authorLists.forEach((authorList) => { + authorList.forEach((author) => { + if (!author.id || seenIds.has(author.id)) { + return; + } + + seenIds.add(author.id); + authors.push(author); + }); + }); + + return this._filterSelectedAuthors(authors); + }, + + _localAuthorMatches(term) { + const availableAuthors = this.availableAuthors?.toArray ? this.availableAuthors.toArray() : (this.availableAuthors || []); + return this._filterSelectedAuthors(availableAuthors).filter((author) => { + return this.matchAuthor(author, term) >= 0; + }); + }, + + _authorPostCount(author) { + return Number(author.count?.posts || 0); + }, + + _sortAuthors(authors) { + return authors.slice().sort((authorA, authorB) => { + const postCountDiff = this._authorPostCount(authorB) - this._authorPostCount(authorA); + + if (postCountDiff !== 0) { + return postCountDiff; + } + + return (authorA.name || '').localeCompare(authorB.name || ''); + }); + }, + + // client-side fallback matcher (sites with <= PAGE_SIZE authors) - mirrors + // the server-side search by matching name, slug, or email. Returns 0 on a + // match and -1 otherwise, per ember-power-select's matcher contract. + matchAuthor(author, searchTerm) { + const term = (searchTerm || '').toLowerCase(); + const matches = [author.name, author.slug, author.email].some((field) => { + return (field || '').toLowerCase().includes(term); + }); + return matches ? 0 : -1; } }); diff --git a/ghost/admin/app/components/gh-resource-select.js b/ghost/admin/app/components/gh-resource-select.js index 5e063f17fe0..151880d2b2e 100644 --- a/ghost/admin/app/components/gh-resource-select.js +++ b/ghost/admin/app/components/gh-resource-select.js @@ -5,23 +5,13 @@ import { defaultMatcher, filterOptions } from 'ember-power-select/utils/group-utils'; +import {escapeNqlString} from '../helpers/escape-nql-string'; import {inject as service} from '@ember/service'; import {task, timeout} from 'ember-concurrency'; import {tracked} from '@glimmer/tracking'; const DEBOUNCE_MS = 200; -// Escape a search term and wrap it in single quotes for safe embedding in an -// NQL filter (e.g. `title:~`). Returns the *quoted* string to match the -// `escapeNqlString` contract used elsewhere (apps/posts, admin-x-framework). -// Only single quotes are escaped: the NQL lexer treats just `\'`/`\"` as escapes -// and reads a lone backslash literally. Verified against @tryghost/nql — escaping -// every quote prevents breakout, and backslashes must NOT be doubled (doubling -// corrupts terms containing a backslash, e.g. `a\b` would be searched as `a\\b`). -function escapeNqlString(term) { - return '\'' + String(term).replace(/'/g, '\\\'') + '\''; -} - function mapResource(resource) { return { id: resource.id, diff --git a/ghost/admin/app/helpers/escape-nql-string.js b/ghost/admin/app/helpers/escape-nql-string.js new file mode 100644 index 00000000000..65dcf69fdf8 --- /dev/null +++ b/ghost/admin/app/helpers/escape-nql-string.js @@ -0,0 +1,10 @@ +// Escape a search term and wrap it in single quotes for safe embedding in an +// NQL filter (e.g. `title:~`). Returns the *quoted* string to match the +// `escapeNqlString` contract used elsewhere (apps/posts, admin-x-framework). +// Only single quotes are escaped: the NQL lexer treats just `\'`/`\"` as escapes +// and reads a lone backslash literally. Verified against @tryghost/nql — escaping +// every quote prevents breakout, and backslashes must NOT be doubled (doubling +// corrupts terms containing a backslash, e.g. `a\b` would be searched as `a\\b`). +export function escapeNqlString(term) { + return '\'' + String(term).replace(/'/g, '\\\'') + '\''; +} diff --git a/ghost/admin/mirage/config/users.js b/ghost/admin/mirage/config/users.js index 3222979d849..b4eb682c919 100644 --- a/ghost/admin/mirage/config/users.js +++ b/ghost/admin/mirage/config/users.js @@ -17,21 +17,35 @@ export default function mockUsers(server) { server.get('/users/', function ({users}, {queryParams}) { let page = +queryParams.page || 1; + let filter = queryParams.filter || ''; + + // author search e.g. `(name:~'John',slug:~'John',email:~'John')` - all + // three fields use the same term, so extract it from the name clause + // (unescaping NQL-escaped single quotes) and match any of the fields + let searchFilter = filter.match(/name:~'((?:\\.|[^'\\])*)'/); + let searchTerm = searchFilter ? searchFilter[1].replace(/\\'/g, '\'').toLowerCase() : null; // NOTE: this is naive and only set up to work with queries that are // actually used - if you use a different filter in the app, add it here! let collection = users.where(function (user) { let statusMatch = true; + let searchMatch = true; - if (queryParams.filter === 'status:-inactive') { + if (filter === 'status:-inactive') { statusMatch = user.status !== 'inactive'; - } else if (queryParams.filter === 'status:inactive') { + } else if (filter === 'status:inactive') { statusMatch = user.status === 'inactive'; } else if (queryParams.status && queryParams.status !== 'all') { statusMatch = user.status === queryParams.status; } - return statusMatch; + if (searchTerm !== null) { + searchMatch = ['name', 'slug', 'email'].some((field) => { + return (user[field] || '').toLowerCase().includes(searchTerm); + }); + } + + return statusMatch && searchMatch; }); return paginateModelCollection('users', collection, page, queryParams.limit); diff --git a/ghost/admin/tests/integration/components/gh-psm-authors-input-test.js b/ghost/admin/tests/integration/components/gh-psm-authors-input-test.js new file mode 100644 index 00000000000..59f0cc02483 --- /dev/null +++ b/ghost/admin/tests/integration/components/gh-psm-authors-input-test.js @@ -0,0 +1,154 @@ +import hbs from 'htmlbars-inline-precompile'; +import mockUsers from '../../../mirage/config/users'; +import {clickTrigger, selectChoose, typeInSearch} from 'ember-power-select/test-support/helpers'; +import {describe, it} from 'mocha'; +import {expect} from 'chai'; +import {findAll, render, settled} from '@ember/test-helpers'; +import {setupRenderingTest} from 'ember-mocha'; +import {startMirage} from 'ghost-admin/initializers/ember-cli-mirage'; + +const TEMPLATE = hbs``; + +// the author dropdown queries `GET /users/?...` - filter to just those browse +// requests (ignores /users/me, /users/:id, etc.) +function browseRequests(server) { + return server.pretender.handledRequests.filter(request => request.url.includes('users/?')); +} + +describe('Integration: Component: gh-psm-authors-input', function () { + setupRenderingTest(); + + let server; + + beforeEach(function () { + server = startMirage(); + mockUsers(server); + + this.set('store', this.owner.lookup('service:store')); + this.set('selectedAuthors', []); + this.set('updateAuthors', () => {}); + }); + + afterEach(function () { + server.shutdown(); + }); + + it('shows selected authors while loading authors in the background', async function () { + server.create('user', {name: 'Adam Author'}); + server.create('user', {name: 'Betty Blogger'}); + + const users = await this.store.query('user', {limit: 100}); + const adam = users.toArray().find(user => user.name === 'Adam Author'); + this.set('selectedAuthors', [adam]); + + const requestCount = server.pretender.handledRequests.length; + + await render(TEMPLATE); + + const selected = findAll('[data-test-selected-token]'); + expect(selected.length, 'selected tokens').to.equal(1); + expect(selected[0]).to.contain.text('Adam Author'); + + expect(server.pretender.handledRequests.length, 'request count').to.equal(requestCount + 1); + }); + + it('loads all authors page-by-page in the background', async function () { + server.createList('user', 150); + + await render(TEMPLATE); + + const requests = browseRequests(server); + expect(requests.length, 'background page requests').to.equal(2); + expect(requests[0].queryParams.include).to.contain('count.posts'); + expect(requests[0].queryParams.order).to.equal('count.posts desc, name asc'); + expect(requests[0].queryParams.limit).to.equal('100'); + expect(requests[0].queryParams.page).to.equal('1'); + expect(requests[1].queryParams.page).to.equal('2'); + }); + + it('excludes already-selected authors from the options', async function () { + server.create('user', {name: 'Adam Author'}); + server.create('user', {name: 'Betty Blogger'}); + + const users = await this.store.query('user', {limit: 100}); + const adam = users.toArray().find(user => user.name === 'Adam Author'); + this.set('selectedAuthors', [adam]); + + await render(TEMPLATE); + await clickTrigger(); + await settled(); + + const optionText = findAll('.ember-power-select-option').map(option => option.textContent.trim()); + expect(optionText).to.not.include('Adam Author'); + expect(optionText).to.include('Betty Blogger'); + }); + + it('uses client-side search when all authors fit on the first page', async function () { + server.createList('user', 3, {name: i => `Author ${i}`}); + + await render(TEMPLATE); + await clickTrigger(); + await settled(); + + const requestCount = server.pretender.handledRequests.length; + await typeInSearch('Author 1'); + await settled(); + + // no extra request - filtering happens client-side + expect(server.pretender.handledRequests.length).to.equal(requestCount); + }); + + it('finds an author by slug or email on a large site', async function () { + server.createList('user', 150, {name: i => `Author ${String(i).padStart(3, '0')}`}); + server.create('user', {name: 'Distinctive Person', slug: 'dperson', email: 'unique@example.com'}); + + await render(TEMPLATE); + await clickTrigger(); + await settled(); + + await typeInSearch('unique@example'); + await settled(); + + const optionText = findAll('.ember-power-select-option').map(option => option.textContent.trim()); + expect(optionText).to.include('Distinctive Person'); + }); + + it('matches authors by slug or email in the client-side fallback', async function () { + server.create('user', {name: 'Adam Author', slug: 'adam', email: 'adam@example.com'}); + server.create('user', {name: 'Betty Blogger', slug: 'betty', email: 'zzz@unique.com'}); + + await render(TEMPLATE); + await clickTrigger(); + await settled(); + + const requestCount = server.pretender.handledRequests.length; + await typeInSearch('zzz@unique'); + await settled(); + + const optionText = findAll('.ember-power-select-option').map(option => option.textContent.trim()); + expect(optionText).to.include('Betty Blogger'); + expect(optionText).to.not.include('Adam Author'); + // small site => filtering stays client-side, no API request + expect(server.pretender.handledRequests.length).to.equal(requestCount); + }); + + it('calls updateAuthors when an author is selected', async function () { + server.create('user', {name: 'Adam Author'}); + server.create('user', {name: 'Betty Blogger'}); + + let updated = null; + this.set('updateAuthors', (authors) => { + updated = authors; + }); + + await render(TEMPLATE); + await selectChoose('.ember-power-select-trigger', 'Betty Blogger'); + + expect(updated, 'updateAuthors called').to.not.be.null; + expect(updated.map(author => author.name)).to.include('Betty Blogger'); + }); +});