",
+ options: {
+ disabled: false,
+
+ // callbacks
+ create: null
+ },
+ _createWidget: function( options, element ) {
+ element = $( element || this.defaultElement || this )[ 0 ];
+ this.element = $( element );
+ this.uuid = uuid++;
+ this.eventNamespace = "." + this.widgetName + this.uuid;
+ this.options = $.widget.extend( {},
+ this.options,
+ this._getCreateOptions(),
+ options );
+
+ this.bindings = $();
+ this.hoverable = $();
+ this.focusable = $();
+
+ if ( element !== this ) {
+ $.data( element, this.widgetFullName, this );
+ this._on( true, this.element, {
+ remove: function( event ) {
+ if ( event.target === element ) {
+ this.destroy();
+ }
+ }
+ });
+ this.document = $( element.style ?
+ // element within the document
+ element.ownerDocument :
+ // element is window or document
+ element.document || element );
+ this.window = $( this.document[0].defaultView || this.document[0].parentWindow );
+ }
+
+ this._create();
+ this._trigger( "create", null, this._getCreateEventData() );
+ this._init();
+ },
+ _getCreateOptions: $.noop,
+ _getCreateEventData: $.noop,
+ _create: $.noop,
+ _init: $.noop,
+
+ destroy: function() {
+ this._destroy();
+ // we can probably remove the unbind calls in 2.0
+ // all event bindings should go through this._on()
+ this.element
+ .unbind( this.eventNamespace )
+ // 1.9 BC for #7810
+ // TODO remove dual storage
+ .removeData( this.widgetName )
+ .removeData( this.widgetFullName )
+ // support: jquery <1.6.3
+ // http://bugs.jquery.com/ticket/9413
+ .removeData( $.camelCase( this.widgetFullName ) );
+ this.widget()
+ .unbind( this.eventNamespace )
+ .removeAttr( "aria-disabled" )
+ .removeClass(
+ this.widgetFullName + "-disabled " +
+ "ui-state-disabled" );
+
+ // clean up events and states
+ this.bindings.unbind( this.eventNamespace );
+ this.hoverable.removeClass( "ui-state-hover" );
+ this.focusable.removeClass( "ui-state-focus" );
+ },
+ _destroy: $.noop,
+
+ widget: function() {
+ return this.element;
+ },
+
+ option: function( key, value ) {
+ var options = key,
+ parts,
+ curOption,
+ i;
+
+ if ( arguments.length === 0 ) {
+ // don't return a reference to the internal hash
+ return $.widget.extend( {}, this.options );
+ }
+
+ if ( typeof key === "string" ) {
+ // handle nested keys, e.g., "foo.bar" => { foo: { bar: ___ } }
+ options = {};
+ parts = key.split( "." );
+ key = parts.shift();
+ if ( parts.length ) {
+ curOption = options[ key ] = $.widget.extend( {}, this.options[ key ] );
+ for ( i = 0; i < parts.length - 1; i++ ) {
+ curOption[ parts[ i ] ] = curOption[ parts[ i ] ] || {};
+ curOption = curOption[ parts[ i ] ];
+ }
+ key = parts.pop();
+ if ( value === undefined ) {
+ return curOption[ key ] === undefined ? null : curOption[ key ];
+ }
+ curOption[ key ] = value;
+ } else {
+ if ( value === undefined ) {
+ return this.options[ key ] === undefined ? null : this.options[ key ];
+ }
+ options[ key ] = value;
+ }
+ }
+
+ this._setOptions( options );
+
+ return this;
+ },
+ _setOptions: function( options ) {
+ var key;
+
+ for ( key in options ) {
+ this._setOption( key, options[ key ] );
+ }
+
+ return this;
+ },
+ _setOption: function( key, value ) {
+ this.options[ key ] = value;
+
+ if ( key === "disabled" ) {
+ this.widget()
+ .toggleClass( this.widgetFullName + "-disabled ui-state-disabled", !!value )
+ .attr( "aria-disabled", value );
+ this.hoverable.removeClass( "ui-state-hover" );
+ this.focusable.removeClass( "ui-state-focus" );
+ }
+
+ return this;
+ },
+
+ enable: function() {
+ return this._setOption( "disabled", false );
+ },
+ disable: function() {
+ return this._setOption( "disabled", true );
+ },
+
+ _on: function( suppressDisabledCheck, element, handlers ) {
+ var delegateElement,
+ instance = this;
+
+ // no suppressDisabledCheck flag, shuffle arguments
+ if ( typeof suppressDisabledCheck !== "boolean" ) {
+ handlers = element;
+ element = suppressDisabledCheck;
+ suppressDisabledCheck = false;
+ }
+
+ // no element argument, shuffle and use this.element
+ if ( !handlers ) {
+ handlers = element;
+ element = this.element;
+ delegateElement = this.widget();
+ } else {
+ // accept selectors, DOM elements
+ element = delegateElement = $( element );
+ this.bindings = this.bindings.add( element );
+ }
+
+ $.each( handlers, function( event, handler ) {
+ function handlerProxy() {
+ // allow widgets to customize the disabled handling
+ // - disabled as an array instead of boolean
+ // - disabled class as method for disabling individual parts
+ if ( !suppressDisabledCheck &&
+ ( instance.options.disabled === true ||
+ $( this ).hasClass( "ui-state-disabled" ) ) ) {
+ return;
+ }
+ return ( typeof handler === "string" ? instance[ handler ] : handler )
+ .apply( instance, arguments );
+ }
+
+ // copy the guid so direct unbinding works
+ if ( typeof handler !== "string" ) {
+ handlerProxy.guid = handler.guid =
+ handler.guid || handlerProxy.guid || $.guid++;
+ }
+
+ var match = event.match( /^(\w+)\s*(.*)$/ ),
+ eventName = match[1] + instance.eventNamespace,
+ selector = match[2];
+ if ( selector ) {
+ delegateElement.delegate( selector, eventName, handlerProxy );
+ } else {
+ element.bind( eventName, handlerProxy );
+ }
+ });
+ },
+
+ _off: function( element, eventName ) {
+ eventName = (eventName || "").split( " " ).join( this.eventNamespace + " " ) + this.eventNamespace;
+ element.unbind( eventName ).undelegate( eventName );
+ },
+
+ _delay: function( handler, delay ) {
+ function handlerProxy() {
+ return ( typeof handler === "string" ? instance[ handler ] : handler )
+ .apply( instance, arguments );
+ }
+ var instance = this;
+ return setTimeout( handlerProxy, delay || 0 );
+ },
+
+ _hoverable: function( element ) {
+ this.hoverable = this.hoverable.add( element );
+ this._on( element, {
+ mouseenter: function( event ) {
+ $( event.currentTarget ).addClass( "ui-state-hover" );
+ },
+ mouseleave: function( event ) {
+ $( event.currentTarget ).removeClass( "ui-state-hover" );
+ }
+ });
+ },
+
+ _focusable: function( element ) {
+ this.focusable = this.focusable.add( element );
+ this._on( element, {
+ focusin: function( event ) {
+ $( event.currentTarget ).addClass( "ui-state-focus" );
+ },
+ focusout: function( event ) {
+ $( event.currentTarget ).removeClass( "ui-state-focus" );
+ }
+ });
+ },
+
+ _trigger: function( type, event, data ) {
+ var prop, orig,
+ callback = this.options[ type ];
+
+ data = data || {};
+ event = $.Event( event );
+ event.type = ( type === this.widgetEventPrefix ?
+ type :
+ this.widgetEventPrefix + type ).toLowerCase();
+ // the original event may come from any element
+ // so we need to reset the target on the new event
+ event.target = this.element[ 0 ];
+
+ // copy original event properties over to the new event
+ orig = event.originalEvent;
+ if ( orig ) {
+ for ( prop in orig ) {
+ if ( !( prop in event ) ) {
+ event[ prop ] = orig[ prop ];
+ }
+ }
+ }
+
+ this.element.trigger( event, data );
+ return !( $.isFunction( callback ) &&
+ callback.apply( this.element[0], [ event ].concat( data ) ) === false ||
+ event.isDefaultPrevented() );
+ }
+};
+
+$.each( { show: "fadeIn", hide: "fadeOut" }, function( method, defaultEffect ) {
+ $.Widget.prototype[ "_" + method ] = function( element, options, callback ) {
+ if ( typeof options === "string" ) {
+ options = { effect: options };
+ }
+ var hasOptions,
+ effectName = !options ?
+ method :
+ options === true || typeof options === "number" ?
+ defaultEffect :
+ options.effect || defaultEffect;
+ options = options || {};
+ if ( typeof options === "number" ) {
+ options = { duration: options };
+ }
+ hasOptions = !$.isEmptyObject( options );
+ options.complete = callback;
+ if ( options.delay ) {
+ element.delay( options.delay );
+ }
+ if ( hasOptions && $.effects && $.effects.effect[ effectName ] ) {
+ element[ method ]( options );
+ } else if ( effectName !== method && element[ effectName ] ) {
+ element[ effectName ]( options.duration, options.easing, callback );
+ } else {
+ element.queue(function( next ) {
+ $( this )[ method ]();
+ if ( callback ) {
+ callback.call( element[ 0 ] );
+ }
+ next();
+ });
+ }
+ };
+});
+
+})( jQuery );
diff --git a/app/assets/js/plugins/visualsearch/visualsearch.js b/app/assets/js/plugins/visualsearch/visualsearch.js
new file mode 100644
index 00000000..8c82a929
--- /dev/null
+++ b/app/assets/js/plugins/visualsearch/visualsearch.js
@@ -0,0 +1,1933 @@
+// 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.4.0
+ * (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.4.0';
+
+ VS.VisualSearch = function(options) {
+ var defaults = {
+ container : '',
+ query : '',
+ autosearch : true,
+ unquotable : [],
+ remainder : 'text',
+ showFacets : true,
+ 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,
+ showFacets: this.options.showFacets
+ });
+
+ 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'
+ },
+ manage: false,
+ // 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(' ');
+ },
+
+ // Returns any facet views that are currently selected. Useful for changing the value
+ // callbacks based on what else is in the search box and which facet is being edited.
+ selected: function() {
+ return _.select(this.facetViews, function(view) {
+ return view.modes.editing == 'is' || view.modes.selected == 'is';
+ });
+ },
+
+ // Similar to `this.selected`, returns any facet models that are currently selected.
+ selectedModels: function() {
+ return _.pluck(this.selected(), 'model');
+ },
+
+ // 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(this.renderFacet, this));
+
+ // Add on an n+1 empty search input on the very end.
+ this.renderSearchInput();
+ this.renderPlaceholder();
+ },
+
+ // 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,
+ showFacets: this.options.showFacets
+ });
+ this.$('.VS-search-inner').append(input.render().el);
+ this.inputViews.push(input);
+ },
+
+ // Handles showing/hiding the placeholder text
+ renderPlaceholder : function() {
+ var $placeholder = this.$('.VS-placeholder');
+ if (this.app.searchQuery.length) {
+ $placeholder.addClass("VS-hidden");
+ } else {
+ $placeholder.removeClass("VS-hidden")
+ .text(this.app.options.placeholder);
+ }
+ },
+
+ // # 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.
+ return false;
+ }
+ 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();
+
+ return true;
+ },
+
+ 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'
+ },
+
+ manage: false,
+
+ 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.label());
+ 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) {
+ if (this.options.app.options.autosearch) {
+ this.search(e);
+ } else {
+ this.options.app.searchBox.renderFacets();
+ this.options.app.searchBox.focusNextFacet(this, 1, {viewPosition: this.options.order});
+ }
+ }
+ 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),
+ autoCompleteData = $value.data('item.autocomplete') || $value.data('ui-autocomplete-item');
+
+ if (autoCompleteData['value'] == box.val() && box.data('uiAutocomplete').menu.activate) {
+ box.data('uiAutocomplete').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('uiAutocomplete');
+ if (autocomplete) {
+ autocomplete.menu.element.position({
+ my : "left top",
+ at : "left bottom",
+ of : this.box.data('uiAutocomplete').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('uiAutocomplete');
+ 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('uiAutocomplete');
+ 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) {
+ resp(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.options.app.options.autosearch) {
+ 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();
+ }
+ }
+
+ // Handle paste events
+ if (e.which == null) {
+ // this.searchAutocomplete(e);
+ _.defer(_.bind(this.resize, this, e));
+ } else {
+ this.resize(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 ui-menu',
+
+ events : {
+ 'keypress input' : 'keypress',
+ 'keydown input' : 'keydown',
+ 'click input' : 'maybeTripleClick',
+ 'dblclick input' : 'startTripleClickTimer'
+ },
+
+ manage: false,
+
+ 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 : this.options.showFacets ? 0 : 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();
+ // stopPropogation does weird things in jquery-ui 1.9
+ // e.stopPropagation();
+ var remainder = this.addTextFacetRemainder(ui.item.value);
+ var position = this.options.position + (remainder ? 1 : 0);
+ this.app.searchBox.addFacet(ui.item instanceof String ? ui.item : ui.item.value, '', position);
+ return false;
+ }, this)
+ });
+
+ // Renders the results grouped by the categories they belong to.
+ this.box.data('uiAutocomplete')._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;
+ }
+
+ if(this._renderItemData) {
+ this._renderItemData(ul, item);
+ } else {
+ 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('uiAutocomplete');
+ 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('uiAutocomplete');
+ if (autocomplete) {
+ autocomplete.menu.element.position({
+ my : "left top",
+ at : "left bottom",
+ of : this.box.data('uiAutocomplete').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('uiAutocomplete');
+ 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+)$/);
+
+ if (!lastWord) {
+ return '';
+ }
+
+ var matcher = new RegExp(lastWord[0], "i");
+ if (facetValue.search(matcher) == 0) {
+ boxValue = boxValue.replace(/\b(\w+)$/, '');
+ }
+ boxValue = boxValue.replace('^\s+|\s+$', '');
+
+ if (boxValue) {
+ this.app.searchBox.addFacet(this.app.options.remainder, 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');
+ if (!this.app.searchBox.allSelected()) {
+ 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') {
+ var value = this.box.val();
+ if (value.length) {
+ e.preventDefault();
+ var remainder = this.addTextFacetRemainder(value);
+ var position = this.options.position + (remainder?1:0);
+ if (value != remainder) {
+ this.app.searchBox.addFacet(value, '', position);
+ }
+ } else {
+ var foundFacet = this.app.searchBox.focusNextFacet(this, 0, {
+ skipToFacet: true,
+ selectText: true
+ });
+ if (foundFacet) {
+ e.preventDefault();
+ }
+ }
+ } 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;
+ }
+ } else if (key == 'end') {
+ var view = this.app.searchBox.inputViews[this.app.searchBox.inputViews.length-1];
+ view.setCursorAtEnd(-1);
+ } else if (key == 'home') {
+ var view = this.app.searchBox.inputViews[0];
+ view.setCursorAtEnd(-1);
+ }
+
+ 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',
+ '35': 'end',
+ '36': 'home',
+ '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 + parseInt($input.css('min-width')));
+ $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.filter(':visible').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 (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 = $('
'+message+' - '+vars+'').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 QUOTES_RE = "('[^']+'|\"[^\"]+\")";
+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 = instance.options.remainder;
+ 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 = instance.options.remainder;
+ 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+(?=' + QUOTES_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'));
+ var remainder = this.get("app").options.remainder;
+
+ if (!value) return '';
+
+ if (!_.contains(this.get("app").options.unquotable || [], category) && category != remainder) {
+ value = this.quoteValue(value);
+ }
+
+ if (category != remainder) {
+ 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 + '"';
+ }
+ },
+
+ // If provided, use a custom label instead of the raw value.
+ label : function() {
+ return this.get('label') || this.get('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').toLowerCase() == category.toLowerCase();
+ });
+ 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').toLowerCase() == category.toLowerCase();
+ }).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').toLowerCase() == category.toLowerCase();
+ });
+ 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').toLowerCase() == category.toLowerCase();
+ if (!value) return categoryMatched;
+ return categoryMatched && facet.get('value') == value;
+ });
+ },
+
+ // Used to temporarily hide specific categories and serialize the search query.
+ withoutCategory : function() {
+ var categories = _.map(_.toArray(arguments), function(cat) { return cat.toLowerCase(); });
+ return this.map(function(facet) {
+ if (!_.include(categories, facet.get('category').toLowerCase())) {
+ 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/nm/code/dataobjects/ProjectTypes/Excursion.php b/nm/code/dataobjects/ProjectTypes/Excursion.php
index e79feaca..d70adc18 100644
--- a/nm/code/dataobjects/ProjectTypes/Excursion.php
+++ b/nm/code/dataobjects/ProjectTypes/Excursion.php
@@ -171,12 +171,16 @@ class Excursion extends DataObject {
'UglyHash',
'Title',
'DateRangeNice',
+ 'YearSearch',
'TeaserText',
'MarkdownedTeaser',
'IsFeatured',
'IsPortfolio',
'IsPublished',
+ 'Space',
+ 'Location',
+
'PreviewImage.Urls',
'Persons.FirstName',
'Persons.Surname',
@@ -190,6 +194,8 @@ class Excursion extends DataObject {
'EndDate',
'TeaserText',
'Text',
+ 'Space',
+ 'Location',
'Persons',
'PreviewImage',
'Images',
diff --git a/nm/code/dataobjects/ProjectTypes/Exhibition.php b/nm/code/dataobjects/ProjectTypes/Exhibition.php
index c27152a3..6f8157cd 100644
--- a/nm/code/dataobjects/ProjectTypes/Exhibition.php
+++ b/nm/code/dataobjects/ProjectTypes/Exhibition.php
@@ -166,12 +166,16 @@ class Exhibition extends DataObject {
'UglyHash',
'Title',
'DateRangeNice',
+ 'YearSearch',
'TeaserText',
'MarkdownedTeaser',
'IsFeatured',
'IsPortfolio',
'IsPublished',
+ 'Space',
+ 'Location',
+
'PreviewImage.Urls',
'Persons.FirstName',
'Persons.Surname',
@@ -185,6 +189,8 @@ class Exhibition extends DataObject {
'EndDate',
'TeaserText',
'Text',
+ 'Space',
+ 'Location',
'Persons',
'PreviewImage',
'Images',
diff --git a/nm/code/dataobjects/ProjectTypes/Project.php b/nm/code/dataobjects/ProjectTypes/Project.php
index 0a43f95d..9551d998 100644
--- a/nm/code/dataobjects/ProjectTypes/Project.php
+++ b/nm/code/dataobjects/ProjectTypes/Project.php
@@ -172,6 +172,7 @@ class Project extends DataObject {
'UglyHash',
'Title',
'FrontendDate',
+ 'YearSearch',
'TeaserText',
'MarkdownedTeaser',
'IsFeatured',
diff --git a/nm/code/dataobjects/ProjectTypes/Workshop.php b/nm/code/dataobjects/ProjectTypes/Workshop.php
index a3979d04..4eaa6608 100644
--- a/nm/code/dataobjects/ProjectTypes/Workshop.php
+++ b/nm/code/dataobjects/ProjectTypes/Workshop.php
@@ -161,12 +161,16 @@ class Workshop extends DataObject {
'UglyHash',
'Title',
'DateRangeNice',
+ 'YearSearch',
'TeaserText',
'MarkdownedTeaser',
'IsFeatured',
'IsPortfolio',
'IsPublished',
+ 'Space',
+ 'Location',
+
'PreviewImage.Urls',
'Persons.FirstName',
'Persons.Surname',
@@ -180,6 +184,8 @@ class Workshop extends DataObject {
'EndDate',
'TeaserText',
'Text',
+ 'Space',
+ 'Location',
'Persons',
'PreviewImage',
'Images',
diff --git a/nm/code/extensions/StartEndDateExtension.php b/nm/code/extensions/StartEndDateExtension.php
index 08cbe07d..fb41a13c 100644
--- a/nm/code/extensions/StartEndDateExtension.php
+++ b/nm/code/extensions/StartEndDateExtension.php
@@ -34,6 +34,14 @@ public function FormattedStartDate() {
return $this->getFormattedDate('Start');
}
+ public function getYearSearch() {
+ $date = $this->owner->dbObject('Date');
+ $start = $this->owner->dbObject('StartDate');
+
+ if ($date) return $date->format('Y');
+ if ($start) return $start->format('Y');
+ }
+
/**
* Gibt das formatiert
*