forty_facets 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: ffffd6ffbdfc630f50719b17036d4442aa08a900
4
+ data.tar.gz: d2e5c895e6c2ad6f422bca70935a0569d98801f3
5
+ SHA512:
6
+ metadata.gz: 1b863db6fb54e3d392cf23e220bd2513f581a077fee1ad448780d390e54e8f9f443719757f01e136b73a76ee4d1d07c0b10f5fa6c1e868af32a14fba81a17446
7
+ data.tar.gz: 09b7f942ae6584790fd6a578af19e2a2046d6bc7dde1753bda80dbc33e40403e5740eaa7dc6587d0c17addb862d7b06dc335a6487f21ca78d2c4bd80859e0d41
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in forty_facets.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Axel Tetzlaff
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # FortyFacets
2
+
3
+ FortyFacets lets you easily build explorative search interfaces based on fields of your ActiveRecord models.
4
+
5
+ ![demo](demo.gif)
6
+
7
+ It offers a simple API to create an interactive UI to browse your data by iteratively adding
8
+ filter values.
9
+
10
+ The search is purely done via SQL queries, which are automatically generated via the AR-mappings.
11
+
12
+ Narrowing down the search result is done purely via `GET` requests. This way all steps are bookmarkable. This way the search natively works together with turbolinks as well.
13
+
14
+ There is no JavaScript involved. The collection returned is a normal ActiveRecord collection - this way it works seamlessly together with other GEMs like will_paginate
15
+
16
+ ## Installation
17
+
18
+ Add this line to your application's Gemfile:
19
+
20
+ gem 'forty_facets'
21
+
22
+ And then execute:
23
+
24
+ $ bundle
25
+
26
+ Or install it yourself as:
27
+
28
+ $ gem install forty_facets
29
+
30
+ ## Usage
31
+
32
+ You can clone a working example at https://github.com/fortytools/forty_facets_demo
33
+
34
+ If you have Movies with a textual title, categotized by genre, studio and year ..
35
+
36
+ class Movie < ActiveRecord::Base
37
+ belongs_to :year
38
+ belongs_to :genre
39
+ belongs_to :studio
40
+ end
41
+
42
+ You can then declare the structure of your search like so:
43
+
44
+ ```ruby
45
+ class HomeController < ApplicationController
46
+
47
+ class MovieSearch < FortyFacets::FacetSearch
48
+ model 'Movie' # which model to search for
49
+ text :title # filter by a generic string entered by the user
50
+ facet :genre, name: 'Genre' # generate a filter with all values of 'genre' occuring in the result
51
+ facet :year, name: 'Releaseyear', order: :year # additionally oder values in the year field
52
+ facet :studio, name: 'Studio', order: :name
53
+ end
54
+
55
+ def index
56
+ @search = MovieSearch.new(params) # this initializes your search object from the request params
57
+ @movies = @search.result.paginate(page: params[:page], per_page: 5) # optionally paginate through your results
58
+ end
59
+ ```
60
+
61
+ In your view you can iterate the result like any other ActiveRecord collection
62
+
63
+ ```haml
64
+ %table.table.table-condensed
65
+ %tbody
66
+ - @movies.each do |movie|
67
+ %tr
68
+ %td
69
+ %strong=movie.title
70
+ ```
71
+
72
+ Use the search object to display further narrowing options to the user
73
+
74
+ ```haml
75
+ - filter = @search.filter(:genre)
76
+ .col-md-4
77
+ .filter
78
+ .filter-title= filter.name
79
+ .filter-values
80
+ %ul.selected
81
+ - filter.selected.each do |genre|
82
+ %li= link_to genre.name, filter.remove(genre).path
83
+ %ul.selectable
84
+ - filter.facet.reject(&:selected).each do |facet_value|
85
+ - genre = facet_value.genre
86
+ %li
87
+ = link_to genre.name, filter.add(genre).path
88
+ %span.count= "(#{facet_value.count})"
89
+ ```
90
+
91
+ ## Contributing
92
+
93
+ 1. Fork it ( http://github.com/<my-github-username>/forty_facets/fork )
94
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
95
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
96
+ 4. Push to the branch (`git push origin my-new-feature`)
97
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
data/demo.gif ADDED
Binary file
@@ -0,0 +1,23 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'forty_facets/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "forty_facets"
8
+ spec.version = FortyFacets::VERSION
9
+ spec.authors = ["Axel Tetzlaff"]
10
+ spec.email = ["axel.tetzlaff@fortytools.com"]
11
+ spec.summary = %q{Library for building facet searches for active_record models}
12
+ spec.description = %q{FortyFacets lets you easily build explorative search interfaces based on fields of your active_record models.}
13
+ spec.homepage = "https://github.com/fortytools/forty_facets"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_development_dependency "bundler", "~> 1.5"
22
+ spec.add_development_dependency "rake"
23
+ end
@@ -0,0 +1,210 @@
1
+ module FortyFacets
2
+ class FacetSearch
3
+ attr_reader :filters
4
+
5
+ FieldDefinition = Struct.new(:search, :model_field, :options) do
6
+ def request_param
7
+ model_field
8
+ end
9
+ end
10
+
11
+ Filter = Struct.new(:field_definition, :search_instance, :value) do
12
+ def name
13
+ field_definition.options[:name] || field_definition.model_field
14
+ end
15
+
16
+ def empty?
17
+ value.nil? || value == '' || value == []
18
+ end
19
+
20
+ # generate a search with this filter removed
21
+ def without
22
+ search = search_instance
23
+ return search if empty?
24
+ new_params = search_instance.params
25
+ new_params.delete(field_definition.request_param)
26
+ search_instance.class.new_unwrapped(new_params)
27
+ end
28
+ end
29
+
30
+ class TextField < FieldDefinition
31
+ class TextFilter < Filter
32
+ def build_scope
33
+ return Proc.new { |base| base } if empty?
34
+ like_value = expression_value(value)
35
+ Proc.new { |base| base.where("#{field_definition.model_field} like ?", like_value ) }
36
+ end
37
+
38
+ def expression_value(term)
39
+ if field_definition.options[:prefix]
40
+ "#{term}%"
41
+ else
42
+ "%#{term}%"
43
+ end
44
+ end
45
+
46
+ def display_value
47
+ value
48
+ end
49
+ end
50
+
51
+ def build_filter(search_instance, value)
52
+ TextFilter.new(self, search_instance, value)
53
+ end
54
+ end
55
+
56
+ class FacetField < FieldDefinition
57
+ FacetValue = Struct.new(:entity, :count, :selected)
58
+
59
+ class FacetFilter < Filter
60
+ def association
61
+ field_definition.search.root_class.reflect_on_association(field_definition.model_field)
62
+ end
63
+
64
+ # class objects in this filter
65
+ def klass
66
+ association.klass
67
+ end
68
+
69
+ def values
70
+ @values ||= Array.wrap(value).sort.uniq
71
+ end
72
+
73
+ def selected
74
+ @selected ||= klass.find(values)
75
+ end
76
+
77
+ def build_scope
78
+ return Proc.new { |base| base } if empty?
79
+ Proc.new { |base| base.where(association.association_foreign_key => values) }
80
+ end
81
+
82
+ def facet
83
+ my_column = association.association_foreign_key
84
+ counts = without.result.select("#{my_column} as foreign_id, count(#{my_column}) as occurrences").group(my_column)
85
+ entities_by_id = klass.find(counts.map(&:foreign_id)).group_by(&:id)
86
+ facet = counts.inject([]) do |sum, count|
87
+ facet_entity = entities_by_id[count.foreign_id].first
88
+ is_selected = selected.include?(facet_entity)
89
+ sum << FacetValue.new(facet_entity, count.occurrences, is_selected)
90
+ end
91
+
92
+ order_accessor = field_definition.options[:order]
93
+ if order_accessor
94
+ facet.sort_by!{|facet_value| facet_value.entity.send(order_accessor) }
95
+ else
96
+ facet.sort_by!{|facet_value| -facet_value.count }
97
+ end
98
+ facet
99
+
100
+ end
101
+
102
+ def without
103
+ new_params = search_instance.params || {}
104
+ new_params.delete(field_definition.request_param)
105
+ search_instance.class.new_unwrapped(new_params)
106
+ end
107
+
108
+ def remove(value)
109
+ new_params = search_instance.params || {}
110
+ old_values = new_params[field_definition.request_param]
111
+ old_values.delete(value.id.to_s)
112
+ new_params.delete(field_definition.request_param) if old_values.empty?
113
+ search_instance.class.new_unwrapped(new_params)
114
+ end
115
+
116
+ def add(entity)
117
+ new_params = search_instance.params || {}
118
+ old_values = new_params[field_definition.request_param] ||= []
119
+ old_values << entity.id.to_s
120
+ search_instance.class.new_unwrapped(new_params)
121
+ end
122
+
123
+ end
124
+
125
+ def build_filter(search_instance, param_value)
126
+ FacetFilter.new(self, search_instance, param_value)
127
+ end
128
+
129
+ end
130
+
131
+ class << self
132
+ def model(model_name)
133
+ @model_name = model_name
134
+ end
135
+
136
+ def text(model_field, opts = {})
137
+ definitions << TextField.new(self, model_field, opts)
138
+ end
139
+
140
+ def facet(model_field, opts = {})
141
+ definitions << FacetField.new(self, model_field, opts)
142
+ end
143
+
144
+ def definitions
145
+ @definitions ||= []
146
+ end
147
+
148
+ def root_class
149
+ raise 'No model given' unless @model_name
150
+ Kernel.const_get(@model_name)
151
+ end
152
+
153
+ def root_scope
154
+ root_class.all
155
+ end
156
+
157
+ def request_param(name)
158
+ @request_param_name = name
159
+ end
160
+
161
+ def request_param_name
162
+ @request_param_name ||= 'search'
163
+ end
164
+ end
165
+
166
+ def initialize(request_params)
167
+ params = if request_params && request_params[self.class.request_param_name]
168
+ request_params[self.class.request_param_name]
169
+ else
170
+ {}
171
+ end
172
+ @filters = self.class.definitions.inject([]) do |sum, definition|
173
+ sum << definition.build_filter(self, params[definition.request_param])
174
+ end
175
+ end
176
+
177
+ def self.new_unwrapped(params)
178
+ self.new(request_param_name => params)
179
+ end
180
+
181
+ def filter(filter_name)
182
+ @filters.find { |f| f.field_definition.model_field == filter_name }
183
+ end
184
+
185
+ def result
186
+ @filters.inject(self.class.root_scope) do |previous, filter|
187
+ filter.build_scope.call(previous)
188
+ end
189
+ end
190
+
191
+ def wrapped_params
192
+ { self.class.request_param_name => params }
193
+ end
194
+
195
+ def params
196
+ @filters.inject({}) do |sum, filter|
197
+ sum[filter.field_definition.request_param] = filter.value.dup unless filter.empty?
198
+ sum
199
+ end
200
+ end
201
+
202
+ def path
203
+ unfiltered? ? '?' : '?' + wrapped_params.to_param
204
+ end
205
+
206
+ def unfiltered?
207
+ @filters.reject(&:empty?).empty?
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,3 @@
1
+ module FortyFacets
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,6 @@
1
+ require "forty_facets/version"
2
+
3
+ module FortyFacets
4
+ end
5
+
6
+ require "forty_facets/facet_search"
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: forty_facets
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Axel Tetzlaff
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-05-15 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.5'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.5'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ description: FortyFacets lets you easily build explorative search interfaces based
42
+ on fields of your active_record models.
43
+ email:
44
+ - axel.tetzlaff@fortytools.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - ".gitignore"
50
+ - Gemfile
51
+ - LICENSE.txt
52
+ - README.md
53
+ - Rakefile
54
+ - demo.gif
55
+ - forty_facets.gemspec
56
+ - lib/forty_facets.rb
57
+ - lib/forty_facets/facet_search.rb
58
+ - lib/forty_facets/version.rb
59
+ homepage: https://github.com/fortytools/forty_facets
60
+ licenses:
61
+ - MIT
62
+ metadata: {}
63
+ post_install_message:
64
+ rdoc_options: []
65
+ require_paths:
66
+ - lib
67
+ required_ruby_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ required_rubygems_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ requirements: []
78
+ rubyforge_project:
79
+ rubygems_version: 2.2.0
80
+ signing_key:
81
+ specification_version: 4
82
+ summary: Library for building facet searches for active_record models
83
+ test_files: []