forty_facets 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: []