Skip to content
Open
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
6 changes: 6 additions & 0 deletions ghost/admin/app/components/gh-psm-authors-input.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand Down
138 changes: 129 additions & 9 deletions ghost/admin/app/components/gh-psm-authors-input.js
Original file line number Diff line number Diff line change
@@ -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({

Expand All @@ -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(),
Comment on lines +82 to +88

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle remote search failures without dropping local results.

If _fetchRemoteAuthors(term) fails, this task rejects and returns no matches, even though local matches are already available. Catch and fall back to local results so search remains usable during transient API errors.

Proposed fix
 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());
+    try {
+        const authors = yield this._fetchRemoteAuthors(term);
+        return this._mergeAuthors(localAuthors, authors.toArray());
+    } catch (error) {
+        return localAuthors;
+    }
 }).restartable(),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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(),
searchAuthorsTask: task(function* (term) {
yield timeout(SEARCH_DEBOUNCE_MS);
const localAuthors = this._localAuthorMatches(term);
try {
const authors = yield this._fetchRemoteAuthors(term);
return this._mergeAuthors(localAuthors, authors.toArray());
} catch (error) {
return localAuthors;
}
}).restartable(),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ghost/admin/app/components/gh-psm-authors-input.js` around lines 82 - 88, The
searchAuthorsTask currently rejects if _fetchRemoteAuthors(term) fails, which
discards already-found local matches. Update the task in gh-psm-authors-input.js
to catch remote fetch errors around _fetchRemoteAuthors and fall back to
returning the localAuthors results (or the merged local-only list) from
_localAuthorMatches so search remains usable during transient API failures. Keep
the restartable searchAuthorsTask behavior intact and ensure _mergeAuthors is
still used when remote results are available.


_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;
}

});
12 changes: 1 addition & 11 deletions ghost/admin/app/components/gh-resource-select.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:~<result>`). 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,
Expand Down
10 changes: 10 additions & 0 deletions ghost/admin/app/helpers/escape-nql-string.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Escape a search term and wrap it in single quotes for safe embedding in an
// NQL filter (e.g. `title:~<result>`). 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, '\\\'') + '\'';
Comment thread
PaulAdamDavis marked this conversation as resolved.
Dismissed
}
20 changes: 17 additions & 3 deletions ghost/admin/mirage/config/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading