Ghost/ghost/admin/app/services/search.js
Kevin Ansfield d6e599dab3
Generalised Admin search for use in editor (#20011)
ref https://linear.app/tryghost/issue/MOM-1

- renamed `searchable` to `groupName` so it better matches usage and avoids leaking internal naming to external clients
- added `url` to the fetched data for each data type as the editor will want to use front-end URLs in content
- added acceptance tests to help avoid regressions as we further generalise/optimise the search behaviour
2024-04-11 14:01:39 +00:00

144 lines
4.1 KiB
JavaScript

import RSVP from 'rsvp';
import Service from '@ember/service';
import {isBlank, isEmpty} from '@ember/utils';
import {pluralize} from 'ember-inflector';
import {inject as service} from '@ember/service';
import {task, timeout, waitForProperty} from 'ember-concurrency';
export default class SearchService extends Service {
@service ajax;
@service notifications;
@service store;
content = [];
contentExpiresAt = false;
contentExpiry = 30000;
searchables = [
{
name: 'Posts',
model: 'post',
fields: ['id', 'url', 'title'],
idField: 'id',
titleField: 'title'
},
{
name: 'Pages',
model: 'page',
fields: ['id', 'url', 'title'],
idField: 'id',
titleField: 'title'
},
{
name: 'Users',
model: 'user',
fields: ['id', 'slug', 'url', 'name'], // id not used but required for API to have correct url
idField: 'slug',
titleField: 'name'
},
{
name: 'Tags',
model: 'tag',
fields: ['slug', 'url', 'name'],
idField: 'slug',
titleField: 'name'
}
];
@task({restartable: true})
*searchTask(term) {
if (isBlank(term)) {
return [];
}
// start loading immediately in the background
this.refreshContentTask.perform();
// debounce searches to 200ms to avoid thrashing CPU
yield timeout(200);
// wait for any on-going refresh to finish
if (this.refreshContentTask.isRunning) {
yield waitForProperty(this, 'refreshContentTask.isIdle');
}
const searchResult = this._searchContent(term);
return searchResult;
}
_searchContent(term) {
const normalizedTerm = term.toString().toLowerCase();
const results = [];
this.searchables.forEach((searchable) => {
const matchedContent = this.content.filter((item) => {
const normalizedTitle = item.title.toString().toLowerCase();
return (
item.groupName === searchable.name &&
normalizedTitle.indexOf(normalizedTerm) >= 0
);
});
if (!isEmpty(matchedContent)) {
results.push({
groupName: searchable.name,
options: matchedContent
});
}
});
return results;
}
@task({drop: true})
*refreshContentTask() {
const now = new Date();
const contentExpiresAt = this.contentExpiresAt;
if (contentExpiresAt > now) {
return true;
}
const content = [];
const promises = this.searchables.map(searchable => this._loadSearchable(searchable, content));
try {
yield RSVP.all(promises);
this.content = content;
} catch (error) {
// eslint-disable-next-line
console.error(error);
}
this.contentExpiresAt = new Date(now.getTime() + this.contentExpiry);
}
async _loadSearchable(searchable, content) {
const url = `${this.store.adapterFor(searchable.model).urlForQuery({}, searchable.model)}/`;
const maxSearchableLimit = '10000';
const query = {fields: searchable.fields, limit: maxSearchableLimit};
try {
const response = await this.ajax.request(url, {data: query});
const items = response[pluralize(searchable.model)].map(
item => ({
id: `${searchable.model}.${item[searchable.idField]}`,
url: item.url,
title: item[searchable.titleField],
groupName: searchable.name
})
);
content.push(...items);
} catch (error) {
console.error(error); // eslint-disable-line
this.notifications.showAPIError(error, {
key: `search.load${searchable.name}.error`
});
}
}
}