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);
+ });
+ }
+});