diff --git a/app/controllers/search.js b/app/controllers/search.js index b666880eba3..7558f097661 100644 --- a/app/controllers/search.js +++ b/app/controllers/search.js @@ -7,6 +7,7 @@ import { restartableTask } from 'ember-concurrency'; import { bool, reads } from 'macro-decorators'; import { pagination } from '../utils/pagination'; +import { CATEGORY_PREFIX, processSearchQuery } from '../utils/search'; export default class SearchController extends Controller { @service store; @@ -48,6 +49,11 @@ export default class SearchController extends Controller { @bool('totalItems') hasItems; + get hasMultiCategoryFilter() { + let tokens = this.q.trim().split(/\s+/); + return tokens.filter(token => token.startsWith(CATEGORY_PREFIX)).length > 1; + } + @action fetchData() { this.dataTask.perform().catch(() => { // we ignore errors here because they are handled in the template already @@ -61,6 +67,10 @@ export default class SearchController extends Controller { q = q.trim(); } - return yield this.store.query('crate', { all_keywords, page, per_page, q, sort }); + let searchOptions = all_keywords + ? { page, per_page, sort, q, all_keywords } + : { page, per_page, sort, ...processSearchQuery(q) }; + + return yield this.store.query('crate', searchOptions); } } diff --git a/app/styles/application.module.css b/app/styles/application.module.css index cb85369db02..86a5acf81ae 100644 --- a/app/styles/application.module.css +++ b/app/styles/application.module.css @@ -6,6 +6,18 @@ --grey200: hsl(200, 17%, 96%); --green800: hsl(115, 31%, 31%); --green900: hsl(115, 31%, 21%); + + --orange-50: #fff7ed; + --orange-100: #ffedd5; + --orange-200: #fed7aa; + --orange-300: #fdba74; + --orange-400: #fb923c; + --orange-500: #f97316; + --orange-600: #ea580c; + --orange-700: #c2410c; + --orange-800: #9a3412; + --orange-900: #7c2d12; + --yellow500: hsl(44, 100%, 60%); --yellow700: hsl(44, 67%, 50%); diff --git a/app/styles/search.module.css b/app/styles/search.module.css index 84c5d76e6be..2c3df955803 100644 --- a/app/styles/search.module.css +++ b/app/styles/search.module.css @@ -5,6 +5,15 @@ margin-bottom: 25px; } +.warning { + margin: 0 0 16px; + padding: 8px; + color: var(--orange-700); + background: var(--orange-100); + border-left: solid var(--orange-400) 4px; + border-radius: 2px; +} + .sort-by-label { composes: small from './shared/typography.module.css'; } diff --git a/app/templates/search.hbs b/app/templates/search.hbs index 349d52764a1..c371a7311ae 100644 --- a/app/templates/search.hbs +++ b/app/templates/search.hbs @@ -8,6 +8,12 @@ data-test-header /> +{{#if this.hasMultiCategoryFilter}} +
+ Support for using multiple category: filters is not yet implemented. +
+{{/if}} + {{#if this.firstResultPending}}

Loading search results...

{{else if this.dataTask.lastComplete.error}} diff --git a/app/utils/search.js b/app/utils/search.js new file mode 100644 index 00000000000..610406cc4ec --- /dev/null +++ b/app/utils/search.js @@ -0,0 +1,45 @@ +export const CATEGORY_PREFIX = 'category:'; +const KEYWORD_PREFIX = 'keyword:'; + +/** + * Process a search query string and extract filters like `keyword:`. + * + * @param {string} query + * @return {{ q: string, keyword?: string, all_keywords?: string, category?: string }} + */ +export function processSearchQuery(query) { + let tokens = query.trim().split(/\s+/); + + let queries = []; + let keywords = []; + let category = null; + for (let token of tokens) { + if (token.startsWith(CATEGORY_PREFIX)) { + let value = token.slice(CATEGORY_PREFIX.length).trim(); + if (value) { + category = value; + } + } else if (token.startsWith(KEYWORD_PREFIX)) { + let value = token.slice(KEYWORD_PREFIX.length).trim(); + if (value) { + keywords.push(value); + } + } else { + queries.push(token); + } + } + + let result = { q: queries.join(' ') }; + + if (keywords.length === 1) { + result.keyword = keywords[0]; + } else if (keywords.length !== 0) { + result.all_keywords = keywords.join(' '); + } + + if (category) { + result.category = category; + } + + return result; +} diff --git a/tests/acceptance/search-test.js b/tests/acceptance/search-test.js index 5ab206ac6b2..99fd9ed9041 100644 --- a/tests/acceptance/search-test.js +++ b/tests/acceptance/search-test.js @@ -191,4 +191,42 @@ module('Acceptance | search', function (hooks) { await visit('/search?q=rust&page=3&per_page=15&sort=new&all_keywords=fire ball'); assert.verifySteps(['/api/v1/crates']); }); + + test('supports `keyword:bla` filters', async function (assert) { + this.server.get('/api/v1/crates', function (schema, request) { + assert.step('/api/v1/crates'); + + assert.deepEqual(request.queryParams, { + all_keywords: 'fire ball', + page: '3', + per_page: '15', + q: 'rust', + sort: 'new', + }); + + return { crates: [], meta: { total: 0 } }; + }); + + await visit('/search?q=rust keyword:fire keyword:ball&page=3&per_page=15&sort=new'); + assert.verifySteps(['/api/v1/crates']); + }); + + test('`all_keywords` query parameter takes precedence over `keyword` filters', async function (assert) { + this.server.get('/api/v1/crates', function (schema, request) { + assert.step('/api/v1/crates'); + + assert.deepEqual(request.queryParams, { + all_keywords: 'fire ball', + page: '3', + per_page: '15', + q: 'rust keywords:foo', + sort: 'new', + }); + + return { crates: [], meta: { total: 0 } }; + }); + + await visit('/search?q=rust keywords:foo&page=3&per_page=15&sort=new&all_keywords=fire ball'); + assert.verifySteps(['/api/v1/crates']); + }); }); diff --git a/tests/utils/search-test.js b/tests/utils/search-test.js new file mode 100644 index 00000000000..ba59b68ac1d --- /dev/null +++ b/tests/utils/search-test.js @@ -0,0 +1,24 @@ +import { module, test } from 'qunit'; + +import { processSearchQuery } from '../../utils/search'; + +module('processSearchQuery()', function () { + const TESTS = [ + ['foo', { q: 'foo' }], + [' foo bar ', { q: 'foo bar' }], + ['foo keyword:bar', { q: 'foo', keyword: 'bar' }], + ['foo keyword:', { q: 'foo' }], + ['keyword:bar foo', { q: 'foo', keyword: 'bar' }], + ['foo \t keyword:bar baz', { q: 'foo baz', keyword: 'bar' }], + ['foo keyword:bar keyword:baz', { q: 'foo', all_keywords: 'bar baz' }], + ['foo category:', { q: 'foo' }], + ['foo category:no-std', { q: 'foo', category: 'no-std' }], + ['foo category:no-std keyword:bar keyword:baz', { q: 'foo', all_keywords: 'bar baz', category: 'no-std' }], + ]; + + for (let [input, expectation] of TESTS) { + test(input, function (assert) { + assert.deepEqual(processSearchQuery(input), expectation); + }); + } +});