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
This commit is contained in:
Kevin Ansfield 2024-04-11 15:01:39 +01:00 committed by GitHub
parent 145a184967
commit d6e599dab3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 159 additions and 45 deletions

View File

@ -7,7 +7,7 @@
<div class="gh-nav-menu-details-sitetitle">{{this.config.blogTitle}}</div>
</div>
<div class="gh-nav-menu-search">
<button class="gh-nav-btn-search" title="Search site (Ctrl/⌘ + K)" type="button" {{on "click" (action "openSearchModal")}}><span>{{svg-jar "search"}}</span></button>
<button class="gh-nav-btn-search" title="Search site (Ctrl/⌘ + K)" type="button" {{on "click" (action "openSearchModal")}} data-test-button="search"><span>{{svg-jar "search"}}</span></button>
</div>
</header>
{{/unless}}

View File

@ -15,22 +15,22 @@ export default class GhSearchInputComponent extends Component {
this.args.onSelected?.(selected);
if (selected.searchable === 'Posts') {
if (selected.groupName === 'Posts') {
let id = selected.id.replace('post.', '');
this.router.transitionTo('lexical-editor.edit', 'post', id);
}
if (selected.searchable === 'Pages') {
if (selected.groupName === 'Pages') {
let id = selected.id.replace('page.', '');
this.router.transitionTo('lexical-editor.edit', 'page', id);
}
if (selected.searchable === 'Users') {
if (selected.groupName === 'Users') {
let id = selected.id.replace('user.', '');
this.router.transitionTo('settings-x.settings-x', `staff/${id}`);
}
if (selected.searchable === 'Tags') {
if (selected.groupName === 'Tags') {
let id = selected.id.replace('tag.', '');
this.router.transitionTo('tag', id);
}

View File

@ -1,4 +1,4 @@
<div class="modal-content">
<div class="modal-content" data-test-modal="search">
<div class="gh-nav-search-modal" {{on "click" this.focusFirstInput}}>
<GhSearchInput class="gh-nav-search-input" @onSelected={{@close}} />
<div class="gh-search-tips">Open with Ctrl/⌘ + K</div>

View File

@ -18,28 +18,28 @@ export default class SearchService extends Service {
{
name: 'Posts',
model: 'post',
fields: ['id', 'title'],
fields: ['id', 'url', 'title'],
idField: 'id',
titleField: 'title'
},
{
name: 'Pages',
model: 'page',
fields: ['id', 'title'],
fields: ['id', 'url', 'title'],
idField: 'id',
titleField: 'title'
},
{
name: 'Users',
model: 'user',
fields: ['slug', 'name'],
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', 'name'],
fields: ['slug', 'url', 'name'],
idField: 'slug',
titleField: 'name'
}
@ -75,7 +75,7 @@ export default class SearchService extends Service {
const matchedContent = this.content.filter((item) => {
const normalizedTitle = item.title.toString().toLowerCase();
return (
item.searchable === searchable.name &&
item.groupName === searchable.name &&
normalizedTitle.indexOf(normalizedTerm) >= 0
);
});
@ -125,8 +125,9 @@ export default class SearchService extends Service {
const items = response[pluralize(searchable.model)].map(
item => ({
id: `${searchable.model}.${item[searchable.idField]}`,
url: item.url,
title: item[searchable.titleField],
searchable: searchable.name
groupName: searchable.name
})
);

View File

@ -1,40 +1,8 @@
import moment from 'moment-timezone';
import {Response} from 'miragejs';
import {dasherize} from '@ember/string';
import {isArray} from '@ember/array';
import {extractFilterParam, paginateModelCollection} from '../utils';
import {isBlank, isEmpty} from '@ember/utils';
import {paginateModelCollection} from '../utils';
function normalizeBooleanParams(arr) {
if (!isArray(arr)) {
return arr;
}
return arr.map((i) => {
if (i === 'true') {
return true;
} else if (i === 'false') {
return false;
} else {
return i;
}
});
}
// TODO: use GQL to parse filter string?
function extractFilterParam(param, filter) {
let filterRegex = new RegExp(`${param}:(.*?)(?:\\+|$)`);
let match;
let [, result] = filter.match(filterRegex) || [];
if (result && result.startsWith('[')) {
match = result.replace(/^\[|\]$/g, '').split(',');
} else if (result) {
match = [result];
}
return normalizeBooleanParams(match);
}
// NOTE: mirage requires Model objects when saving relationships, however the
// `attrs` on POST/PUT requests will contain POJOs for authors and tags so we

View File

@ -0,0 +1,145 @@
import ctrlOrCmd from 'ghost-admin/utils/ctrl-or-cmd';
import {authenticateSession} from 'ember-simple-auth/test-support';
import {click, currentURL, find, findAll, triggerKeyEvent, visit} from '@ember/test-helpers';
import {describe, it} from 'mocha';
import {expect} from 'chai';
import {setupApplicationTest} from 'ember-mocha';
import {setupMirage} from 'ember-cli-mirage/test-support';
import {typeInSearch} from 'ember-power-select/test-support/helpers';
describe('Acceptance: Search', function () {
const trigger = '[data-test-modal="search"] .ember-power-select-trigger';
// eslint-disable-next-line no-unused-vars
let firstUser, firstPost, secondPost, firstPage, firstTag;
const hooks = setupApplicationTest();
setupMirage(hooks);
this.beforeEach(async function () {
this.server.loadFixtures();
// create user to authenticate as
let role = this.server.create('role', {name: 'Owner'});
firstUser = this.server.create('user', {roles: [role], slug: 'owner', name: 'First user'});
// populate store with data we'll be searching
firstPost = this.server.create('post', {title: 'First post', slug: 'first-post'});
secondPost = this.server.create('post', {title: 'Second post', slug: 'second-post'});
firstPage = this.server.create('page', {title: 'First page', slug: 'first-page'});
firstTag = this.server.create('tag', {name: 'First tag', slug: 'first-tag'});
return await authenticateSession();
});
it('opens search modal when clicking icon', async function () {
await visit('/dashboard');
expect(currentURL(), 'currentURL').to.equal('/dashboard');
expect(find('[data-test-modal="search"]'), 'search modal').to.not.exist;
await click('[data-test-button="search"]');
expect(find('[data-test-modal="search"]'), 'search modal').to.exist;
});
it('opens search icon when pressing Ctrl/Cmd+K', async function () {
await visit('/dashboard');
expect(find('[data-test-modal="search"]'), 'search modal').to.not.exist;
await triggerKeyEvent(document, 'keydown', 'K', {
metaKey: ctrlOrCmd === 'command',
ctrlKey: ctrlOrCmd === 'ctrl'
});
expect(find('[data-test-modal="search"]'), 'search modal').to.exist;
});
it('closes search modal on escape key', async function () {
await visit('/dashboard');
await click('[data-test-button="search"]');
expect(find('[data-test-modal="search"]'), 'search modal').to.exist;
await triggerKeyEvent(document, 'keydown', 'Escape');
expect(find('[data-test-modal="search"]'), 'search modal').to.not.exist;
});
it('closes search modal on click outside', async function () {
await visit('/dashboard');
await click('[data-test-button="search"]');
expect(find('[data-test-modal="search"]'), 'search modal').to.exist;
await click('.epm-backdrop');
expect(find('[data-test-modal="search"]'), 'search modal').to.not.exist;
});
it('finds posts, pages, users, and tags when typing', async function () {
await visit('/dashboard');
await click('[data-test-button="search"]');
await typeInSearch('first'); // search is not case sensitive
// all groups are present
const groupNames = findAll('.ember-power-select-group-name');
expect(groupNames, 'group names').to.have.length(4);
expect(groupNames.map(el => el.textContent.trim())).to.deep.equal(['Posts', 'Pages', 'Users', 'Tags']);
// correct results are found
const options = findAll('.ember-power-select-option');
expect(options, 'number of search results').to.have.length(4);
expect(options.map(el => el.textContent.trim())).to.deep.equal(['First post', 'First page', 'First user', 'First tag']);
// first item is selected
expect(options[0]).to.have.attribute('aria-current', 'true');
});
it('up/down arrows move selected item', async function () {
await visit('/dashboard');
await click('[data-test-button="search"]');
await typeInSearch('first');
expect(findAll('.ember-power-select-option')[0], 'first option (initial)').to.have.attribute('aria-current', 'true');
await triggerKeyEvent(trigger, 'keyup', 'ArrowDown');
expect(findAll('.ember-power-select-option')[0], 'second option (after down)').to.have.attribute('aria-current', 'true');
await triggerKeyEvent(trigger, 'keyup', 'ArrowUp');
expect(findAll('.ember-power-select-option')[0], 'first option (after up)').to.have.attribute('aria-current', 'true');
});
it('navigates to editor when post selected (Enter)', async function () {
await visit('/dashboard');
await click('[data-test-button="search"]');
await typeInSearch('first');
await triggerKeyEvent(trigger, 'keydown', 'Enter');
expect(currentURL(), 'url after selecting post').to.equal(`/editor/post/${firstPost.id}`);
});
it('navigates to editor when post selected (Clicked)', async function () {
await visit('/dashboard');
await click('[data-test-button="search"]');
await typeInSearch('first');
await click('.ember-power-select-option[aria-current="true"]');
expect(currentURL(), 'url after selecting post').to.equal(`/editor/post/${firstPost.id}`);
});
it('navigates to editor when page selected', async function () {
await visit('/dashboard');
await click('[data-test-button="search"]');
await typeInSearch('page');
await triggerKeyEvent(trigger, 'keydown', 'Enter');
expect(currentURL(), 'url after selecting page').to.equal(`/editor/page/${firstPage.id}`);
});
it('navigates to tag edit screen when tag selected', async function () {
await visit('/dashboard');
await click('[data-test-button="search"]');
await typeInSearch('tag');
await triggerKeyEvent(trigger, 'keydown', 'Enter');
expect(currentURL(), 'url after selecting tag').to.equal(`/tags/${firstTag.slug}`);
});
// TODO: Staff settings are now part of AdminX so this isn't working, can we test AdminX from Ember tests?
it.skip('navigates to user edit screen when user selected', async function () {
await visit('/dashboard');
await click('[data-test-button="search"]');
await typeInSearch('user');
await triggerKeyEvent(trigger, 'keydown', 'Enter');
expect(currentURL(), 'url after selecting user').to.equal(`/settings/staff/${firstUser.slug}`);
});
it('shows no results message when no results', async function () {
await visit('/dashboard');
await click('[data-test-button="search"]');
await typeInSearch('x');
expect(find('.ember-power-select-option--no-matches-message'), 'no results message').to.contain.text('No results found');
});
});