diff --git a/addons/web/__openerp__.py b/addons/web/__openerp__.py index 26ee5613dc5..417fea91434 100644 --- a/addons/web/__openerp__.py +++ b/addons/web/__openerp__.py @@ -36,19 +36,6 @@ "static/lib/underscore/underscore.string.js", "static/lib/backbone/backbone.js", - "static/lib/visualsearch/lib/js/visualsearch.js", - "static/lib/visualsearch/lib/js/utils/backbone_extensions.js", - "static/lib/visualsearch/lib/js/utils/hotkeys.js", - "static/lib/visualsearch/lib/js/utils/inflector.js", - "static/lib/visualsearch/lib/js/utils/jquery_extensions.js", - "static/lib/visualsearch/lib/js/utils/search_parser.js", - "static/lib/visualsearch/lib/js/models/search_facets.js", - "static/lib/visualsearch/lib/js/models/search_query.js", - "static/lib/visualsearch/lib/js/templates/templates.js", - "static/lib/visualsearch/lib/js/views/search_facet.js", - "static/lib/visualsearch/lib/js/views/search_input.js", - "static/lib/visualsearch/lib/js/views/search_box.js", - "static/lib/labjs/LAB.src.js", "static/lib/py.js/lib/py.js", "static/src/js/boot.js", @@ -74,9 +61,6 @@ "static/lib/jquery.ui.timepicker/css/jquery-ui-timepicker-addon.css", "static/lib/jquery.ui.notify/css/ui.notify.css", "static/lib/jquery.tipsy/tipsy.css", - "static/lib/visualsearch/lib/css/reset.css", - "static/lib/visualsearch/lib/css/workspace.css", - "static/lib/visualsearch/lib/css/icons.css", # "static/src/css/base_old.css", "static/src/css/base.css", "static/src/css/data_export.css", diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index 12ef0b53524..6eb8ed94909 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -1287,25 +1287,6 @@ class SearchView(View): del filter['domain'] return filters - @openerpweb.jsonrequest - def save_filter(self, req, model, name, context_to_save, domain): - Model = req.session.model("ir.filters") - ctx = common.nonliterals.CompoundContext(context_to_save) - ctx.session = req.session - ctx = ctx.evaluate() - domain = common.nonliterals.CompoundDomain(domain) - domain.session = req.session - domain = domain.evaluate() - uid = req.session._uid - context = req.session.eval_context(req.context) - to_return = Model.create_or_replace({"context": ctx, - "domain": domain, - "model_id": model, - "name": name, - "user_id": uid - }, context) - return to_return - @openerpweb.jsonrequest def add_to_dashboard(self, req, menu_id, action_id, context_to_save, domain, view_mode, name=''): ctx = common.nonliterals.CompoundContext(context_to_save) diff --git a/addons/web/static/lib/visualsearch/LICENSE b/addons/web/static/lib/visualsearch/LICENSE deleted file mode 100755 index fe0ea1154c4..00000000000 --- a/addons/web/static/lib/visualsearch/LICENSE +++ /dev/null @@ -1,22 +0,0 @@ -Copyright (c) 2011 Samuel Clay, @samuelclay, DocumentCloud - -Permission is hereby granted, free of charge, to any person -obtaining a copy of this software and associated documentation -files (the "Software"), to deal in the Software without -restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/addons/web/static/lib/visualsearch/README b/addons/web/static/lib/visualsearch/README deleted file mode 100755 index c059a9824d6..00000000000 --- a/addons/web/static/lib/visualsearch/README +++ /dev/null @@ -1,16 +0,0 @@ - __ ___ _ _____ _ _ - \ \ / (_) | |/ ____| | | (_) - \ \ / / _ ___ _ _ __ _| | (___ ___ __ _ _ __ ___| |__ _ ___ - \ \/ / | / __| | | |/ _` | |\___ \ / _ \/ _` | '__/ __| '_ \ | / __| - \ / | \__ \ |_| | (_| | |____) | __/ (_| | | | (__| | | |_| \__ \ - \/ |_|___/\__,_|\__,_|_|_____/ \___|\__,_|_| \___|_| |_(_) |___/ - _/ | - |__/ - -VisualSearch.js enhances ordinary search boxes with the ability to autocomplete -faceted search queries. Specify the facets for completion, along with the -completable values for any facet. You can retrieve the search query as a -structured object, so you don't have to parse the query string yourself. - -For documentation, pre-packed downloads, demos, and tests, see: -http://documentcloud.github.com/visualsearch \ No newline at end of file diff --git a/addons/web/static/lib/visualsearch/Rakefile b/addons/web/static/lib/visualsearch/Rakefile deleted file mode 100755 index 192a50b944e..00000000000 --- a/addons/web/static/lib/visualsearch/Rakefile +++ /dev/null @@ -1,37 +0,0 @@ -require 'rubygems' -require 'jammit' -require 'fileutils' - -desc "Use Jammit to compile the multiple versions of Visual Search" -task :build do - $VS_MIN = false - Jammit.package!({ - :config_path => "assets.yml", - :output_folder => "build" - }) - - $VS_MIN = true - Jammit.package!({ - :config_path => "assets.yml", - :output_folder => "build-min" - }) - - # Move the JSTs back to lib to accomodate the demo page. - FileUtils.mv("build/visualsearch_templates.js", "lib/js/templates/templates.js") - - # Fix image url paths. - ['build', 'build-min'].each do |build| - File.open("#{build}/visualsearch.css", 'r+') do |file| - css = file.read - css.gsub!(/url\((.*?)images\/embed\/icons/, 'url(../images/embed/icons') - file.rewind - file.write(css) - file.truncate(css.length) - end - end -end - -desc "Build the docco documentation" -task :docs do - sh "docco lib/js/*.js lib/js/**/*.js" -end diff --git a/addons/web/static/lib/visualsearch/assets.yml b/addons/web/static/lib/visualsearch/assets.yml deleted file mode 100755 index 357c37c74aa..00000000000 --- a/addons/web/static/lib/visualsearch/assets.yml +++ /dev/null @@ -1,28 +0,0 @@ -embed_assets: datauri -javascript_compressor: closure -template_function: _.template -gzip_assets: <% if $VS_MIN %>on<% else %>off<% end %> -compress_assets: <% if $VS_MIN %>on<% else %>off<% end %> - -javascripts: - dependencies: - - vendor/jquery-*.js - - vendor/jquery.ui.core.js - - vendor/jquery.ui.widget.js - - vendor/jquery.ui.position.js - - vendor/jquery.ui.*.js - - vendor/underscore-*.js - - vendor/backbone-*.js - visualsearch: - - lib/js/visualsearch.js - - lib/js/views/*.js - - lib/js/utils/*.js - - lib/js/models/*.js - - lib/js/templates/*.jst - <% unless $VS_MIN %>visualsearch_templates: - - lib/js/templates/*.jst - <% end %> - -stylesheets: - visualsearch: - - lib/css/*.css \ No newline at end of file diff --git a/addons/web/static/lib/visualsearch/build/visualsearch-datauri.css b/addons/web/static/lib/visualsearch/build/visualsearch-datauri.css deleted file mode 100755 index 3e3edd0063f..00000000000 --- a/addons/web/static/lib/visualsearch/build/visualsearch-datauri.css +++ /dev/null @@ -1,310 +0,0 @@ -.VS-search .VS-icon { - background-repeat: no-repeat; - background-position: center center; - vertical-align: middle; - width: 16px; height: 16px; -} - .VS-search .VS-icon-cancel { - width: 11px; height: 11px; - background-position: center 0; - background-image: url("data:image/png;charset=utf-8;base64,iVBORw0KGgoAAAANSUhEUgAAAAsAAAAWCAYAAAAW5GZjAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAb9JREFUeNqUUr1qAkEQ3j0khQp6kihaeGgEEa18gTQR0iRY+BaBSMDGwidIEUKqFL6BopgqBAJ5AMFGjUU0d4WHEvwJarvZ77gRIzGYgb1hZr+Z75vZ40IIzqTNZrPj8Xicn0wmmcViEXS73aaqqq+BQODG6/W+A8MBNk3zfDAY3C6Xy0O2ZS6X6zMSiVwHg8FHLjtq7Xb7RQKj7BeTzVCgJ5PJU2U0GhUk7REuMpkMi8fjFggeMeecrVYrFRId0CgTAgDDMFg4HLbA8IjJgHNgGEr0er0fQIphUmZAwdSUADUB4RFDsz3oSMF6CLzZkQqgGebz+Z75dDqNdTqdp13bgDmdTj2VSp0oWHg0Gr2UNH2Z/9o+yMv7K4/HY/C/XhDUfr//jl7QQVT9fp/V63VWqVRYt9tliUSCZbPZg1wux9Lp9PqFeK1Wu9A0DdXz7YM87i0FrVZLs4Fi1wmFQh/NZjOmVKvVgq7rR/QflMtlixGedjwcDlUpMQ9tbzalkAAB2/R297mNW+sT2wUbUnA//V/nYrH4QOBNABUQuFQq3TNMuc82sDVrz41G42yvPeODAwZQ0QzwiJEnzLcAAwBJ6WXlwoBgZAAAAABJRU5ErkJggg=="); - cursor: pointer; - } - .VS-search .VS-icon-cancel:hover { - background-position: center -11px; - } - .VS-search .VS-icon-search { - width: 12px; height: 12px; - background-image: url("data:image/png;charset=utf-8;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAUZJREFUeNpUUM2qgmAQzS8NiUgLzTIXLZQW1QuI9AY9QPSW9gQ9QiriwpJQEBVrVWT2d7p2L9xZzDdzZs7M+YYqy/J8Ptu2vd/v4zgeDAaqqk4mE47jar9GnU6nzWbjOA5FUa/Xq0Jns9l8Pud5vkpp58cwAOzhcBhFkeu6GNztdg3D+Db5vo9nOp2iiWGYTqdDCMFe4LquI0aVpGmKR9M0lmUbjQY8YiBJklTb4YkoilBzOBzq9TogeMQIJEmqmlAlo9EIyXa7tSyrKAp4xEBkWUb5q2k8Hh+PR8/zwjCEgufz+aESstvtoKnVan2GgY31kBkEAfT1ej1FUZDiNIIgrFYr9H1ug3teLpfH43G/3/FBUJGu1+s8z8FZLpc0mmiabrfbf5fEumazuVgsTNO8Xq+3242qRNT+G0CMz7IMzH6//xZgAA60tj6rqzxpAAAAAElFTkSuQmCC"); - } - -/*------------------------------ RESET + DEFAULT STYLES ---------------------------------*/ - -/* -Eric Meyer's final reset.css -Source: http://meyerweb.com/eric/thoughts/2007/05/01/reset-reloaded/ -*/ -.VS-search div, .VS-search span, .VS-search a, .VS-search img, -.VS-search ul, .VS-search li, .VS-search form, .VS-search label, -.VS-interface ul, .VS-interface li, .VS-interface { - margin: 0; - padding: 0; - border: 0; - outline: 0; - font-weight: inherit; - font-style: inherit; - font-size: 100%; - font-family: inherit; - vertical-align: baseline; -} - -.VS-search :focus { - outline: 0; -} -.VS-search { - line-height: 1; - color: black; -} -.VS-search ol, .VS-search ul { - list-style: none; -} - -/* ===================== */ -/* = General and Reset = */ -/* ===================== */ - -.VS-search { - font-family: Arial, sans-serif; - color: #373737; - font-size: 12px; -} -.VS-search input { - display: block; - border: none; - -moz-box-shadow: none; - -webkit-box-shadow: none; - box-shadow: none; - outline: none; - margin: 0; padding: 4px; - background: transparent; - font-size: 16px; - line-height: 20px; - width: 100%; -} -.VS-interface, .VS-search .dialog, .VS-search input { - font-family: "Lucida Grande", "Lucida Sans Unicode", Helvetica, Arial, sans-serif !important; - line-height: 1.1em; -} - -/* ========== */ -/* = Layout = */ -/* ========== */ - -.VS-search .VS-search-box { - cursor: text; - position: relative; - background: transparent; - border: 2px solid #ccc; - border-radius: 16px; -webkit-border-radius: 16px; -moz-border-radius: 16px; - background-color: #fafafa; - -webkit-box-shadow: inset 0px 0px 3px #ccc; - -moz-box-shadow: inset 0px 0px 3px #ccc; - box-shadow: inset 0px 0px 3px #ccc; - min-height: 28px; - height: auto; -} - .VS-search .VS-search-box.VS-focus { - border-color: #acf; - -webkit-box-shadow: inset 0px 0px 3px #acf; - -moz-box-shadow: inset 0px 0px 3px #acf; - box-shadow: inset 0px 0px 3px #acf; - } - .VS-search .VS-search-inner { - position: relative; - margin: 0 20px 0 22px; - overflow: hidden; - } - .VS-search input { - width: 100px; - } - .VS-search input, - .VS-search .VS-input-width-tester { - padding: 6px 0; - float: left; - color: #808080; - font: 13px/17px Helvetica, Arial; - } - .VS-search.VS-focus input { - color: #606060; - } - .VS-search .VS-icon-search { - position: absolute; - left: 9px; top: 8px; - } - .VS-search .VS-icon-cancel { - position: absolute; - right: 9px; top: 8px; - } - -/* ================ */ -/* = Search Facet = */ -/* ================ */ - -.VS-search .search_facet { - float: left; - margin: 0; - padding: 0 0 0 14px; - position: relative; - border: 1px solid transparent; - height: 20px; - margin: 3px -3px 3px 0; -} - .VS-search .search_facet.is_selected { - margin-left: -3px; - -webkit-border-radius: 16px; - -moz-border-radius: 16px; - border-radius: 16px; - background-color: #d2e6fd; - background-image: -moz-linear-gradient(top, #d2e6fd, #b0d1f9); /* FF3.6 */ - background-image: -webkit-gradient(linear, left top, left bottom, from(#d2e6fd), to(#b0d1f9)); /* Saf4+, Chrome */ - background-image: linear-gradient(top, #d2e6fd, #b0d1f9); - border: 1px solid #6eadf5; - } - .VS-search .search_facet .category { - float: left; - text-transform: uppercase; - font-weight: bold; - font-size: 10px; - color: #808080; - padding: 8px 0 5px; - line-height: 13px; - cursor: pointer; - padding: 4px 0 0; - } - .VS-search .search_facet.is_selected .category { - margin-left: 3px; - } - .VS-search .search_facet .search_facet_input_container { - float: left; - } - .VS-search .search_facet input { - margin: 0; - padding: 0; - color: #000; - font-size: 13px; - line-height: 16px; - padding: 5px 0 5px 4px; - height: 16px; - width: auto; - z-index: 100; - position: relative; - padding-top: 1px; - padding-bottom: 2px; - padding-right: 3px; - - } - .VS-search .search_facet.is_editing input, - .VS-search .search_facet.is_selected input { - color: #000; - } - .VS-search .search_facet .search_facet_remove { - position: absolute; - left: 0; - top: 4px; - } - .VS-search .search_facet.is_selected .search_facet_remove { - opacity: 0.4; - left: 3px; - filter: alpha(opacity=40); - background-position: center -11px; - } - .VS-search .search_facet .search_facet_remove:hover { - opacity: 1; - } - .VS-search .search_facet.is_editing .category, - .VS-search .search_facet.is_selected .category { - color: #000; - } - .VS-search .search_facet.search_facet_maybe_delete .category, - .VS-search .search_facet.search_facet_maybe_delete input { - color: darkred; - } - -/* ================ */ -/* = Search Input = */ -/* ================ */ - -.VS-search .search_input { - height: 28px; - float: left; - margin-left: -1px; -} - .VS-search .search_input input { - padding: 6px 3px 6px 2px; - line-height: 10px; - height: 22px; - margin-top: -4px; - width: 10px; - z-index: 100; - min-width: 4px; - position: relative; - } - .VS-search .search_input.is_editing input { - color: #202020; - } - -/* ================ */ -/* = Autocomplete = */ -/* ================ */ - -.VS-interface.ui-autocomplete { - position: absolute; - border: 1px solid #C0C0C0; - border-top: 1px solid #D9D9D9; - background-color: #F6F6F6; - cursor: pointer; - z-index: 10000; - padding: 0; - margin: 0; - width: auto; - min-width: 80px; - max-width: 220px; - max-height: 240px; - overflow-y: auto; - overflow-x: hidden; - font-size: 13px; - top: 5px; - opacity: 0.97; - box-shadow: 3px 4px 5px -2px rgba(0, 0, 0, 0.5); -webkit-box-shadow: 3px 4px 5px -2px rgba(0, 0, 0, 0.5); -moz-box-shadow: 3px 4px 5px -2px rgba(0, 0, 0, 0.5); -} - .VS-interface.ui-autocomplete .ui-autocomplete-category { - text-transform: capitalize; - font-size: 11px; - padding: 4px 4px 4px; - border-top: 1px solid #A2A2A2; - border-bottom: 1px solid #A2A2A2; - background-color: #B7B7B7; - text-shadow: 0 -1px 0 #999; - font-weight: bold; - color: white; - cursor: default; - } - .VS-interface.ui-autocomplete .ui-menu-item { - float: none; - } - .VS-interface.ui-autocomplete .ui-menu-item a { - color: #000; - outline: none; - display: block; - padding: 3px 4px 5px; - border-radius: none; - line-height: 1; - background-color: #F8F8F8; - background-image: -moz-linear-gradient(top, #F8F8F8, #F3F3F3); /* FF3.6 */ - background-image: -webkit-gradient(linear, left top, left bottom, from(#F8F8F8), to(#F3F3F3)); /* Saf4+, Chrome */ - background-image: linear-gradient(top, #F8F8F8, #F3F3F3); - border-top: 1px solid #FAFAFA; - border-bottom: 1px solid #f0f0f0; - } - .VS-interface.ui-autocomplete .ui-menu-item a:active { - outline: none; - } - .VS-interface.ui-autocomplete .ui-menu-item .ui-state-hover { - background-color: #6483F7; - background-image: -moz-linear-gradient(top, #648bF5, #2465f3); /* FF3.6 */ - background-image: -webkit-gradient(linear, left top, left bottom, from(#648bF5), to(#2465f3)); /* Saf4+, Chrome */ - background-image: linear-gradient(top, #648bF5, #2465f3); - border-top: 1px solid #5b83ec; - border-bottom: 1px solid #1459e9; - border-left: none; - border-right: none; - color: white; - margin: 0; - } - .VS-interface.ui-autocomplete .ui-corner-all { - border-radius: 0; - } - .VS-interface.ui-autocomplete li { - list-style: none; - width: auto; - } diff --git a/addons/web/static/lib/visualsearch/build/visualsearch.css b/addons/web/static/lib/visualsearch/build/visualsearch.css deleted file mode 100755 index 079ff122300..00000000000 --- a/addons/web/static/lib/visualsearch/build/visualsearch.css +++ /dev/null @@ -1,310 +0,0 @@ -.VS-search .VS-icon { - background-repeat: no-repeat; - background-position: center center; - vertical-align: middle; - width: 16px; height: 16px; -} - .VS-search .VS-icon-cancel { - width: 11px; height: 11px; - background-position: center 0; - background-image: url(../images/embed/icons/cancel_search.png?1311104738); - cursor: pointer; - } - .VS-search .VS-icon-cancel:hover { - background-position: center -11px; - } - .VS-search .VS-icon-search { - width: 12px; height: 12px; - background-image: url(../images/embed/icons/search_glyph.png?1311104738); - } - -/*------------------------------ RESET + DEFAULT STYLES ---------------------------------*/ - -/* -Eric Meyer's final reset.css -Source: http://meyerweb.com/eric/thoughts/2007/05/01/reset-reloaded/ -*/ -.VS-search div, .VS-search span, .VS-search a, .VS-search img, -.VS-search ul, .VS-search li, .VS-search form, .VS-search label, -.VS-interface ul, .VS-interface li, .VS-interface { - margin: 0; - padding: 0; - border: 0; - outline: 0; - font-weight: inherit; - font-style: inherit; - font-size: 100%; - font-family: inherit; - vertical-align: baseline; -} - -.VS-search :focus { - outline: 0; -} -.VS-search { - line-height: 1; - color: black; -} -.VS-search ol, .VS-search ul { - list-style: none; -} - -/* ===================== */ -/* = General and Reset = */ -/* ===================== */ - -.VS-search { - font-family: Arial, sans-serif; - color: #373737; - font-size: 12px; -} -.VS-search input { - display: block; - border: none; - -moz-box-shadow: none; - -webkit-box-shadow: none; - box-shadow: none; - outline: none; - margin: 0; padding: 4px; - background: transparent; - font-size: 16px; - line-height: 20px; - width: 100%; -} -.VS-interface, .VS-search .dialog, .VS-search input { - font-family: "Lucida Grande", "Lucida Sans Unicode", Helvetica, Arial, sans-serif !important; - line-height: 1.1em; -} - -/* ========== */ -/* = Layout = */ -/* ========== */ - -.VS-search .VS-search-box { - cursor: text; - position: relative; - background: transparent; - border: 2px solid #ccc; - border-radius: 16px; -webkit-border-radius: 16px; -moz-border-radius: 16px; - background-color: #fafafa; - -webkit-box-shadow: inset 0px 0px 3px #ccc; - -moz-box-shadow: inset 0px 0px 3px #ccc; - box-shadow: inset 0px 0px 3px #ccc; - min-height: 28px; - height: auto; -} - .VS-search .VS-search-box.VS-focus { - border-color: #acf; - -webkit-box-shadow: inset 0px 0px 3px #acf; - -moz-box-shadow: inset 0px 0px 3px #acf; - box-shadow: inset 0px 0px 3px #acf; - } - .VS-search .VS-search-inner { - position: relative; - margin: 0 20px 0 22px; - overflow: hidden; - } - .VS-search input { - width: 100px; - } - .VS-search input, - .VS-search .VS-input-width-tester { - padding: 6px 0; - float: left; - color: #808080; - font: 13px/17px Helvetica, Arial; - } - .VS-search.VS-focus input { - color: #606060; - } - .VS-search .VS-icon-search { - position: absolute; - left: 9px; top: 8px; - } - .VS-search .VS-icon-cancel { - position: absolute; - right: 9px; top: 8px; - } - -/* ================ */ -/* = Search Facet = */ -/* ================ */ - -.VS-search .search_facet { - float: left; - margin: 0; - padding: 0 0 0 14px; - position: relative; - border: 1px solid transparent; - height: 20px; - margin: 3px -3px 3px 0; -} - .VS-search .search_facet.is_selected { - margin-left: -3px; - -webkit-border-radius: 16px; - -moz-border-radius: 16px; - border-radius: 16px; - background-color: #d2e6fd; - background-image: -moz-linear-gradient(top, #d2e6fd, #b0d1f9); /* FF3.6 */ - background-image: -webkit-gradient(linear, left top, left bottom, from(#d2e6fd), to(#b0d1f9)); /* Saf4+, Chrome */ - background-image: linear-gradient(top, #d2e6fd, #b0d1f9); - border: 1px solid #6eadf5; - } - .VS-search .search_facet .category { - float: left; - text-transform: uppercase; - font-weight: bold; - font-size: 10px; - color: #808080; - padding: 8px 0 5px; - line-height: 13px; - cursor: pointer; - padding: 4px 0 0; - } - .VS-search .search_facet.is_selected .category { - margin-left: 3px; - } - .VS-search .search_facet .search_facet_input_container { - float: left; - } - .VS-search .search_facet input { - margin: 0; - padding: 0; - color: #000; - font-size: 13px; - line-height: 16px; - padding: 5px 0 5px 4px; - height: 16px; - width: auto; - z-index: 100; - position: relative; - padding-top: 1px; - padding-bottom: 2px; - padding-right: 3px; - - } - .VS-search .search_facet.is_editing input, - .VS-search .search_facet.is_selected input { - color: #000; - } - .VS-search .search_facet .search_facet_remove { - position: absolute; - left: 0; - top: 4px; - } - .VS-search .search_facet.is_selected .search_facet_remove { - opacity: 0.4; - left: 3px; - filter: alpha(opacity=40); - background-position: center -11px; - } - .VS-search .search_facet .search_facet_remove:hover { - opacity: 1; - } - .VS-search .search_facet.is_editing .category, - .VS-search .search_facet.is_selected .category { - color: #000; - } - .VS-search .search_facet.search_facet_maybe_delete .category, - .VS-search .search_facet.search_facet_maybe_delete input { - color: darkred; - } - -/* ================ */ -/* = Search Input = */ -/* ================ */ - -.VS-search .search_input { - height: 28px; - float: left; - margin-left: -1px; -} - .VS-search .search_input input { - padding: 6px 3px 6px 2px; - line-height: 10px; - height: 22px; - margin-top: -4px; - width: 10px; - z-index: 100; - min-width: 4px; - position: relative; - } - .VS-search .search_input.is_editing input { - color: #202020; - } - -/* ================ */ -/* = Autocomplete = */ -/* ================ */ - -.VS-interface.ui-autocomplete { - position: absolute; - border: 1px solid #C0C0C0; - border-top: 1px solid #D9D9D9; - background-color: #F6F6F6; - cursor: pointer; - z-index: 10000; - padding: 0; - margin: 0; - width: auto; - min-width: 80px; - max-width: 220px; - max-height: 240px; - overflow-y: auto; - overflow-x: hidden; - font-size: 13px; - top: 5px; - opacity: 0.97; - box-shadow: 3px 4px 5px -2px rgba(0, 0, 0, 0.5); -webkit-box-shadow: 3px 4px 5px -2px rgba(0, 0, 0, 0.5); -moz-box-shadow: 3px 4px 5px -2px rgba(0, 0, 0, 0.5); -} - .VS-interface.ui-autocomplete .ui-autocomplete-category { - text-transform: capitalize; - font-size: 11px; - padding: 4px 4px 4px; - border-top: 1px solid #A2A2A2; - border-bottom: 1px solid #A2A2A2; - background-color: #B7B7B7; - text-shadow: 0 -1px 0 #999; - font-weight: bold; - color: white; - cursor: default; - } - .VS-interface.ui-autocomplete .ui-menu-item { - float: none; - } - .VS-interface.ui-autocomplete .ui-menu-item a { - color: #000; - outline: none; - display: block; - padding: 3px 4px 5px; - border-radius: none; - line-height: 1; - background-color: #F8F8F8; - background-image: -moz-linear-gradient(top, #F8F8F8, #F3F3F3); /* FF3.6 */ - background-image: -webkit-gradient(linear, left top, left bottom, from(#F8F8F8), to(#F3F3F3)); /* Saf4+, Chrome */ - background-image: linear-gradient(top, #F8F8F8, #F3F3F3); - border-top: 1px solid #FAFAFA; - border-bottom: 1px solid #f0f0f0; - } - .VS-interface.ui-autocomplete .ui-menu-item a:active { - outline: none; - } - .VS-interface.ui-autocomplete .ui-menu-item .ui-state-hover { - background-color: #6483F7; - background-image: -moz-linear-gradient(top, #648bF5, #2465f3); /* FF3.6 */ - background-image: -webkit-gradient(linear, left top, left bottom, from(#648bF5), to(#2465f3)); /* Saf4+, Chrome */ - background-image: linear-gradient(top, #648bF5, #2465f3); - border-top: 1px solid #5b83ec; - border-bottom: 1px solid #1459e9; - border-left: none; - border-right: none; - color: white; - margin: 0; - } - .VS-interface.ui-autocomplete .ui-corner-all { - border-radius: 0; - } - .VS-interface.ui-autocomplete li { - list-style: none; - width: auto; - } diff --git a/addons/web/static/lib/visualsearch/build/visualsearch.js b/addons/web/static/lib/visualsearch/build/visualsearch.js deleted file mode 100755 index 319d978db3c..00000000000 --- a/addons/web/static/lib/visualsearch/build/visualsearch.js +++ /dev/null @@ -1,1845 +0,0 @@ -// This is the annotated source code for -// [VisualSearch.js](http://documentcloud.github.com/visualsearch/), -// a rich search box for real data. -// -// The annotated source HTML is generated by -// [Docco](http://jashkenas.github.com/docco/). - -/** @license VisualSearch.js 0.2.2 - * (c) 2011 Samuel Clay, @samuelclay, DocumentCloud Inc. - * VisualSearch.js may be freely distributed under the MIT license. - * For all details and documentation: - * http://documentcloud.github.com/visualsearch - */ - -(function() { - - var $ = jQuery; // Handle namespaced jQuery - - // Setting up VisualSearch globals. These will eventually be made instance-based. - if (!window.VS) window.VS = {}; - if (!VS.app) VS.app = {}; - if (!VS.ui) VS.ui = {}; - if (!VS.model) VS.model = {}; - if (!VS.utils) VS.utils = {}; - - // Sets the version for VisualSearch to be used programatically elsewhere. - VS.VERSION = '0.2.2'; - - VS.VisualSearch = function(options) { - var defaults = { - container : '', - query : '', - unquotable : [], - callbacks : { - search : $.noop, - focus : $.noop, - blur : $.noop, - facetMatches : $.noop, - valueMatches : $.noop - } - }; - this.options = _.extend({}, defaults, options); - this.options.callbacks = _.extend({}, defaults.callbacks, options.callbacks); - - VS.app.hotkeys.initialize(); - this.searchQuery = new VS.model.SearchQuery(); - this.searchBox = new VS.ui.SearchBox({app: this}); - - if (options.container) { - var searchBox = this.searchBox.render().el; - $(this.options.container).html(searchBox); - } - this.searchBox.value(this.options.query || ''); - - // Disable page caching for browsers that incorrectly cache the visual search inputs. - // This is forced the browser to re-render the page when it is retrieved in its history. - $(window).bind('unload', function(e) {}); - - // Gives the user back a reference to the `searchBox` so they - // can use public methods. - return this; - }; - - // Entry-point used to tie all parts of VisualSearch together. It will either attach - // itself to `options.container`, or pass back the `searchBox` so it can be rendered - // at will. - VS.init = function(options) { - return new VS.VisualSearch(options); - }; - -})(); -(function() { - -var $ = jQuery; // Handle namespaced jQuery - -// The search box is responsible for managing the many facet views and input views. -VS.ui.SearchBox = Backbone.View.extend({ - - id : 'search', - - events : { - 'click .VS-cancel-search-box' : 'clearSearch', - 'mousedown .VS-search-box' : 'maybeFocusSearch', - 'dblclick .VS-search-box' : 'highlightSearch', - 'click .VS-search-box' : 'maybeTripleClick' - }, - - // Creating a new SearchBox registers handlers for re-rendering facets when necessary, - // as well as handling typing when a facet is selected. - initialize : function() { - this.app = this.options.app; - this.flags = { - allSelected : false - }; - this.facetViews = []; - this.inputViews = []; - _.bindAll(this, 'renderFacets', '_maybeDisableFacets', 'disableFacets', - 'deselectAllFacets', 'addedFacet', 'removedFacet', 'changedFacet'); - this.app.searchQuery - .bind('reset', this.renderFacets) - .bind('add', this.addedFacet) - .bind('remove', this.removedFacet) - .bind('change', this.changedFacet); - $(document).bind('keydown', this._maybeDisableFacets); - }, - - // Renders the search box, but requires placement on the page through `this.el`. - render : function() { - $(this.el).append(JST['search_box']({})); - $(document.body).setMode('no', 'search'); - - return this; - }, - - // # Querying Facets # - - // Either gets a serialized query string or sets the faceted query from a query string. - value : function(query) { - if (query == null) return this.serialize(); - return this.setQuery(query); - }, - - // Uses the VS.app.searchQuery collection to serialize the current query from the various - // facets that are in the search box. - serialize : function() { - var query = []; - var inputViewsCount = this.inputViews.length; - - this.app.searchQuery.each(_.bind(function(facet, i) { - query.push(this.inputViews[i].value()); - query.push(facet.serialize()); - }, this)); - - if (inputViewsCount) { - query.push(this.inputViews[inputViewsCount-1].value()); - } - - return _.compact(query).join(' '); - }, - - // Takes a query string and uses the SearchParser to parse and render it. Note that - // `VS.app.SearchParser` refreshes the `VS.app.searchQuery` collection, which is bound - // here to call `this.renderFacets`. - setQuery : function(query) { - this.currentQuery = query; - VS.app.SearchParser.parse(this.app, query); - }, - - // Returns the position of a facet/input view. Useful when moving between facets. - viewPosition : function(view) { - var views = view.type == 'facet' ? this.facetViews : this.inputViews; - var position = _.indexOf(views, view); - if (position == -1) position = 0; - return position; - }, - - // Used to launch a search. Hitting enter or clicking the search button. - searchEvent : function(e) { - var query = this.value(); - this.focusSearch(e); - this.value(query); - this.app.options.callbacks.search(query, this.app.searchQuery); - }, - - // # Rendering Facets # - - // Add a new facet. Facet will be focused and ready to accept a value. Can also - // specify position, in the case of adding facets from an inbetween input. - addFacet : function(category, initialQuery, position) { - category = VS.utils.inflector.trim(category); - initialQuery = VS.utils.inflector.trim(initialQuery || ''); - if (!category) return; - - var model = new VS.model.SearchFacet({ - category : category, - value : initialQuery || '', - app : this.app - }); - this.app.searchQuery.add(model, {at: position}); - }, - - // Renders a newly added facet, and selects it. - addedFacet : function (model) { - this.renderFacets(); - var facetView = _.detect(this.facetViews, function(view) { - if (view.model == model) return true; - }); - - _.defer(function() { - facetView.enableEdit(); - }); - }, - - // Changing a facet programmatically re-renders it. - changedFacet: function () { - this.renderFacets(); - }, - - // When removing a facet, potentially do something. For now, the adjacent - // remaining facet is selected, but this is handled by the facet's view, - // since its position is unknown by the time the collection triggers this - // remove callback. - removedFacet : function (facet, query, options) {}, - - // Renders each facet as a searchFacet view. - renderFacets : function() { - this.facetViews = []; - this.inputViews = []; - - this.$('.VS-search-inner').empty(); - - this.app.searchQuery.each(_.bind(function(facet, i) { - this.renderFacet(facet, i); - }, this)); - - // Add on an n+1 empty search input on the very end. - this.renderSearchInput(); - }, - - // Render a single facet, using its category and query value. - renderFacet : function(facet, position) { - var view = new VS.ui.SearchFacet({ - app : this.app, - model : facet, - order : position - }); - - // Input first, facet second. - this.renderSearchInput(); - this.facetViews.push(view); - this.$('.VS-search-inner').children().eq(position*2).after(view.render().el); - - view.calculateSize(); - _.defer(_.bind(view.calculateSize, view)); - - return view; - }, - - // Render a single input, used to create and autocomplete facets - renderSearchInput : function() { - var input = new VS.ui.SearchInput({position: this.inputViews.length, app: this.app}); - this.$('.VS-search-inner').append(input.render().el); - this.inputViews.push(input); - }, - - // # Modifying Facets # - - // Clears out the search box. Command+A + delete can trigger this, as can a cancel button. - // - // If a `clearSearch` callback was provided, the callback is invoked and - // provided with a function performs the actual removal of the data. This - // allows third-party developers to either clear data asynchronously, or - // prior to performing their custom "clear" logic. - clearSearch : function(e) { - var actualClearSearch = _.bind(function() { - this.disableFacets(); - this.value(''); - this.flags.allSelected = false; - this.searchEvent(e); - this.focusSearch(e); - }, this); - - if (this.app.options.callbacks.clearSearch) { - this.app.options.callbacks.clearSearch(actualClearSearch); - } else { - actualClearSearch(); - } - }, - - // Command+A selects all facets. - selectAllFacets : function() { - this.flags.allSelected = true; - - $(document).one('click.selectAllFacets', this.deselectAllFacets); - - _.each(this.facetViews, function(facetView, i) { - facetView.selectFacet(); - }); - _.each(this.inputViews, function(inputView, i) { - inputView.selectText(); - }); - }, - - // Used by facets and input to see if all facets are currently selected. - allSelected : function(deselect) { - if (deselect) this.flags.allSelected = false; - return this.flags.allSelected; - }, - - // After `selectAllFacets` is engaged, this method is bound to the entire document. - // This immediate disables and deselects all facets, but it also checks if the user - // has clicked on either a facet or an input, and properly selects the view. - deselectAllFacets : function(e) { - this.disableFacets(); - - if (this.$(e.target).is('.category,input')) { - var el = $(e.target).closest('.search_facet,.search_input'); - var view = _.detect(this.facetViews.concat(this.inputViews), function(v) { - return v.el == el[0]; - }); - if (view.type == 'facet') { - view.selectFacet(); - } else if (view.type == 'input') { - _.defer(function() { - view.enableEdit(true); - }); - } - } - }, - - // Disables all facets except for the passed in view. Used when switching between - // facets, so as not to have to keep state of active facets. - disableFacets : function(keepView) { - _.each(this.inputViews, function(view) { - if (view && view != keepView && - (view.modes.editing == 'is' || view.modes.selected == 'is')) { - view.disableEdit(); - } - }); - _.each(this.facetViews, function(view) { - if (view && view != keepView && - (view.modes.editing == 'is' || view.modes.selected == 'is')) { - view.disableEdit(); - view.deselectFacet(); - } - }); - - this.flags.allSelected = false; - this.removeFocus(); - $(document).unbind('click.selectAllFacets'); - }, - - // Resize all inputs to account for extra keystrokes which may be changing the facet - // width incorrectly. This is a safety check to ensure inputs are correctly sized. - resizeFacets : function(view) { - _.each(this.facetViews, function(facetView, i) { - if (!view || facetView == view) { - facetView.resize(); - } - }); - }, - - // Handles keydown events on the document. Used to complete the Cmd+A deletion, and - // blurring focus. - _maybeDisableFacets : function(e) { - if (this.flags.allSelected && VS.app.hotkeys.key(e) == 'backspace') { - e.preventDefault(); - this.clearSearch(e); - return false; - } else if (this.flags.allSelected && VS.app.hotkeys.printable(e)) { - this.clearSearch(e); - } - }, - - // # Focusing Facets # - - // Move focus between facets and inputs. Takes a direction as well as many options - // for skipping over inputs and only to facets, placement of cursor position in facet - // (i.e. at the end), and selecting the text in the input/facet. - focusNextFacet : function(currentView, direction, options) { - options = options || {}; - var viewCount = this.facetViews.length; - var viewPosition = options.viewPosition || this.viewPosition(currentView); - - if (!options.skipToFacet) { - // Correct for bouncing between matching text and facet arrays. - if (currentView.type == 'text' && direction > 0) direction -= 1; - if (currentView.type == 'facet' && direction < 0) direction += 1; - } else if (options.skipToFacet && currentView.type == 'text' && - viewCount == viewPosition && direction >= 0) { - // Special case of looping around to a facet from the last search input box. - viewPosition = 0; - direction = 0; - } - var view, next = Math.min(viewCount, viewPosition + direction); - - if (currentView.type == 'text') { - if (next >= 0 && next < viewCount) { - view = this.facetViews[next]; - } else if (next == viewCount) { - view = this.inputViews[this.inputViews.length-1]; - } - if (view && options.selectFacet && view.type == 'facet') { - view.selectFacet(); - } else if (view) { - view.enableEdit(); - view.setCursorAtEnd(direction || options.startAtEnd); - } - } else if (currentView.type == 'facet') { - if (options.skipToFacet) { - if (next >= viewCount || next < 0) { - view = _.last(this.inputViews); - view.enableEdit(); - } else { - view = this.facetViews[next]; - view.enableEdit(); - view.setCursorAtEnd(direction || options.startAtEnd); - } - } else { - view = this.inputViews[next]; - view.enableEdit(); - } - } - if (options.selectText) view.selectText(); - this.resizeFacets(); - }, - - maybeFocusSearch : function(e) { - if ($(e.target).is('.VS-search-box') || - $(e.target).is('.VS-search-inner') || - e.type == 'keydown') { - this.focusSearch(e); - } - }, - - // Bring focus to last input field. - focusSearch : function(e, selectText) { - var view = this.inputViews[this.inputViews.length-1]; - view.enableEdit(selectText); - if (!selectText) view.setCursorAtEnd(-1); - if (e.type == 'keydown') { - view.keydown(e); - view.box.trigger('keydown'); - } - _.defer(_.bind(function() { - if (!this.$('input:focus').length) { - view.enableEdit(selectText); - } - }, this)); - }, - - // Double-clicking on the search wrapper should select the existing text in - // the last search input. Also start the triple-click timer. - highlightSearch : function(e) { - if ($(e.target).is('.VS-search-box') || - $(e.target).is('.VS-search-inner') || - e.type == 'keydown') { - var lastinput = this.inputViews[this.inputViews.length-1]; - lastinput.startTripleClickTimer(); - this.focusSearch(e, true); - } - }, - - maybeTripleClick : function(e) { - var lastinput = this.inputViews[this.inputViews.length-1]; - return lastinput.maybeTripleClick(e); - }, - - // Used to show the user is focused on some input inside the search box. - addFocus : function() { - this.app.options.callbacks.focus(); - this.$('.VS-search-box').addClass('VS-focus'); - }, - - // User is no longer focused on anything in the search box. - removeFocus : function() { - this.app.options.callbacks.blur(); - var focus = _.any(this.facetViews.concat(this.inputViews), function(view) { - return view.isFocused(); - }); - if (!focus) this.$('.VS-search-box').removeClass('VS-focus'); - }, - - // Show a menu which adds pre-defined facets to the search box. This is unused for now. - showFacetCategoryMenu : function(e) { - e.preventDefault(); - e.stopPropagation(); - if (this.facetCategoryMenu && this.facetCategoryMenu.modes.open == 'is') { - return this.facetCategoryMenu.close(); - } - - var items = [ - {title: 'Account', onClick: _.bind(this.addFacet, this, 'account', '')}, - {title: 'Project', onClick: _.bind(this.addFacet, this, 'project', '')}, - {title: 'Filter', onClick: _.bind(this.addFacet, this, 'filter', '')}, - {title: 'Access', onClick: _.bind(this.addFacet, this, 'access', '')} - ]; - - var menu = this.facetCategoryMenu || (this.facetCategoryMenu = new dc.ui.Menu({ - items : items, - standalone : true - })); - - this.$('.VS-icon-search').after(menu.render().open().content); - return false; - } - -}); - -})(); - -(function() { - -var $ = jQuery; // Handle namespaced jQuery - -// This is the visual search facet that holds the category and its autocompleted -// input field. -VS.ui.SearchFacet = Backbone.View.extend({ - - type : 'facet', - - className : 'search_facet', - - events : { - 'click .category' : 'selectFacet', - 'keydown input' : 'keydown', - 'mousedown input' : 'enableEdit', - 'mouseover .VS-icon-cancel' : 'showDelete', - 'mouseout .VS-icon-cancel' : 'hideDelete', - 'click .VS-icon-cancel' : 'remove' - }, - - initialize : function(options) { - this.flags = { - canClose : false - }; - _.bindAll(this, 'set', 'keydown', 'deselectFacet', 'deferDisableEdit'); - }, - - // Rendering the facet sets up autocompletion, events on blur, and populates - // the facet's input with its starting value. - render : function() { - $(this.el).html(JST['search_facet']({ - model : this.model - })); - - this.setMode('not', 'editing'); - this.setMode('not', 'selected'); - this.box = this.$('input'); - this.box.val(this.model.get('value')); - this.box.bind('blur', this.deferDisableEdit); - // Handle paste events with `propertychange` - this.box.bind('input propertychange', this.keydown); - this.setupAutocomplete(); - - return this; - }, - - // This method is used to setup the facet's input to auto-grow. - // This is defered in the searchBox so it can be attached to the - // DOM to get the correct font-size. - calculateSize : function() { - this.box.autoGrowInput(); - this.box.unbind('updated.autogrow'); - this.box.bind('updated.autogrow', _.bind(this.moveAutocomplete, this)); - }, - - // Forces a recalculation of this facet's input field's value. Called when - // the facet is focused, removed, or otherwise modified. - resize : function(e) { - this.box.trigger('resize.autogrow', e); - }, - - // Watches the facet's input field to see if it matches the beginnings of - // words in `autocompleteValues`, which is different for every category. - // If the value, when selected from the autocompletion menu, is different - // than what it was, commit the facet and search for it. - setupAutocomplete : function() { - this.box.autocomplete({ - source : _.bind(this.autocompleteValues, this), - minLength : 0, - delay : 0, - autoFocus : true, - position : {offset : "0 5"}, - create : _.bind(function(e, ui) { - $(this.el).find('.ui-autocomplete-input').css('z-index','auto'); - }, this), - select : _.bind(function(e, ui) { - e.preventDefault(); - var originalValue = this.model.get('value'); - this.set(ui.item.value); - if (originalValue != ui.item.value || this.box.val() != ui.item.value) { - this.search(e); - } - return false; - }, this), - open : _.bind(function(e, ui) { - var box = this.box; - this.box.autocomplete('widget').find('.ui-menu-item').each(function() { - var $value = $(this); - if ($value.data('item.autocomplete')['value'] == box.val()) { - box.data('autocomplete').menu.activate(new $.Event("mouseover"), $value); - } - }); - }, this) - }); - - this.box.autocomplete('widget').addClass('VS-interface'); - }, - - // As the facet's input field grows, it may move to the next line in the - // search box. `autoGrowInput` triggers an `updated` event on the input - // field, which is bound to this method to move the autocomplete menu. - moveAutocomplete : function() { - var autocomplete = this.box.data('autocomplete'); - if (autocomplete) { - autocomplete.menu.element.position({ - my : "left top", - at : "left bottom", - of : this.box.data('autocomplete').element, - collision : "flip", - offset : "0 5" - }); - } - }, - - // When a user enters a facet and it is being edited, immediately show - // the autocomplete menu and size it to match the contents. - searchAutocomplete : function(e) { - var autocomplete = this.box.data('autocomplete'); - if (autocomplete) { - var menu = autocomplete.menu.element; - autocomplete.search(); - - // Resize the menu based on the correctly measured width of what's bigger: - // the menu's original size or the menu items' new size. - menu.outerWidth(Math.max( - menu.width('').outerWidth(), - autocomplete.element.outerWidth() - )); - } - }, - - // Closes the autocomplete menu. Called on disabling, selecting, deselecting, - // and anything else that takes focus out of the facet's input field. - closeAutocomplete : function() { - var autocomplete = this.box.data('autocomplete'); - if (autocomplete) autocomplete.close(); - }, - - // Search terms used in the autocomplete menu. These are specific to the facet, - // and only match for the facet's category. The values are then matched on the - // first letter of any word in matches, and finally sorted according to the - // value's own category. You can pass `preserveOrder` as an option in the - // `facetMatches` callback to skip any further ordering done client-side. - autocompleteValues : function(req, resp) { - var category = this.model.get('category'); - var value = this.model.get('value'); - var searchTerm = req.term; - - this.options.app.options.callbacks.valueMatches(category, searchTerm, function(matches, options) { - options = options || {}; - matches = matches || []; - - if (searchTerm && value != searchTerm) { - if (options.preserveMatches) { - return matches; - } else { - var re = VS.utils.inflector.escapeRegExp(searchTerm || ''); - var matcher = new RegExp('\\b' + re, 'i'); - matches = $.grep(matches, function(item) { - return matcher.test(item) || - matcher.test(item.value) || - matcher.test(item.label); - }); - } - } - - if (options.preserveOrder) { - resp(matches); - } else { - resp(_.sortBy(matches, function(match) { - if (match == value || match.value == value) return ''; - else return match; - })); - } - }); - - }, - - // Sets the facet's model's value. - set : function(value) { - if (!value) return; - this.model.set({'value': value}); - }, - - // Before the searchBox performs a search, we need to close the - // autocomplete menu. - search : function(e, direction) { - if (!direction) direction = 1; - this.closeAutocomplete(); - this.options.app.searchBox.searchEvent(e); - _.defer(_.bind(function() { - this.options.app.searchBox.focusNextFacet(this, direction, {viewPosition: this.options.order}); - }, this)); - }, - - // Begin editing the facet's input. This is called when the user enters - // the input either from another facet or directly clicking on it. - // - // This method tells all other facets and inputs to disable so it can have - // the sole focus. It also prepares the autocompletion menu. - enableEdit : function() { - if (this.modes.editing != 'is') { - this.setMode('is', 'editing'); - this.deselectFacet(); - if (this.box.val() == '') { - this.box.val(this.model.get('value')); - } - } - - this.flags.canClose = false; - this.options.app.searchBox.disableFacets(this); - this.options.app.searchBox.addFocus(); - _.defer(_.bind(function() { - this.options.app.searchBox.addFocus(); - }, this)); - this.resize(); - this.searchAutocomplete(); - this.box.focus(); - }, - - // When the user blurs the input, they may either be going to another input - // or off the search box entirely. If they go to another input, this facet - // will be instantly disabled, and the canClose flag will be turned back off. - // - // However, if the user clicks elsewhere on the page, this method starts a timer - // that checks if any of the other inputs are selected or are being edited. If - // not, then it can finally close itself and its autocomplete menu. - deferDisableEdit : function() { - this.flags.canClose = true; - _.delay(_.bind(function() { - if (this.flags.canClose && !this.box.is(':focus') && - this.modes.editing == 'is' && this.modes.selected != 'is') { - this.disableEdit(); - } - }, this), 250); - }, - - // Called either by other facets receiving focus or by the timer in `deferDisableEdit`, - // this method will turn off the facet, remove any text selection, and close - // the autocomplete menu. - disableEdit : function() { - var newFacetQuery = VS.utils.inflector.trim(this.box.val()); - if (newFacetQuery != this.model.get('value')) { - this.set(newFacetQuery); - } - this.flags.canClose = false; - this.box.selectRange(0, 0); - this.box.blur(); - this.setMode('not', 'editing'); - this.closeAutocomplete(); - this.options.app.searchBox.removeFocus(); - }, - - // Selects the facet, which blurs the facet's input and highlights the facet. - // If this is the only facet being selected (and not part of a select all event), - // we attach a mouse/keyboard watcher to check if the next action by the user - // should delete this facet or just deselect it. - selectFacet : function(e) { - if (e) e.preventDefault(); - var allSelected = this.options.app.searchBox.allSelected(); - if (this.modes.selected == 'is') return; - - if (this.box.is(':focus')) { - this.box.setCursorPosition(0); - this.box.blur(); - } - - this.flags.canClose = false; - this.closeAutocomplete(); - this.setMode('is', 'selected'); - this.setMode('not', 'editing'); - if (!allSelected || e) { - $(document).unbind('keydown.facet', this.keydown); - $(document).unbind('click.facet', this.deselectFacet); - _.defer(_.bind(function() { - $(document).unbind('keydown.facet').bind('keydown.facet', this.keydown); - $(document).unbind('click.facet').one('click.facet', this.deselectFacet); - }, this)); - this.options.app.searchBox.disableFacets(this); - this.options.app.searchBox.addFocus(); - } - return false; - }, - - // Turns off highlighting on the facet. Called in a variety of ways, this - // only deselects the facet if it is selected, and then cleans up the - // keyboard/mouse watchers that were created when the facet was first - // selected. - deselectFacet : function(e) { - if (e) e.preventDefault(); - if (this.modes.selected == 'is') { - this.setMode('not', 'selected'); - this.closeAutocomplete(); - this.options.app.searchBox.removeFocus(); - } - $(document).unbind('keydown.facet', this.keydown); - $(document).unbind('click.facet', this.deselectFacet); - return false; - }, - - // Is the user currently focused in this facet's input field? - isFocused : function() { - return this.box.is(':focus'); - }, - - // Hovering over the delete button styles the facet so the user knows that - // the delete button will kill the entire facet. - showDelete : function() { - $(this.el).addClass('search_facet_maybe_delete'); - }, - - // On `mouseout`, the user is no longer hovering on the delete button. - hideDelete : function() { - $(this.el).removeClass('search_facet_maybe_delete'); - }, - - // When switching between facets, depending on the direction the cursor is - // coming from, the cursor in this facet's input field should match the original - // direction. - setCursorAtEnd : function(direction) { - if (direction == -1) { - this.box.setCursorPosition(this.box.val().length); - } else { - this.box.setCursorPosition(0); - } - }, - - // Deletes the facet and sends the cursor over to the nearest input field. - remove : function(e) { - var committed = this.model.get('value'); - this.deselectFacet(); - this.disableEdit(); - this.options.app.searchQuery.remove(this.model); - if (committed) { - this.search(e, -1); - } else { - this.options.app.searchBox.renderFacets(); - this.options.app.searchBox.focusNextFacet(this, -1, {viewPosition: this.options.order}); - } - }, - - // Selects the text in the facet's input field. When the user tabs between - // facets, convention is to highlight the entire field. - selectText: function() { - this.box.selectRange(0, this.box.val().length); - }, - - // Handles all keyboard inputs when in the facet's input field. This checks - // for movement between facets and inputs, entering a new value that needs - // to be autocompleted, as well as the removal of this facet. - keydown : function(e) { - var key = VS.app.hotkeys.key(e); - - if (key == 'enter' && this.box.val()) { - this.disableEdit(); - this.search(e); - } else if (key == 'left') { - if (this.modes.selected == 'is') { - this.deselectFacet(); - this.options.app.searchBox.focusNextFacet(this, -1, {startAtEnd: -1}); - } else if (this.box.getCursorPosition() == 0 && !this.box.getSelection().length) { - this.selectFacet(); - } - } else if (key == 'right') { - if (this.modes.selected == 'is') { - e.preventDefault(); - this.deselectFacet(); - this.setCursorAtEnd(0); - this.enableEdit(); - } else if (this.box.getCursorPosition() == this.box.val().length) { - e.preventDefault(); - this.disableEdit(); - this.options.app.searchBox.focusNextFacet(this, 1); - } - } else if (VS.app.hotkeys.shift && key == 'tab') { - e.preventDefault(); - this.options.app.searchBox.focusNextFacet(this, -1, { - startAtEnd : -1, - skipToFacet : true, - selectText : true - }); - } else if (key == 'tab') { - e.preventDefault(); - this.options.app.searchBox.focusNextFacet(this, 1, { - skipToFacet : true, - selectText : true - }); - } else if (VS.app.hotkeys.command && (e.which == 97 || e.which == 65)) { - e.preventDefault(); - this.options.app.searchBox.selectAllFacets(); - return false; - } else if (VS.app.hotkeys.printable(e) && this.modes.selected == 'is') { - this.options.app.searchBox.focusNextFacet(this, -1, {startAtEnd: -1}); - this.remove(e); - } else if (key == 'backspace') { - if (this.modes.selected == 'is') { - e.preventDefault(); - this.remove(e); - } else if (this.box.getCursorPosition() == 0 && - !this.box.getSelection().length) { - e.preventDefault(); - this.selectFacet(); - } - } - - this.resize(e); - - // Handle paste events - if (e.which == null) { - this.searchAutocomplete(e); - _.defer(_.bind(this.resize, this, e)); - } - } - -}); - -})(); - -(function() { - -var $ = jQuery; // Handle namespaced jQuery - -// This is the visual search input that is responsible for creating new facets. -// There is one input placed in between all facets. -VS.ui.SearchInput = Backbone.View.extend({ - - type : 'text', - - className : 'search_input', - - events : { - 'keypress input' : 'keypress', - 'keydown input' : 'keydown', - 'click input' : 'maybeTripleClick', - 'dblclick input' : 'startTripleClickTimer' - }, - - initialize : function() { - this.app = this.options.app; - this.flags = { - canClose : false - }; - _.bindAll(this, 'removeFocus', 'addFocus', 'moveAutocomplete', 'deferDisableEdit'); - }, - - // Rendering the input sets up autocomplete, events on focusing and blurring - // the input, and the auto-grow of the input. - render : function() { - $(this.el).html(JST['search_input']({})); - - this.setMode('not', 'editing'); - this.setMode('not', 'selected'); - this.box = this.$('input'); - this.box.autoGrowInput(); - this.box.bind('updated.autogrow', this.moveAutocomplete); - this.box.bind('blur', this.deferDisableEdit); - this.box.bind('focus', this.addFocus); - this.setupAutocomplete(); - - return this; - }, - - // Watches the input and presents an autocompleted menu, taking the - // remainder of the input field and adding a separate facet for it. - // - // See `addTextFacetRemainder` for explanation on how the remainder works. - setupAutocomplete : function() { - this.box.autocomplete({ - minLength : 1, - delay : 50, - autoFocus : true, - position : {offset : "0 -1"}, - source : _.bind(this.autocompleteValues, this), - create : _.bind(function(e, ui) { - $(this.el).find('.ui-autocomplete-input').css('z-index','auto'); - }, this), - select : _.bind(function(e, ui) { - e.preventDefault(); - e.stopPropagation(); - var remainder = this.addTextFacetRemainder(ui.item.value); - var position = this.options.position + (remainder ? 1 : 0); - this.app.searchBox.addFacet(ui.item.value, '', position); - return false; - }, this) - }); - - // Renders the results grouped by the categories they belong to. - this.box.data('autocomplete')._renderMenu = function(ul, items) { - var category = ''; - _.each(items, _.bind(function(item, i) { - if (item.category && item.category != category) { - ul.append('
  • '+item.category+'
  • '); - category = item.category; - } - this._renderItem(ul, item); - }, this)); - }; - - this.box.autocomplete('widget').addClass('VS-interface'); - }, - - // Search terms used in the autocomplete menu. The values are matched on the - // first letter of any word in matches, and finally sorted according to the - // value's own category. You can pass `preserveOrder` as an option in the - // `facetMatches` callback to skip any further ordering done client-side. - autocompleteValues : function(req, resp) { - var searchTerm = req.term; - var lastWord = searchTerm.match(/\w+$/); // Autocomplete only last word. - var re = VS.utils.inflector.escapeRegExp(lastWord && lastWord[0] || ' '); - this.app.options.callbacks.facetMatches(function(prefixes, options) { - options = options || {}; - prefixes = prefixes || []; - - // Only match from the beginning of the word. - var matcher = new RegExp('^' + re, 'i'); - var matches = $.grep(prefixes, function(item) { - return item && matcher.test(item.label || item); - }); - - if (options.preserveOrder) { - resp(matches); - } else { - resp(_.sortBy(matches, function(match) { - if (match.label) return match.category + '-' + match.label; - else return match; - })); - } - }); - - }, - - // Closes the autocomplete menu. Called on disabling, selecting, deselecting, - // and anything else that takes focus out of the facet's input field. - closeAutocomplete : function() { - var autocomplete = this.box.data('autocomplete'); - if (autocomplete) autocomplete.close(); - }, - - // As the input field grows, it may move to the next line in the - // search box. `autoGrowInput` triggers an `updated` event on the input - // field, which is bound to this method to move the autocomplete menu. - moveAutocomplete : function() { - var autocomplete = this.box.data('autocomplete'); - if (autocomplete) { - autocomplete.menu.element.position({ - my : "left top", - at : "left bottom", - of : this.box.data('autocomplete').element, - collision : "none", - offset : '0 -1' - }); - } - }, - - // When a user enters a facet and it is being edited, immediately show - // the autocomplete menu and size it to match the contents. - searchAutocomplete : function(e) { - var autocomplete = this.box.data('autocomplete'); - if (autocomplete) { - var menu = autocomplete.menu.element; - autocomplete.search(); - - // Resize the menu based on the correctly measured width of what's bigger: - // the menu's original size or the menu items' new size. - menu.outerWidth(Math.max( - menu.width('').outerWidth(), - autocomplete.element.outerWidth() - )); - } - }, - - // If a user searches for "word word category", the category would be - // matched and autocompleted, and when selected, the "word word" would - // also be caught as the remainder and then added in its own facet. - addTextFacetRemainder : function(facetValue) { - var boxValue = this.box.val(); - var lastWord = boxValue.match(/\b(\w+)$/); - var matcher = new RegExp(lastWord[0], "i"); - if (lastWord && facetValue.search(matcher) == 0) { - boxValue = boxValue.replace(/\b(\w+)$/, ''); - } - boxValue = boxValue.replace('^\s+|\s+$', ''); - if (boxValue) { - this.app.searchBox.addFacet('text', boxValue, this.options.position); - } - return boxValue; - }, - - // Directly called to focus the input. This is different from `addFocus` - // because this is not called by a focus event. This instead calls a - // focus event causing the input to become focused. - enableEdit : function(selectText) { - this.addFocus(); - if (selectText) { - this.selectText(); - } - this.box.focus(); - }, - - // Event called on user focus on the input. Tells all other input and facets - // to give up focus, and starts revving the autocomplete. - addFocus : function() { - this.flags.canClose = false; - if (!this.app.searchBox.allSelected()) { - this.app.searchBox.disableFacets(this); - } - this.app.searchBox.addFocus(); - this.setMode('is', 'editing'); - this.setMode('not', 'selected'); - this.searchAutocomplete(); - }, - - // Directly called to blur the input. This is different from `removeFocus` - // because this is not called by a blur event. - disableEdit : function() { - this.box.blur(); - this.removeFocus(); - }, - - // Event called when user blur's the input, either through the keyboard tabbing - // away or the mouse clicking off. Cleans up - removeFocus : function() { - this.flags.canClose = false; - this.app.searchBox.removeFocus(); - this.setMode('not', 'editing'); - this.setMode('not', 'selected'); - this.closeAutocomplete(); - }, - - // When the user blurs the input, they may either be going to another input - // or off the search box entirely. If they go to another input, this facet - // will be instantly disabled, and the canClose flag will be turned back off. - // - // However, if the user clicks elsewhere on the page, this method starts a timer - // that checks if any of the other inputs are selected or are being edited. If - // not, then it can finally close itself and its autocomplete menu. - deferDisableEdit : function() { - this.flags.canClose = true; - _.delay(_.bind(function() { - if (this.flags.canClose && - !this.box.is(':focus') && - this.modes.editing == 'is') { - this.disableEdit(); - } - }, this), 250); - }, - - // Starts a timer that will cause a triple-click, which highlights all facets. - startTripleClickTimer : function() { - this.tripleClickTimer = setTimeout(_.bind(function() { - this.tripleClickTimer = null; - }, this), 500); - }, - - // Event on click that checks if a triple click is in play. The - // `tripleClickTimer` is counting down, ready to be engaged and intercept - // the click event to force a select all instead. - maybeTripleClick : function(e) { - if (!!this.tripleClickTimer) { - e.preventDefault(); - this.app.searchBox.selectAllFacets(); - return false; - } - }, - - // Is the user currently focused in the input field? - isFocused : function() { - return this.box.is(':focus'); - }, - - // When serializing the facets, the inputs need to also have their values represented, - // in case they contain text that is not yet faceted (but will be once the search is - // completed). - value : function() { - return this.box.val(); - }, - - // When switching between facets and inputs, depending on the direction the cursor - // is coming from, the cursor in this facet's input field should match the original - // direction. - setCursorAtEnd : function(direction) { - if (direction == -1) { - this.box.setCursorPosition(this.box.val().length); - } else { - this.box.setCursorPosition(0); - } - }, - - // Selects the entire range of text in the input. Useful when tabbing between inputs - // and facets. - selectText : function() { - this.box.selectRange(0, this.box.val().length); - if (!this.app.searchBox.allSelected()) { - this.box.focus(); - } else { - this.setMode('is', 'selected'); - } - }, - - // Before the searchBox performs a search, we need to close the - // autocomplete menu. - search : function(e, direction) { - if (!direction) direction = 0; - this.closeAutocomplete(); - this.app.searchBox.searchEvent(e); - _.defer(_.bind(function() { - this.app.searchBox.focusNextFacet(this, direction); - }, this)); - }, - - // Callback fired on key press in the search box. We search when they hit return. - keypress : function(e) { - var key = VS.app.hotkeys.key(e); - - if (key == 'enter') { - return this.search(e, 100); - } else if (VS.app.hotkeys.colon(e)) { - this.box.trigger('resize.autogrow', e); - var query = this.box.val(); - var prefixes = []; - if (this.app.options.callbacks.facetMatches) { - this.app.options.callbacks.facetMatches(function(p) { - prefixes = p; - }); - } - var labels = _.map(prefixes, function(prefix) { - if (prefix.label) return prefix.label; - else return prefix; - }); - if (_.contains(labels, query)) { - e.preventDefault(); - var remainder = this.addTextFacetRemainder(query); - var position = this.options.position + (remainder?1:0); - this.app.searchBox.addFacet(query, '', position); - return false; - } - } else if (key == 'backspace') { - if (this.box.getCursorPosition() == 0 && !this.box.getSelection().length) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - this.app.searchBox.resizeFacets(); - return false; - } - } - }, - - // Handles all keyboard inputs when in the input field. This checks - // for movement between facets and inputs, entering a new value that needs - // to be autocompleted, as well as stepping between facets with backspace. - keydown : function(e) { - var key = VS.app.hotkeys.key(e); - - if (key == 'left') { - if (this.box.getCursorPosition() == 0) { - e.preventDefault(); - this.app.searchBox.focusNextFacet(this, -1, {startAtEnd: -1}); - } - } else if (key == 'right') { - if (this.box.getCursorPosition() == this.box.val().length) { - e.preventDefault(); - this.app.searchBox.focusNextFacet(this, 1, {selectFacet: true}); - } - } else if (VS.app.hotkeys.shift && key == 'tab') { - e.preventDefault(); - this.app.searchBox.focusNextFacet(this, -1, {selectText: true}); - } else if (key == 'tab') { - e.preventDefault(); - var value = this.box.val(); - if (value.length) { - var remainder = this.addTextFacetRemainder(value); - var position = this.options.position + (remainder?1:0); - this.app.searchBox.addFacet(value, '', position); - } else { - this.app.searchBox.focusNextFacet(this, 0, { - skipToFacet: true, - selectText: true - }); - } - } else if (VS.app.hotkeys.command && - String.fromCharCode(e.which).toLowerCase() == 'a') { - e.preventDefault(); - this.app.searchBox.selectAllFacets(); - return false; - } else if (key == 'backspace' && !this.app.searchBox.allSelected()) { - if (this.box.getCursorPosition() == 0 && !this.box.getSelection().length) { - e.preventDefault(); - this.app.searchBox.focusNextFacet(this, -1, {backspace: true}); - return false; - } - } - - this.box.trigger('resize.autogrow', e); - } - -}); - -})(); - -(function(){ - - var $ = jQuery; // Handle namespaced jQuery - - // Makes the view enter a mode. Modes have both a 'mode' and a 'group', - // and are mutually exclusive with any other modes in the same group. - // Setting will update the view's modes hash, as well as set an HTML class - // of *[mode]_[group]* on the view's element. Convenient way to swap styles - // and behavior. - Backbone.View.prototype.setMode = function(mode, group) { - this.modes || (this.modes = {}); - if (this.modes[group] === mode) return; - $(this.el).setMode(mode, group); - this.modes[group] = mode; - }; - -})(); -(function() { - -var $ = jQuery; // Handle namespaced jQuery - -// DocumentCloud workspace hotkeys. To tell if a key is currently being pressed, -// just ask `VS.app.hotkeys.[key]` on `keypress`, or ask `VS.app.hotkeys.key(e)` -// on `keydown`. -// -// For the most headache-free way to use this utility, check modifier keys, -// like shift and command, with `VS.app.hotkeys.shift`, and check every other -// key with `VS.app.hotkeys.key(e) == 'key_name'`. -VS.app.hotkeys = { - - // Keys that will be mapped to the `hotkeys` namespace. - KEYS: { - '16': 'shift', - '17': 'command', - '91': 'command', - '93': 'command', - '224': 'command', - '13': 'enter', - '37': 'left', - '38': 'upArrow', - '39': 'right', - '40': 'downArrow', - '46': 'delete', - '8': 'backspace', - '9': 'tab', - '188': 'comma' - }, - - // Binds global keydown and keyup events to listen for keys that match `this.KEYS`. - initialize : function() { - _.bindAll(this, 'down', 'up', 'blur'); - $(document).bind('keydown', this.down); - $(document).bind('keyup', this.up); - $(window).bind('blur', this.blur); - }, - - // On `keydown`, turn on all keys that match. - down : function(e) { - var key = this.KEYS[e.which]; - if (key) this[key] = true; - }, - - // On `keyup`, turn off all keys that match. - up : function(e) { - var key = this.KEYS[e.which]; - if (key) this[key] = false; - }, - - // If an input is blurred, all keys need to be turned off, since they are no longer - // able to modify the document. - blur : function(e) { - for (var key in this.KEYS) this[this.KEYS[key]] = false; - }, - - // Check a key from an event and return the common english name. - key : function(e) { - return this.KEYS[e.which]; - }, - - // Colon is special, since the value is different between browsers. - colon : function(e) { - var charCode = e.which; - return charCode && String.fromCharCode(charCode) == ":"; - }, - - // Check a key from an event and match it against any known characters. - // The `keyCode` is different depending on the event type: `keydown` vs. `keypress`. - // - // These were determined by looping through every `keyCode` and `charCode` that - // resulted from `keydown` and `keypress` events and counting what was printable. - printable : function(e) { - var code = e.which; - if (e.type == 'keydown') { - if (code == 32 || // space - (code >= 48 && code <= 90) || // 0-1a-z - (code >= 96 && code <= 111) || // 0-9+-/*. - (code >= 186 && code <= 192) || // ;=,-./^ - (code >= 219 && code <= 222)) { // (\)' - return true; - } - } else { - // [space]!"#$%&'()*+,-.0-9:;<=>?@A-Z[\]^_`a-z{|} and unicode characters - if ((code >= 32 && code <= 126) || - (code >= 160 && code <= 500) || - (String.fromCharCode(code) == ":")) { - return true; - } - } - return false; - } - -}; - -})(); -(function() { - -var $ = jQuery; // Handle namespaced jQuery - -// Naive English transformations on words. Only used for a few transformations -// in VisualSearch.js. -VS.utils.inflector = { - - // Delegate to the ECMA5 String.prototype.trim function, if available. - trim : function(s) { - return s.trim ? s.trim() : s.replace(/^\s+|\s+$/g, ''); - }, - - // Escape strings that are going to be used in a regex. Escapes punctuation - // that would be incorrect in a regex. - escapeRegExp : function(s) { - return s.replace(/([.*+?^${}()|[\]\/\\])/g, '\\$1'); - } -}; - -})(); -(function() { - -var $ = jQuery; // Handle namespaced jQuery - -$.fn.extend({ - - // Makes the selector enter a mode. Modes have both a 'mode' and a 'group', - // and are mutually exclusive with any other modes in the same group. - // Setting will update the view's modes hash, as well as set an HTML class - // of *[mode]_[group]* on the view's element. Convenient way to swap styles - // and behavior. - setMode : function(state, group) { - group = group || 'mode'; - var re = new RegExp("\\w+_" + group + "(\\s|$)", 'g'); - var mode = (state === null) ? "" : state + "_" + group; - this.each(function() { - this.className = (this.className.replace(re, '')+' '+mode) - .replace(/\s\s/g, ' '); - }); - return mode; - }, - - // When attached to an input element, this will cause the width of the input - // to match its contents. This calculates the width of the contents of the input - // by measuring a hidden shadow div that should match the styling of the input. - autoGrowInput: function() { - return this.each(function() { - var $input = $(this); - var $tester = $('
    ').css({ - opacity : 0, - top : -9999, - left : -9999, - position : 'absolute', - whiteSpace : 'nowrap' - }).addClass('VS-input-width-tester').addClass('VS-interface'); - - // Watch for input value changes on all of these events. `resize` - // event is called explicitly when the input has been changed without - // a single keypress. - var events = 'keydown.autogrow keypress.autogrow ' + - 'resize.autogrow change.autogrow'; - $input.next('.VS-input-width-tester').remove(); - $input.after($tester); - $input.unbind(events).bind(events, function(e, realEvent) { - if (realEvent) e = realEvent; - var value = $input.val(); - - // Watching for the backspace key is tricky because it may not - // actually be deleting the character, but instead the key gets - // redirected to move the cursor from facet to facet. - if (VS.app.hotkeys.key(e) == 'backspace') { - var position = $input.getCursorPosition(); - if (position > 0) value = value.slice(0, position-1) + - value.slice(position, value.length); - } else if (VS.app.hotkeys.printable(e) && - !VS.app.hotkeys.command) { - value += String.fromCharCode(e.which); - } - value = value.replace(/&/g, '&') - .replace(/\s/g,' ') - .replace(//g, '>'); - - $tester.html(value); - $input.width($tester.width() + 3); - $input.trigger('updated.autogrow'); - }); - - // Sets the width of the input on initialization. - $input.trigger('resize.autogrow'); - }); - }, - - - // Cross-browser method used for calculating where the cursor is in an - // input field. - getCursorPosition: function() { - var position = 0; - var input = this.get(0); - - if (document.selection) { // IE - input.focus(); - var sel = document.selection.createRange(); - var selLen = document.selection.createRange().text.length; - sel.moveStart('character', -input.value.length); - position = sel.text.length - selLen; - } else if (input && $(input).is(':visible') && - input.selectionStart != null) { // Firefox/Safari - position = input.selectionStart; - } - - return position; - }, - - // A simple proxy for `selectRange` that sets the cursor position in an - // input field. - setCursorPosition: function(position) { - return this.each(function() { - return $(this).selectRange(position, position); - }); - }, - - // Cross-browser way to select text in an input field. - selectRange: function(start, end) { - return this.each(function() { - if (this.setSelectionRange) { // FF/Webkit - this.focus(); - this.setSelectionRange(start, end); - } else if (this.createTextRange) { // IE - var range = this.createTextRange(); - range.collapse(true); - range.moveEnd('character', end); - range.moveStart('character', start); - if (end - start >= 0) range.select(); - } - }); - }, - - // Returns an object that contains the text selection range values for - // an input field. - getSelection: function() { - var input = this[0]; - - if (input.selectionStart != null) { // FF/Webkit - var start = input.selectionStart; - var end = input.selectionEnd; - return { - start : start, - end : end, - length : end-start, - text : input.value.substr(start, end-start) - }; - } else if (document.selection) { // IE - var range = document.selection.createRange(); - if (range) { - var textRange = input.createTextRange(); - var copyRange = textRange.duplicate(); - textRange.moveToBookmark(range.getBookmark()); - copyRange.setEndPoint('EndToStart', textRange); - var start = copyRange.text.length; - var end = start + range.text.length; - return { - start : start, - end : end, - length : end-start, - text : range.text - }; - } - } - return {start: 0, end: 0, length: 0}; - } - -}); - -// Debugging in Internet Explorer. This allows you to use -// `console.log(['message', var1, var2, ...])`. Just remove the `false` and -// add your console.logs. This will automatically stringify objects using -// `JSON.stringify', so you can read what's going out. Think of this as a -// *Diet Firebug Lite Zero with Lemon*. -if ($.browser.msie && false) { - window.console = {}; - var _$ied; - window.console.log = function(msg) { - if (_.isArray(msg)) { - var message = msg[0]; - var vars = _.map(msg.slice(1), function(arg) { - return JSON.stringify(arg); - }).join(' - '); - } - if(!_$ied){ - _$ied = $('
      ').css({ - 'position': 'fixed', - 'bottom': 10, - 'left': 10, - 'zIndex': 20000, - 'width': $('body').width() - 80, - 'border': '1px solid #000', - 'padding': '10px', - 'backgroundColor': '#fff', - 'fontFamily': 'arial,helvetica,sans-serif', - 'fontSize': '11px' - }); - $('body').append(_$ied); - } - var $message = $('
    1. '+message+' - '+vars+'
    2. ').css({ - 'borderBottom': '1px solid #999999' - }); - _$ied.find('ol').append($message); - _.delay(function() { - $message.fadeOut(500); - }, 5000); - }; - -} - -})(); - -(function() { - -var $ = jQuery; // Handle namespaced jQuery - -// Used to extract keywords and facets from the free text search. -var FREETEXT_RE = '(\'[^\']+\'|"[^"]+"|[^\'"\\s]\\S*)'; -var CATEGORY_RE = FREETEXT_RE + ':\\s*'; -VS.app.SearchParser = { - - // Matches `category: "free text"`, with and without quotes. - ALL_FIELDS : new RegExp(CATEGORY_RE + FREETEXT_RE, 'g'), - - // Matches a single category without the text. Used to correctly extract facets. - CATEGORY : new RegExp(CATEGORY_RE), - - // Called to parse a query into a collection of `SearchFacet` models. - parse : function(instance, query) { - var searchFacets = this._extractAllFacets(instance, query); - instance.searchQuery.reset(searchFacets); - return searchFacets; - }, - - // Walks the query and extracts facets, categories, and free text. - _extractAllFacets : function(instance, query) { - var facets = []; - var originalQuery = query; - - while (query) { - var category, value; - originalQuery = query; - var field = this._extractNextField(query); - if (!field) { - category = 'text'; - value = this._extractSearchText(query); - query = VS.utils.inflector.trim(query.replace(value, '')); - } else if (field.indexOf(':') != -1) { - category = field.match(this.CATEGORY)[1].replace(/(^['"]|['"]$)/g, ''); - value = field.replace(this.CATEGORY, '').replace(/(^['"]|['"]$)/g, ''); - query = VS.utils.inflector.trim(query.replace(field, '')); - } else if (field.indexOf(':') == -1) { - category = 'text'; - value = field; - query = VS.utils.inflector.trim(query.replace(value, '')); - } - - if (category && value) { - var searchFacet = new VS.model.SearchFacet({ - category : category, - value : VS.utils.inflector.trim(value), - app : instance - }); - facets.push(searchFacet); - } - if (originalQuery == query) break; - } - - return facets; - }, - - // Extracts the first field found, capturing any free text that comes - // before the category. - _extractNextField : function(query) { - var textRe = new RegExp('^\\s*(\\S+)\\s+(?=' + CATEGORY_RE + FREETEXT_RE + ')'); - var textMatch = query.match(textRe); - if (textMatch && textMatch.length >= 1) { - return textMatch[1]; - } else { - return this._extractFirstField(query); - } - }, - - // If there is no free text before the facet, extract the category and value. - _extractFirstField : function(query) { - var fields = query.match(this.ALL_FIELDS); - return fields && fields.length && fields[0]; - }, - - // If the found match is not a category and facet, extract the trimmed free text. - _extractSearchText : function(query) { - query = query || ''; - var text = VS.utils.inflector.trim(query.replace(this.ALL_FIELDS, '')); - return text; - } - -}; - -})(); - -(function() { - -var $ = jQuery; // Handle namespaced jQuery - -// The model that holds individual search facets and their categories. -// Held in a collection by `VS.app.searchQuery`. -VS.model.SearchFacet = Backbone.Model.extend({ - - // Extract the category and value and serialize it in preparation for - // turning the entire searchBox into a search query that can be sent - // to the server for parsing and searching. - serialize : function() { - var category = this.quoteCategory(this.get('category')); - var value = VS.utils.inflector.trim(this.get('value')); - - if (!value) return ''; - - if (!_.contains(this.get("app").options.unquotable || [], category) && category != 'text') { - value = this.quoteValue(value); - } - - if (category != 'text') { - category = category + ': '; - } else { - category = ""; - } - return category + value; - }, - - // Wrap categories that have spaces or any kind of quote with opposite matching - // quotes to preserve the complex category during serialization. - quoteCategory : function(category) { - var hasDoubleQuote = (/"/).test(category); - var hasSingleQuote = (/'/).test(category); - var hasSpace = (/\s/).test(category); - - if (hasDoubleQuote && !hasSingleQuote) { - return "'" + category + "'"; - } else if (hasSpace || (hasSingleQuote && !hasDoubleQuote)) { - return '"' + category + '"'; - } else { - return category; - } - }, - - // Wrap values that have quotes in opposite matching quotes. If a value has - // both single and double quotes, just use the double quotes. - quoteValue : function(value) { - var hasDoubleQuote = (/"/).test(value); - var hasSingleQuote = (/'/).test(value); - - if (hasDoubleQuote && !hasSingleQuote) { - return "'" + value + "'"; - } else { - return '"' + value + '"'; - } - } - -}); - -})(); -(function() { - -var $ = jQuery; // Handle namespaced jQuery - -// Collection which holds all of the individual facets (category: value). -// Used for finding and removing specific facets. -VS.model.SearchQuery = Backbone.Collection.extend({ - - // Model holds the category and value of the facet. - model : VS.model.SearchFacet, - - // Turns all of the facets into a single serialized string. - serialize : function() { - return this.map(function(facet){ return facet.serialize(); }).join(' '); - }, - - facets : function() { - return this.map(function(facet) { - var value = {}; - value[facet.get('category')] = facet.get('value'); - return value; - }); - }, - - // Find a facet by its category. Multiple facets with the same category - // is fine, but only the first is returned. - find : function(category) { - var facet = this.detect(function(facet) { - return facet.get('category') == category; - }); - return facet && facet.get('value'); - }, - - // Counts the number of times a specific category is in the search query. - count : function(category) { - return this.select(function(facet) { - return facet.get('category') == category; - }).length; - }, - - // Returns an array of extracted values from each facet in a category. - values : function(category) { - var facets = this.select(function(facet) { - return facet.get('category') == category; - }); - return _.map(facets, function(facet) { return facet.get('value'); }); - }, - - // Checks all facets for matches of either a category or both category and value. - has : function(category, value) { - return this.any(function(facet) { - var categoryMatched = facet.get('category') == category; - if (!value) return categoryMatched; - return categoryMatched && facet.get('value') == value; - }); - }, - - // Used to temporarily hide a specific category and serialize the search query. - withoutCategory : function(category) { - return this.map(function(facet) { - if (facet.get('category') != category) return facet.serialize(); - }).join(' '); - } - -}); - -})();(function(){ -window.JST = window.JST || {}; - -window.JST['search_box'] = _.template(''); -window.JST['search_facet'] = _.template('<% if (model.has(\'category\')) { %>\n
      <%= model.get(\'category\') %>:
      \n<% } %>\n\n
      \n \n
      \n\n
      '); -window.JST['search_input'] = _.template(''); -})(); \ No newline at end of file diff --git a/addons/web/static/lib/visualsearch/demo.html b/addons/web/static/lib/visualsearch/demo.html deleted file mode 100755 index 212fb7cee28..00000000000 --- a/addons/web/static/lib/visualsearch/demo.html +++ /dev/null @@ -1,453 +0,0 @@ - - - - - - DocumentCloud's VisualSearch.js - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      - -

      VisualSearch.js

      - -

      - VisualSearch.js - enhances ordinary search boxes with the ability to autocomplete - faceted search queries. Specify the facets for completion, along with the - completable values for any facet. You can retrieve the search query as a - structured object, so you don't have to parse the query string yourself. -

      - -

      - The complete annotated source code - is also available. -

      - -

      - The project is - hosted on GitHub. - You can report bugs and discuss features on the - issues page, - on Freenode in the #documentcloud channel, - or send tweets to @documentcloud. -

      - -

      - VisualSearch.js is an open-source component of DocumentCloud. -

      -

      Demo Try searching for: account, filter, access, title, city, state, or country.

      - -
      -
       
      - - - - - -
      -
       
      - - - -
      - - - diff --git a/addons/web/static/lib/visualsearch/index.html b/addons/web/static/lib/visualsearch/index.html deleted file mode 100755 index 07cc8de20e8..00000000000 --- a/addons/web/static/lib/visualsearch/index.html +++ /dev/null @@ -1,537 +0,0 @@ - - - - - - DocumentCloud's VisualSearch.js - - - - - - - - - - - - - - - -
      - -

      VisualSearch.js

      - -

      - - Created by Samuel Clay, - @samuelclay. - -

      - -

      - VisualSearch.js - enhances ordinary search boxes with the ability to autocomplete - faceted search queries. Specify the facets for completion, along with the - completable values for any facet. You can retrieve the search query as a - structured object, so you don't have to parse the query string yourself. -

      - -

      - Here's an example of a search on DocumentCloud.org that uses facets. -

      - -

      - The project is - hosted on GitHub. - You can report bugs and discuss features on the - issues page, - on Freenode in the #documentcloud channel, - or send tweets to @documentcloud. -

      - -

      - VisualSearch.js is an open-source component of DocumentCloud. -
      - The complete annotated source code - is also available.
      -

      - -

      Table of Contents

      - - Demo | Downloads | Usage | Change Log - - -

      Demo Try searching for: account, filter, access, title, city, state, or country.

      - - - -
      -
       
      - - - -

      Downloads (Right-click, and use "Save As")

      - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      0. Everything (visualsearch.zip)
      Download everything
      1. VisualSearch JavaScript (visualsearch.js)
      Production Version (0.2.2)8kb, Minified and Gzipped
      Development Version (0.2.2)45kb, Uncompressed with Comments
      2. VisualSearch Stylesheets (visualsearch.css)
      You should include both the datauri and image urls versions. See how to include both
      Production Version - datauri4kb, Minified and Gzipped
      Production Version - image urls4kb, Minified and Gzipped
      Development Version8kb, Uncompressed with Comments
      3. VisualSearch Images
      Search Glyph4kb, embedded in visualsearch-datauri.css
      Cancel Button4kb, embedded in visualsearch-datauri.css
      4. VisualSearch Dependencies (jQuery 1.4+, jQuery UI, Underscore.js, Backbone.js)
      You should only include the dependencies you don't already have.
      Production Version - All49kb, Minified and Gzipped
      Development Version - All340kb, Uncompressed with Comments
      jQuery 1.6.1238kb, Uncompressed with Comments
      jQuery UI 1.8.13:
      Core Position Widget Autocomplete
      48kb, Uncompressed with Comments
      Underscore 1.1.529kb, Uncompressed with Comments
      Backbone 0.5.041kb, Uncompressed with Comments
      - -

      Usage

      - -

      To use VisualSearch.js on your site, follow these instructions on installation, configuration, and customization.

      - -
        -
      1. Insert the JavaScript and CSS into your page:
        -
        <script src="visualsearch.js" type="text/javascript"></script>
        -<!--[if (!IE)|(gte IE 8)]><!-->
        -  <link href="visualsearch-datauri.css" media="screen" rel="stylesheet" type="text/css" />
        -<!--<![endif]-->
        -<!--[if lte IE 7]><!-->
        -  <link href="visualsearch.css" media="screen" rel="stylesheet" type="text/css" />
        -<!--<![endif]-->
        -
      2. -
      3. Initialize the Visual Search box:
        -
        <div class="visual_search"></div>
        -
        -<script type="text/javascript" charset="utf-8">
        -  $(document).ready(function() {
        -    var visualSearch = VS.init({
        -      container : $('.visual_search'),
        -      query     : '',
        -      callbacks : {
        -        search       : function(query, searchCollection) {},
        -        facetMatches : function(callback) {},
        -        valueMatches : function(facet, searchTerm, callback) {}
        -      }
        -    });
        -  });
        -</script>
        -
      4. -
      5. Customize the autocompleted facets and values: -
        callbacks : {
        -  ...
        -  // These are the facets that will be autocompleted in an empty input.
        -  facetMatches : function(callback) {
        -    callback([
        -      'account', 'filter', 'access', 'title',
        -      { label: 'city',    category: 'location' },
        -      { label: 'address', category: 'location' },
        -      { label: 'country', category: 'location' },
        -      { label: 'state',   category: 'location' },
        -    ]);
        -  }
        -  ...
        -  // These are the values that match specific categories, autocompleted
        -  // in a category's input field.  searchTerm can be used to filter the
        -  // list on the server-side, prior to providing a list to the widget.
        -  valueMatches : function(facet, searchTerm, callback) {
        -    switch (facet) {
        -    case 'account':
        -        callback([
        -          { value: '1-amanda', label: 'Amanda' },
        -          { value: '2-aron',   label: 'Aron' },
        -          { value: '3-eric',   label: 'Eric' },
        -          { value: '4-jeremy', label: 'Jeremy' },
        -          { value: '5-samuel', label: 'Samuel' },
        -          { value: '6-scott',  label: 'Scott' }
        -        ]);
        -        break;
        -      case 'filter':
        -        callback(['published', 'unpublished', 'draft']);
        -        break;
        -      case 'access':
        -        callback(['public', 'private', 'protected']);
        -        break;
        -      case 'title':
        -        callback([
        -          'Pentagon Papers',
        -          'CoffeeScript Manual',
        -          'Laboratory for Object Oriented Thinking',
        -          'A Repository Grows in Brooklyn'
        -        ]);
        -        break;
        -    }
        -  }
        -...
        -}
        -
      6. -
      7. Inspect the Visual Search box -
        // Returns the unstructured search query
        -visualSearch.searchBox.value() 
        -// "country: "South Africa" account: 5-samuel title: "Pentagon Papers""
        -
        -// Returns an array of Facet model instances
        -visualSearch.searchQuery.facets()
        -// [FacetModel<country:"South Africa">, 
        -//  FacetModel<account:5-samuel>, 
        -//  FacetModel<title:"Pentagon Papers">]
        -
        -// Set the search query with raw text
        -visualSearch.searchBox.value("Country: US State: \"New York\" Key: Value")
        -
        -
        -
      8. -
      - - -

      Change Log

      - -

      - 0.2.2 March 10th, 2012
      - If you do not want to automatically filter the value matches, you can pass an - options hash with preserveMatches: true as the second argument to the callback. - See pull request #44 for details. -

      -

      - 0.2.1 November 14th, 2011
      - The autocompleted facets and values that are provided by your callbacks facetMatches - and valueMatches can now preserve the order of items you give them. Simply pass an - options hash with preserveOrder: true as the second argument to the callback. See - the demo page for an example. -

      -

      - 0.2.0 August 10th, 2011
      - Multiple instances of VisualSearch on a single page. VS.init now returns - a reference to the instance. The search callback now contains both the - serialized search query and a reference to the search query collection (as a - Backbone.Collection), - which can be used to manipulate each facet directly. See - the source code for search_query.js for available - methods on the collection. -

      -

      - 0.1.0 June 23rd, 2011
      - Initial release of VisualSearch.js. -

      - -

      - - A DocumentCloud Project - -

      - -
      - - - diff --git a/addons/web/static/src/css/base.css b/addons/web/static/src/css/base.css index 4a426e13e39..f2fcc9abf33 100644 --- a/addons/web/static/src/css/base.css +++ b/addons/web/static/src/css/base.css @@ -1160,140 +1160,164 @@ white-space: nowrap; } .openerp .oe_searchview { + cursor: text; position: relative; float: right; -} -.openerp .oe_searchview .VS-search .VS-search-box { - min-height: 0; - padding: 0; - width: 480px; + padding-right: 20px; + width: 410px; border: 1px solid #ababab; - -moz-border-radius: 13px; - -webkit-border-radius: 13px; - border-radius: 13px; + background: white; + -moz-border-radius: 1em; + -webkit-border-radius: 1em; + border-radius: 1em; -moz-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2) inset; -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2) inset; -box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2) inset; } -.openerp .oe_searchview .VS-search .VS-icon { - top: 6px; +.openerp .oe_searchview.oe_focused { + border-color: #a6a6fe; + -moz-box-shadow: 0 1px 2px #a6a6fe inset; + -webkit-box-shadow: 0 1px 2px #a6a6fe inset; + -box-shadow: 0 1px 2px #a6a6fe inset; } -.openerp .oe_searchview .VS-search .VS-icon-search { - left: 2px; - top: 3px; - height: 18px; - width: 18px; +.openerp .oe_searchview .oe_searchview_clear { + cursor: pointer; + position: absolute; + top: 0; + right: 22px; + width: 15px; + height: 100%; + background: url(../img/attachments-close.png) center center no-repeat; } -.openerp .oe_searchview .VS-search .VS-search-inner { - margin: 0 40px 0 17px; - padding: 1px 0; - font-size: 13px; +.openerp .oe_searchview .oe_searchview_unfold_drawer { + position: absolute; + top: 0; + right: 0; + height: 100%; + line-height: 2.5em; + padding: 0 7px 0 4px; + color: #cccccc; + cursor: pointer; } -.openerp .oe_searchview .VS-search .VS-search-inner input { - font-size: inherit; - line-height: inherit; - height: auto; - padding: 0; +.openerp .oe_searchview .oe_searchview_unfold_drawer:hover { + color: #999999; } -.openerp .oe_searchview .VS-search .VS-search-inner .VS-input-width-tester { - font-size: inherit; - padding: 0; +.openerp .oe_searchview .oe_searchview_unfold_drawer:before { + content: "◀"; } -.openerp .oe_searchview .VS-search .VS-search-inner .search_input { - margin-left: 0; - height: 22px; +.openerp .oe_searchview .oe_searchview_facets:before { + color: #cccccc; + font-family: "mnmliconsRegular"; + content: "r"; + font-size: 150%; + padding: 0 1px 0 3px; + display: inline; } -.openerp .oe_searchview .VS-search .VS-search-inner .search_input input, .openerp .oe_searchview .VS-search .VS-search-inner .search_input .VS-input-width-tester { - height: inherit; - margin: 0; +.openerp .oe_searchview .oe_searchview_facets * { + vertical-align: top; + display: inline-block; + line-height: 26px; } -.openerp .oe_searchview .VS-search .VS-search-inner .search_facet { - padding: 0; - margin: 1px 0; +.openerp .oe_searchview .oe_searchview_facets .oe_searchview_input, .openerp .oe_searchview .oe_searchview_facets .oe_searchview_facet { + height: 26px; +} +.openerp .oe_searchview .oe_searchview_facets .oe_searchview_input:focus, .openerp .oe_searchview .oe_searchview_facets .oe_searchview_facet:focus { + outline: none; +} +.openerp .oe_searchview .oe_searchview_facets .oe_searchview_input { + padding: 0 3px; +} +.openerp .oe_searchview .oe_searchview_facets .oe_searchview_facet { + position: relative; + cursor: pointer; border: 1px solid #afafb6; -moz-border-radius: 3px; -webkit-border-radius: 3px; border-radius: 3px; - background: #f0f0fa; - height: 18px; + background: #8786b7; + -webkit-font-smoothing: auto; + padding-left: 1.1em; + margin: 1px 0; } -.openerp .oe_searchview .VS-search .VS-search-inner .search_facet.is_selected { +.openerp .oe_searchview .oe_searchview_facets .oe_searchview_facet:focus { border-color: #a6a6fe; -moz-box-shadow: 0 0 3px 1px #a6a6fe; -webkit-box-shadow: 0 0 3px 1px #a6a6fe; -box-shadow: 0 0 3px 1px #a6a6fe; } -.openerp .oe_searchview .VS-search .VS-search-inner .search_facet.is_selected .category { - margin-left: 0; +.openerp .oe_searchview .oe_searchview_facets .oe_searchview_facet .oe_facet_values { + background: #f0f0fa; + -moz-border-radius: 0 3px 3px 0; + -webkit-border-radius: 0 3px 3px 0; + border-radius: 0 3px 3px 0; } -.openerp .oe_searchview .VS-search .VS-search-inner .search_facet.is_selected .VS-icon-cancel { - filter: alpha(opacity=100); - opacity: 1; - background-position: center 0; +.openerp .oe_searchview .oe_searchview_facets .oe_searchview_facet .oe_facet_category, .openerp .oe_searchview .oe_searchview_facets .oe_searchview_facet .oe_facet_value { + height: 24px; + padding: 1px 0.1em; } -.openerp .oe_searchview .VS-search .VS-search-inner .search_facet .category { - padding: 0 4px 0 14px; +.openerp .oe_searchview .oe_searchview_facets .oe_searchview_facet .oe_facet_category { color: white; - background: #8786b7; text-shadow: 0 1px 1px rgba(0, 0, 0, 0.4); - font-weight: normal; - text-transform: none; - height: 18px; - line-height: 18px; - font-size: inherit; } -.openerp .oe_searchview .VS-search .VS-search-inner .search_facet .search_facet_input_container { +.openerp .oe_searchview .oe_searchview_facets .oe_searchview_facet .oe_facet_value { border-left: 1px solid #afafb6; - height: 18px; - line-height: 18px; - padding: 0 4px; - cursor: pointer; } -.openerp .oe_searchview .VS-search .VS-search-inner .search_facet .search_facet_remove { - left: 1px; - top: 3px; -} -.openerp .oe_searchview .VS-search .VS-icon-cancel { - right: 24px; -} -.openerp .oe_searchview .VS-search .oe_vs_unfold_drawer { +.openerp .oe_searchview .oe_searchview_facets .oe_searchview_facet .oe_facet_remove { position: absolute; top: 0; - right: 0; - height: 100%; - line-height: 23px; - padding: 0 7px 0 4px; - color: #4c4c4c; - cursor: pointer; + left: 2px; + color: white; } -.openerp .oe_searchview .VS-search .oe_vs_unfold_drawer:before { - position: absolute; - top: 9px; - right: 8px; - width: 0; - height: 0; - display: inline-block; - content: ""; - vertical-align: top; - border-top: 5px solid #4c4c4c; - border-left: 5px solid transparent; - border-right: 5px solid transparent; - filter: alpha(opacity=50); - opacity: 0.5; +.openerp .oe_searchview .oe_searchview_facets .oe_searchview_facet .oe_facet_remove:hover { + color: #cccccc; } .openerp .oe_searchview.oe_searchview_open_drawer .oe_searchview_drawer { display: block; } +.openerp .oe_searchview.oe_searchview_open_drawer .oe_searchview_unfold_drawer:before { + content: "▼"; +} .openerp .oe_searchview .oe_searchview_drawer { position: absolute; z-index: 1; + margin-top: 3px; top: 100%; right: 0; background-color: white; - width: 480px; + min-width: 100%; display: none; border: 1px solid #cccccc; text-align: left; + padding-bottom: 0.5em; + -moz-border-radius: 1em; + -webkit-border-radius: 1em; + border-radius: 1em; +} +.openerp .oe_searchview .oe_searchview_drawer > div { + border-top: 1px solid #cccccc; + margin: 3px 0; +} +.openerp .oe_searchview .oe_searchview_drawer > div:first-child { + border-top: none; + margin: 0; +} +.openerp .oe_searchview .oe_searchview_drawer h4, .openerp .oe_searchview .oe_searchview_drawer h4 * { + margin: 0; + cursor: pointer; +} +.openerp .oe_searchview .oe_searchview_drawer h4:before { + content: "▸ "; +} +.openerp .oe_searchview .oe_searchview_drawer button, .openerp .oe_searchview .oe_searchview_drawer .button { + border: none; + background: transparent; + padding: 0 2px; + -moz-box-shadow: none; + -webkit-box-shadow: none; + -box-shadow: none; + -moz-border-radius: 0; + -webkit-border-radius: 0; + border-radius: 0; } .openerp .oe_searchview .oe_searchview_drawer .oe_searchview_filters { display: table; @@ -1317,23 +1341,49 @@ } .openerp .oe_searchview .oe_searchview_drawer .oe_searchview_filters li { list-style: none; - padding: 3px 6px; - height: 14px; + padding: 3px 6px 3px 18px; + line-height: 14px; color: inherit; cursor: pointer; } +.openerp .oe_searchview .oe_searchview_drawer .oe_searchview_filters li.oe_selected { + background: url(/web/static/src/img/icons/gtk-apply.png) left 2px no-repeat; +} .openerp .oe_searchview .oe_searchview_drawer .oe_searchview_filters li:hover { background-color: #f0f0fa; } -.openerp .oe_searchview .oe_searchview_drawer .oe_searchview_advanced h4 { - border-top: 1px solid #cccccc; - margin: 5px 0 3px; +.openerp .oe_searchview .oe_searchview_drawer .oe_searchview_custom form { + display: none; +} +.openerp .oe_searchview .oe_searchview_drawer .oe_searchview_custom form button { + font-size: 1px; + letter-spacing: -1px; + color: transparent; +} +.openerp .oe_searchview .oe_searchview_drawer .oe_searchview_custom form button:before { + font-family: "mnmliconsRegular"; + content: "S"; + font-size: 20px; + color: #404040; +} +.openerp .oe_searchview .oe_searchview_drawer .oe_searchview_custom li { cursor: pointer; + position: relative; + line-height: 1.2em; + padding: 2px 20px 2px 25px; } -.openerp .oe_searchview .oe_searchview_drawer .oe_searchview_advanced h4:before { - content: "▸ "; +.openerp .oe_searchview .oe_searchview_drawer .oe_searchview_custom li.oe_searchview_custom_private { + background: url(/web/static/src/img/icons/terp-locked.png) 5px center no-repeat; } -.openerp .oe_searchview .oe_searchview_drawer .oe_searchview_advanced div { +.openerp .oe_searchview .oe_searchview_drawer .oe_searchview_custom li:hover { + background-color: #f0f0fa; +} +.openerp .oe_searchview .oe_searchview_drawer .oe_searchview_custom li button { + position: absolute; + top: 0; + right: 5px; +} +.openerp .oe_searchview .oe_searchview_drawer .oe_searchview_advanced form { display: none; } .openerp .oe_searchview .oe_searchview_drawer .oe_searchview_advanced button.oe_add_condition:before { @@ -1349,13 +1399,25 @@ .openerp .oe_searchview .oe_searchview_drawer .oe_searchview_advanced li { list-style: none; margin: 0; + white-space: nowrap; } -.openerp .oe_searchview .oe_searchview_drawer .oe_searchview_advanced.oe_opened h4:before { +.openerp .oe_searchview .oe_searchview_drawer .oe_opened h4:before { content: "▾ "; } -.openerp .oe_searchview .oe_searchview_drawer .oe_searchview_advanced.oe_opened div { +.openerp .oe_searchview .oe_searchview_drawer .oe_opened form { display: block; } +.openerp .oe_searchview .oe_searchview_drawer .oe_searchview_custom_delete, .openerp .oe_searchview .oe_searchview_drawer .searchview_extended_delete_prop { + font-size: 1px; + letter-spacing: -1px; + color: transparent; +} +.openerp .oe_searchview .oe_searchview_drawer .oe_searchview_custom_delete:before, .openerp .oe_searchview .oe_searchview_drawer .searchview_extended_delete_prop:before { + font-family: "mnmliconsRegular"; + content: "d"; + font-size: 20px; + color: #404040; +} .openerp .oe_view_nocontent > img { float: left; margin: 1.5em; diff --git a/addons/web/static/src/css/base.sass b/addons/web/static/src/css/base.sass index f128d93c4c1..3dcde9c9f70 100644 --- a/addons/web/static/src/css/base.sass +++ b/addons/web/static/src/css/base.sass @@ -4,6 +4,7 @@ $section-title-color: #8786b7 $facets-border: #afafb6 $facets-border-selected: #a6a6fe +$hover-background: #f0f0fa $colour4: #8a89ba // }}} // Mixins {{{ @@ -74,6 +75,18 @@ $colour4: #8a89ba -webkit-box-sizing: border-box box-sizing: border-box +// Transforms the (readable) text of an inline element into an mmlicons icon, +// allows for actual readable text in-code (and in readers?) with iconic looks +@mixin text-to-icon($icon-name, $color: #404040) + font-size: 1px + letter-spacing: -1px + color: transparent + &:before + font-family: "mnmliconsRegular" + content: $icon-name + font-size: 20px + color: $color + // }}} .openerp.openerp-web-client-container @@ -918,132 +931,145 @@ $colour4: #8a89ba // }}} // SearchView xmo {{{ .oe_searchview + cursor: text position: relative float: right - .VS-search - .VS-search-box - min-height: 0 - padding: 0 - width: 480px - border: 1px solid #ababab - @include radius(13px) - @include box-shadow(0 1px 2px rgba(0,0,0,0.2) inset) - //padding: 4px 19px - //font-size: 13px - //background: url('../img/search.png') no-repeat 5px - //background-color: white - .VS-icon - top: 6px + padding-right: 20px + width: 410px + border: 1px solid #ababab + background: white + @include radius(1em) + @include box-shadow(0 1px 2px rgba(0,0,0,0.2) inset) - .VS-icon-search - left: 2px - top: 3px - height: 18px - width: 18px + &.oe_focused + border-color: $facets-border-selected + @include box-shadow(0 1px 2px $facets-border-selected inset) - .VS-search-inner - margin: 0 40px 0 17px - padding: 1px 0 - font-size: 13px + .oe_searchview_clear + cursor: pointer + position: absolute + top: 0 + right: 22px + width: 15px + height: 100% + background: url(../img/attachments-close.png) center center no-repeat - input - font-size: inherit - line-height: inherit - height: auto - padding: 0 + .oe_searchview_unfold_drawer + position: absolute + top: 0 + right: 0 + height: 100% + line-height: 2.5em + padding: 0 7px 0 4px + color: #ccc + cursor: pointer + &:hover + color: #999 + &:before + content: "◀" - .VS-input-width-tester - font-size: inherit - padding: 0 + .oe_searchview_facets + &:before + color: #ccc + font-family: "mnmliconsRegular" + content: "r" + font-size: 150% + padding: 0 1px 0 3px + display: inline - .search_input - margin-left: 0 - height: 22px - input, .VS-input-width-tester - height: inherit - margin: 0 + * + vertical-align: top + display: inline-block + line-height: 26px - .search_facet - padding: 0 - margin: 1px 0 - border: 1px solid $facets-border - @include radius(3px) - background: #f0f0fa - height: 18px + .oe_searchview_input, .oe_searchview_facet + height: 26px + &:focus + outline: none - &.is_selected - border-color: $facets-border-selected - @include box-shadow(0 0 3px 1px $facets-border-selected) + .oe_searchview_input + padding: 0 3px - .category - margin-left: 0 - .VS-icon-cancel - // don't change the icon on selection - @include opacity(1.0) - background-position: center 0 - - .category - padding: 0 4px 0 14px - color: white - background: #8786b7 - text-shadow: 0 1px 1px rgba(0, 0, 0, 0.4) - font-weight: normal - text-transform: none - height: 18px - line-height: 18px - font-size: inherit - - .search_facet_input_container - border-left: 1px solid $facets-border - height: 18px - line-height: 18px - padding: 0 4px - cursor: pointer - - .search_facet_remove - left: 1px - top: 3px - - .VS-icon-cancel - right: 24px - - .oe_vs_unfold_drawer - position: absolute - top: 0 - right: 0 - height: 100% - line-height: 23px - padding: 0 7px 0 4px - color: #4c4c4c + .oe_searchview_facet + position: relative cursor: pointer - &:before + border: 1px solid $facets-border + @include radius(3px) + background: #8786b7 + -webkit-font-smoothing: auto + padding-left: 1.1em + // spacing for opera, FF + margin: 1px 0 + + &:focus + border-color: $facets-border-selected + @include box-shadow(0 0 3px 1px $facets-border-selected) + + .oe_facet_values + background: #f0f0fa + @include radius(0 3px 3px 0) + + .oe_facet_category, .oe_facet_value + height: 24px + padding: 1px 0.1em + + .oe_facet_category + color: white + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.4) + + .oe_facet_value + border-left: 1px solid $facets-border + + .oe_facet_remove position: absolute - top: 9px - right: 8px - width: 0 - height: 0 - display: inline-block - content: "" - vertical-align: top - border-top: 5px solid #4c4c4c - border-left: 5px solid transparent - border-right: 5px solid transparent - @include opacity(0.5) + top: 0 + left: 2px + color: white + + &:hover + color: #ccc &.oe_searchview_open_drawer .oe_searchview_drawer display: block + .oe_searchview_unfold_drawer:before + content: "▼" .oe_searchview_drawer position: absolute z-index: 1 + // detach drawer from field slightly + margin-top: 3px top: 100% right: 0 background-color: white - width: 480px + min-width: 100% display: none border: 1px solid #ccc text-align: left + padding-bottom: 0.5em + @include radius(1em) + + > div + border-top: 1px solid #ccc + margin: 3px 0 + > div:first-child + border-top: none + margin: 0 + + h4, h4 * + margin: 0 + cursor: pointer + + h4:before + content: "▸ " + + button, .button + border: none + background: transparent + padding: 0 2px + @include box-shadow(none) + @include radius(0) .oe_searchview_filters display: table @@ -1065,24 +1091,44 @@ $colour4: #8a89ba li list-style: none - padding: 3px 6px - height: 14px + padding: 3px 6px 3px 18px + line-height: 14px color: inherit cursor: pointer + &.oe_selected + background: url(/web/static/src/img/icons/gtk-apply.png) left 2px no-repeat + // after oe_selected so background color is not overridden &:hover - background-color: #f0f0fa + background-color: $hover-background + + + .oe_searchview_custom + form + display: none + + button + @include text-to-icon("S") + li + cursor: pointer + position: relative + line-height: 1.2em + padding: 2px 20px 2px 25px + + &.oe_searchview_custom_private + background: url(/web/static/src/img/icons/terp-locked.png) 5px center no-repeat + + &:hover + background-color: $hover-background + + button + position: absolute + top: 0 + right: 5px + .oe_searchview_advanced - h4 - border-top: 1px solid #ccc - margin: 5px 0 3px - cursor: pointer - - h4:before - content: "▸ " - - div + form display: none button.oe_add_condition:before @@ -1096,12 +1142,17 @@ $colour4: #8a89ba li list-style: none margin: 0 + white-space: nowrap - &.oe_opened - h4:before - content: "▾ " - div - display: block + .oe_opened + h4:before + content: "▾ " + form + display: block + + // delete buttons + .oe_searchview_custom_delete, .searchview_extended_delete_prop + @include text-to-icon("d") // }}} // Views Common {{{ .oe_view_nocontent diff --git a/addons/web/static/src/js/formats.js b/addons/web/static/src/js/formats.js index 640ba4d5a93..2b527686ddd 100644 --- a/addons/web/static/src/js/formats.js +++ b/addons/web/static/src/js/formats.js @@ -111,7 +111,7 @@ instance.web.format_value = function (value, descriptor, value_if_empty) { return value_if_empty === undefined ? '' : value_if_empty; } var l10n = _t.database.parameters; - switch (descriptor.type || (descriptor.field && descriptor.field.type)) { + switch (descriptor.widget || descriptor.type || (descriptor.field && descriptor.field.type)) { case 'id': return value.toString(); case 'integer': @@ -171,7 +171,7 @@ instance.web.parse_value = function (value, descriptor, value_if_empty) { case "": return value_if_empty === undefined ? false : value_if_empty; } - switch (descriptor.type || (descriptor.field && descriptor.field.type)) { + switch (descriptor.widget || descriptor.type || (descriptor.field && descriptor.field.type)) { case 'integer': var tmp; do { diff --git a/addons/web/static/src/js/search.js b/addons/web/static/src/js/search.js index c2df866b5c6..c70cf2ab470 100644 --- a/addons/web/static/src/js/search.js +++ b/addons/web/static/src/js/search.js @@ -6,67 +6,269 @@ _.mixin({ sum: function (obj) { return _.reduce(obj, function (a, b) { return a + b; }, 0); } }); -// Have SearchBox optionally use callback function to produce inputs and facets -// (views) set on callbacks.make_facet and callbacks.make_input keys when -// initializing VisualSearch -var SearchBox_renderFacet = function (facet, position) { - var view = new (this.app.options.callbacks['make_facet'] || VS.ui.SearchFacet)({ - app : this.app, - model : facet, - order : position - }); +/** @namespace */ +var my = instance.web.search = {}; - // Input first, facet second. - this.renderSearchInput(); - this.facetViews.push(view); - this.$('.VS-search-inner').children().eq(position*2).after(view.render().el); +var B = Backbone; +my.FacetValue = B.Model.extend({ - view.calculateSize(); - _.defer(_.bind(view.calculateSize, view)); - - return view; - }; // warning: will not match -// Ensure we're replacing the function we think -if (SearchBox_renderFacet.toString() !== VS.ui.SearchBox.prototype.renderFacet.toString().replace(/(VS\.ui\.SearchFacet)/, "(this.app.options.callbacks['make_facet'] || $1)")) { - throw new Error( - "Trying to replace wrong version of VS.ui.SearchBox#renderFacet. " - + "Please fix replacement."); -} -var SearchBox_renderSearchInput = function () { - var input = new (this.app.options.callbacks['make_input'] || VS.ui.SearchInput)({position: this.inputViews.length, app: this.app}); - this.$('.VS-search-inner').append(input.render().el); - this.inputViews.push(input); - }; -// Ensure we're replacing the function we think -if (SearchBox_renderSearchInput.toString() !== VS.ui.SearchBox.prototype.renderSearchInput.toString().replace(/(VS\.ui\.SearchInput)/, "(this.app.options.callbacks['make_input'] || $1)")) { - throw new Error( - "Trying to replace wrong version of VS.ui.SearchBox#renderSearchInput. " - + "Please fix replacement."); -} -var SearchBox_searchEvent = function (e) { - var query = null; - this.renderFacets(); - this.focusSearch(e); - this.app.options.callbacks.search(query, this.app.searchQuery); - }; -if (SearchBox_searchEvent.toString() !== VS.ui.SearchBox.prototype.searchEvent.toString().replace( - /this\.value\(\);\n[ ]{4}this\.focusSearch\(e\);\n[ ]{4}this\.value\(query\)/, - 'null;\n this.renderFacets();\n this.focusSearch(e)')) { - throw new Error( - "Trying to replace wrong version of VS.ui.SearchBox#searchEvent. " - + "Please fix replacement."); -} -_.extend(VS.ui.SearchBox.prototype, { - renderFacet: SearchBox_renderFacet, - renderSearchInput: SearchBox_renderSearchInput, - searchEvent: SearchBox_searchEvent }); -_.extend(VS.model.SearchFacet.prototype, { - value: function () { - if (this.has('json')) { - return this.get('json'); +my.FacetValues = B.Collection.extend({ + model: my.FacetValue +}); +my.Facet = B.Model.extend({ + initialize: function (attrs) { + var values = attrs.values; + delete attrs.values; + + B.Model.prototype.initialize.apply(this, arguments); + + this.values = new my.FacetValues(values || []); + this.values.on('add remove change reset', function () { + this.trigger('change', this); + }, this); + }, + get: function (key) { + if (key !== 'values') { + return B.Model.prototype.get.call(this, key); } - return this.get('value'); + return this.values.toJSON(); + }, + set: function (key, value) { + if (key !== 'values') { + return B.Model.prototype.set.call(this, key, value); + } + this.values.reset(value); + }, + toJSON: function () { + var out = {}; + var attrs = this.attributes; + for(var att in attrs) { + if (!attrs.hasOwnProperty(att) || att === 'field') { + continue; + } + out[att] = attrs[att]; + } + out.values = this.values.toJSON(); + return out; + } +}); +my.SearchQuery = B.Collection.extend({ + model: my.Facet, + initialize: function () { + B.Collection.prototype.initialize.apply( + this, arguments); + this.on('change', function (facet) { + if(!facet.values.isEmpty()) { return; } + + this.remove(facet); + }, this); + }, + add: function (values, options) { + options || (options = {}); + if (!(values instanceof Array)) { + values = [values]; + } + + _(values).each(function (value) { + var model = this._prepareModel(value, options); + var previous = this.detect(function (facet) { + return facet.get('category') === model.get('category') + && facet.get('field') === model.get('field'); + }); + if (previous) { + previous.values.add(model.get('values')); + return; + } + B.Collection.prototype.add.call(this, model, options); + }, this); + return this; + }, + toggle: function (value, options) { + options || (options = {}); + + var facet = this.detect(function (facet) { + return facet.get('category') === value.category + && facet.get('field') === value.field; + }); + if (!facet) { + return this.add(value, options); + } + + var changed = false; + _(value.values).each(function (val) { + var already_value = facet.values.detect(function (v) { + return v.get('value') === val.value + && v.get('label') === val.label; + }); + // toggle value + if (already_value) { + facet.values.remove(already_value, {silent: true}); + } else { + facet.values.add(val, {silent: true}); + } + changed = true; + }); + // "Commit" changes to values array as a single call, so observers of + // change event don't get misled by intermediate incomplete toggling + // states + facet.trigger('change', facet); + return this; + } +}); + +function assert(condition, message) { + if(!condition) { + throw new Error(message); + } +} +my.InputView = instance.web.Widget.extend({ + template: 'SearchView.InputView', + start: function () { + var p = this._super.apply(this, arguments); + this.$element.on('focus', this.proxy('onFocus')); + this.$element.on('blur', this.proxy('onBlur')); + this.$element.on('keydown', this.proxy('onKeydown')); + return p; + }, + onFocus: function () { + this.trigger('focused', this); + }, + onBlur: function () { + this.$element.text(''); + this.trigger('blurred', this); + }, + getSelection: function () { + // get Text node + var root = this.$element[0].childNodes[0]; + if (!root || !root.textContent) { + // if input does not have a child node, or the child node is an + // empty string, then the selection can only be (0, 0) + return {start: 0, end: 0}; + } + if (window.getSelection) { + var domRange = window.getSelection().getRangeAt(0); + assert(domRange.startContainer === root, + "selection should be in the input view"); + assert(domRange.endContainer === root, + "selection should be in the input view"); + return { + start: domRange.startOffset, + end: domRange.endOffset + } + } else if (document.selection) { + var ieRange = document.selection.createRange(); + var rangeParent = ieRange.parentElement(); + assert(rangeParent === root, + "selection should be in the input view"); + var offsetRange = document.body.createTextRange(); + offsetRange = offsetRange.moveToElementText(rangeParent); + offsetRange.setEndPoint("EndToStart", ieRange); + var start = offsetRange.text.length; + return { + start: start, + end: start + ieRange.text.length + } + } + throw new Error("Could not get caret position"); + }, + onKeydown: function (e) { + var sel; + switch (e.which) { + // Do not insert newline, but let it bubble so searchview can use it + case $.ui.keyCode.ENTER: + e.preventDefault(); + break; + + // FIXME: may forget content if non-empty but caret at index 0, ok? + case $.ui.keyCode.BACKSPACE: + sel = this.getSelection(); + if (sel.start === 0 && sel.start === sel.end) { + e.preventDefault(); + var preceding = this.getParent().siblingSubview(this, -1); + if (preceding && (preceding instanceof my.FacetView)) { + preceding.model.destroy(); + } + } + break; + + // let left/right events propagate to view if caret is at input border + // and not a selection + case $.ui.keyCode.LEFT: + sel = this.getSelection(); + if (sel.start !== 0 || sel.start !== sel.end) { + e.stopPropagation(); + } + break; + case $.ui.keyCode.RIGHT: + sel = this.getSelection(); + var len = this.$element.text().length; + if (sel.start !== len || sel.start !== sel.end) { + e.stopPropagation(); + } + break; + } + } +}); +my.FacetView = instance.web.Widget.extend({ + template: 'SearchView.FacetView', + init: function (parent, model) { + this._super(parent); + this.model = model; + this.model.on('change', this.model_changed, this); + }, + destroy: function () { + this.model.off('change', this.model_changed, this); + this._super(); + }, + start: function () { + var self = this; + this.$element.on('focus', function () { self.trigger('focused', self); }); + this.$element.on('blur', function () { self.trigger('blurred', self); }); + this.$element.on('click', function (e) { + if ($(e.target).is('.oe_facet_remove')) { + self.model.destroy(); + return false; + } + self.$element.focus(); + e.stopPropagation(); + }); + this.$element.on('keydown', function (e) { + var keys = $.ui.keyCode; + switch (e.which) { + case keys.BACKSPACE: + case keys.DELETE: + self.model.destroy(); + return false; + } + }); + var $e = self.$element.find('> span:last-child'); + var q = $.when(this._super()); + return q.pipe(function () { + var values = self.model.values.map(function (value) { + return new my.FacetValueView(self, value).appendTo($e); + }); + + return $.when.apply(null, values); + }); + }, + model_changed: function () { + this.$element.text(this.$element.text() + '*'); + } +}); +my.FacetValueView = instance.web.Widget.extend({ + template: 'SearchView.FacetView.Value', + init: function (parent, model) { + this._super(parent); + this.model = model; + this.model.on('change', this.model_changed, this); + }, + destroy: function () { + this.model.off('change', this.model_changed, this); + this._super(); + }, + model_changed: function () { + this.$element.text(this.$element.text() + '*'); } }); @@ -74,13 +276,13 @@ instance.web.SearchView = instance.web.Widget.extend(/** @lends instance.web.Sea template: "SearchView", /** * @constructs instance.web.SearchView - * @extends instance.web.OldWidget + * @extends instance.web.Widget * * @param parent - * @param element_id * @param dataset * @param view_id * @param defaults + * @param hidden */ init: function(parent, dataset, view_id, defaults, hidden) { this._super(parent); @@ -97,7 +299,7 @@ instance.web.SearchView = instance.web.Widget.extend(/** @lends instance.web.Sea this.hidden = !!hidden; this.headless = this.hidden && !this.has_defaults; - this.filter_data = {}; + this.input_subviews = []; this.ready = $.Deferred(); }, @@ -106,33 +308,9 @@ instance.web.SearchView = instance.web.Widget.extend(/** @lends instance.web.Sea var p = this._super(); this.setup_global_completion(); - this.vs = VS.init({ - container: this.$element, - query: '', - callbacks: { - make_facet: this.proxy('make_visualsearch_facet'), - make_input: this.proxy('make_visualsearch_input'), - search: function (query, searchCollection) { - self.do_search(); - }, - facetMatches: function (callback) { - }, - valueMatches : function(facet, searchTerm, callback) { - } - } - }); - - var search = function () { self.vs.searchBox.searchEvent({}); }; - // searchQuery operations - this.vs.searchQuery - .off('add').on('add', search) - .off('change').on('change', search) - .off('reset').on('reset', search) - .off('remove').on('remove', function (record, collection, options) { - if (options['trigger_search']) { - search(); - } - }); + this.query = new my.SearchQuery() + .on('add change reset remove', this.proxy('do_search')) + .on('add change reset remove', this.proxy('renderFacets')); if (this.hidden) { this.$element.hide(); @@ -144,19 +322,52 @@ instance.web.SearchView = instance.web.Widget.extend(/** @lends instance.web.Sea model: this.model, view_id: this.view_id, context: this.dataset.get_context() }); - // FIXME: local eval of domain and context to get rid of special endpoint - var filters = this.rpc('/web/searchview/get_filters', { - model: this.model - }).then(function (filters) { self.custom_filters = filters; }); - $.when(load_view, filters) - .pipe(function (load) { return load[0]; }) - .then(this.on_loaded); + $.when(load_view) + .pipe(this.on_loaded) + .fail(function () { + self.ready.reject.apply(null, arguments); + }); } - this.$element.on('click', '.oe_vs_unfold_drawer', function () { + this.$element.on('keydown', + '.oe_searchview_input, .oe_searchview_facet', function (e) { + switch(e.which) { + case $.ui.keyCode.LEFT: + self.focusPreceding(this); + e.preventDefault(); + break; + case $.ui.keyCode.RIGHT: + self.focusFollowing(this); + e.preventDefault(); + break; + } + }); + + this.$element.on('click', '.oe_searchview_clear', function (e) { + e.stopImmediatePropagation(); + self.query.reset(); + }); + this.$element.on('click', '.oe_searchview_unfold_drawer', function (e) { + e.stopImmediatePropagation(); self.$element.toggleClass('oe_searchview_open_drawer'); }); + // Focus last input if the view itself is clicked (empty section of + // facets element) + this.$element.on('click', function (e) { + if (e.target === self.$element.find('.oe_searchview_facets')[0]) { + self.$element.find('.oe_searchview_input:last').focus(); + } + }); + // when the completion list opens/refreshes, automatically select the + // first completion item so if the user just hits [RETURN] or [TAB] it + // automatically selects it + this.$element.on('autocompleteopen', function () { + var menu = self.$element.data('autocomplete').menu; + menu.activate( + $.Event({ type: "mouseenter" }), + menu.element.children().first()); + }); return $.when(p, this.ready); }, @@ -167,81 +378,57 @@ instance.web.SearchView = instance.web.Widget.extend(/** @lends instance.web.Sea this.$element.hide(); }, + subviewForRoot: function (subview_root) { + return _(this.input_subviews).detect(function (subview) { + return subview.$element[0] === subview_root; + }); + }, + siblingSubview: function (subview, direction, wrap_around) { + var index = _(this.input_subviews).indexOf(subview) + direction; + if (wrap_around && index < 0) { + index = this.input_subviews.length - 1; + } else if (wrap_around && index >= this.input_subviews.length) { + index = 0; + } + return this.input_subviews[index]; + }, + focusPreceding: function (subview_root) { + return this.siblingSubview( + this.subviewForRoot(subview_root), -1, true) + .$element.focus(); + }, + focusFollowing: function (subview_root) { + return this.siblingSubview( + this.subviewForRoot(subview_root), +1, true) + .$element.focus(); + }, + /** * Sets up thingie where all the mess is put? */ - setup_stuff_drawer: function () { - var self = this; - $('
      ').appendTo(this.$element.find('.VS-search-box')); - var $drawer = $('
      ').appendTo(this.$element); - var $filters = $('
      ').appendTo($drawer); - - var running_count = 0; - // get total filters count - var is_group = function (i) { return i instanceof instance.web.search.FilterGroup; }; - var filters_count = _(this.controls).chain() - .flatten() - .filter(is_group) - .map(function (i) { return i.filters.length; }) - .sum() - .value(); - - var col1 = [], col2 = _(this.controls).map(function (inputs, group) { - var filters = _(inputs).filter(is_group); - return { - name: group === 'null' ? _t("Filters") : group, - filters: filters, - length: _(filters).chain().map(function (i) { - return i.filters.length; }).sum().value() - }; + select_for_drawer: function () { + return _(this.inputs).filter(function (input) { + return input.in_drawer(); }); - - while (col2.length) { - // col1 + group should be smaller than col2 + group - if ((running_count + col2[0].length) <= (filters_count - running_count)) { - running_count += col2[0].length; - col1.push(col2.shift()); - } else { - break; - } - } - - // Create a Custom Filter FilterGroup for each custom filter read from - // the db, add all of this as a group in the smallest column - [].push.call(col1.length <= col2.length ? col1 : col2, { - name: _t("Custom Filters"), - filters: _.map(this.custom_filters, function (filter) { - // FIXME: handling of ``disabled`` being set - var f = new instance.web.search.Filter({attrs: { - string: filter.name, - context: filter.context, - domain: filter.domain - }}, self); - return new instance.web.search.FilterGroup([f], self); - }), - length: 3 - }); - - return $.when( - this.render_column(col1, $('
      ').appendTo($filters)), - this.render_column(col2, $('
      ').appendTo($filters)), - (new instance.web.search.Advanced(this).appendTo($drawer))); - }, - render_column: function (column, $el) { - return $.when.apply(null, _(column).map(function (group) { - $('

      ').text(group.name).appendTo($el); - return $.when.apply(null, - _(group.filters).invoke('appendTo', $el)); - })); }, /** * Sets up search view's view-wide auto-completion widget */ setup_global_completion: function () { - // Prevent keydown from within a facet's input from reaching the - // auto-completion widget and opening the completion list - this.$element.on('keydown', '.search_facet input', function (e) { - e.stopImmediatePropagation(); + var self = this; + + // autocomplete only correctly handles being initialized on the actual + // editable element (and only an element with a @value in 1.8 e.g. + // input or textarea), cheat by setting val() on $element + this.$element.on('keydown', function () { + // keydown is triggered *before* the element's value is set, so + // delay this. Pray that setTimeout are executed in FIFO (if they + // have the same delay) as autocomplete uses the exact same trick. + // FIXME: brittle as fuck + setTimeout(function () { + self.$element.val(self.currentInputValue()); + }, 0); + }); this.$element.autocomplete({ @@ -249,7 +436,7 @@ instance.web.SearchView = instance.web.Widget.extend(/** @lends instance.web.Sea select: this.proxy('select_completion'), focus: function (e) { e.preventDefault(); }, html: true, - minLength: 0, + minLength: 1, delay: 0 }).data('autocomplete')._renderItem = function (ul, item) { // item of completion list @@ -257,14 +444,14 @@ instance.web.SearchView = instance.web.Widget.extend(/** @lends instance.web.Sea .data( "item.autocomplete", item ) .appendTo( ul ); - if (item.value !== undefined) { + if (item.facet !== undefined) { // regular completion item return $item.append( (item.label) ? $('').html(item.label) : $('').text(item.value)); } - return $item.text(item.category) + return $item.text(item.label) .css({ borderTop: '1px solid #cccccc', margin: 0, @@ -274,7 +461,14 @@ instance.web.SearchView = instance.web.Widget.extend(/** @lends instance.web.Sea clear: 'left', width: '100%' }); - } + }; + }, + /** + * Gets value out of the currently focused "input" (a + * div[contenteditable].oe_searchview_input) + */ + currentInputValue: function () { + return this.$element.find('div.oe_searchview_input:focus').text(); }, /** * Provide auto-completion result for req.term (an array to `resp`) @@ -301,50 +495,62 @@ instance.web.SearchView = instance.web.Widget.extend(/** @lends instance.web.Sea */ select_completion: function (e, ui) { e.preventDefault(); - this.vs.searchQuery.add(new VS.model.SearchFacet(_.extend( - {app: this.vs}, ui.item))); - this.vs.searchBox.searchEvent({}); + + var input_index = _(this.input_subviews).indexOf( + this.subviewForRoot( + this.$element.find('div.oe_searchview_input:focus')[0])); + this.query.add(ui.item.facet, {at: input_index / 2}); }, - - /** - * Builds the right SearchFacet view based on the facet object to render - * (e.g. readonly facets for filters) - * - * @param {Object} options - * @param {VS.model.SearchFacet} options.model facet object to render - */ - make_visualsearch_facet: function (options) { - return new instance.web.search.FilterGroupFacet(options); - -// if (options.model.get('field') instanceof instance.web.search.FilterGroup) { -// return new instance.web.search.FilterGroupFacet(options); -// } -// return new VS.ui.SearchFacet(options); + childFocused: function () { + this.$element.addClass('oe_focused'); + }, + childBlurred: function () { + this.$element.removeClass('oe_focused'); }, /** - * Proxies searches on a SearchInput to the search view's global completion * - * Also disables SearchInput.autocomplete#_move so search view's - * autocomplete can get the corresponding events, or something. - * - * @param options + * @param {openerp.web.search.SearchQuery | openerp.web.search.Facet} _1 + * @param {openerp.web.search.Facet} [_2] + * @param {Object} [options] */ - make_visualsearch_input: function (options) { - var self = this, input = new VS.ui.SearchInput(options); - input.setupAutocomplete = function () { - _.extend(this.box.autocomplete({ - minLength: 1, - delay: 0, - search: function () { - self.$element.autocomplete('search', input.box.val()); - return false; - } - }).data('autocomplete'), { - _move: function () {}, - close: function () { self.$element.autocomplete('close'); } - }); - }; - return input; + renderFacets: function (_1, _2, options) { + // _1: model if event=change, otherwise collection + // _2: undefined if event=change, otherwise model + var self = this; + var started = []; + var $e = this.$element.find('div.oe_searchview_facets'); + _.invoke(this.input_subviews, 'destroy'); + this.input_subviews = []; + + var i = new my.InputView(this); + started.push(i.appendTo($e)); + this.input_subviews.push(i); + this.query.each(function (facet) { + var f = new my.FacetView(this, facet); + started.push(f.appendTo($e)); + self.input_subviews.push(f); + + var i = new my.InputView(this); + started.push(i.appendTo($e)); + self.input_subviews.push(i); + }, this); + _.each(this.input_subviews, function (childView) { + childView.on('focused', self, self.proxy('childFocused')); + childView.on('blurred', self, self.proxy('childBlurred')); + }); + + $.when.apply(null, started).then(function () { + var input_to_focus; + // options.at: facet inserted at given index, focus next input + // otherwise just focus last input + if (!options || typeof options.at !== 'number') { + input_to_focus = _.last(self.input_subviews); + } else { + input_to_focus = self.input_subviews[(options.at + 1) * 2]; + } + + input_to_focus.$element.focus(); + }); }, /** @@ -423,14 +629,25 @@ instance.web.SearchView = instance.web.Widget.extend(/** @lends instance.web.Sea data.fields_view['arch'].children, data.fields_view.fields); + // add Filters to this.inputs, need view.controls filled + (new instance.web.search.Filters(this)); + // add custom filters to this.inputs + (new instance.web.search.CustomFilters(this)); + // add Advanced to this.inputs + (new instance.web.search.Advanced(this)); + + // build drawer + var drawer_started = $.when.apply( + null, _(this.select_for_drawer()).invoke( + 'appendTo', this.$element.find('.oe_searchview_drawer'))); + // load defaults - return $.when( - this.setup_stuff_drawer(), - $.when.apply(null, _(this.inputs).invoke('facet_for_defaults', this.defaults)) - .then(function () { - self.vs.searchQuery.reset(_(arguments).compact(), {silent: true}); - self.vs.searchBox.renderFacets(); - })) + var defaults_fetched = $.when.apply(null, _(this.inputs).invoke( + 'facet_for_defaults', this.defaults)).then(function () { + self.query.reset(_(arguments).compact(), {preventSearch: true}); + }); + + return $.when(drawer_started, defaults_fetched) .then(function () { self.ready.resolve(); }) }, /** @@ -447,53 +664,6 @@ instance.web.SearchView = instance.web.Widget.extend(/** @lends instance.web.Sea case 'add_to_dashboard': this.on_add_to_dashboard(); break; - case 'manage_filters': - this.do_action({ - res_model: 'ir.filters', - views: [[false, 'list'], [false, 'form']], - type: 'ir.actions.act_window', - context: {"search_default_user_id": this.session.uid, - "search_default_model_id": this.dataset.model}, - target: "current", - limit : 80 - }); - break; - case 'save_filter': - var data = this.build_search_data(); - var context = new instance.web.CompoundContext(); - _.each(data.contexts, function(x) { - context.add(x); - }); - var domain = new instance.web.CompoundDomain(); - _.each(data.domains, function(x) { - domain.add(x); - }); - var groupbys = _.pluck(data.groupbys, "group_by").join(); - context.add({"group_by": groupbys}); - var dial_html = QWeb.render("SearchView.managed-filters.add"); - var $dial = $(dial_html); - instance.web.dialog($dial, { - modal: true, - title: _t("Filter Entry"), - buttons: [ - {text: _t("Cancel"), click: function() { - $(this).dialog("close"); - }}, - {text: _t("OK"), click: function() { - $(this).dialog("close"); - var name = $(this).find("input").val(); - self.rpc('/web/searchview/save_filter', { - model: self.dataset.model, - context_to_save: context, - domain: domain, - name: name - }).then(function() { - self.reload_managed_filters(); - }); - }} - ] - }); - break; case '': this.do_clear(); } @@ -570,20 +740,26 @@ instance.web.SearchView = instance.web.Widget.extend(/** @lends instance.web.Sea }); }, /** - * Performs the search view collection of widget data. + * Extract search data from the view's facets. * - * If the collection went well (all fields are valid), then triggers - * :js:func:`instance.web.SearchView.on_search`. + * Result is an object with 4 (own) properties: * - * If at least one field failed its validation, triggers - * :js:func:`instance.web.SearchView.on_invalid` instead. + * errors + * An array of any error generated during data validation and + * extraction, contains the validation error objects + * domains + * Array of domains + * contexts + * Array of contexts + * groupbys + * Array of domains, in groupby order rather than view order * - * @param e jQuery event object coming from the "Search" button + * @return {Object} */ - do_search: function () { + build_search_data: function () { var domains = [], contexts = [], groupbys = [], errors = []; - this.vs.searchQuery.each(function (facet) { + this.query.each(function (facet) { var field = facet.get('field'); try { var domain = field.get_domain(facet); @@ -606,12 +782,33 @@ instance.web.SearchView = instance.web.Widget.extend(/** @lends instance.web.Sea } } }); - - if (!_.isEmpty(errors)) { - this.on_invalid(errors); + return { + domains: domains, + contexts: contexts, + groupbys: groupbys, + errors: errors + }; + }, /** + * Performs the search view collection of widget data. + * + * If the collection went well (all fields are valid), then triggers + * :js:func:`instance.web.SearchView.on_search`. + * + * If at least one field failed its validation, triggers + * :js:func:`instance.web.SearchView.on_invalid` instead. + * + * @param e jQuery event object coming from the "Search" button + */ + do_search: function (_query, options) { + if (options && options.preventSearch) { return; } - return this.on_search(domains, contexts, groupbys); + var search = this.build_search_data(); + if (!_.isEmpty(search.errors)) { + this.on_invalid(search.errors); + return; + } + return this.on_search(search.domains, search.contexts, search.groupbys); }, /** * Triggered after the SearchView has collected all relevant domains and @@ -647,40 +844,6 @@ instance.web.SearchView = instance.web.Widget.extend(/** @lends instance.web.Sea } }); -/** @namespace */ -instance.web.search = {}; - -instance.web.search.FilterGroupFacet = VS.ui.SearchFacet.extend({ - events: _.extend({ - 'click': 'selectFacet' - }, VS.ui.SearchFacet.prototype.events), - - render: function () { - this.setMode('not', 'editing'); - this.setMode('not', 'selected'); - - var value = this.model.get('value'); - this.$el.html(QWeb.render('SearchView.filters.facet', { - facet: this.model - })); - // virtual input so SearchFacet code has something to play with - this.box = $('').val(value); - - return this; - }, - enableEdit: function () { - this.selectFacet() - }, - keydown: function (e) { - var key = VS.app.hotkeys.key(e); - if (key !== 'right') { - return VS.ui.SearchFacet.prototype.keydown.call(this, e); - } - e.preventDefault(); - this.deselectFacet(); - this.options.app.searchBox.focusNextFacet(this, 1); - } -}); /** * Registry of search fields, called by :js:class:`instance.web.SearchView` to * find and instantiate its field widgets. @@ -756,6 +919,7 @@ instance.web.search.Group = instance.web.search.Widget.extend({ }); instance.web.search.Input = instance.web.search.Widget.extend( /** @lends instance.web.search.Input# */{ + _in_drawer: false, /** * @constructs instance.web.search.Input * @extends instance.web.search.Widget @@ -780,8 +944,8 @@ instance.web.search.Input = instance.web.search.Widget.extend( /** @lends instan return $.when(null) }, /** - * Returns a VS.model.SearchFacet instance for the provided defaults if - * they apply to this widget, or null if they don't. + * Returns a Facet instance for the provided defaults if they apply to + * this widget, or null if they don't. * * This default implementation will try calling * :js:func:`instance.web.search.Input#facet_for` if the widget's name @@ -797,6 +961,9 @@ instance.web.search.Input = instance.web.search.Widget.extend( /** @lends instan } return this.facet_for(defaults[this.attrs.name]); }, + in_drawer: function () { + return !!this._in_drawer; + }, get_context: function () { throw new Error( "get_context not implemented for widget " + this.attrs.type); @@ -822,6 +989,8 @@ instance.web.search.Input = instance.web.search.Widget.extend( /** @lends instan }); instance.web.search.FilterGroup = instance.web.search.Input.extend(/** @lends instance.web.search.FilterGroup# */{ template: 'SearchView.filters', + icon: 'q', + completion_label: _lt("Filter on: %s"), /** * Inclusive group of filters, creates a continuous "button" with clickable * sections (the normal display for filters is to be a self-contained button) @@ -833,36 +1002,77 @@ instance.web.search.FilterGroup = instance.web.search.Input.extend(/** @lends in * @param {instance.web.SearchView} view view in which the filters are contained */ init: function (filters, view) { + // If all filters are group_by and we're not initializing a GroupbyGroup, + // create a GroupbyGroup instead of the current FilterGroup + if (!(this instanceof instance.web.search.GroupbyGroup) && + _(filters).all(function (f) { + return f.attrs.context && f.attrs.context.group_by; })) { + return new instance.web.search.GroupbyGroup(filters, view); + } this._super(view); this.filters = filters; + this.view.query.on('add remove change reset', this.proxy('search_change')); }, start: function () { this.$element.on('click', 'li', this.proxy('toggle_filter')); return $.when(null); }, - facet_for_defaults: function (defaults) { - var fs = _(this.filters).filter(function (f) { - return f.attrs && f.attrs.name && !!defaults[f.attrs.name]; + /** + * Handles change of the search query: any of the group's filter which is + * in the search query should be visually checked in the drawer + */ + search_change: function () { + var self = this; + var $filters = this.$element.find('> li').removeClass('oe_selected'); + var facet = this.view.query.find(_.bind(this.match_facet, this)); + if (!facet) { return; } + facet.values.each(function (v) { + var i = _(self.filters).indexOf(v.get('value')); + if (i === -1) { return; } + $filters.eq(i).addClass('oe_selected'); }); - if (_.isEmpty(fs)) { return $.when(null); } - return $.when(new VS.model.SearchFacet({ + }, + /** + * Matches the group to a facet, in order to find if the group is + * represented in the current search query + */ + match_facet: function (facet) { + return facet.get('field') === this; + }, + make_facet: function (values) { + return { category: _t("Filter"), - value: _(fs).map(function (f) { - return f.attrs.string || f.attrs.name }).join(' | '), - json: fs, - field: this, - app: this.view.vs - })); + icon: this.icon, + values: values, + field: this + } + }, + make_value: function (filter) { + return { + label: filter.attrs.string || filter.attrs.help || filter.attrs.name, + value: filter + }; + }, + facet_for_defaults: function (defaults) { + var self = this; + var fs = _(this.filters).chain() + .filter(function (f) { + return f.attrs && f.attrs.name && !!defaults[f.attrs.name]; + }).map(function (f) { + return self.make_value(f); + }).value(); + if (_.isEmpty(fs)) { return $.when(null); } + return $.when(this.make_facet(fs)); }, /** * Fetches contexts for all enabled filters in the group * - * @param {VS.model.SearchFacet} facet + * @param {openerp.web.search.Facet} facet * @return {*} combined contexts of the enabled filters in this group */ get_context: function (facet) { - var contexts = _(facet.get('json')).chain() - .map(function (filter) { return filter.attrs.context; }) + var contexts = facet.values.chain() + .map(function (f) { return f.get('value').attrs.context; }) .reject(_.isEmpty) .value(); @@ -879,8 +1089,8 @@ instance.web.search.FilterGroup = instance.web.search.Input.extend(/** @lends in * @return {Array} enabled filters in this group */ get_groupby: function (facet) { - return _(facet.get('json')).chain() - .map(function (filter) { return filter.attrs.context; }) + return facet.values.chain() + .map(function (f) { return f.get('value').attrs.context; }) .reject(_.isEmpty) .value(); }, @@ -891,8 +1101,8 @@ instance.web.search.FilterGroup = instance.web.search.Input.extend(/** @lends in * @return {*} combined domains of the enabled filters in this group */ get_domain: function (facet) { - var domains = _(facet.get('json')).chain() - .map(function (filter) { return filter.attrs.domain; }) + var domains = facet.values.chain() + .map(function (f) { return f.get('value').attrs.domain; }) .reject(_.isEmpty) .value(); @@ -909,40 +1119,66 @@ instance.web.search.FilterGroup = instance.web.search.Input.extend(/** @lends in this.toggle(this.filters[$(e.target).index()]); }, toggle: function (filter) { - // FIXME: oh god, my eyes, they hurt - var self = this, fs; - var facet = this.view.vs.searchQuery.detect(function (f) { - return f.get('field') === self; }); - if (facet) { - fs = facet.get('json'); - - if (_.include(fs, filter)) { - fs = _.without(fs, filter); - } else { - fs.push(filter); + this.view.query.toggle(this.make_facet([this.make_value(filter)])); + }, + complete: function (item) { + var self = this; + item = item.toLowerCase(); + var facet_values = _(this.filters).chain() + .filter(function (filter) { + var at = { + string: filter.attrs.string || '', + help: filter.attrs.help || '', + name: filter.attrs.name || '' + }; + var include = _.str.include; + return include(at.string.toLowerCase(), item) + || include(at.help.toLowerCase(), item) + || include(at.name.toLowerCase(), item); + }) + .map(this.make_value) + .value(); + if (_(facet_values).isEmpty()) { return $.when(null); } + return $.when(_.map(facet_values, function (facet_value) { + return { + label: _.str.sprintf(self.completion_label.toString(), + facet_value.label), + facet: self.make_facet([facet_value]) } - if (_(fs).isEmpty()) { - this.view.vs.searchQuery.remove(facet, {trigger_search: true}); - } else { - facet.set({ - json: fs, - value: _(fs).map(function (f) { - return f.attrs.string || f.attrs.name }).join(' | ') - }); + })); + } +}); +instance.web.search.GroupbyGroup = instance.web.search.FilterGroup.extend({ + icon: 'w', + completion_label: _lt("Group by: %s"), + init: function (filters, view) { + this._super(filters, view); + // Not flanders: facet unicity is handled through the + // (category, field) pair of facet attributes. This is all well and + // good for regular filter groups where a group matches a facet, but for + // groupby we want a single facet. So cheat: add an attribute on the + // view which proxies to the first GroupbyGroup, so it can be used + // for every GroupbyGroup and still provides the various methods needed + // by the search view. Use weirdo name to avoid risks of conflicts + if (!this.getParent()._s_groupby) { + this.getParent()._s_groupby = { + help: "See GroupbyGroup#init", + get_context: this.proxy('get_context'), + get_domain: this.proxy('get_domain'), + get_groupby: this.proxy('get_groupby') } - return; - } else { - fs = [filter]; } - - this.view.vs.searchQuery.add({ - category: _t("Filter"), - value: _(fs).map(function (f) { - return f.attrs.string || f.attrs.name }).join(' | '), - json: fs, - field: this, - app: this.view.vs - }); + }, + match_facet: function (facet) { + return facet.get('field') === this.getParent()._s_groupby; + }, + make_facet: function (values) { + return { + category: _t("GroupBy"), + icon: this.icon, + values: values, + field: this.getParent()._s_groupby + }; } }); instance.web.search.Filter = instance.web.search.Input.extend(/** @lends instance.web.search.Filter# */{ @@ -985,28 +1221,32 @@ instance.web.search.Field = instance.web.search.Input.extend( /** @lends instanc this.load_attrs(_.extend({}, field, view_section.attrs)); }, facet_for: function (value) { - return $.when(new VS.model.SearchFacet({ - category: this.attrs.string || this.attrs.name, - value: String(value), - json: value, + return $.when({ field: this, - app: this.view.vs - })); + category: this.attrs.string || this.attrs.name, + values: [{label: String(value), value: value}] + }); }, - get_value: function (facet) { - return facet.value(); + value_from: function (facetValue) { + return facetValue.get('value'); }, get_context: function (facet) { - var val = this.get_value(facet); - // A field needs a value to be "active", and a context to send when - // active - var has_value = (val !== null && val !== ''); + var self = this; + // A field needs a context to send when active var context = this.attrs.context; - if (!(has_value && context)) { + if (!context || !facet.values.length) { return; } - return new instance.web.CompoundContext(context) - .set_eval_context({self: val}); + var contexts = facet.values.map(function (facetValue) { + return new instance.web.CompoundContext(context) + .set_eval_context({self: self.value_from(facetValue)}); + }); + + if (contexts.length === 1) { return contexts[0]; } + + return _.extend(new instance.web.CompoundContext, { + __contexts: contexts + }); }, get_groupby: function () { }, /** @@ -1021,23 +1261,37 @@ instance.web.search.Field = instance.web.search.Input.extend( /** @lends instanc * @returns {Array} domain to include in the resulting search */ make_domain: function (name, operator, facet) { - return [[name, operator, this.get_value(facet)]]; + return [[name, operator, this.value_from(facet)]]; }, get_domain: function (facet) { - var val = this.get_value(facet); - if (val === null || val === '') { - return; + if (!facet.values.length) { return; } + + var value_to_domain; + var self = this; + var domain = this.attrs['filter_domain']; + if (domain) { + value_to_domain = function (facetValue) { + return new instance.web.CompoundDomain(domain) + .set_eval_context({self: self.value_from(facetValue)}); + }; + } else { + value_to_domain = function (facetValue) { + return self.make_domain( + self.attrs.name, + self.attrs.operator || self.default_operator, + facetValue); + }; + } + var domains = facet.values.map(value_to_domain); + + if (domains.length === 1) { return domains[0]; } + for (var i = domains.length; --i;) { + domains.unshift(['|']); } - var domain = this.attrs['filter_domain']; - if (!domain) { - return this.make_domain( - this.attrs.name, - this.attrs.operator || this.default_operator, - facet); - } - return new instance.web.CompoundDomain(domain) - .set_eval_context({self: val}); + return _.extend(new instance.web.CompoundDomain, { + __domains: domains + }); } }); /** @@ -1059,15 +1313,17 @@ instance.web.search.CharField = instance.web.search.Field.extend( /** @lends ins field: '' + this.attrs.string + '', value: '' + _.str.escapeHTML(value) + ''}); return $.when([{ - category: this.attrs.string, label: label, - value: value, - field: this + facet: { + category: this.attrs.string, + field: this, + values: [{label: value, value: value}] + } }]); } }); instance.web.search.NumberField = instance.web.search.Field.extend(/** @lends instance.web.search.NumberField# */{ - get_value: function () { + value_from: function () { if (!this.$element.val()) { return null; } @@ -1110,6 +1366,22 @@ instance.web.search.FloatField = instance.web.search.NumberField.extend(/** @len } } }); + +/** + * Utility function for m2o & selection fields taking a selection/name_get pair + * (value, name) and converting it to a Facet descriptor + * + * @param {instance.web.search.Field} field holder field + * @param {Array} pair pair value to convert + */ +function facet_from(field, pair) { + return { + field: field, + category: field['attrs'].string, + values: [{label: pair[1], value: pair[0]}] + }; +} + /** * @class * @extends instance.web.search.Field @@ -1143,15 +1415,13 @@ instance.web.search.SelectionField = instance.web.search.Field.extend(/** @lends }) .map(function (sel) { return { - category: self.attrs.string, - field: self, - value: sel[1], - json: sel[0] + label: sel[1], + facet: facet_from(self, sel) }; }).value(); if (_.isEmpty(results)) { return $.when(null); } - return $.when.apply(null, [{ - category: this.attrs.string + return $.when.call(null, [{ + label: this.attrs.string }].concat(results)); }, facet_for: function (value) { @@ -1159,16 +1429,7 @@ instance.web.search.SelectionField = instance.web.search.Field.extend(/** @lends return sel[0] === value; }); if (!match) { return $.when(null); } - return $.when(new VS.model.SearchFacet({ - category: this.attrs.string, - value: match[1], - json: match[0], - field: this, - app: this.view.app - })); - }, - get_value: function (facet) { - return facet.get('json'); + return $.when(facet_from(this, match)); } }); instance.web.search.BooleanField = instance.web.search.SelectionField.extend(/** @lends instance.web.search.BooleanField# */{ @@ -1179,16 +1440,9 @@ instance.web.search.BooleanField = instance.web.search.SelectionField.extend(/** init: function () { this._super.apply(this, arguments); this.attrs.selection = [ - ['true', _t("Yes")], - ['false', _t("No")] + [true, _t("Yes")], + [false, _t("No")] ]; - }, - get_value: function (facet) { - switch (this._super(facet)) { - case 'false': return false; - case 'true': return true; - default: return null; - } } }); /** @@ -1196,23 +1450,24 @@ instance.web.search.BooleanField = instance.web.search.SelectionField.extend(/** * @extends instance.web.search.DateField */ instance.web.search.DateField = instance.web.search.Field.extend(/** @lends instance.web.search.DateField# */{ - get_value: function (facet) { - return instance.web.date_to_str(facet.get('json')); + value_from: function (facetValue) { + return instance.web.date_to_str(facetValue.get('value')); }, complete: function (needle) { var d = Date.parse(needle); if (!d) { return $.when(null); } - var value = instance.web.format_value(d, this.attrs); + var date_string = instance.web.format_value(d, this.attrs); var label = _.str.sprintf(_.str.escapeHTML( _t("Search %(field)s at: %(value)s")), { field: '' + this.attrs.string + '', - value: '' + value + ''}); + value: '' + date_string + ''}); return $.when([{ - category: this.attrs.string, label: label, - value: value, - json: d, - field: this + facet: { + category: this.attrs.string, + field: this, + values: [{label: date_string, value: d}] + } }]); } }); @@ -1228,11 +1483,12 @@ instance.web.search.DateField = instance.web.search.Field.extend(/** @lends inst * @extends instance.web.DateField */ instance.web.search.DateTimeField = instance.web.search.DateField.extend(/** @lends instance.web.search.DateTimeField# */{ - get_value: function (facet) { - return instance.web.datetime_to_str(facet.get('json')); + value_from: function (facetValue) { + return instance.web.datetime_to_str(facetValue.get('value')); } }); instance.web.search.ManyToOneField = instance.web.search.CharField.extend({ + default_operator: {}, init: function (view_section, field, view) { this._super(view_section, field, view); this.model = new instance.web.Model(this.attrs.relation); @@ -1247,13 +1503,11 @@ instance.web.search.ManyToOneField = instance.web.search.CharField.extend({ context: {} }).pipe(function (results) { if (_.isEmpty(results)) { return null; } - return [{category: self.attrs.string}].concat( + return [{label: self.attrs.string}].concat( _(results).map(function (result) { return { - category: self.attrs.string, - value: result[1], - json: result[0], - field: self + label: result[1], + facet: facet_from(self, result) }; })); }); @@ -1261,36 +1515,166 @@ instance.web.search.ManyToOneField = instance.web.search.CharField.extend({ facet_for: function (value) { var self = this; if (value instanceof Array) { - return $.when(new VS.model.SearchFacet({ - category: this.attrs.string, - value: value[1], - json: value[0], - field: this, - app: this.view.vs - })); + return $.when(facet_from(this, value)); } return this.model.call('name_get', [value], {}).pipe(function (names) { - return new VS.model.SearchFacet({ - category: self.attrs.string, - value: names[0][1], - json: names[0][0], - field: self, - app: self.view.vs - }); + if (_(names).isEmpty()) { return null; } + return facet_from(self, names[0]); }) }, - make_domain: function (name, operator, facet) { - // ``json`` -> actual auto-completed id - if (facet.get('json')) { - return [[name, '=', facet.get('json')]]; + value_from: function (facetValue) { + return facetValue.get('label'); + }, + make_domain: function (name, operator, facetValue) { + if (operator === this.default_operator) { + return [[name, '=', facetValue.get('value')]]; } - - return this._super(name, operator, facet); + return this._super(name, operator, facetValue); } }); +instance.web.search.CustomFilters = instance.web.search.Input.extend({ + template: 'SearchView.CustomFilters', + _in_drawer: true, + start: function () { + var self = this; + this.model = new instance.web.Model('ir.filters'); + this.filters = {}; + this.$element.on('submit', 'form', this.proxy('save_current')); + this.$element.on('click', 'h4', function () { + self.$element.toggleClass('oe_opened'); + }); + // FIXME: local eval of domain and context to get rid of special endpoint + return this.rpc('/web/searchview/get_filters', { + model: this.view.model + }).pipe(this.proxy('set_filters')); + }, + append_filter: function (filter) { + var self = this; + var key = _.str.sprintf('(%s)%s', filter.user_id, filter.name); + + var $filter; + if (key in this.filters) { + $filter = this.filters[key]; + } else { + var id = filter.id; + $filter = this.filters[key] = $('
    3. ') + .appendTo(this.$element.find('.oe_searchview_custom_list')) + .addClass(filter.user_id ? 'oe_searchview_custom_private' + : 'oe_searchview_custom_public') + .text(filter.name); + + $('
        -
      • - +
      • +
      @@ -1471,8 +1475,22 @@ +
      + +
      +
      +
        +

        +

        +
        + +
        + + +
        +
      -

      Advanced Search...

      +

      Advanced Search

        @@ -1494,26 +1512,23 @@ + - + - + - - - + - diff --git a/addons/web/static/test/rpc.js b/addons/web/static/test/rpc.js index 8a49a48685f..fbe7147f2e6 100644 --- a/addons/web/static/test/rpc.js +++ b/addons/web/static/test/rpc.js @@ -66,7 +66,7 @@ $(document).ready(function () { ok(!fail2); }); - asyncTest('Resolve all correctly ordered, sync', 1, function () { + asyncTest('Resolve all correctly ordered, async', 1, function () { var dm = new openerp.web.DropMisordered(); var d1 = $.Deferred(), d2 = $.Deferred(), @@ -80,7 +80,7 @@ $(document).ready(function () { ok(true); }); }); - asyncTest("Don't resolve mis-ordered, sync", 4, function () { + asyncTest("Don't resolve mis-ordered, async", 4, function () { var dm = new openerp.web.DropMisordered(), done1 = false, done2 = false, fail1 = false, fail2 = false; @@ -104,7 +104,7 @@ $(document).ready(function () { ok(!fail2); }, 400); }); - asyncTest('Fail mis-ordered flag, sync', 4, function () { + asyncTest('Fail mis-ordered flag, async', 4, function () { var dm = new openerp.web.DropMisordered(true), done1 = false, done2 = false, fail1 = false, fail2 = false; diff --git a/addons/web/static/test/search.js b/addons/web/static/test/search.js new file mode 100644 index 00000000000..e5beab22498 --- /dev/null +++ b/addons/web/static/test/search.js @@ -0,0 +1,1193 @@ +$(document).ready(function () { + var xhr = QWeb2.Engine.prototype.get_xhr(); + xhr.open('GET', '/web/static/src/xml/base.xml', false); + xhr.send(null); + var doc = xhr.responseXML; + + var noop = function () {}; + /** + * Make connection RPC responses mockable by setting keys on the + * Connection#responses object (key is the URL, value is the function to + * call with the RPC request payload) + * + * @param {openerp.web.Connection} connection connection instance to mockify + * @param {Object} [responses] url:function mapping to seed the mock connection + */ + var mockifyRPC = function (connection, responses) { + connection.responses = responses || {}; + connection.rpc_function = function (url, payload) { + if (!(url.url in this.responses)) { + return $.Deferred().reject({}, 'failed', _.str.sprintf("Url %s not found in mock responses", url.url)).promise(); + } + return $.when(this.responses[url.url](payload)); + }; + }; + + var instance; + module('query', { + setup: function () { + instance = window.openerp.init([]); + window.openerp.web.corelib(instance); + window.openerp.web.coresetup(instance); + window.openerp.web.chrome(instance); + window.openerp.web.data(instance); + window.openerp.web.search(instance); + } + }); + test('Adding a facet to the query creates a facet and a value', function () { + var query = new instance.web.search.SearchQuery; + var field = {}; + query.add({ + category: 'Foo', + field: field, + values: [{label: 'Value', value: 3}] + }); + + var facet = query.at(0); + equal(facet.get('category'), 'Foo'); + equal(facet.get('field'), field); + deepEqual(facet.get('values'), [{label: 'Value', value: 3}]); + }); + test('Adding two facets', function () { + var query = new instance.web.search.SearchQuery; + query.add([ + { category: 'Foo', field: {}, values: [{label: 'Value', value: 3}] }, + { category: 'Bar', field: {}, values: [{label: 'Value 2', value: 4}] } + ]); + + equal(query.length, 2); + equal(query.at(0).values.length, 1); + equal(query.at(1).values.length, 1); + }); + test('If a facet already exists, add values to it', function () { + var query = new instance.web.search.SearchQuery; + var field = {}; + query.add({category: 'A', field: field, values: [{label: 'V1', value: 0}]}); + query.add({category: 'A', field: field, values: [{label: 'V2', value: 1}]}); + + equal(query.length, 1, "adding an existing facet should merge new values into old facet"); + var facet = query.at(0); + deepEqual(facet.get('values'), [ + {label: 'V1', value: 0}, + {label: 'V2', value: 1} + ]); + }); + test('Facet being implicitly changed should trigger change, not add', function () { + var query = new instance.web.search.SearchQuery; + var field = {}, added = false, changed = false; + query.add({category: 'A', field: field, values: [{label: 'V1', value: 0}]}); + query.on('add', function () { added = true; }) + .on('change', function () { changed = true }); + query.add({category: 'A', field: field, values: [{label: 'V2', value: 1}]}); + + ok(!added, "query.add adding values to a facet should not trigger an add"); + ok(changed, "query.add adding values to a facet should not trigger a change"); + }); + test('Toggling a facet, value which does not exist should add it', function () { + var query = new instance.web.search.SearchQuery; + var field = {}; + query.toggle({category: 'A', field: field, values: [{label: 'V1', value: 0}]}); + + equal(query.length, 1, "Should have created a single facet"); + var facet = query.at(0); + equal(facet.values.length, 1, "Facet should have a single value"); + deepEqual(facet.get('values'), [{label: 'V1', value: 0}], + "Facet's value should match input"); + }); + test('Toggling a facet which exists with a value which does not should add the value to the facet', function () { + var field = {}; + var query = new instance.web.search.SearchQuery; + query.add({category: 'A', field: field, values: [{label: 'V1', value: 0}]}); + query.toggle({category: 'A', field: field, values: [{label: 'V2', value: 1}]}); + + equal(query.length, 1, "Should have edited the existing facet"); + var facet = query.at(0); + equal(facet.values.length, 2, "Should have added the value to the existing facet"); + deepEqual(facet.get('values'), [ + {label: 'V1', value: 0}, + {label: 'V2', value: 1} + ]); + }); + test('Toggling a facet which exists with a value which does as well should remove the value from the facet', function () { + var field = {}; + var query = new instance.web.search.SearchQuery; + query.add({category: 'A', field: field, values: [{label: 'V1', value: 0}]}); + query.add({category: 'A', field: field, values: [{label: 'V2', value: 1}]}); + + query.toggle({category: 'A', field: field, values: [{label: 'V2', value: 1}]}); + + equal(query.length, 1, 'Should have the same single facet'); + var facet = query.at(0); + equal(facet.values.length, 1, "Should only have one value left in the facet"); + deepEqual(facet.get('values'), [ + {label: 'V1', value: 0} + ]); + }); + test('Toggling off the last value of a facet should remove the facet', function () { + var field = {}; + var query = new instance.web.search.SearchQuery; + query.add({category: 'A', field: field, values: [{label: 'V1', value: 0}]}); + + query.toggle({category: 'A', field: field, values: [{label: 'V1', value: 0}]}); + + equal(query.length, 0, 'Should have removed the facet'); + }); + test('Intermediate emptiness should not remove the facet', function () { + var field = {}; + var query = new instance.web.search.SearchQuery; + query.add({category: 'A', field: field, values: [{label: 'V1', value: 0}]}); + + query.toggle({category: 'A', field: field, values: [ + {label: 'V1', value: 0}, + {label: 'V2', value: 1} + ]}); + + equal(query.length, 1, 'Should not have removed the facet'); + var facet = query.at(0); + equal(facet.values.length, 1, "Should have one value"); + deepEqual(facet.get('values'), [ + {label: 'V2', value: 1} + ]); + }); + + test('Reseting with multiple facets should still work to load defaults', function () { + var query = new instance.web.search.SearchQuery; + var field = {}; + query.reset([ + {category: 'A', field: field, values: [{label: 'V1', value: 0}]}, + {category: 'A', field: field, values: [{label: 'V2', value: 1}]}]); + + equal(query.length, 1, 'Should have created a single facet'); + equal(query.at(0).values.length, 2, 'the facet should have merged two values'); + deepEqual(query.at(0).get('values'), [ + {label: 'V1', value: 0}, + {label: 'V2', value: 1} + ]) + }); + + module('defaults', { + setup: function () { + instance = window.openerp.init([]); + window.openerp.web.corelib(instance); + window.openerp.web.coresetup(instance); + window.openerp.web.chrome(instance); + window.openerp.web.data(instance); + window.openerp.web.search(instance); + + instance.web.qweb.add_template(doc); + + mockifyRPC(instance.connection); + } + }); + + /** + * Builds a basic search view with a single "dummy" field. The dummy + * extends `instance.web.search.Field`, it does not add any (class) + * attributes beyond what is provided through ``dummy_widget_attributes``. + * + * The view is returned un-started, it is the caller's role to start it + * (or use DOM-insertion methods to start it indirectly). + * + * @param [dummy_widget_attributes={}] + * @param [defaults={}] + * @return {instance.web.SearchView} + */ + function makeSearchView(dummy_widget_attributes, defaults) { + instance.web.search.fields.add( + 'dummy', 'instance.dummy.DummyWidget'); + instance.dummy = {}; + instance.dummy.DummyWidget = instance.web.search.Field.extend( + dummy_widget_attributes || {}); + if (!('/web/searchview/load' in instance.connection.responses)) { + instance.connection.responses['/web/searchview/load'] = function () { + return {result: {fields_view: { + type: 'search', + fields: { + dummy: {type: 'char', string: "Dummy"} + }, + arch: { + tag: 'search', + attrs: {}, + children: [{ + tag: 'field', + attrs: { + name: 'dummy', + widget: 'dummy' + }, + children: [] + }] + } + }}}; + }; + } + instance.connection.responses['/web/searchview/get_filters'] = function () { + return {result: []}; + }; + instance.connection.responses['/web/searchview/fields_get'] = function () { + return {result: {fields: { + dummy: {type: 'char', string: 'Dummy'} + }}}; + }; + + var dataset = {model: 'dummy.model', get_context: function () { return {}; }}; + var view = new instance.web.SearchView(null, dataset, false, defaults); + view.on_invalid.add(function () { + ok(false, JSON.stringify([].slice(arguments))); + }); + return view; + } + asyncTest('calling', 2, function () { + var defaults_called = false; + + var view = makeSearchView({ + facet_for_defaults: function (defaults) { + defaults_called = true; + return $.when({ + field: this, + category: 'Dummy', + values: [{label: 'dummy', value: defaults.dummy}] + }); + } + }, {dummy: 42}); + view.appendTo($('#qunit-fixture')) + .always(start) + .fail(function (error) { ok(false, error.message); }) + .done(function () { + ok(defaults_called, "should have called defaults"); + deepEqual( + view.query.toJSON(), + [{category: 'Dummy', values: [{label: 'dummy', value: 42}]}], + "should have generated a facet with the default value"); + }); + }); + asyncTest('FilterGroup', 3, function () { + var view = {inputs: [], query: {on: function () {}}}; + var filter_a = new instance.web.search.Filter( + {attrs: {name: 'a'}}, view); + var filter_b = new instance.web.search.Filter( + {attrs: {name: 'b'}}, view); + var group = new instance.web.search.FilterGroup( + [filter_a, filter_b], view); + group.facet_for_defaults({a: true, b: true}) + .always(start) + .fail(function (error) { ok(false, error && error.message); }) + .done(function (facet) { + var model = facet; + if (!(model instanceof instance.web.search.Facet)) { + model = new instance.web.search.Facet(facet); + } + var values = model.values; + equal(values.length, 2, 'facet should have two values'); + strictEqual(values.at(0).get('value'), filter_a); + strictEqual(values.at(1).get('value'), filter_b); + }); + }); + asyncTest('Field', 4, function () { + var view = {inputs: []}; + var f = new instance.web.search.Field( + {attrs: {string: 'Dummy', name: 'dummy'}}, {}, view); + f.facet_for_defaults({dummy: 42}) + .always(start) + .fail(function (error) { ok(false, error && error.message); }) + .done(function (facet) { + var model = facet; + if (!(model instanceof instance.web.search.Facet)) { + model = new instance.web.search.Facet(facet); + } + strictEqual( + model.get('category'), + f.attrs.string, + "facet category should be field label"); + strictEqual( + model.get('field'), f, + "facet field should be field which created default"); + equal(model.values.length, 1, "facet should have a single value"); + deepEqual( + model.values.toJSON(), + [{label: '42', value: 42}], + "facet value should match provided default"); + }); + }); + asyncTest('Selection: valid value', 4, function () { + var view = {inputs: []}; + var f = new instance.web.search.SelectionField( + {attrs: {name: 'dummy', string: 'Dummy'}}, + {selection: [[1, "Foo"], [2, "Bar"], [3, "Baz"], [4, "Qux"]]}, + view); + f.facet_for_defaults({dummy: 3}) + .always(start) + .fail(function (error) { ok(false, error && error.message); }) + .done(function (facet) { + var model = facet; + if (!(model instanceof instance.web.search.Facet)) { + model = new instance.web.search.Facet(facet); + } + strictEqual( + model.get('category'), + f.attrs.string, + "facet category should be field label"); + strictEqual( + model.get('field'), f, + "facet field should be field which created default"); + equal(model.values.length, 1, "facet should have a single value"); + deepEqual( + model.values.toJSON(), + [{label: 'Baz', value: 3}], + "facet value should match provided default's selection"); + }); + }); + asyncTest('Selection: invalid value', 1, function () { + var view = {inputs: []}; + var f = new instance.web.search.SelectionField( + {attrs: {name: 'dummy', string: 'Dummy'}}, + {selection: [[1, "Foo"], [2, "Bar"], [3, "Baz"], [4, "Qux"]]}, + view); + f.facet_for_defaults({dummy: 42}) + .always(start) + .fail(function (error) { ok(false, error && error.message); }) + .done(function (facet) { + ok(!facet, "an invalid value should result in a not-facet"); + }); + }); + asyncTest("M2O default: value", 7, function () { + var view = {inputs: []}, id = 4; + var f = new instance.web.search.ManyToOneField( + {attrs: {name: 'dummy', string: 'Dummy'}}, + {relation: 'dummy.model.name'}, + view); + instance.connection.responses['/web/dataset/call_kw'] = function (req) { + equal(req.params.method, 'name_get', + "m2o should resolve default id"); + equal(req.params.model, f.attrs.relation, + "query model should match m2o relation"); + equal(req.params.args[0], id); + return {result: [[id, "DumDumDum"]]}; + }; + f.facet_for_defaults({dummy: id}) + .always(start) + .fail(function (error) { ok(false, error && error.message); }) + .done(function (facet) { + var model = facet; + if (!(model instanceof instance.web.search.Facet)) { + model = new instance.web.search.Facet(facet); + } + strictEqual( + model.get('category'), + f.attrs.string, + "facet category should be field label"); + strictEqual( + model.get('field'), f, + "facet field should be field which created default"); + equal(model.values.length, 1, "facet should have a single value"); + deepEqual( + model.values.toJSON(), + [{label: 'DumDumDum', value: id}], + "facet value should match provided default's selection"); + }); + }); + asyncTest("M2O default: value", 1, function () { + var view = {inputs: []}, id = 4; + var f = new instance.web.search.ManyToOneField( + {attrs: {name: 'dummy', string: 'Dummy'}}, + {relation: 'dummy.model.name'}, + view); + instance.connection.responses['/web/dataset/call_kw'] = function (req) { + return {result: []}; + }; + f.facet_for_defaults({dummy: id}) + .always(start) + .fail(function (error) { ok(false, error && error.message); }) + .done(function (facet) { + ok(!facet, "an invalid m2o default should yield a non-facet"); + }); + }); + + module('completions', { + setup: function () { + instance = window.openerp.init([]); + window.openerp.web.corelib(instance); + window.openerp.web.coresetup(instance); + window.openerp.web.chrome(instance); + window.openerp.web.data(instance); + // date complete + window.openerp.web.formats(instance); + window.openerp.web.search(instance); + + instance.web.qweb.add_template(doc); + + mockifyRPC(instance.connection); + } + }); + asyncTest('calling', 4, function () { + var view = makeSearchView({ + complete: function () { + return $.when({ + label: "Dummy", + facet: { + field: this, + category: 'Dummy', + values: [{label: 'dummy', value: 42}] + } + }); + } + }); + view.appendTo($('#qunit-fixture')) + .always(start) + .fail(function (error) { ok(false, error.message); }) + .done(function () { + stop(); + view.complete_global_search({term: "dum"}, function (completions) { + start(); + equal(completions.length, 1, "should have a single completion"); + var completion = completions[0]; + equal(completion.label, "Dummy", + "should have provided label"); + equal(completion.facet.category, "Dummy", + "should have provided category"); + deepEqual(completion.facet.values, + [{label: 'dummy', value: 42}], + "should have provided values"); + }); + }); + }); + asyncTest('facet selection', 2, function () { + var completion = { + label: "Dummy", + facet: { + field: {get_domain: noop, get_context: noop, get_groupby: noop}, + category: 'Dummy', + values: [{label: 'dummy', value: 42}] + } + }; + + var view = makeSearchView({}); + view.appendTo($('#qunit-fixture')) + .always(start) + .fail(function (error) { ok(false, error.message); }) + .done(function () { + view.select_completion( + {preventDefault: function () {}}, + {item: completion}); + equal(view.query.length, 1, "should have one facet in the query"); + deepEqual( + view.query.at(0).toJSON(), + {category: 'Dummy', values: [{label: 'dummy', value: 42}]}, + "should have the right facet in the query"); + }); + }); + asyncTest('facet selection: new value existing facet', 3, function () { + var field = {get_domain: noop, get_context: noop, get_groupby: noop}; + var completion = { + label: "Dummy", + facet: { + field: field, + category: 'Dummy', + values: [{label: 'dummy', value: 42}] + } + }; + + var view = makeSearchView({}); + view.appendTo($('#qunit-fixture')) + .always(start) + .fail(function (error) { ok(false, error.message); }) + .done(function () { + view.query.add({field: field, category: 'Dummy', + values: [{label: 'previous', value: 41}]}); + equal(view.query.length, 1, 'should have newly added facet'); + view.select_completion( + {preventDefault: function () {}}, + {item: completion}); + equal(view.query.length, 1, "should still have only one facet"); + var facet = view.query.at(0); + deepEqual( + facet.get('values'), + [{label: 'previous', value: 41}, {label: 'dummy', value: 42}], + "should have added selected value to old one"); + }); + }); + asyncTest('Field', 1, function () { + var view = {inputs: []}; + var f = new instance.web.search.Field({attrs: {}}, {}, view); + f.complete('foo') + .always(start) + .fail(function (error) { ok(false, error.message); }) + .done(function (completions) { + ok(_(completions).isEmpty(), "field should not provide any completion"); + }); + }); + asyncTest('CharField', 6, function () { + var view = {inputs: []}; + var f = new instance.web.search.CharField( + {attrs: {string: "Dummy"}}, {}, view); + f.complete('foo<') + .always(start) + .fail(function (error) { ok(false, error.message); }) + .done(function (completions) { + equal(completions.length, 1, "should provide a single completion"); + var c = completions[0]; + equal(c.label, "Search Dummy for: foo<", + "should propose a fuzzy matching/searching, with the" + + " value escaped"); + ok(c.facet, "completion should contain a facet proposition"); + var facet = new instance.web.search.Facet(c.facet); + equal(facet.get('category'), f.attrs.string, + "completion facet should bear the field's name"); + strictEqual(facet.get('field'), f, + "completion facet should yield the field"); + deepEqual(facet.values.toJSON(), [{label: 'foo<', value: 'foo<'}], + "facet should have single value using completion item"); + }); + }); + asyncTest('Selection: match found', 14, function () { + var view = {inputs: []}; + var f = new instance.web.search.SelectionField( + {attrs: {string: "Dummy"}}, + {selection: [[1, "Foo"], [2, "Bar"], [3, "Baz"], [4, "Bazador"]]}, + view); + f.complete("ba") + .always(start) + .fail(function (error) { ok(false, error.message); }) + .done(function (completions) { + equal(completions.length, 4, + "should provide two completions and a section title"); + deepEqual(completions[0], {label: "Dummy"}); + + var c1 = completions[1]; + equal(c1.label, "Bar"); + equal(c1.facet.category, f.attrs.string); + strictEqual(c1.facet.field, f); + deepEqual(c1.facet.values, [{label: "Bar", value: 2}]); + + var c2 = completions[2]; + equal(c2.label, "Baz"); + equal(c2.facet.category, f.attrs.string); + strictEqual(c2.facet.field, f); + deepEqual(c2.facet.values, [{label: "Baz", value: 3}]); + + var c3 = completions[3]; + equal(c3.label, "Bazador"); + equal(c3.facet.category, f.attrs.string); + strictEqual(c3.facet.field, f); + deepEqual(c3.facet.values, [{label: "Bazador", value: 4}]); + }); + }); + asyncTest('Selection: no match', 1, function () { + var view = {inputs: []}; + var f = new instance.web.search.SelectionField( + {attrs: {string: "Dummy"}}, + {selection: [[1, "Foo"], [2, "Bar"], [3, "Baz"], [4, "Bazador"]]}, + view); + f.complete("qux") + .always(start) + .fail(function (error) { ok(false, error.message); }) + .done(function (completions) { + ok(!completions, "if no value matches the needle, no completion shall be provided"); + }); + }); + asyncTest('Date', 6, function () { + instance.web._t.database.parameters = { + date_format: '%Y-%m-%d', + time_format: '%H:%M:%S' + }; + var view = {inputs: []}; + var f = new instance.web.search.DateField( + {attrs: {string: "Dummy"}}, {type: 'datetime'}, view); + f.complete('2012-05-21T21:21:21') + .always(start) + .fail(function (error) { ok(false, error.message); }) + .done(function (completions) { + equal(completions.length, 1, "should provide a single completion"); + var c = completions[0]; + equal(c.label, "Search Dummy at: 2012-05-21 21:21:21"); + var facet = new instance.web.search.Facet(c.facet); + equal(facet.get('category'), f.attrs.string); + equal(facet.get('field'), f); + var value = facet.values.at(0); + equal(value.get('label'), "2012-05-21 21:21:21"); + equal(value.get('value').getTime(), + new Date(2012, 4, 21, 21, 21, 21).getTime()); + }); + }); + asyncTest("M2O", 15, function () { + instance.connection.responses['/web/dataset/call_kw'] = function (req) { + equal(req.params.method, "name_search"); + equal(req.params.model, "dummy.model"); + deepEqual(req.params.args, []); + deepEqual(req.params.kwargs.name, 'bob'); + return {result: [[42, "choice 1"], [43, "choice @"]]} + }; + + var view = {inputs: []}; + var f = new instance.web.search.ManyToOneField( + {attrs: {string: 'Dummy'}}, {relation: 'dummy.model'}, view); + f.complete("bob") + .always(start) + .fail(function (error) { ok(false, error.message); }) + .done(function (c) { + equal(c.length, 3, "should return results + title"); + var title = c[0]; + equal(title.label, f.attrs.string, "title should match field name"); + ok(!title.facet, "title should not have a facet"); + + var f1 = new instance.web.search.Facet(c[1].facet); + equal(c[1].label, "choice 1"); + equal(f1.get('category'), f.attrs.string); + equal(f1.get('field'), f); + deepEqual(f1.values.toJSON(), [{label: 'choice 1', value: 42}]); + + var f2 = new instance.web.search.Facet(c[2].facet); + equal(c[2].label, "choice @"); + equal(f2.get('category'), f.attrs.string); + equal(f2.get('field'), f); + deepEqual(f2.values.toJSON(), [{label: 'choice @', value: 43}]); + }); + }); + asyncTest("M2O no match", 5, function () { + instance.connection.responses['/web/dataset/call_kw'] = function (req) { + equal(req.params.method, "name_search"); + equal(req.params.model, "dummy.model"); + deepEqual(req.params.args, []); + deepEqual(req.params.kwargs.name, 'bob'); + return {result: []} + }; + var view = {inputs: []}; + var f = new instance.web.search.ManyToOneField( + {attrs: {string: 'Dummy'}}, {relation: 'dummy.model'}, view); + f.complete("bob") + .always(start) + .fail(function (error) { ok(false, error.message); }) + .done(function (c) { + ok(!c, "no match should yield no completion"); + }); + }); + + module('search-serialization', { + setup: function () { + instance = window.openerp.init([]); + window.openerp.web.corelib(instance); + window.openerp.web.coresetup(instance); + window.openerp.web.chrome(instance); + window.openerp.web.data(instance); + window.openerp.web.search(instance); + + instance.web.qweb.add_template(doc); + + mockifyRPC(instance.connection); + } + }); + asyncTest('No facet, no call', 6, function () { + var got_domain = false, got_context = false, got_groupby = false; + var $fix = $('#qunit-fixture'); + var view = makeSearchView({ + get_domain: function () { + got_domain = true; + return null; + }, + get_context: function () { + got_context = true; + return null; + }, + get_groupby: function () { + got_groupby = true; + return null; + } + }); + var ds, cs, gs; + view.on_search.add(function (d, c, g) { + ds = d, cs = c, gs = g; + }); + view.appendTo($fix) + .always(start) + .fail(function (error) { ok(false, error.message); }) + .done(function () { + view.do_search(); + ok(!got_domain, "no facet, should not have fetched domain"); + ok(_(ds).isEmpty(), "domains list should be empty"); + + ok(!got_context, "no facet, should not have fetched context"); + ok(_(cs).isEmpty(), "contexts list should be empty"); + + ok(!got_groupby, "no facet, should not have fetched groupby"); + ok(_(gs).isEmpty(), "groupby list should be empty"); + }) + }); + asyncTest('London, calling', 8, function () { + var got_domain = false, got_context = false, got_groupby = false; + var $fix = $('#qunit-fixture'); + var view = makeSearchView({ + get_domain: function (facet) { + equal(facet.get('category'), "Dummy"); + deepEqual(facet.values.toJSON(), [{label: "42", value: 42}]); + got_domain = true; + return null; + }, + get_context: function () { + got_context = true; + return null; + }, + get_groupby: function () { + got_groupby = true; + return null; + } + }, {dummy: 42}); + var ds, cs, gs; + view.on_search.add(function (d, c, g) { + ds = d, cs = c, gs = g; + }); + view.appendTo($fix) + .always(start) + .fail(function (error) { ok(false, error.message); }) + .done(function () { + view.do_search(); + ok(got_domain, "should have fetched domain"); + ok(_(ds).isEmpty(), "domains list should be empty"); + + ok(got_context, "should have fetched context"); + ok(_(cs).isEmpty(), "contexts list should be empty"); + + ok(got_groupby, "should have fetched groupby"); + ok(_(gs).isEmpty(), "groupby list should be empty"); + }) + }); + asyncTest('Generate domains', 1, function () { + var $fix = $('#qunit-fixture'); + var view = makeSearchView({ + get_domain: function (facet) { + return facet.values.map(function (value) { + return ['win', '4', value.get('value')]; + }); + } + }, {dummy: 42}); + var ds; + view.on_search.add(function (d) { ds = d; }); + view.appendTo($fix) + .always(start) + .fail(function (error) { ok(false, error.message); }) + .done(function () { + view.do_search(); + deepEqual(ds, [[['win', '4', 42]]], + "search should yield an array of contexts"); + }); + }); + + test('Field single value, default domain & context', function () { + var f = new instance.web.search.Field({}, {name: 'foo'}, {inputs: []}); + var facet = new instance.web.search.Facet({ + field: f, + values: [{value: 42}] + }); + + deepEqual(f.get_domain(facet), [['foo', '=', 42]], + "default field domain is a strict equality of name to facet's value"); + equal(f.get_context(facet), null, + "default field context is null"); + }); + test('Field multiple values, default domain & context', function () { + var f = new instance.web.search.Field({}, {name: 'foo'}, {inputs: []}); + var facet = new instance.web.search.Facet({ + field: f, + values: [{value: 42}, {value: 68}, {value: 999}] + }); + + var actual_domain = f.get_domain(facet); + equal(actual_domain.__ref, "compound_domain", + "multiple value should yield compound domain"); + deepEqual(actual_domain.__domains, [ + ['|'], + ['|'], + [['foo', '=', 42]], + [['foo', '=', 68]], + [['foo', '=', 999]] + ], + "domain should OR a default domain for each value"); + equal(f.get_context(facet), null, + "default field context is null"); + }); + test('Field single value, custom domain & context', function () { + var f = new instance.web.search.Field({attrs:{ + context: "{'bob': self}", + filter_domain: "[['edmund', 'is', self]]" + }}, {name: 'foo'}, {inputs: []}); + var facet = new instance.web.search.Facet({ + field: f, + values: [{value: "great"}] + }); + + var actual_domain = f.get_domain(facet); + equal(actual_domain.__ref, "compound_domain", + "@filter_domain should yield compound domain"); + deepEqual(actual_domain.__domains, [ + "[['edmund', 'is', self]]" + ], 'should hold unevaluated custom domain'); + deepEqual(actual_domain.get_eval_context(), { + self: "great" + }, "evaluation context should hold facet value as self"); + + var actual_context = f.get_context(facet); + equal(actual_context.__ref, "compound_context", + "@context should yield compound context"); + deepEqual(actual_context.__contexts, [ + "{'bob': self}" + ], 'should hold unevaluated custom context'); + deepEqual(actual_context.get_eval_context(), { + self: "great" + }, "evaluation context should hold facet value as self"); + }); + test("M2O default", function () { + var f = new instance.web.search.ManyToOneField( + {}, {name: 'foo'}, {inputs: []}); + var facet = new instance.web.search.Facet({ + field: f, + values: [{label: "Foo", value: 42}] + }); + + deepEqual(f.get_domain(facet), [['foo', '=', 42]], + "m2o should use identity if default domain"); + }); + test("M2O custom operator", function () { + var f = new instance.web.search.ManyToOneField( + {attrs: {operator: 'boos'}}, {name: 'foo'}, {inputs: []}); + var facet = new instance.web.search.Facet({ + field: f, + values: [{label: "Foo", value: 42}] + }); + + deepEqual(f.get_domain(facet), [['foo', 'boos', 'Foo']], + "m2o should use label with custom operators"); + }); + test("M2O custom domain & context", function () { + var f = new instance.web.search.ManyToOneField({attrs: { + context: "{'whee': self}", + filter_domain: "[['filter', 'is', self]]" + }}, {name: 'foo'}, {inputs: []}); + var facet = new instance.web.search.Facet({ + field: f, + values: [{label: "Foo", value: 42}] + }); + + var domain = f.get_domain(facet); + deepEqual(domain.__domains, [ + "[['filter', 'is', self]]" + ]); + deepEqual(domain.get_eval_context(), { + self: "Foo" + }, "custom domain's self should be label"); + var context = f.get_context(facet); + deepEqual(context.__contexts, [ + "{'whee': self}" + ]); + deepEqual(context.get_eval_context(), { + self: "Foo" + }, "custom context's self should be label"); + }); + + asyncTest('FilterGroup', 6, function () { + var view = {inputs: [], query: {on: function () {}}}; + var filter_a = new instance.web.search.Filter( + {attrs: {name: 'a', context: 'c1', domain: 'd1'}}, view); + var filter_b = new instance.web.search.Filter( + {attrs: {name: 'b', context: 'c2', domain: 'd2'}}, view); + var filter_c = new instance.web.search.Filter( + {attrs: {name: 'c', context: 'c3', domain: 'd3'}}, view); + var group = new instance.web.search.FilterGroup( + [filter_a, filter_b, filter_c], view); + group.facet_for_defaults({a: true, c: true}) + .always(start) + .fail(function (error) { ok(false, error && error.message); }) + .done(function (facet) { + var model = facet; + if (!(model instanceof instance.web.search.Facet)) { + model = new instance.web.search.Facet(facet); + } + + var domain = group.get_domain(model); + equal(domain.__ref, 'compound_domain', + "domain should be compound"); + deepEqual(domain.__domains, [ + ['|'], 'd1', 'd3' + ], "domain should OR filter domains"); + ok(!domain.get_eval_context(), "domain should have no evaluation context"); + var context = group.get_context(model); + equal(context.__ref, 'compound_context', + "context should be compound"); + deepEqual(context.__contexts, [ + 'c1', 'c3' + ], "context should merge all filter contexts"); + ok(!context.get_eval_context(), "context should have no evaluation context"); + }); + }); + + module('removal', { + setup: function () { + instance = window.openerp.init([]); + window.openerp.web.corelib(instance); + window.openerp.web.coresetup(instance); + window.openerp.web.chrome(instance); + window.openerp.web.data(instance); + window.openerp.web.search(instance); + + instance.web.qweb.add_template(doc); + + mockifyRPC(instance.connection); + } + }); + asyncTest('clear button', function () { + var $fix = $('#qunit-fixture'); + var view = makeSearchView({ + facet_for_defaults: function (defaults) { + return $.when({ + field: this, + category: 'Dummy', + values: [{label: 'dummy', value: defaults.dummy}] + }); + } + }, {dummy: 42}); + view.appendTo($fix) + .always(start) + .fail(function (error) { ok(false, error.message); }) + .done(function () { + equal(view.query.length, 1, "view should have default facet"); + $fix.find('.oe_searchview_clear').click(); + equal(view.query.length, 0, "cleared view should not have any facet"); + }); + }); + + module('drawer', { + setup: function () { + instance = window.openerp.init([]); + window.openerp.web.corelib(instance); + window.openerp.web.coresetup(instance); + window.openerp.web.chrome(instance); + window.openerp.web.data(instance); + window.openerp.web.search(instance); + + instance.web.qweb.add_template(doc); + + mockifyRPC(instance.connection); + } + }); + asyncTest('is-drawn', 2, function () { + var view = makeSearchView(); + var $fix = $('#qunit-fixture'); + view.appendTo($fix) + .always(start) + .fail(function (error) { ok(false, error.message); }) + .done(function () { + ok($fix.find('.oe_searchview_filters').length, + "filters drawer control has been drawn"); + ok($fix.find('.oe_searchview_advanced').length, + "filters advanced search has been drawn"); + }); + }); + + module('filters', { + setup: function () { + instance = window.openerp.init([]); + window.openerp.web.corelib(instance); + window.openerp.web.coresetup(instance); + window.openerp.web.chrome(instance); + window.openerp.web.data(instance); + window.openerp.web.search(instance); + + instance.web.qweb.add_template(doc); + + mockifyRPC(instance.connection, { + '/web/searchview/load': function () { + // view with a single group of filters + return {result: {fields_view: { + type: 'search', + fields: {}, + arch: { + tag: 'search', + attrs: {}, + children: [{ + tag: 'filter', + attrs: { string: "Foo1", domain: [ ['foo', '=', '1'] ] }, + children: [] + }, { + tag: 'filter', + attrs: { + name: 'foo2', + string: "Foo2", + domain: [ ['foo', '=', '2'] ] }, + children: [] + }, { + tag: 'filter', + attrs: { string: "Foo3", domain: [ ['foo', '=', '3'] ] }, + children: [] + }] + } + }}}; + } + }); + } + }); + asyncTest('drawn', 3, function () { + var view = makeSearchView(); + var $fix = $('#qunit-fixture'); + view.appendTo($fix) + .always(start) + .fail(function (error) { ok(false, error.message); }) + .done(function () { + var $fs = $fix.find('.oe_searchview_filters ul'); + // 3 filters, 1 filtergroup, 1 custom filters widget, + // 1 advanced and 1 Filters widget + equal(view.inputs.length, 7, + 'view should have 7 inputs total'); + equal($fs.children().length, 3, + "drawer should have a filter group with 3 filters"); + equal(_.str.strip($fs.children().eq(0).text()), "Foo1", + "Text content of first filter option should match filter string"); + }); + }); + asyncTest('click adding from empty query', 4, function () { + var view = makeSearchView(); + var $fix = $('#qunit-fixture'); + view.appendTo($fix) + .always(start) + .fail(function (error) { ok(false, error.message); }) + .done(function () { + var $fs = $fix.find('.oe_searchview_filters ul'); + $fs.children(':eq(2)').trigger('click'); + equal(view.query.length, 1, "click should have added a facet"); + var facet = view.query.at(0); + equal(facet.values.length, 1, "facet should have a single value"); + var value = facet.values.at(0); + ok(value.get('value') instanceof instance.web.search.Filter, + "value should be a filter"); + equal(value.get('label'), "Foo3", + "value should be third filter"); + }); + }); + asyncTest('click adding from existing query', 4, function () { + var view = makeSearchView({}, {foo2: true}); + var $fix = $('#qunit-fixture'); + view.appendTo($fix) + .always(start) + .fail(function (error) { ok(false, error.message); }) + .done(function () { + var $fs = $fix.find('.oe_searchview_filters ul'); + $fs.children(':eq(2)').trigger('click'); + equal(view.query.length, 1, "click should not have changed facet count"); + var facet = view.query.at(0); + equal(facet.values.length, 2, "facet should have a second value"); + var v1 = facet.values.at(0); + equal(v1.get('label'), "Foo2", + "first value should be default"); + var v2 = facet.values.at(1); + equal(v2.get('label'), "Foo3", + "second value should be clicked filter"); + }); + }); + asyncTest('click removing from query', 2, function () { + var view = makeSearchView({}, {foo2: true}); + var $fix = $('#qunit-fixture'); + view.appendTo($fix) + .always(start) + .fail(function (error) { ok(false, error.message); }) + .done(function () { + var $fs = $fix.find('.oe_searchview_filters ul'); + // sanity check + equal(view.query.length, 1, "query should have default facet"); + $fs.children(':eq(1)').trigger('click'); + equal(view.query.length, 0, "click should have removed facet"); + }); + }); + + module('advanced', { + setup: function () { + instance = window.openerp.init([]); + window.openerp.web.corelib(instance); + window.openerp.web.coresetup(instance); + window.openerp.web.chrome(instance); + window.openerp.web.data(instance); + window.openerp.web.formats(instance); + window.openerp.web.search(instance); + + instance.web.qweb.add_template(doc); + + mockifyRPC(instance.connection); + } + }); + asyncTest('single-advanced', 6, function () { + var view = makeSearchView(); + var $fix = $('#qunit-fixture'); + + view.appendTo($fix) + .always(start) + .fail(function (error) { ok(false, error.message); }) + .done(function () { + var $advanced = $fix.find('.oe_searchview_advanced'); + // open advanced search (not actually useful) + $advanced.find('> h4').click(); + // select proposition (only one) + var $prop = $advanced.find('> form li:first'); + // field select should have two possible values, dummy and id + equal($prop.find('.searchview_extended_prop_field option').length, + 2, "advanced search should provide choice between two fields"); + // field should be dummy + equal($prop.find('.searchview_extended_prop_field').val(), + 'dummy', + "only field should be dummy"); + // operator should be "contains"/'ilike' + equal($prop.find('.searchview_extended_prop_op').val(), + 'ilike', "default char operator should be ilike"); + // put value in + $prop.find('.searchview_extended_prop_value input') + .val("stupid value"); + // validate advanced search + $advanced.find('button.oe_apply').click(); + + // resulting search + equal(view.query.length, 1, "search query should have a single facet"); + var facet = view.query.at(0); + ok(!facet.get('field').get_context(facet), + "advanced search facets should yield no context"); + deepEqual(facet.get('field').get_domain(facet), + [['dummy', 'ilike', "stupid value"]], + "advanced search facet should return proposed domain"); + }); + }); + asyncTest('multiple-advanced', 3, function () { + var view = makeSearchView(); + var $fix = $('#qunit-fixture'); + + view.appendTo($fix) + .always(start) + .fail(function (error) { ok(false, error.message); }) + .done(function () { + var $advanced = $fix.find('.oe_searchview_advanced'); + // open advanced search (not actually useful) + $advanced.find('> h4').click(); + // open second condition + $advanced.find('button.oe_add_condition').click(); + // select first proposition + var $prop1 = $advanced.find('> form li:first'); + $prop1.find('.searchview_extended_prop_field').val('dummy').change(); + $prop1.find('.searchview_extended_prop_op').val('ilike'); + $prop1.find('.searchview_extended_prop_value input') + .val("stupid value"); + + // select first proposition + var $prop2 = $advanced.find('> form li:last'); + // need to trigger event manually or op not changed + $prop2.find('.searchview_extended_prop_field').val('id').change(); + $prop2.find('.searchview_extended_prop_op').val('='); + $prop2.find('.searchview_extended_prop_value input') + .val(42); + // validate advanced search + $advanced.find('button.oe_apply').click(); + + // resulting search + equal(view.query.length, 1, "search query should have a single facet"); + var facet = view.query.at(0); + ok(!facet.get('field').get_context(facet), + "advanced search facets should yield no context"); + deepEqual(facet.get('field').get_domain(facet), + ['|', ['dummy', 'ilike', "stupid value"], + ['id', '=', 42]], + "advanced search facet should return proposed domain"); + }); + }); + // TODO: UI tests? +}); diff --git a/addons/web/static/test/test.html b/addons/web/static/test/test.html index 84dd3ee7f27..e352f63c17c 100644 --- a/addons/web/static/test/test.html +++ b/addons/web/static/test/test.html @@ -10,9 +10,10 @@ + - + @@ -53,4 +54,5 @@ + diff --git a/doc/conf.py b/doc/conf.py index 69e49d0e5a7..a5ff6bed7e1 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -247,6 +247,7 @@ texinfo_documents = [ # How to display URL addresses: 'footnote', 'no', or 'inline'. #texinfo_show_urls = 'footnote' +todo_include_todos = True # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = { diff --git a/doc/index.rst b/doc/index.rst index 888ff7705f2..9f54b879acc 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -16,14 +16,14 @@ Contents: async rpc + search-view + Older stuff ----------- .. toctree:: :maxdepth: 2 - search-view - getting-started production widgets diff --git a/doc/search-view.rst b/doc/search-view.rst index 89051884596..f94132bf3bd 100644 --- a/doc/search-view.rst +++ b/doc/search-view.rst @@ -13,217 +13,388 @@ multiple fields). The goal for this change is twofold: * Improve the looks and behaviors of the view, and the fit within OpenERP Web's new design. -The faceted search is implemented through a monkey-patched +The internal structure of the faceted search is inspired by `VisualSearch `_ -[#]_. VisualSearch is based on `Backbone -`_ and makes significant -use of Backbone's models and views. As a result, understanding the -implementation of the OpenERP Web 6.2 search view also requires a -basic understanding of Backbone. +[#previous]_. + +As does VisualSearch, the new search view is based on `Backbone`_ and +makes significant use of Backbone's models and collections (OpenERP +Web's widgets make a good replacement for Backbone's own views). As a +result, understanding the implementation details of the OpenERP Web 7 +search view also requires a basic understanding of Backbone's models, +collections and events. .. note:: This document may mention *fetching* data. This is a shortcut for - "returning a deferred to [whatever is being fetched]". Unless - further noted, the function or method may opt to return nothing by - fetching ``null`` (which can easily be done by returning - ``$.when(null)``, which simply wraps the ``null`` in a Deferred). + "returning a :js:class:`Deferred` to [whatever is being + fetched]". Unless further noted, the function or method may opt to + return nothing by fetching ``null`` (which can easily be done by + returning ``$.when(null)``, which simply wraps the ``null`` in a + Deferred). -Interaction between the Search View and VisualSearch ----------------------------------------------------- +Working with the search view: creating new inputs +------------------------------------------------- -The core data abstraction in VisualSearch is -:js:class:`VS.model.SearchQuery`, a backbone Collection holding -instances of the :js:class:`VS.model.SearchFacet` backbone Model. +The primary component of search views, as with all other OpenERP +views, are inputs. The search view has two types of inputs — filters +and fields — but only one is easly customizable: fields. -Backbone models can hold any number of informal properties interacted -with through the :js:func:`~Backbone.Model.get` and -:js:func:`~Backbone.Model.set` methods. VisualSearch reserves three -such properties for its behavior, these properties *must* be correctly -set on all search facets created programmatically: +The mapping from OpenERP field types (and widgets) to search view +objects is stored in the ``openerp.web.search.fields`` +:js:class:`~openerp.web.Registry` where new field types and widgets +can be added. -``app`` - a reference to the VisualSearch instance using this facet. In the - search view, this instance is available as the - :js:attr:`~openerp.web.SearchView.vs` attribute to the searchview - instance. +Search view inputs have four main roles: -``category`` - the *name* of the facet, displayed in the first section of a facet - view. +Loading defaults +++++++++++++++++ -``value`` - the *displayed value* of the facet, it is directly printed to the - right of the category. +Once the search view has initialized all its inputs, it will call +:js:func:`~openerp.web.search.Input.facet_for_defaults` on each input, +passing it a mapping (a javascript object) of ``name:value`` extracted +from the action's context. -The search view uses additional keys to store state and data it needs -to associate with facet objects: +This method should fetch a :js:class:`~openerp.web.search.Facet` (or +an equivalent object) for the field's default value if applicable (if +a default value for the field is found in the ``defaults`` mapping). -``field`` - the search field instance which created the facet, used when the - search view needs to serialize the facets. +A default implementation is provided which checks if ``defaults`` +contains a non-falsy value for the field's ``@name`` and calls +:js:func:`openerp.web.search.Input.facet_for` with that value. -``json`` - the "logical" value of the facet, can be absent if the logical and - "printable" values of the facet are the same (e.g. for a basic text - field). +There is no default implementation of +:js:func:`openerp.web.search.Input.facet_for` [#no_impl]_, but +:js:class:`openerp.web.search.Field` provides one, which uses the +value as-is to fetch a :js:class:`~openerp.web.search.Facet`. - This value may be a complex javascript object such as an array (the - name stands for json-compatible value, it is not a JSON-encoded - string). +Providing completions ++++++++++++++++++++++ -.. note:: +An important component of the new search view is the auto-completion +pane, and the task of providing completion items is delegated to +inputs through the :js:func:`~openerp.web.search.Input.complete` +method. - in order to simplify getting the logical value of a search facet - model, :js:class:`VS.model.SearchFacet` has been extended with a - :js:func:`~VS.model.SearchFacet.value` method +This method should take a single argument (the string being typed by +the user) and should fetch an ``Array`` of possible completions +[#completion]_. -Extensions and patches to VisualSearch -++++++++++++++++++++++++++++++++++++++ +A default implementation is provided which fetches nothing. -.. js:function:: VS.model.SearchFacet.value() +A completion item is a javascript object with two keys (technically it +can have any number of keys, but only these two will be used by the +search view): - Bundles the logic of selecting between ``json`` and ``value`` in - order to get the logical value of a facet. +``label`` -.. js:attribute:: VS.options.callbacks.make_facet + The string which will be displayed in the completion pane. It may + be formatted using HTML (inline only), as a result if ``value`` is + interpolated into it it *must* be escaped. ``_.escape`` can be + used for this. - Called by :js:class:`VS.ui.SearchBox` when it needs to create a - new search facet *view*. By default this is not supported by - VisualSearch, and requires monkey-patching - :js:func:`VS.ui.SearchBox.renderFacet`. +``facet`` - This patch should not alter any behavior if - :js:attr:`~VS.options.callbacks.make_facet` is not used. + Either a :js:class:`~openerp.web.search.Facet` object or (more + commonly) the corresponding attributes object. This is the facet + which will be inserted into the search query if the completion + item is selected by the user. -.. js:attribute:: VS.options.callbacks.make_input +If the ``facet`` is not provided (not present, ``null``, ``undefined`` +or any other falsy value), the completion item will not be selectable +and will act as a section title of sort (the ``label`` will be +formatted differently). If an input *may* fetch multiple completion +items, it *should* prefix those with a section title using its own +name. This has no technical consequence but is clearer for users. - Similar to :js:attr:`~VS.options.callbacks.make_facet`, but called - when the :js:class:`~VS.ui.SearchBox` needs to create a search - input view. It requires monkey-patching - :js:func:`VS.ui.SearchBox.renderSearchInput`. +Providing drawer/supplementary UI ++++++++++++++++++++++++++++++++++ -Finally, :js:func:`VS.ui.SearchBox.searchEvent` is monkey-patched to -get rid of its serialize/load round-tripping of facet data: the -additional attributes needed by the search view don't round-trip (at -all) so VisualSearch must not load any data from its (fairly -simplistic) text-serialization format. +For some inputs (fields or not), interaction via autocompletion may be +awkward or even impossible. -.. note:: +These may opt to being rendered in a "drawer" as well or instead. In +that case, they will undergo the normal widget lifecycle and be +rendered inside the drawer. - a second issue is that — as of `commit 3fca87101d`_ — VisualSearch - correctly serializes facet categories containing spaces but is - unable to load them back in. It also does not handle facets with - *empty* categories correctly. +.. Found no good type-based way to handle this, since there is no MI + (so no type-tagging) and it's possible for both Field and non-Field + input to be put into the drawer, for whatever reason (e.g. some + sort of auto-detector completion item for date widgets, but a + second more usual calendar widget in the drawer for more + obvious/precise interactions) -Loading Defaults ----------------- +Any input can note its desire to be rendered in the drawer by +returning a truthy value from +:js:func:`~openerp.web.search.Input.in_drawer`. -After loading the view data, the SearchView will call -:js:func:`openerp.web.search.Input.facet_for_defaults` on each of its -inputs with the ``defaults`` mapping of key:values (where each key -corresponds to an input). This method should look into the -``defaults`` mapping and fetch the field's default value as a -:js:class:`~VS.models.SearchFacet` if applicable. +By default, :js:func:`~openerp.web.search.Input.in_drawer` returns the +value of :js:attr:`~openerp.web.search.Input._in_drawer`, which is +``false``. The behavior can be toggled either by redefining the +attribute to ``true`` (either on the class or on the input), or by +overriding :js:func:`~openerp.web.search.Input.in_drawer` itself. -The default implementation is to check if there is a default value for -the current input's name (via -:js:attr:`openerp.web.search.Input.attrs.name`) and if there is to -convert this value to a :js:class:`~VS.models.SearchFacet` by calling -:js:func:`openerp.web.search.Input.facet_for`. +The input will be rendered in the full width of the drawer, it will be +started only once (per view). -There is no built-in (default) implementation of -:js:func:`openerp.web.search.Input.facet_for`. This method should -fetch the :js:class:`~VS.models.SearchFacet` corresponding to the -"raw" value passed as argument. +.. todo:: drawer API (if a widget wants to close the drawer in some + way), part of the low-level SearchView API/interactions? -Providing auto-completion -------------------------- -An important component of the unified search view is the faceted -autocompletion pane. In order to provide good user and developer -experiences, this pane is pluggable (value-wise): each and every -control of the search view can check for (and provide) categorized -auto-completions for a given value being typed by the user. +.. todo:: handle filters and filter groups via a "driver" input which + dynamically collects, lays out and renders filters? => + exercises drawer thingies -This is done by implementing -:js:func:`openerp.web.search.Input.complete`: the method is provided -with a value to complete, and should fetch an ``Array`` of completion -values. These completion values will then be provided to the global -autocompletion list, implemented via `jquery-ui autocomplete -`_. +Programmatic interactions: internal model +----------------------------------------- -Because the search view uses a custom renderer for its completion, it -was possible to fix some incompatibilities between the attributes of -completion items and VisualSearch's facet model: +This new searchview is built around an instance of +:js:class:`~openerp.web.search.SearchQuery` available as +:js:attr:`openerp.web.SearchView.query`. -Actual completion items -+++++++++++++++++++++++ +The query is a `backbone collection`_ of +:js:class:`~openerp.web.search.Facet` objects, which can be interacted +with directly by external objects or search view controls +(e.g. widgets displayed in the drawer). -These are selectable items, and upon selection are turned into actual -search facet objects. They should have all the properties of a search -facet (as described above) and can have one more optional property: -``label``. +.. js:class:: openerp.web.search.SearchQuery -When rendering an item in the list, the renderer will first try to use -the ``label`` property if it exists (``label`` can contain HTML and -will be inserted as-is, so it can bold or emphasize some of its -elements), if it does not the ``value`` property will be used. + The current search query of the search view, provides convenience + behaviors for manipulating :js:class:`~openerp.web.search.Facet` + on top of the usual `backbone collection`_ methods. -.. note:: the ``app`` key should not be specified on completion item, - it will be set automatically when the search view creates - the facet from the item. + The query ensures all of its facets contain at least one + :js:class:`~openerp.web.search.FacetValue` instance. Otherwise, + the facet is automatically removed from the query. -Section titles -++++++++++++++ + .. js:function:: openerp.web.search.SearchQuery.add(values, options) -A second kind of completion values is the section titles. Section -titles are similar to completion items but only have a ``category`` -property. They will be rendered in a different style and can not be -selected in the auto-completion (they will be skipped). + Overridden from the base ``add`` method so that adding a facet + which is *already* in the collection will merge the value of + the new facet into the old one rather than add a second facet + with different values. -.. note:: + :param values: facet, facet attributes or array thereof + :returns: the collection itself - Technically, section title items can have any property they want - *as long as they do not have a value property*. A ``value`` - property set to ``false``, ``null`` or ``undefined`` is **not** - equivalent to not having a ``value`` property. + .. js:function:: openerp.web.search.SearchQuery.toggle(value, options) -If an input *may* fetch more than one completion item, it *should* -prepend a section title (using its own name) to the completion items. + Convenience method for toggling facet values in a query: + removes the values (through the facet itself) if they are + present, adds them if they are not. If the facet itself is not + in the collection, adds it automatically. + + A toggling is atomic: only one change event will be triggered + on the facet regardless of the number of values added to or + removed from the facet (if the facet already exists), and the + facet is only removed from the query if it has no value *at + the end* of the toggling. + + :param value: facet or facet attributes + :returns: the collection + +.. js:class:: openerp.web.search.Facet + + A `backbone model`_ representing a single facet of the current + research. May map to a search field, or to a more complex or + fuzzier input (e.g. a custom filter or an advanced search). + + .. js:attribute:: category + + The displayed name of the facet, as a ``String``. This is a + backbone model attribute. + + .. js:attribute:: field + + The :js:class:`~openerp.web.search.Input` instance which + originally created the facet [#facet-field]_, used to delegate + some operations (such as serializing the facet's values to + domains and contexts). This is a backbone model attribute. + + .. js:attribute:: values + + :js:class:`~openerp.web.search.FacetValues` as a javascript + attribute, stores all the values for the facet and helps + propagate their events to the facet. Is also available as a + backbone attribute (via ``#get`` and ``#set``) in which cases + it serializes to and deserializes from javascript arrays (via + ``Collection#toJSON`` and ``Collection#reset``). + + .. js:attribute:: [icon] + + optional, a single ASCII letter (a-z or A-Z) mapping to the + bundled mnmliconsRegular icon font. + + When a facet with an ``icon`` attribute is rendered, the icon + is displayed (in the icon font) in the first section of the + facet instead of the ``category``. + + By default, only filters make use of this facility. + +.. js:class:: openerp.web.search.FacetValues + + `Backbone collection`_ of + :js:class:`~openerp.web.search.FacetValue` instances. + +.. js:class:: openerp.web.search.FacetValue + + `Backbone model`_ representing a single value within a facet, + represents a pair of (displayed name, logical value). + + .. js:attribute:: label + + Backbone model attribute storing the "displayable" + representation of the value, visually output to the + user. Must be a string. + + .. js:attribute:: value + + Backbone model attribute storing the logical/internal value + (of itself), will be used by + :js:class:`~openerp.web.search.Input` to serialize to domains + and contexts. + + Can be of any type. Converting from facet objects ----------------------------- Ultimately, the point of the search view is to allow searching. In OpenERP this is done via :ref:`domains `. On -the other hand, the OpenERP Web 6.2 search view's state is modelled -after a collection of :js:class:`~VS.model.SearchFacet`, and each +the other hand, the OpenERP Web 7 search view's state is modelled +after a collection of :js:class:`~openerp.web.search.Facet`, and each field of a search view may have special requirements when it comes to -the domains it produces [#]_. +the domains it produces [#special]_. So there needs to be some way of mapping -:js:class:`~VS.model.SearchFacet` objects to OpenERP search data. +:js:class:`~openerp.web.search.Facet` objects to OpenERP search data. This is done via an input's :js:func:`~openerp.web.search.Input.get_domain` and :js:func:`~openerp.web.search.Input.get_context`. Each takes a -:js:class:`~VS.model.SearchFacet` and returns whatever it's supposed -to generate (a domain or a context, respectively). Either can return -``null`` if the current value does not map to a domain or context, and -can throw an :js:class:`~openerp.web.search.Invalid` exception if the -value is not valid at all for the field. +:js:class:`~openerp.web.search.Facet` and returns whatever it's +supposed to generate (a domain or a context, respectively). Either can +return ``null`` if the current value does not map to a domain or +context, and can throw an :js:class:`~openerp.web.search.Invalid` +exception if the value is not valid at all for the field. -Converting to facet objects ---------------------------- +.. note:: + + The :js:class:`~openerp.web.search.Facet` object can have any + number of values (from 1 upwards) + +.. note:: + + There is a third conversion method, + :js:func:`~openerp.web.search.Input.get_groupby`, which returns an + ``Array`` of groupby domains rather than a single context. At this + point, it is only implemented on (and used by) filters. + +Field services +++++++++++++++ + +:js:class:`~openerp.web.search.Field` provides a default +implementation of :js:func:`~openerp.web.search.Input.get_domain` and +:js:func:`~openerp.web.search.Input.get_context` taking care of most +of the peculiarities pertaining to OpenERP's handling of fields in +search views. It also provides finer hooks to let developers of new +fields and widgets customize the behavior they want without +necessarily having to reimplement all of +:js:func:`~openerp.web.search.Input.get_domain` or +:js:func:`~openerp.web.search.Input.get_context`: + +.. js:function:: openerp.web.search.Field.get_context(facet) + + If the field has no ``@context``, simply returns + ``null``. Otherwise, calls + :js:func:`~openerp.web.search.Field.value_from` once for each + :js:class:`~openerp.web.search.FacetValue` of the current + :js:class:`~openerp.web.search.Facet` (in order to extract the + basic javascript object from the + :js:class:`~openerp.web.search.FacetValue` then evaluates + ``@context`` with each of these values set as ``self``, and + returns the union of all these contexts. + + :param facet: + :type facet: openerp.web.search.Facet + :returns: a context (literal or compound) + +.. js:function:: openerp.web.search.Field.get_domain(facet) + + If the field has no ``@filter_domain``, calls + :js:func:`~openerp.web.search.Field.make_domain` once with each + :js:class:`~openerp.web.search.FacetValue` of the current + :js:class:`~openerp.web.search.Facet` as well as the field's + ``@name`` and either its ``@operator`` or + :js:attr:`~openerp.web.search.Field.default_operator`. + + If the field has an ``@filter_value``, calls + :js:func:`~openerp.web.search.Field.value_from` once per + :js:class:`~openerp.web.search.FacetValue` and evaluates + ``@filter_value`` with each of these values set as ``self``. + + In either case, "ors" all of the resulting domains (using ``|``) + if there is more than one + :js:class:`~openerp.web.search.FacetValue` and returns the union + of the result. + + :param facet: + :type facet: openerp.web.search.Facet + :returns: a domain (literal or compound) + +.. js:function:: openerp.web.search.Field.make_domain(name, operator, facetValue) + + Builds a literal domain from the provided data. Calls + :js:func:`~openerp.web.search.Field.value_from` on the + :js:class:`~openerp.web.search.FacetValue` and evaluates and sets + it as the domain's third value, uses the other two parameters as + the first two values. + + Can be overridden to build more complex default domains. + + :param String name: the field's name + :param String operator: the operator to use in the field's domain + :param facetValue: + :type facetValue: openerp.web.search.FacetValue + :returns: Array<(String, String, Object)> + +.. js:function:: openerp.web.search.Field.value_from(facetValue) + + Extracts a "bare" javascript value from the provided + :js:class:`~openerp.web.search.FacetValue`, and returns it. + + The default implementation will simply return the ``value`` + backbone property of the argument. + + :param facetValue: + :type facetValue: openerp.web.search.FacetValue + :returns: Object + +.. js:attribute:: openerp.web.search.Field.default_operator + + Operator used to build a domain when a field has no ``@operator`` + or ``@filter_domain``. ``"="`` for + :js:class:`~openerp.web.search.Field` + +Arbitrary data storage +++++++++++++++++++++++ + +:js:class:`~openerp.web.search.Facet` and +:js:class:`~openerp.web.search.FacetValue` objects (and structures) +provided by your widgets should never be altered by the search view +(or an other widget). This means you are free to add arbitrary fields +in these structures if you need to (because you have more complex +needs than the attributes described in this document). + +Ideally this should be avoided, but the possibility remains. Changes ------- -.. todo:: merge in changelog instead +.. todo:: merge in changelog instead? The displaying of the search view was significantly altered from -OpenERP Web 6.1 to OpenERP Web 6.2. +OpenERP Web 6.1 to OpenERP Web 7. As a result, while the external API used to interact with the search view does not change many internal details — including the interaction @@ -247,11 +418,11 @@ Widgets API * :js:func:`~openerp.web.search.Input.clear` has been removed since clearing the search view now simply consists of removing all search - facets from VisualSearch + facets * :js:func:`~openerp.web.search.Input.get_domain` and :js:func:`~openerp.web.search.Input.get_context` now take a - :js:class:`~VS.model.SearchFacet` as parameter, from which it's + :js:class:`~openerp.web.search.Facet` as parameter, from which it's their job to get whatever value they want * :js:func:`~openerp.web.search.Input.get_groupby` has been added. It returns @@ -271,19 +442,16 @@ Filters Fields ++++++ -* ``get_value`` now takes a :js:class:`~VS.model.SearchFacet` (instead - of taking no argument). - - A default implementation is provided as - :js:func:`openerp.web.search.Field.get_value` and simply calls - :js:func:`VS.model.SearchFacet.value`. +* ``get_value`` has been replaced by + :js:func:`~openerp.web.search.Field.value_from` as it now takes a + :js:class:`~openerp.web.search.FacetValue` argument (instead of no + argument). It provides a default implementation returning the + ``value`` property of its argument. * The third argument to - :js:func:`~openerp.web.search.Field.make_domain` is now the - :js:class:`~VS.model.SearchFacet` received by - :js:func:`~openerp.web.search.Field.get_domain`, so child classes - have all the information they need to derive the "right" resulting - domain. + :js:func:`~openerp.web.search.Field.make_domain` is now a + :js:class:`~openerp.web.search.FacetValue` so child classes have all + the information they need to derive the "right" resulting domain. Custom filters ++++++++++++++ @@ -300,12 +468,82 @@ Many To One :js:func:`openerp.web.search.ManyToOneField.setup_autocomplete` has been removed. -.. [#] the library code is untouched, all patching is performed in the - Search view's implementation module. Changes to the - VisualSearch code should only update the library to new - revisions or releases. -.. [#] search view fields may also bundle context data to add to the - search context +Advanced Search ++++++++++++++++ + +* The advanced search is now a more standard + :js:class:`~openerp.web.search.Input` configured to be rendered in + the drawer. + +* :js:class:`~openerp.web.search.ExtendedSearchProposition.Field` are + now standard widgets, with the "right" behaviors (they don't rebind + their ``$element`` in ``start()``) + +* The ad-hoc optional setting of the openerp field descriptor on a + :js:class:`~openerp.web.search.ExtendedSearchProposition.Field` has + been removed, the field descriptor is now passed as second argument + to the + :js:class:`~openerp.web.search.ExtendedSearchProposition.Field`'s + constructor, and bound to its + :js:attr:`~openerp.web.search.ExtendedSearchProposition.Field.field`. + +* Instead of its former domain triplet ``(field, operator, value)``, + :js:func:`~openerp.web.search.ExtendedSearchProposition.get_proposition` + now returns an object with two fields ``label`` and ``value``, + respectively a human-readable version of the proposition and the + corresponding domain triplet for the proposition. + +.. [#previous] + + the original view was implemented on top of a monkey-patched + VisualSearch, but as our needs diverged from VisualSearch's goal + this made less and less sense ultimately leading to a clean-room + reimplementation + +.. [#no_impl] + + In case you are extending the search view with a brand new type of + input + +.. [#completion] + + Ideally this array should not hold more than about 10 items, but + the search view does not put any constraint on this at the + moment. Note that this may change. + +.. [#facet-field] + + ``field`` does not actually need to be an instance of + :js:class:`~openerp.web.search.Input`, nor does it need to be what + created the facet, it just needs to provide the three + facet-serialization methods + :js:func:`~openerp.web.search.Input.get_domain`, + :js:func:`~openerp.web.search.Input.get_context` and + :js:func:`~openerp.web.search.Input.get_gropuby`, existing + :js:class:`~openerp.web.search.Input` subtypes merely provide + convenient base implementation for those methods. + + Complex search view inputs (especially those living in the drawer) + may prefer using object literals with the right slots returning + closed-over values or some other scheme un-bound to an actual + :js:class:`~openerp.web.search.Input`, as + :js:class:`~openerp.web.search.CustomFilters` and + :js:class:`~openerp.web.search.Advanced` do. + +.. [#special] + + search view fields may also bundle context data to add to the + search context + +.. _Backbone: + http://documentcloud.github.com/backbone/ + +.. _Backbone.Collection: +.. _Backbone collection: + http://documentcloud.github.com/backbone/#Collection + +.. _Backbone model: + http://documentcloud.github.com/backbone/#Model .. _commit 3fca87101d: - https://github.com/documentcloud/visualsearch/commit/3fca87101d + https://github.com/documentcloud/visualsearch/commit/3fca87101d