mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-04-01 02:41:39 -05:00
Extracted Admin's search behaviour from component to service (#20008)
ref https://linear.app/tryghost/issue/MOM-1 - pre-requisite to exposing the search behaviour to the editor for internal linking
This commit is contained in:
parent
ebd36f2503
commit
a788a9673c
3 changed files with 144 additions and 124 deletions
ghost/admin/app
|
@ -1,7 +1,7 @@
|
|||
<div ...attributes>
|
||||
<div class="ember-power-select-search">
|
||||
<PowerSelect
|
||||
@search={{perform this.searchTask}}
|
||||
@search={{perform this.search.searchTask}}
|
||||
@onChange={{this.openSelected}}
|
||||
@onClose={{this.onClose}}
|
||||
@placeholder="Search site"
|
||||
|
|
|
@ -1,48 +1,11 @@
|
|||
/* eslint-disable camelcase */
|
||||
import Component from '@glimmer/component';
|
||||
import RSVP from 'rsvp';
|
||||
import {action} from '@ember/object';
|
||||
import {isBlank, isEmpty} from '@ember/utils';
|
||||
import {pluralize} from 'ember-inflector';
|
||||
import {run} from '@ember/runloop';
|
||||
import {inject as service} from '@ember/service';
|
||||
import {task, timeout, waitForProperty} from 'ember-concurrency';
|
||||
|
||||
export default class GhSearchInputComponent extends Component {
|
||||
@service ajax;
|
||||
@service notifications;
|
||||
@service router;
|
||||
@service store;
|
||||
|
||||
content = [];
|
||||
contentExpiresAt = false;
|
||||
contentExpiry = 30000;
|
||||
|
||||
searchables = [{
|
||||
name: 'Posts',
|
||||
model: 'post',
|
||||
fields: ['id', 'title'],
|
||||
idField: 'id',
|
||||
titleField: 'title'
|
||||
}, {
|
||||
name: 'Pages',
|
||||
model: 'page',
|
||||
fields: ['id', 'title'],
|
||||
idField: 'id',
|
||||
titleField: 'title'
|
||||
}, {
|
||||
name: 'Users',
|
||||
model: 'user',
|
||||
fields: ['slug', 'name'],
|
||||
idField: 'slug',
|
||||
titleField: 'name'
|
||||
}, {
|
||||
name: 'Tags',
|
||||
model: 'tag',
|
||||
fields: ['slug', 'name'],
|
||||
idField: 'slug',
|
||||
titleField: 'name'
|
||||
}];
|
||||
@service search;
|
||||
|
||||
@action
|
||||
openSelected(selected) {
|
||||
|
@ -80,89 +43,4 @@ export default class GhSearchInputComponent extends Component {
|
|||
keyboardEvent?.target.focus();
|
||||
});
|
||||
}
|
||||
|
||||
@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.searchable === searchable.name) && (normalizedTitle.indexOf(normalizedTerm) >= 0);
|
||||
});
|
||||
|
||||
if (!isEmpty(matchedContent)) {
|
||||
results.push({
|
||||
groupName: searchable.name,
|
||||
options: matchedContent
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
@task({drop: true})
|
||||
*refreshContentTask() {
|
||||
let now = new Date();
|
||||
let 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);
|
||||
}
|
||||
|
||||
let contentExpiry = this.contentExpiry;
|
||||
this.contentExpiresAt = new Date(now.getTime() + contentExpiry);
|
||||
}
|
||||
|
||||
_loadSearchable(searchable, content) {
|
||||
let url = `${this.store.adapterFor(searchable.model).urlForQuery({}, searchable.model)}/`;
|
||||
let maxSearchableLimit = '10000';
|
||||
let query = {fields: searchable.fields, limit: maxSearchableLimit};
|
||||
|
||||
return this.ajax.request(url, {data: query}).then((response) => {
|
||||
const items = response[pluralize(searchable.model)].map(item => ({
|
||||
id: `${searchable.model}.${item[searchable.idField]}`,
|
||||
title: item[searchable.titleField],
|
||||
searchable: searchable.name
|
||||
}));
|
||||
|
||||
content.push(...items);
|
||||
}).catch((error) => {
|
||||
this.notifications.showAPIError(error, {key: `search.load${searchable.name}.error`});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
142
ghost/admin/app/services/search.js
Normal file
142
ghost/admin/app/services/search.js
Normal file
|
@ -0,0 +1,142 @@
|
|||
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', 'title'],
|
||||
idField: 'id',
|
||||
titleField: 'title'
|
||||
},
|
||||
{
|
||||
name: 'Pages',
|
||||
model: 'page',
|
||||
fields: ['id', 'title'],
|
||||
idField: 'id',
|
||||
titleField: 'title'
|
||||
},
|
||||
{
|
||||
name: 'Users',
|
||||
model: 'user',
|
||||
fields: ['slug', 'name'],
|
||||
idField: 'slug',
|
||||
titleField: 'name'
|
||||
},
|
||||
{
|
||||
name: 'Tags',
|
||||
model: 'tag',
|
||||
fields: ['slug', '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.searchable === 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]}`,
|
||||
title: item[searchable.titleField],
|
||||
searchable: searchable.name
|
||||
})
|
||||
);
|
||||
|
||||
content.push(...items);
|
||||
} catch (error) {
|
||||
console.error(error); // eslint-disable-line
|
||||
|
||||
this.notifications.showAPIError(error, {
|
||||
key: `search.load${searchable.name}.error`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue