mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-23 22:11:09 +03:00
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:
parent
145a184967
commit
d6e599dab3
@ -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}}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
})
|
||||
);
|
||||
|
||||
|
@ -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
|
||||
|
145
ghost/admin/tests/acceptance/search-test.js
Normal file
145
ghost/admin/tests/acceptance/search-test.js
Normal 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');
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user