diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3085b8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +*.pyc +*.sw? +*.sqlite3 +.DS_STORE +*.egg-info +.venv +.tox +build +dist diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100755 index 0000000..1ce94ae --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,8 @@ +include README.txt +recursive-include xstatic/pkg/magic_search * + +global-exclude *.pyc +global-exclude *.pyo +global-exclude *.orig +global-exclude *.rej + diff --git a/README.txt b/README.txt new file mode 100755 index 0000000..24bf752 --- /dev/null +++ b/README.txt @@ -0,0 +1,21 @@ +XStatic-MagicSearch +------------------- + +MagicSearch is an AngularJS directive that provides a UI for both faceted +filtering and as-you-type filtering. It is intended for filtering tables, +such as an AngularJS smart-table, but it can be used in any situation +where you can provide it with facets/options and consume its events. + +MagicSearch was initially developed by David Kavanagh for Eucalyptus. + + +MagicSearch javascript library packaged for setuptools (easy_install) / pip. + +This package is intended to be used by **any** project that needs these files. + +It intentionally does **not** provide any extra code except some metadata +**nor** has any extra requirements. You MAY use some minimal support code from +the XStatic base package, if you like. + +You can find more info about the xstatic packaging way in the package `XStatic`. + diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..b8765cd --- /dev/null +++ b/setup.py @@ -0,0 +1,27 @@ +from xstatic.pkg import magic_search as xs + +# The README.txt file should be written in reST so that PyPI can use +# it to generate your project's PyPI page. +long_description = open('README.txt').read() + +from setuptools import setup, find_packages + +setup( + name=xs.PACKAGE_NAME, + version=xs.PACKAGE_VERSION, + description=xs.DESCRIPTION, + long_description=long_description, + classifiers=xs.CLASSIFIERS, + keywords=xs.KEYWORDS, + maintainer=xs.MAINTAINER, + maintainer_email=xs.MAINTAINER_EMAIL, + license=xs.LICENSE, + url=xs.HOMEPAGE, + platforms=xs.PLATFORMS, + packages=find_packages(), + namespace_packages=['xstatic', 'xstatic.pkg', ], + include_package_data=True, + zip_safe=False, + install_requires=[], # nothing! :) + # if you like, you MAY use the 'XStatic' package. +) diff --git a/xstatic/__init__.py b/xstatic/__init__.py new file mode 100755 index 0000000..de40ea7 --- /dev/null +++ b/xstatic/__init__.py @@ -0,0 +1 @@ +__import__('pkg_resources').declare_namespace(__name__) diff --git a/xstatic/pkg/__init__.py b/xstatic/pkg/__init__.py new file mode 100755 index 0000000..de40ea7 --- /dev/null +++ b/xstatic/pkg/__init__.py @@ -0,0 +1 @@ +__import__('pkg_resources').declare_namespace(__name__) diff --git a/xstatic/pkg/magic_search/__init__.py b/xstatic/pkg/magic_search/__init__.py new file mode 100755 index 0000000..9d03d34 --- /dev/null +++ b/xstatic/pkg/magic_search/__init__.py @@ -0,0 +1,49 @@ +""" +XStatic resource package + +See package 'XStatic' for documentation and basic tools. +""" + +DISPLAY_NAME = 'Magic-Search' # official name, upper/lowercase allowed, no spaces +PACKAGE_NAME = 'XStatic-%s' % DISPLAY_NAME # name used for PyPi + +NAME = __name__.split('.')[-1] # package name (e.g. 'foo' or 'foo_bar') + # please use a all-lowercase valid python + # package name + +VERSION = '0.1.5' # version of the packaged files, please use the upstream + # version number +BUILD = '9' # our package build number, so we can release new builds + # with fixes for xstatic stuff. +PACKAGE_VERSION = VERSION + '.' + BUILD # version used for PyPi + +DESCRIPTION = "%s %s (XStatic packaging standard)" % (DISPLAY_NAME, VERSION) + +PLATFORMS = 'any' +CLASSIFIERS = [] +KEYWORDS = '%s xstatic' % NAME + +# XStatic-* package maintainer: +MAINTAINER = 'Randy Bertram' +MAINTAINER_EMAIL = 'rbertram@us.ibm.com' + +# this refers to the project homepage of the stuff we packaged: +HOMEPAGE = 'https://github.com/eucalyptus/magic-search' + +# this refers to all files: +LICENSE = '(same as %s)' % DISPLAY_NAME + +from os.path import join, dirname +BASE_DIR = join(dirname(__file__), 'data') +# linux package maintainers just can point to their file locations like this: +#BASE_DIR = '/usr/share/javascript/jquery' + +LOCATIONS = { + # CDN locations (if no public CDN exists, use an empty dict) + # if value is a string, it is a base location, just append relative + # path/filename. if value is a dict, do another lookup using the + # relative path/filename you want. + # your relative path/filenames should usually be without version + # information, because either the base dir/url is exactly for this + # version or the mapping will care for accessing this version. +} diff --git a/xstatic/pkg/magic_search/data/magic_search.css b/xstatic/pkg/magic_search/data/magic_search.css new file mode 100755 index 0000000..ef63fea --- /dev/null +++ b/xstatic/pkg/magic_search/data/magic_search.css @@ -0,0 +1,85 @@ +/* Copyright 2014-2015 Eucalyptus Systems, Inc. */ +/*----------------------------------------- + Colors + ----------------------------------------- */ +/*----------------------------------------- + Item list + ----------------------------------------- */ +/*----------------------------------------- + Magic Search bar + ----------------------------------------- */ +/* line 30, ../src/magic_search.scss */ +.search-bar { + position: relative; + border: 1px solid black; + background-color: white; + margin-bottom: 0.5rem; + padding: 4px; + height: auto; +} +/* line 37, ../src/magic_search.scss */ +.search-bar i.fi-filter { + color: #444444; + position: absolute; + top: 0.5rem; + left: 0.65rem; +} +/* line 46, ../src/magic_search.scss */ +.search-bar #search-main-area { + position: relative; + margin-left: 1.65rem; + margin-right: 1.65rem; + cursor: text; +} +/* line 14, ../src/magic_search.scss */ +.search-bar .item-list { + margin-bottom: 6px; +} +/* line 16, ../src/magic_search.scss */ +.search-bar .item-list .item { + color: #333; + background-color: #e6e7e8; + margin-right: 8px; +} +/* line 20, ../src/magic_search.scss */ +.search-bar .item-list .item a { + color: white; +} +/* line 53, ../src/magic_search.scss */ +.search-bar .item-list { + margin-bottom: 2px; +} +/* line 56, ../src/magic_search.scss */ +.search-bar #search-selected { + background-color: white; + color: #444444; +} +/* line 60, ../src/magic_search.scss */ +.search-bar #search-entry { + display: inline-block; + height: 1.5rem; +} +/* line 64, ../src/magic_search.scss */ +.search-bar #search-input { + width: 220px; + border: 0; + box-shadow: none; + height: 1.5rem; + padding: 3px; + background-color: white; +} +/* line 75, ../src/magic_search.scss */ +.search-bar .match { + font-weight: bold; +} +/* line 78, ../src/magic_search.scss */ +.search-bar i.cancel { + color: #444444; + position: absolute; + top: 0.5rem; + right: 0.65rem; +} +/* line 80, ../src/magic_search.scss */ +.search-bar i.cancel:hover { + color: darkred; +} diff --git a/xstatic/pkg/magic_search/data/magic_search.html b/xstatic/pkg/magic_search/data/magic_search.html new file mode 100755 index 0000000..e32e795 --- /dev/null +++ b/xstatic/pkg/magic_search/data/magic_search.html @@ -0,0 +1,49 @@ + + diff --git a/xstatic/pkg/magic_search/data/magic_search.js b/xstatic/pkg/magic_search/data/magic_search.js new file mode 100755 index 0000000..943eda9 --- /dev/null +++ b/xstatic/pkg/magic_search/data/magic_search.js @@ -0,0 +1,334 @@ +/** + * @fileOverview Magic Search JS + * @requires AngularJS + * + */ + +// Allow the module to be pre-defined with additional dependencies +try{ + angular.module('MagicSearch'); +} catch (exception) { + angular.module('MagicSearch', []); +} + +angular.module('MagicSearch') + .directive('magicSearch', function($compile) { + return { + restrict: 'E', + scope: { + facets_json: '@facets', + filter_keys: '=filterKeys', + strings: '=strings' + }, + templateUrl: function (scope, elem) { + return elem.template; + }, + controller: function ($scope, $timeout) { + $scope.currentSearch = []; + $scope.initSearch = function() { + // Parse facets JSON and convert to a list of facets. + $scope.facetsJson = $scope.facets_json.replace(/__apos__/g, "\'").replace(/__dquote__/g, '\\"').replace(/__bslash__/g, "\\"); + $scope.facetsObj = JSON.parse($scope.facetsJson); + // set facets selected and remove them from facetsObj + var initialFacets = window.location.search; + if (initialFacets.indexOf('?') === 0) { + initialFacets = initialFacets.slice(1); + } + initialFacets = initialFacets.split('&'); + if (initialFacets.length > 1 || initialFacets[0].length > 0) { + $timeout(function() { + $scope.strings['prompt'] = ''; + }); + } + angular.forEach(initialFacets, function(facet, idx) { + var facetParts = facet.split('='); + angular.forEach($scope.facetsObj, function(value, idx) { + if (value.name == facetParts[0]) { + if (value.options === undefined) { + $scope.currentSearch.push({'name':facet, 'label':[value.label, facetParts[1]]}); + // allow free-form facets to remain + } + else { + angular.forEach(value.options, function(option, idx) { + if (option.key == facetParts[1]) { + $scope.currentSearch.push({'name':facet, 'label':[value.label, option.label]}); + $scope.deleteFacetSelection(facetParts); + } + }); + } + } + }); + }); + $scope.filteredObj = $scope.facetsObj; + }; + // removes a facet from the menu + $scope.deleteFacetSelection = function(facet_parts) { + angular.forEach($scope.facetsObj.slice(), function(facet, idx) { + if (facet.name == facet_parts[0]) { + if (facet.options === undefined) { + return; // allow free-form facets to remain + } + for (var i=0; i -1) { + label = [facet.label.substring(0, idx), facet.label.substring(idx, idx + search_val.length), facet.label.substring(idx + search_val.length)]; + filtered.push({'name':facet.name, 'label':label, 'options':facet.options}); + } + } + if (filtered.length > 0) { + $scope.showMenu(); + $timeout(function() { + $scope.filteredObj = filtered; + }, 0.1); + } + else { + $scope.$emit('textSearch', search_val, $scope.filter_keys); + $scope.hideMenu(); + } + } + else { // assume option search + $scope.filteredOptions = $scope.facetOptions; + if ($scope.facetOptions === undefined) { // no options, assume free form text facet + return; + } + for (i=0; i<$scope.filteredOptions.length; i++) { + var option = $scope.filteredOptions[i]; + idx = option.label.toLowerCase().indexOf(search_val); + if (idx > -1) { + label = [option.label.substring(0, idx), option.label.substring(idx, idx + search_val.length), option.label.substring(idx + search_val.length)]; + filtered.push({'key':option.key, 'label':label}); + } + } + if (filtered.length > 0) { + $scope.showMenu(); + $timeout(function() { + $scope.filteredOptions = filtered; + }, 0.1); + } + } + }; + // enable text entry when mouse clicked anywhere in search box + $('#search-main-area').on("click", function($event) { + $('#search-input').trigger("focus"); + if ($scope.facetSelected === undefined) { + $scope.showMenu(); + } + }); + // when facet clicked, add 1st part of facet and set up options + $scope.facetClicked = function($index, $event, name) { + $scope.hideMenu(); + var facet = $scope.filteredObj[$index]; + var label = facet.label; + if (Array.isArray(label)) { + label = label.join(''); + } + $scope.facetSelected = {'name':facet.name, 'label':[label, '']}; + if (facet.options !== undefined) { + $scope.filteredOptions = $scope.facetOptions = facet.options; + $scope.showMenu(); + } + $timeout(function() { + $('#search-input').val(''); + }); + $scope.strings['prompt'] = ''; + $timeout(function() { + $('#search-input').focus(); + }); + }; + // when option clicked, complete facet and send event + $scope.optionClicked = function($index, $event, name) { + $scope.hideMenu(); + var curr = $scope.facetSelected; + curr.name = curr.name + '=' + name; + curr.label[1] = $scope.filteredOptions[$index].label; + if (Array.isArray(curr.label[1])) { + curr.label[1] = curr.label[1].join(''); + } + $scope.currentSearch.push(curr); + $scope.resetState(); + $scope.emitQuery(); + $scope.showMenu(); + }; + // send event with new query string + $scope.emitQuery = function(removed) { + var query = ''; + for (var i=0; i<$scope.currentSearch.length; i++) { + if ($scope.currentSearch[i].name.indexOf('text') !== 0) { + if (query.length > 0) query = query + "&"; + query = query + $scope.currentSearch[i].name; + } + } + if (removed !== undefined && removed.indexOf('text') === 0) { + $scope.$emit('textSearch', '', $scope.filter_keys); + } + else { + $scope.$emit('searchUpdated', query); + if ($scope.currentSearch.length > 0) { + var newFacet = $scope.currentSearch[$scope.currentSearch.length-1].name; + $scope.deleteFacetSelection(newFacet.split('=')); + } + } + }; + // remove facet and either update filter or search + $scope.removeFacet = function($index, $event) { + var removed = $scope.currentSearch[$index].name; + $scope.currentSearch.splice($index, 1); + if ($scope.facetSelected === undefined) { + $scope.emitQuery(removed); + } + else { + $scope.resetState(); + $('#search-input').val(''); + } + // facet re-enabled by reload + }; + // clear entire searchbar + $scope.clearSearch = function() { + if ($scope.currentSearch.length > 0) { + $scope.currentSearch = []; + $scope.facetsObj = JSON.parse($scope.facetsJson); + $scope.resetState(); + $scope.$emit('searchUpdated', ''); + $scope.$emit('textSearch', '', $scope.filter_keys); + } + }; + $scope.isMatchLabel = function(label) { + return Array.isArray(label); + }; + $scope.resetState = function() { + $('#search-input').val(''); + $scope.filteredObj = $scope.facetsObj; + $scope.facetSelected = undefined; + $scope.facetOptions = undefined; + $scope.filteredOptions = undefined + }; + // showMenu and hideMenu depend on foundation's dropdown. They need + // to be modified to work with another dropdown implemenation (i.e. bootstrap) + $scope.showMenu = function() { + $timeout(function() { + if ($('#facet-drop').hasClass('open') === false) { + $('#search-input').trigger('click'); + } + }); + }; + $scope.hideMenu = function() { + $(document).foundation('dropdown', 'closeall'); + }; + $scope.initSearch(); + } + }; + }) +; diff --git a/xstatic/pkg/magic_search/data/magic_search.scss b/xstatic/pkg/magic_search/data/magic_search.scss new file mode 100755 index 0000000..4993fe3 --- /dev/null +++ b/xstatic/pkg/magic_search/data/magic_search.scss @@ -0,0 +1,87 @@ +/* Copyright 2014-2015 Eucalyptus Systems, Inc. */ + +/*----------------------------------------- + Colors + ----------------------------------------- */ +$textcolor: #444; +$background: white; +$itembackground: #e6e7e8; + +/*----------------------------------------- + Item list + ----------------------------------------- */ +@mixin item-list { + .item-list { + margin-bottom: 6px; + .item { + color: #333; + background-color: $itembackground; + margin-right: 8px; + a { + color: white; + } + } + } +} + +/*----------------------------------------- + Magic Search bar + ----------------------------------------- */ +.search-bar { + position: relative; + border: 1px solid black; + background-color: $background; + margin-bottom: 0.5rem; + padding: 4px; + height: auto; + i.fi-filter { + color: $textcolor; + position: absolute; + top: 0.5rem; + left: 0.65rem; + //&.has-items { + // margin-top: 6px; + //} + } + #search-main-area { + position: relative; + margin-left: 1.65rem; + margin-right: 1.65rem; + cursor: text; + } + @include item-list; + .item-list { + margin-bottom: 2px; + } + #search-selected { + background-color: $background; + color: $textcolor; + } + #search-entry { + display: inline-block; + height: 1.5rem; + } + #search-input { + width: 220px; + border: 0; + box-shadow: none; + height: 1.5rem; + padding: 3px; + background-color: $background; + //&.has-items { + // margin-top: 6px; + //} + } + .match { + font-weight: bold; + } + i.cancel { + color: $textcolor; + &:hover { + color: darkred; + } + position: absolute; + top: 0.5rem; + right: 0.65rem; + } +} diff --git a/xstatic/pkg/magic_search/data/magic_search_bootstrap.html b/xstatic/pkg/magic_search/data/magic_search_bootstrap.html new file mode 100755 index 0000000..b8639b0 --- /dev/null +++ b/xstatic/pkg/magic_search/data/magic_search_bootstrap.html @@ -0,0 +1,50 @@ + + diff --git a/xstatic/pkg/magic_search/data/magic_search_bootstrap.js b/xstatic/pkg/magic_search/data/magic_search_bootstrap.js new file mode 100755 index 0000000..69810fe --- /dev/null +++ b/xstatic/pkg/magic_search/data/magic_search_bootstrap.js @@ -0,0 +1,28 @@ +angular.module('MagicSearch', ['ui.bootstrap']) + .directive('magicOverrides', function() { + return { + restrict: 'A', + controller: function($scope) { + // showMenu and hideMenu depend on foundation's dropdown. They need + // to be modified to work with another dropdown implemenation. + // For bootstrap, they are not needed at all. + $scope.showMenu = function() { + $scope.isMenuOpen = true; + }; + $scope.hideMenu = function() { + $scope.isMenuOpen = false; + }; + $scope.isMenuOpen = false; + + // remove the following when magic_search.js handles changing the facets/options + $scope.$watch('facets_json', function(newVal, oldVal) { + if (newVal === oldVal) { + return; + } + $scope.currentSearch = []; + $scope.initSearch(); + }); + + } + }; + }); \ No newline at end of file