From f1fe211e0b65d6536ef738f47b13e52aeb60c1e4 Mon Sep 17 00:00:00 2001 From: Paul Davis Date: Thu, 25 Jun 2026 17:22:39 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20post=20settings=20author?= =?UTF-8?q?=20search=20to=20load=20users=20progressively=20and=20query=20t?= =?UTF-8?q?he=20users=20API=20for=20not-yet-loaded=20authors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref Selecting authors in the post settings menu on a site with a large number of authors can be a very slow process, as users need to wait for them to all load before the dropdown list is populated. This change introduces an API query to find users that match what the user has typed into the input, so they can find a specific author before they have all loaded in. All users continue to load in the background as they currently do, so users can still browse all available authors without searching. For sites with <= 100 users, all users load instantly in a single query. For sites with > 100 users, users are loaded in batches of 100, and instantly added to the dropdown. When searching for a user that has not yet been loaded, it queries the API for users that match the typed query --- .../app/components/gh-psm-authors-input.hbs | 6 + .../app/components/gh-psm-authors-input.js | 138 +++++++++++++++- .../app/components/gh-resource-select.js | 12 +- ghost/admin/app/helpers/escape-nql-string.js | 10 ++ ghost/admin/mirage/config/users.js | 20 ++- .../components/gh-psm-authors-input-test.js | 154 ++++++++++++++++++ 6 files changed, 317 insertions(+), 23 deletions(-) create mode 100644 ghost/admin/app/helpers/escape-nql-string.js create mode 100644 ghost/admin/tests/integration/components/gh-psm-authors-input-test.js 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'); + }); +});