Commit d711fc55 authored by Randall Leeds's avatar Randall Leeds

Merge pull request #1326 from hypothesis/simplified-search

Simplified search
parents 54af6f72 b323b4b0
No preview for this file type
This diff is collapsed.
No preview for this file type
No preview for this file type
This diff is collapsed.
@font-face {
font-family: 'h';
src:url('fonts/h.eot');
src:url('fonts/h.eot?#iefix') format('embedded-opentype'),
url('fonts/h.woff') format('woff'),
url('fonts/h.ttf') format('truetype'),
url('fonts/h.svg#h') format('svg');
src:url('fonts/h.eot?jsvc9t');
src:url('fonts/h.eot?#iefixjsvc9t') format('embedded-opentype'),
url('fonts/h.woff?jsvc9t') format('woff'),
url('fonts/h.ttf?jsvc9t') format('truetype'),
url('fonts/h.svg?jsvc9t#h') format('svg');
font-weight: normal;
font-style: normal;
}
......@@ -245,3 +245,6 @@
.icon-link:before {
content: "\e60a";
}
.icon-search:before {
content: "\e60c";
}
......@@ -2,16 +2,9 @@ imports = [
'bootstrap'
'h.helpers'
'h.socket'
'h.streamfilter'
'h.searchfilters'
]
SEARCH_FACETS = ['text', 'tags', 'uri', 'quote', 'since', 'user', 'results']
SEARCH_VALUES =
group: ['Public', 'Private'],
since: ['5 min', '30 min', '1 hour', '12 hours',
'1 day', '1 week', '1 month', '1 year']
class App
scope:
frame:
......@@ -47,8 +40,6 @@ class App
socialView: annotator.socialView
ongoingHighlightSwitch: false
search:
facets: SEARCH_FACETS
values: SEARCH_VALUES
query: $location.search()
show: not angular.equals($location.search(), {})
session: session
......@@ -228,38 +219,35 @@ class App
$rootScope.applySort "Location"
$scope.query = $location.search()
$scope.search = {}
$scope.search.update = angular.noop
$scope.search.clear = angular.noop
#$scope.show_search = Object.keys($scope.query).length > 0
$rootScope.$on '$routeChangeSuccess', (event, next, current) ->
unless next.$$route? then return
$scope.search.query = $location.search()
$scope.search.show = not angular.equals($location.search(), {})
if next.$$route.originalPath is '/viewer'
$rootScope.viewState.show = true
else
$rootScope.viewState.show = false
unless next.$$route.originalPath is '/stream'
if current and next.$$route.originalPath is '/a/:id'
$scope.reloadAnnotations()
$scope.search.update = (searchCollection) ->
return unless annotator.discardDrafts()
return unless searchCollection.models.length
models = searchCollection.models
matched = []
query =
tags: []
quote: []
for item in models
{category, value} = item.attributes
# Stuff we need to collect
switch
when category in ['text', 'user', 'time', 'group']
query[category] = value
when category == 'tags'
# Tags are specials, because we collect those into an array
query.tags.push value.toLowerCase()
when category == 'quote'
query.quote = query.quote.concat(value.split(/\s+/))
query = {query: searchCollection}
unless angular.equals $location.search(), query
if $location.path() == '/viewer'
if $location.path() == '/viewer' or $location.path() == '/page_search'
$location.path('/page_search').search(query)
else
$location.path('/stream').search(query)
......@@ -846,12 +834,18 @@ class Search
refresh()
refresh = =>
$scope.matches = viewFilter.filter $rootScope.annotations, $routeParams
[$scope.matches, $scope.filters] = viewFilter.filter $rootScope.annotations, $routeParams
# Create the regexps for highlighting the matches inside the annotations' bodies
$scope.text_tokens = $routeParams.text?.split(/\s+/) or []
$scope.text_tokens = $scope.filters.text.terms.slice()
$scope.text_regexp = []
$scope.quote_tokens = $routeParams.quote
$scope.quote_tokens = $scope.filters.quote.terms.slice()
$scope.quote_regexp = []
# Highligh any matches
for term in $scope.filters.any.terms
$scope.text_tokens.push term
$scope.quote_tokens.push term
# Saving the regexps and higlighter to the annotator for highlighttext regeneration
for token in $scope.text_tokens
regexp = new RegExp(token,"ig")
......
......@@ -326,11 +326,10 @@ username = ['$filter', '$window', ($filter, $window) ->
link: (scope, elem, attr) ->
scope.$watch 'user', ->
scope.uname = $filter('persona')(scope.user, 'username')
scope.provider = $filter('persona')(scope.user, 'provider')
scope.uclick = (event) ->
event.preventDefault()
$window.open "/u/#{scope.uname}@#{scope.provider}"
$window.open "/u/#{scope.uname}"
return
scope:
......@@ -399,44 +398,34 @@ fuzzytime = ['$filter', '$window', ($filter, $window) ->
]
visualSearch = ['$parse', ($parse) ->
simpleSearch = ['$parse', ($parse) ->
link: (scope, elem, attr, ctrl) ->
_search = $parse(attr.onsearch)
_clear = $parse(attr.onclear)
_facets = $parse(attr.facets)
_values = $parse(attr.values)
_vs = VS.init
container: elem
callbacks:
search: (query, modelCollection) ->
scope.$apply ->
_search(scope, {"this": modelCollection})
clearSearch: (original) ->
_vs.searchBox.value('')
if attr.onclear
scope.$apply ->
_clear(scope)
else
original()
facetMatches: (callback) ->
facets = _facets(scope) or []
callback(facets or [], preserveOrder: true)
valueMatches: (facet, term, callback) ->
values = _values(scope)?[facet]
callback(values or [], preserveOrder: true)
scope.dosearch = ->
_search(scope, {"this": scope.searchtext})
scope.reset = (event) ->
event.preventDefault()
scope.searchtext = ''
_clear(scope) if attr.onclear
scope.$watch attr.query, (query) ->
p = 0
_vs.searchBox.value('')
for k, values of query
continue unless values?.length
unless angular.isArray values then values = [values]
for v in values
_vs.searchBox.addFacet(k, v, p++)
_search(scope, {"this": _vs.searchQuery})
if query.query?
scope.searchtext = query.query
_search(scope, {"this": scope.searchtext})
restrict: 'C'
template: '''
<form class="simple-search-form" ng-class="!searchtext && 'simple-search-inactive'" name="searchBox" ng-submit="dosearch()">
<input class="simple-search-input" type="text" ng-model="searchtext" name="searchText" placeholder="Search…" />
<i class="simple-search-icon icon-search"></i>
<button class="simple-search-clear" type="reset" ng-hide="!searchtext" ng-click="reset($event)">
<i class="icon-x"></i>
</button>
</form>
'''
]
whenscrolled = ['$window', ($window) ->
......@@ -462,5 +451,5 @@ angular.module('h.directives', ['ngSanitize'])
.directive('username', username)
.directive('userPicker', userPicker)
.directive('repeatAnim', repeatAnim)
.directive('visualSearch', visualSearch)
.directive('simpleSearch', simpleSearch)
.directive('whenscrolled', whenscrolled)
......@@ -4,7 +4,7 @@ imports = [
'h.directives'
'h.helpers'
'h.socket'
'h.streamfilter'
'h.searchfilters'
]
......
# This class will parse the search filter and produce a faceted search filter object
# It expects a search query string where the search term are separated by space character
# and collects them into the given term arrays
class SearchFilter
# This function will slice the search-text input
# Slice character: space,
# but an expression between quotes (' or ") is considered one
# I.e from the string: "text user:john 'to be or not to be' it will produce:
# ["text", "user:john", "to be or not to be"]
_tokenize: (searchtext) ->
return [] unless searchtext
tokens = searchtext.match /(?:[^\s"']+|"[^"]*"|'[^']*')+/g
# Cut the opening and closing quote characters
for token, index in tokens
start = token.slice 0,1
end = token.slice -1
if (start is '"' or start is "'") and (start is end)
tokens[index] = token.slice 1, token.length - 1
tokens
# This function will generate the facets from the search-text input
# It'll first tokenize it and then sorts them into facet lists
# The output will be a dict with the following structure:
# An object with facet_names as keys.
# A value for a key:
# [facet_name]:
# [operator]: 'and'|'or'|'min' (for the elements of the facet terms list)
# [lowercase]: true|false
# [terms]: an array for the matched terms for this facet
# The facet selection is done by analyzing each token.
# It generally expects a <facet_name>:<facet_term> structure for a token
# Where the facet names are: 'quote', 'result', 'since', 'tag', 'text', 'uri', 'user
# Anything that didn't match go to the 'any' facet
# For the 'since' facet the the time string is scanned and is converted to seconds
# So i.e the 'since:7min' token will be converted to 7*60 = 420 for the since facet value
generateFacetedFilter: (searchtext) ->
any = []
quote = []
result = []
since = []
tag = []
text = []
uri = []
user = []
if searchtext
terms = @_tokenize(searchtext)
for term in terms
filter = term.slice 0, term.indexOf ":"
unless filter? then filter = ""
switch filter
when 'quote' then quote.push term[6..]
when 'result' then result.push term[7..]
when 'since'
# We'll turn this into seconds
time = term[6..].toLowerCase()
if time.match /^\d+$/
# Only digits, assuming seconds
since.push time
if time.match /^\d+sec$/
# Time given in seconds
t = /^(\d+)sec$/.exec(time)[1]
since.push t
if time.match /^\d+min$/
# Time given in minutes
t = /^(\d+)min$/.exec(time)[1]
since.push t * 60
if time.match /^\d+hour$/
# Time given in hours
t = /^(\d+)hour$/.exec(time)[1]
since.push t * 60 * 60
if time.match /^\d+day$/
# Time given in days
t = /^(\d+)day$/.exec(time)[1]
since.push t * 60 * 60 * 24
if time.match /^\d+week$/
# Time given in week
t = /^(\d+)week$/.exec(time)[1]
since.push t * 60 * 60 * 24 * 7
if time.match /^\d+month$/
# Time given in month
t = /^(\d+)month$/.exec(time)[1]
since.push t * 60 * 60 * 24 * 30
if time.match /^\d+year$/
# Time given in year
t = /^(\d+)year$/.exec(time)[1]
since.push t * 60 * 60 * 24 * 365
when 'tag' then tag.push term[4..]
when 'text' then text.push term[5..]
when 'uri' then uri.push term[4..]
when 'user' then user.push term[5..]
else any.push term
any:
terms: any
operator: 'and'
lowercase: true
quote:
terms: quote
operator: 'and'
lowercase: true
result:
terms: result
operator: 'min'
lowercase: false
since:
terms: since
operator: 'and'
lowercase: false
tag:
terms: tag
operator: 'and'
lowercase: true
text:
terms: text
operator: 'and'
lowercase: true
uri:
terms: uri
operator: 'or'
lowercase: true
user:
terms: user
operator: 'or'
lowercase: true
# This class will process the results of search and generate the correct filter
# It expects the following dict format as rules
# { facet_name : {
......@@ -10,10 +140,12 @@
#
# options: backend specific options
# options.es: elasticsearch specific options
# options.es.query_type : can be: simple, query_string, match
# options.es.query_type : can be: simple, query_string, match, multi_match
# defaults to: simple, determines which es query type to use
# options.es.cutoff_frequency: if set, the query will be given a cutoff_frequency for this facet
# options.es.and_or: match queries can use this, defaults to and
# options.es.and_or: match and multi_match queries can use this, defaults to and
# options.es.match_type: multi_match query type
# options.es.fields: fields to search for in multi-match query
# }
# The models is the direct output from visualsearch
class QueryParser
......@@ -70,6 +202,18 @@ class QueryParser
case_sensitive: true
and_or: 'and'
operator: 'ge'
any:
exact_match: false
case_sensitive: false
and_or: 'and'
path: ['/quote', '/tags', '/text', '/uri', '/user']
options:
es:
query_type: 'multi_match'
match_type: 'cross_fields'
and_or: 'and'
fields: ['quote', 'tags', 'text', 'uri', 'user']
parseModels: (models) ->
# Cluster facets together
......@@ -85,13 +229,12 @@ class QueryParser
populateFilter: (filter, query) =>
# Populate a filter with a query object
for category, values of query
for category, value of query
unless @rules[category]? then continue
unless values.length then continue
terms = value.terms
unless terms.length then continue
rule = @rules[category]
unless angular.isArray values
values = [values]
# Now generate the clause with the help of the rule
exact_match = if rule.exact_match? then rule.exact_match else true
......@@ -102,7 +245,7 @@ class QueryParser
if and_or is 'or'
val_list = ''
first = true
for val in values
for val in terms
unless first then val_list += ',' else first = false
value_part = if rule.formatter then rule.formatter val else val
val_list += value_part
......@@ -114,7 +257,7 @@ class QueryParser
oper_part =
if rule.operator? then rule.operator
else if exact_match then 'equals' else 'matches'
for val in values
for val in terms
value_part = if rule.formatter then rule.formatter val else val
filter.addClause mapped_field, oper_part, value_part, case_sensitive, rule.options
......@@ -220,6 +363,7 @@ class StreamFilter
this
angular.module('h.streamfilter', [])
angular.module('h.searchfilters', [])
.service('searchfilter', SearchFilter)
.service('queryparser', QueryParser)
.service('streamfilter', StreamFilter)
This diff is collapsed.
......@@ -6,23 +6,17 @@ imports = [
'h.flash'
'h.helpers'
'h.session'
'h.streamfilter'
'h.searchfilters'
]
SEARCH_FACETS = ['text', 'tags', 'uri', 'quote', 'since', 'user', 'results']
SEARCH_VALUES =
group: ['Public', 'Private'],
since: ['5 min', '30 min', '1 hour', '12 hours',
'1 day', '1 week', '1 month', '1 year']
class StreamSearch
this.inject = [
'$location', '$scope', '$rootScope',
'queryparser', 'session', 'streamfilter'
'queryparser', 'session', 'searchfilter', 'streamfilter'
]
constructor: (
$location, $scope, $rootScope,
queryparser, session, streamfilter
queryparser, session, searchfilter, streamfilter
) ->
# Initialize the base filter
streamfilter
......@@ -31,7 +25,9 @@ class StreamSearch
.setPastDataHits(50)
# Apply query clauses
queryparser.populateFilter streamfilter, $location.search()
$scope.query = $location.search()['query']
terms = searchfilter.generateFacetedFilter $scope.query
queryparser.populateFilter streamfilter, terms
$scope.updater?.then (sock) ->
filter = streamfilter.getFilter()
......@@ -44,11 +40,9 @@ class StreamSearch
$scope.search.query = $location.search()
$scope.search.show = not angular.equals($location.search(), {})
$scope.search.update = (searchCollection) ->
# Update the query parameters
query = queryparser.parseModels searchCollection.models
unless angular.equals $location.search(), query
$location.search query
$scope.search.update = (query) ->
unless angular.equals $location.search(query), query
$location.search {query:query}
$scope.search.clear = ->
$location.search({})
......@@ -66,6 +60,4 @@ class StreamSearch
angular.module('h.streamsearch', imports, configure)
.constant('searchFacets', SEARCH_FACETS)
.constant('searchValues', SEARCH_VALUES)
.controller('StreamSearchController', StreamSearch)
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -160,6 +160,42 @@ $input-border-radius: 2px;
background-image: url("../images/#{$icon}");
}
//FONTICON////////////////////////////////
@mixin fonticon($char, $iconside, $offset: .5em) {
text-decoration: none;
cursor: pointer;
&:hover {
opacity: 1;
}
@if $iconside == left {
&:before {
content: $char !important;
font-family: 'icomoon';
margin-right: $offset;
speak: none;
font-weight: normal;
@content; // Allow additonal styles for the icon.
}
&:after {
content: "" !important;
}
}
@if $iconside == right {
&:before {
content: "" !important;
}
&:after {
content: $char !important;
font-family: 'icomoon';
margin-left: $offset;
speak: none;
font-weight: normal;
}
}
}
//NOISE///////////
//Provides the noise background
.noise {
......
......@@ -6,6 +6,7 @@
@import 'spinner';
@import 'responsive';
@import 'yui_grid';
@import 'simple-search';
$base-font-size: 16px;
......@@ -41,8 +42,8 @@ p {
em { font-style: italic; }
html {
font-size: $base-font-size / 16px * 1em;
line-height: $base-line-height / 16px * 1em;
font-size: $base-font-size;
line-height: $base-line-height;
@include yui_grid();
......@@ -350,6 +351,61 @@ blockquote {
//ICON CLASSES////////////////////////////////
.flag-icon {
@include fonticon("\28", left);
}
.fave-icon {
@include fonticon("\e006", left);
&.checked:before {
content: "\e005";
}
}
.reply-icon {
@include fonticon("\e004", left);
}
.share-icon {
@include fonticon("\25", left);
}
.down-icon {
@include fonticon("\e007", left);
}
.clipboard-icon {
@include fonticon("\33", left);
}
.check-icon {
@include fonticon("\35", left);
}
.plus-icon {
@include fonticon("\e012", left);
}
.x-icon {
@include fonticon("\36", left);
}
.vis-icon {
@include fonticon("\e001", left);
}
.highlight-icon {
@include fonticon("\e601", left);
}
.comment-icon {
@include fonticon("\e600", left);
}
.launch-icon {
@include fonticon("\2a", left);
}
.loading-icon {
text-align: center
}
......@@ -752,53 +808,6 @@ pre {outline: 1px solid #ccc; padding: 5px; margin: 5px; }
}
}
//Visual Search////////////////////////////////
//Override their css here
.VS-search {
overflow: hidden;
* {
@include box-sizing(content-box);
}
.search_facet input,
.search_input input,
.VS-input-width-tester {
@include box-shadow(none);
border: 0;
}
.VS-search-box {
border: 0;
height: 26px;
margin: 1px 0;
min-height: 26px;
}
&.VS-search-collapsed .VS-search-box {
@include box-shadow(none);
background: inherit;
}
.VS-icon-cancel {
background-image: url(../images/svg/cancel.svg);
background-size: contain;
&:hover {
background-image: url(../images/svg/cancel_black.svg);
background-position: center 0;
}
}
.VS-icon-search {
background-image: url(../images/svg/search.svg);
background-size: contain;
&:hover {
background-image: url(../images/svg/search_dark.svg);
cursor: pointer;
}
}
}
// View and Sort tabs ////////////////////
.viewsort {
@include single-transition(top, .25s);
......
@import "base.scss";
.simple-search {
overflow: hidden;
}
.simple-search-form {
position: relative;
color: $gray;
}
.simple-search-icon {
position: absolute;
font-size: 1.1em;
top: 50%;
left: .6em;
margin-top: -.5em;
pointer-events: none;
:not(:focus) ~ & {
color: $gray-lighter;
}
}
.simple-search-input {
outline: none;
color: $text-color;
line-height: 24px;
width: 100%;
border-radius: 2em;
border: 1px solid $gray-lighter !important; // Override base input styles.
padding: 0 2em;
.simple-search-inactive &:not(:focus) {
border-color: #f3f3f3 !important;
}
}
.simple-search-clear {
[class^="icon-"], [class*=" icon-"] {
position: absolute;
top: 50%;
left: 50%;
margin-top: -.5em;
margin-left: -.5em;
font-size: .7em;
}
position: absolute;
outline: none;
right: .35em;
top: 50%;
border: none;
border-radius: 50%;
background: $gray-light;
color: #fff;
width: 1.33em;
height: 1.33em;
padding: 0;
margin-top: -.65em;
@include box-shadow(none);
&:focus, &:hover, &:active:not([disabled]) {
@include transition(background-color 200ms ease-in);
background-color: $gray-light;
color: #fff;
border: none;
background-image: none;
box-shadow: none;
}
}
......@@ -57,6 +57,9 @@ body {
font-family: $sans-font-family;
}
& > * { margin: 0 .72em; }
& > * {
line-height: 28px;
margin: 0 .72em;
}
}
}
This diff is collapsed.
......@@ -54,9 +54,6 @@ module.exports = function(config) {
'h/static/scripts/vendor/jquery.ui.effect-highlight.js',
'h/static/scripts/vendor/tag-it.js',
'h/static/scripts/vendor/uuid.js',
'h/static/scripts/vendor/underscore-1.4.3.js',
'h/static/scripts/vendor/backbone-0.9.10.js',
'h/static/scripts/vendor/visualsearch.js',
'h/static/scripts/hypothesis.js',
'h/static/scripts/vendor/sinon.js',
'h/static/scripts/vendor/chai.js',
......
......@@ -112,7 +112,7 @@ describe 'h.directives', ->
it 'opens a new window for the user when clicked', ->
$element.find('.user').click()
sinon.assert.calledWith(fakeWindow.open, '/u/bill@127.0.0.1')
sinon.assert.calledWith(fakeWindow.open, '/u/bill')
it 'prevents the default browser action on click', ->
event = jQuery.Event('click')
......@@ -129,6 +129,56 @@ describe 'h.directives', ->
text = $element.find('.user').text()
assert.equal(text, 'jim')
it 'keeps the url in sync', ->
it 'opens with only the username', ->
$element.find('.user').click()
sinon.assert.calledWith(fakeWindow.open, '/u/jim@hypothesis')
sinon.assert.calledWith(fakeWindow.open, '/u/jim')
describe '.simpleSearch', ->
$element = null
beforeEach ->
$scope.query = {}
$scope.update = sinon.spy()
$scope.clear = sinon.spy()
template= '''
<div class="simpleSearch"
query="query"
onsearch="update(this)"
onclear="clear()">
</div>
'''
$element = $compile(angular.element(template))($scope)
$scope.$digest()
it 'updates the search-bar', ->
$scope.query = {query: "Test query"}
$scope.$digest()
assert.equal($scope.searchtext, $scope.query.query)
it 'calls the given search function', ->
$scope.query = {query: "Test query"}
$scope.$digest()
$element.trigger('submit')
sinon.assert.calledWith($scope.update, "Test query")
it 'calls the given clear function', ->
$element.find('.simple-search-clear').click()
assert($scope.clear.called)
it 'clears the search-bar', ->
$scope.query = {query: "Test query"}
$scope.$digest()
$element.find('.simple-search-clear').click()
assert.equal($scope.searchtext, '')
it 'adds a class to the form when there is no input value', ->
$form = $element.find('.simple-search-form')
assert.include($form.prop('className'), 'simple-search-inactive')
it 'removes the class from the form when there is an input value', ->
$scope.query = {query: "Test query"}
$scope.$digest()
$form = $element.find('.simple-search-form')
assert.notInclude($form.prop('className'), 'simple-search-inactive')
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment