card-mod-filter 0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (31) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +60 -0
  3. data/lib/card/filter_query.rb +78 -0
  4. data/set/abstract/0_filter/filter_fields.rb +46 -0
  5. data/set/abstract/0_filter/filter_form/_compact_filter_input.haml +7 -0
  6. data/set/abstract/0_filter/filter_form/compact_filter_form.haml +46 -0
  7. data/set/abstract/0_filter/filter_form/compact_quick_filters.haml +14 -0
  8. data/set/abstract/0_filter/filter_form/filter_bars.haml +4 -0
  9. data/set/abstract/0_filter/filter_form/filter_sort_dropdown.haml +12 -0
  10. data/set/abstract/0_filter/filter_form/filtered_results_header.haml +33 -0
  11. data/set/abstract/0_filter/filter_form/offcanvas_filters.haml +9 -0
  12. data/set/abstract/0_filter/filter_form/open_filter_button.haml +1 -0
  13. data/set/abstract/0_filter/filter_form/select_item.haml +15 -0
  14. data/set/abstract/0_filter/filter_form/selectable_filtered_content.haml +2 -0
  15. data/set/abstract/0_filter/filter_form.rb +123 -0
  16. data/set/abstract/0_filter/filter_input_fields/check_filter.haml +13 -0
  17. data/set/abstract/0_filter/filter_input_fields.rb +111 -0
  18. data/set/abstract/0_filter.rb +131 -0
  19. data/set/abstract/cached_type_options.rb +8 -0
  20. data/set/abstract/cql_search/filter.rb +33 -0
  21. data/set/abstract/filter_link.rb +15 -0
  22. data/set/abstract/filterable_list.rb +16 -0
  23. data/set/abstract/filtered_list/filter_items.haml +28 -0
  24. data/set/abstract/filtered_list/filtered_list_input.haml +7 -0
  25. data/set/abstract/filtered_list.rb +105 -0
  26. data/set/abstract/list/filter.rb +1 -0
  27. data/set/abstract/search/filter_inclusion.rb +7 -0
  28. data/set/all/filtered_list_item.haml +8 -0
  29. data/set/all/filtered_list_item.rb +11 -0
  30. data/set/type/list.rb +4 -0
  31. metadata +116 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8a74d8d165d6938cdcc7dba028a7e9d4c51f5c81bafff831be97e2b941af7cd3
4
+ data.tar.gz: 8de27d3a0e862aef786b5121d0f51ae4809f15ea90ed6ba1d7ab83f1f8c0a6c2
5
+ SHA512:
6
+ metadata.gz: 1b3b5cb680b5be0c170c6a9c9b651e1aadac8a56fdc5ac772363bdf1741640a51c676419ea3df0c131bb0b8529330507e930d44acfbf2d03e3d12d1da26493a5
7
+ data.tar.gz: 525485ae8d031e33f3f653cfbe99a70f67c1098071ea7985a7107852e32f5b5b5743c8b1c86f610472c76c06e3b457398f48dba48904978df6e3b9957877a4b4
data/README.md ADDED
@@ -0,0 +1,60 @@
1
+ <!--
2
+ # @title README - mod: filter
3
+ -->
4
+
5
+ # Filter mod
6
+
7
+ This mod provides a framework for enhancing card searches with advanced filtering
8
+ interfaces.
9
+
10
+ Include the Abstract::Filter set in any search set to add both:
11
+
12
+ - a `filter_form` view with progressive disclosure filter accordions inside an offcanvas,
13
+ and
14
+ - a `compact_filter_form` view that opens compact filter interfaces in place above search
15
+ results.
16
+
17
+ The mod also adds support for a `filtered_list` view of list/pointer cards that makes it
18
+ easier to choose list items from options too numerous or complicated to fit into a
19
+ standard dropdown.
20
+
21
+ Filters for a given set can be specified with the `#filter_map` method, which should
22
+ return a list of filter configurations. Each configuration can be a Symbol or a Hash.
23
+ For example, here is a simple configuration with three filters.
24
+
25
+ ```
26
+ def filter_map
27
+ [name topic bookmark]
28
+ end
29
+ ```
30
+
31
+ For each filter you can use methods to define:
32
+ 1. labels, eg `#filter_topic_label` (same as the filter key if not defined)
33
+ 2. options, eg `#filter_topic_options` (empty by default)
34
+ 3. a default value, eg `#filter_topic_default` (blank unless specified), and
35
+ 4. a type, eg `#filter_topic_type` (defaults to text).
36
+
37
+ Type options include:
38
+
39
+ - text
40
+ - autocomplete
41
+ - radio
42
+ - check
43
+ - select
44
+ - multiselect
45
+ - range
46
+
47
+ It is also possible to write custom filter types in the following manner:
48
+
49
+ ```
50
+ def filter_myfield_type
51
+ :my_custom_type
52
+ end
53
+
54
+ def my_custom_type_filter
55
+ # returns filter ui
56
+ end
57
+ ```
58
+
59
+ Note that fields that use radio and check filtering in the `filter_form` view will use
60
+ select and multiselect filtering respectively in the `compact_filter_form` view.
@@ -0,0 +1,78 @@
1
+ class Card
2
+ # Class for generating CQL based on filter params
3
+ class FilterQuery
4
+ def initialize filter_keys_with_values, extra_cql={}
5
+ @filter_cql = Hash.new { |h, k| h[k] = [] }
6
+ @rules = yield if block_given?
7
+ @rules ||= {}
8
+ @filter_keys_with_values = filter_keys_with_values
9
+ @extra_cql = extra_cql
10
+ prepare_filter_cql
11
+ end
12
+
13
+ def add_to_cql key, value
14
+ @filter_cql[key] << value
15
+ end
16
+
17
+ def add_rule key, value
18
+ return unless value.present?
19
+
20
+ case @rules[key]
21
+ when Symbol
22
+ send("#{@rules[key]}_rule", key, value)
23
+ when Proc
24
+ @rules[key].call(key, value).each do |cql_key, val|
25
+ @filter_cql[cql_key] << val
26
+ end
27
+ else
28
+ send "#{key}_cql", value
29
+ end
30
+ end
31
+
32
+ def to_cql
33
+ @cql = {}
34
+ @filter_cql.each do |cql_key, values|
35
+ next if values.empty?
36
+
37
+ case cql_key
38
+ when :right_plus, :left_plus, :type
39
+ merge_using_and cql_key, values
40
+ else
41
+ merge_using_array cql_key, values
42
+ end
43
+ end
44
+ @cql.merge @extra_cql
45
+ end
46
+
47
+ private
48
+
49
+ def prepare_filter_cql
50
+ @filter_keys_with_values.each do |key, values|
51
+ add_rule key, values
52
+ end
53
+ end
54
+
55
+ def merge_using_array cql_key, values
56
+ @cql[cql_key] = values.one? ? values.first : values
57
+ end
58
+
59
+ def merge_using_and cql_key, values
60
+ hash = build_nested_hash cql_key, values
61
+ @cql.deep_merge! hash
62
+ end
63
+
64
+ # nest values with the same key using :and
65
+ def build_nested_hash key, values
66
+ return { key => values[0] } if values.one?
67
+
68
+ val = values.pop
69
+ { key => val, and: build_nested_hash(key, values) }
70
+ end
71
+
72
+ def name_cql name
73
+ return unless name.present?
74
+
75
+ @filter_cql[:name] = ["match", name]
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,46 @@
1
+ format :html do
2
+ def filter_config category
3
+ @filter_config ||= {}
4
+ @filter_config[category] ||=
5
+ %i[type default options label].each_with_object({}) do |trait, hash|
6
+ method = "filter_#{category}_#{trait}"
7
+ hash[trait] = send method if respond_to? method
8
+ end
9
+ end
10
+
11
+ def filter_name_type
12
+ :text
13
+ end
14
+
15
+ def filter_label field
16
+ filter_config(field)[:label] || filter_label_from_name(field)
17
+ end
18
+
19
+ def filter_label_from_name field
20
+ Card.fetch_name(field) { field.to_s.sub(/^\*/, "").titleize }
21
+ end
22
+
23
+ def filter_closer_value field, value
24
+ try("filter_#{field}_closer_value", value) || value
25
+ end
26
+
27
+ def filter_options raw
28
+ return raw if raw.is_a? Array
29
+
30
+ raw.each_with_object([]) do |(key, value), array|
31
+ array << [key, value.to_s]
32
+ array
33
+ end
34
+ end
35
+
36
+ def type_options type_codename, order="asc", max_length=nil
37
+ Card.cache.fetch "#{type_codename}-TYPE-OPTIONS" do
38
+ res = Card.search type: type_codename, return: :name, sort_by: "name", dir: order
39
+ max_length ? (res.map { |i| [trim_option(i, max_length), i] }) : res
40
+ end
41
+ end
42
+
43
+ def trim_option option, max_length
44
+ option.size > max_length ? "#{option[0..max_length]}..." : option
45
+ end
46
+ end
@@ -0,0 +1,7 @@
1
+ ._filter-input.filter-input-group.input-group.input-group-sm{"data-category": field[:key],
2
+ "class": "_filter-input-#{field[:key]}" }
3
+ %button.btn.btn-secondary.input-group-text.p-1._delete-filter-input{:type => "button"}
4
+ = icon_tag :remove
5
+ %span.input-group-text._selected-category.text-muted
6
+ = field[:label]
7
+ = field[:input_field]
@@ -0,0 +1,46 @@
1
+ - @compact_filter_form = true
2
+ .card.w-100.nodblclick
3
+ .card-body
4
+ ._compact-filter
5
+ // FILTERING PROTOTYPES
6
+ ._filter-input-field-prototypes.d-none
7
+ - compact_filter_form_fields.each do |field|
8
+ = haml_partial :compact_filter_input, field: field
9
+
10
+ // FORM
11
+ %form._compact-filter-form._filter-form{ filter_form_args }
12
+ %input{ type: :hidden,
13
+ name: "filter[not_ids]",
14
+ class: "_not-ids",
15
+ value: params.dig(:filter, :not_ids) }
16
+ = render_compact_quick_filters
17
+ .filter-and-sort.d-flex.flex-wrap
18
+ = render_filter_sort_dropdown
19
+
20
+
21
+ // FILTERING
22
+ .filter-in-filter-form
23
+ = icon_tag :filter, "filter-section-icon"
24
+ ._filter-container.d-flex.flew-row.flex-wrap.align-items-start
25
+ // FILTERS inserted here dynamically from prototypes
26
+
27
+ // ADD FILTER DROPDOWN
28
+ .dropdown._add-filter-dropdown.me-2
29
+ %button.btn.btn-sm.btn-primary._filter-dropdown.dropdown-toggle{"aria-expanded": "false",
30
+ "aria-haspopup": "true",
31
+ "data-bs-toggle": "dropdown",
32
+ type: "button" }
33
+ More Filters
34
+ .dropdown-menu
35
+ - compact_filter_form_fields.each do |field|
36
+ %a{ class: "dropdown-item _filter-category-select",
37
+ href: "#",
38
+ "data-category": field[:key],
39
+ "data-label": field[:label],
40
+ "data-active": ("true" if field[:active])}
41
+ = field[:label]
42
+
43
+ // RESET BUTTON
44
+ ._reset-filter{ "data-reset": reset_filter_data }
45
+ %button.btn.btn-sm.btn-secondary{ type: "button" }
46
+ = icon_tag :reset
@@ -0,0 +1,14 @@
1
+ - if (quick_filters = quick_filter_list).present?
2
+ .quick-filter._quick-filter
3
+
4
+ = icon_tag :quick_filter, "filter-section-icon"
5
+ .quick-filter-links.d-flex.flex-wrap
6
+ - quick_filters.each do |hash|
7
+ - f = quick_filter_item hash.clone, hash.keys.first
8
+ %a{ "data-filter": f[:filter],
9
+ href: "#",
10
+ class: f[:class],
11
+ title: "Quick Filter: #{f[:text]}" }
12
+ = f[:icon]
13
+ = f[:text]
14
+ = custom_quick_filters
@@ -0,0 +1,4 @@
1
+ %form._filter-form._filter-bars{ filter_form_args }
2
+ .filter-form.accordion
3
+ - filter_map.each do |item|
4
+ = filter_bar item
@@ -0,0 +1,12 @@
1
+ // SORTING
2
+ - options = sort_options
3
+ - current = current_sort
4
+ - if options.present?
5
+ .sort-in-filter-form
6
+ .input-group.input-group-sm.flex-nowrap.sort-input-group.mb-2.me-2
7
+ %span.input-group-text.text-muted Sort
8
+ = select_tag "sort_by",
9
+ options_for_select(options, current),
10
+ class: "pointer-select _filter-sort form-control",
11
+ include_blank: ("--" unless options.values.include? current),
12
+ "data-minimum-results-for-search": "Infinity"
@@ -0,0 +1,33 @@
1
+ .filtered-results-header._filtered-results-header
2
+ %form.filtered-results-form{ filter_form_args }
3
+ .header-top-row.mb-4.d-flex.justify-content-between.align-items-center
4
+ .filter-badges._filter-closers
5
+ - removables = removable_filters
6
+ - removables.each do |key, value|
7
+ - filter = filter_hash_without key, value
8
+ %a.btn.btn-sm.btn-outline-primary.m-1.ms-0{ "data-filter": JSON(filter),
9
+ class: "filter-closer close-filter-#{key}" }
10
+ = "#{filter_label key}: "
11
+ %span.fw-bold
12
+ = filter_closer_value key, value
13
+
14
+ = icon_tag :remove, class: "ms-2"
15
+ - if removables.size > 1
16
+ %a.clear-filters.m-1.ms-0{ "data-filter": "{}", href: "#" }
17
+ Clear All
18
+ &nbsp;
19
+
20
+ ._filters-button.ms-3
21
+ %a.text-reset.fw-bold{ "data-bs": { toggle: "offcanvas",
22
+ target: "##{offcanvas_filter_id}" },
23
+ title: "All Filters",
24
+ href: "" }
25
+ = icon_tag :filter_list
26
+ ALL FILTERS
27
+
28
+ .header-middle-row.my-4
29
+ = render_filtered_results_visualization
30
+ .header-bottom-row.mb-2.mt-4.d-flex.justify-content-between.align-items-center
31
+ = render_filtered_results_stats
32
+ = render_filter_sort_dropdown
33
+
@@ -0,0 +1,9 @@
1
+ ._offcanvas-filter.offcanvas.offcanvas-end{ id: offcanvas_filter_id,
2
+ "data-bs": {
3
+ scroll: "true", backdrop: "false"
4
+ } }
5
+ .offcanvas-header
6
+ %h5.offcanvas-title Filters
7
+ %button.btn.btn-close{ "data-bs": { dismiss: "offcanvas" } }
8
+ .offcanvas-body{ data: { path: path, query: JSON(filter: filter_hash) } }
9
+
@@ -0,0 +1,15 @@
1
+ .filter-header.card-header
2
+ %h5
3
+ Select Item
4
+ -#.badge.bg-secondary
5
+ -# = card.count
6
+ .filter-selector.p-2
7
+ - if card.count > 0
8
+ .filter-bulk-selector.pb-3
9
+ %input#select-all._select-all{name: "", type: "checkbox", value: ""}
10
+ %label{for: "select-all"}
11
+ select
12
+ %span._unselected-items
13
+ = search_with_params.size
14
+ following
15
+ = render :checkbox_list
@@ -0,0 +1,2 @@
1
+ ._selectable-filtered-content._over-card-link
2
+ = render_compact_filtered_content hide: :export_button
@@ -0,0 +1,123 @@
1
+ format :html do
2
+ view :filter_bars, cache: :never, template: :haml
3
+
4
+ # ~~~~ Compact (inline) sort and filter ui
5
+ # including prototypes, filters, sorting, "More", and reset
6
+
7
+ view :compact_filter_form, cache: :never, template: :haml
8
+ view :filter_sort_dropdown, cache: :never, template: :haml
9
+ view :compact_quick_filters, cache: :never, template: :haml
10
+
11
+ # ~~~~ FILTER RESULTS
12
+
13
+ view :filtered_content do
14
+ wrap true, class: "_filtered-content nodblclick" do
15
+ [render_offcanvas_filters, render_filtered_results(home_view: :filtered_results)]
16
+ end
17
+ end
18
+
19
+ view :compact_filtered_content do
20
+ wrap true, class: "_filtered-content nodblclick" do
21
+ voo.hide! :filtered_results_header
22
+ [render_compact_filter_form, render_filtered_results(home_view: :filtered_results)]
23
+ end
24
+ end
25
+
26
+ view :filtered_results, cache: :never do
27
+ wrap true, class: "_filter-result-slot" do
28
+ [render_filtered_results_header, render_core, render_filtered_results_footer]
29
+ end
30
+ end
31
+
32
+ view :offcanvas_filters, template: :haml, cache: :never
33
+ view :filtered_results_header, template: :haml, cache: :never
34
+ view :filtered_results_stats, cache: :never do
35
+ labeled_badge count_with_params, "Results"
36
+ end
37
+
38
+ # for override
39
+ view(:filtered_results_visualization) { "" }
40
+ view(:filtered_results_footer) { "" }
41
+
42
+ view :selectable_filtered_content, template: :haml, cache: :never
43
+
44
+ before(:select_item) { class_up "card-slot", "_filter-result-slot" }
45
+ view :select_item, cache: :never, wrap: :slot, template: :haml
46
+
47
+ def filter_form_args
48
+ {
49
+ action: path,
50
+ class: "slotter",
51
+ method: "get",
52
+ "accept-charset": "UTF-8",
53
+ "data-remote": true,
54
+ "data-slot-selector": "._filter-result-slot",
55
+ "data-filter": filter_hash.to_json
56
+ }
57
+ end
58
+
59
+ def compact_filter_form_fields
60
+ @compact_filter_form_fields ||=
61
+ all_filter_keys.map do |key|
62
+ { key: key,
63
+ label: filter_label(key),
64
+ input_field: filter_input_field(key, compact: true),
65
+ active: active_filter?(key) }
66
+ end
67
+ end
68
+
69
+ def filter_bar item
70
+ item = { key: item } unless item.is_a? Hash
71
+ body = filter_bar_content item
72
+ title = filter_label item[:key]
73
+ context = item[:key].to_name.safe_key
74
+ accordion_item title, body: body, open: item[:open], context: context
75
+ end
76
+
77
+ def filter_bar_content item
78
+ if item[:type] == :group
79
+ accordion do
80
+ item[:filters].map do |subitem|
81
+ filter_bar subitem
82
+ end.join
83
+ end
84
+ else
85
+ filter_input_field item[:key]
86
+ end
87
+ end
88
+
89
+ def offcanvas_filter_id
90
+ "d0-#{card.name.safe_key}-offCanvasFilters"
91
+ end
92
+
93
+ def reset_filter_data
94
+ JSON default_filter_hash
95
+ end
96
+
97
+ def quick_filter_item hash, filter_key
98
+ {
99
+ text: (hash.delete(:text) || hash[filter_key]),
100
+ class: css_classes(hash.delete(:class),
101
+ "_compact-filter-link quick-filter-by-#{filter_key}"),
102
+ filter: JSON(hash[:filter] || hash)
103
+ }
104
+ end
105
+
106
+ # for override
107
+ def quick_filter_list
108
+ []
109
+ end
110
+
111
+ # for override
112
+ def custom_quick_filters
113
+ ""
114
+ end
115
+
116
+ def active_filter? field
117
+ if filter_keys_from_params.present?
118
+ filter_hash.key? field
119
+ else
120
+ default_filter_hash.key? field
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,13 @@
1
+ - tag_method = field_type == :check ? :check_box_tag : :radio_button_tag
2
+ .filter-list._filter-list{ class: "filter-#{field_type}-list _filter-#{field}-field"}
3
+ - options.each_with_index do |(label, value), index|
4
+ - id = "filter-#{input_name.to_name.key}-#{value.to_name.key}"
5
+ - value ||= label
6
+ .form-check{ class: ("_more-filter-option" if index > 10) }
7
+ %label.form-check-label{ for: id }<
8
+ = label
9
+ = send tag_method, input_name, value, value.in?(default),
10
+ id: id, class: "form-check-input _submit-on-change"
11
+ - if options.size > 10
12
+ ._show-more-filter-options.show-more-filter-options
13
+ %a{ href: "#" } show more
@@ -0,0 +1,111 @@
1
+ format :html do
2
+ COMPACT_FILTER_TYPES = { radio: :select, check: :multiselect }.freeze
3
+
4
+ def filter_input_field field, default: nil, compact: false
5
+ fc = filter_config field
6
+ default ||= fc[:default]
7
+ filter_type = (compact && COMPACT_FILTER_TYPES[fc[:type]]) || fc[:type] || :text
8
+ send "#{filter_type}_filter", field, default, fc[:options]
9
+ end
10
+
11
+ private
12
+
13
+ # ~~~~~~~ FILTER TYPES ~~~~~~~~~~~~~~~ #
14
+
15
+ def check_filter *args
16
+ check_or_radio_filter :check, *args
17
+ end
18
+
19
+ def radio_filter *args
20
+ check_or_radio_filter :radio, *args
21
+ end
22
+
23
+ def select_filter field, default, options, multiple: false
24
+ options = filter_options options
25
+ options = [["--", ""]] + options unless default
26
+ select_filter_tag field, default, options, multiple: multiple
27
+ end
28
+
29
+ def multiselect_filter field, default, options
30
+ select_filter field, default, options, multiple: true
31
+ end
32
+
33
+ def autocomplete_filter type_code, _default, options_card=nil
34
+ options_card ||= Card::Name[type_code, :type, :by_name]
35
+ text_filter type_code, "", class: "#{type_code}_autocomplete",
36
+ "data-options-card": options_card
37
+ end
38
+
39
+ def text_filter field, default, opts
40
+ opts ||= {}
41
+ value = filter_param(field) || default
42
+ text_filter_with_name_and_value filter_input_name(field), value, opts
43
+ end
44
+
45
+ def range_filter field, default, opts
46
+ opts ||= {}
47
+ default ||= {}
48
+ add_class opts, "simple-text range-filter-field"
49
+ wrap_with :div, class: "input-group" do
50
+ [range_sign(:from),
51
+ sub_text_filter(field, :from, default, opts),
52
+ range_sign(:to),
53
+ sub_text_filter(field, :to, default, opts)]
54
+ end
55
+ end
56
+
57
+ # ~~~~~~~ HELPER METHODS ~~~~~~~~~~~~~~~ #
58
+
59
+ def check_or_radio_filter check_or_radio, field, default, options
60
+ haml :check_filter,
61
+ field_type: check_or_radio,
62
+ field: field,
63
+ input_name: filter_input_name(field, multi: (check_or_radio == :check)),
64
+ options: filter_options(options),
65
+ default: Array.wrap(filter_param(field) || default)
66
+ end
67
+
68
+ def text_filter_with_name_and_value name, value, opts
69
+ opts[:class] ||= "simple-text"
70
+ add_class opts, "form-control _submit-after-typing"
71
+ text_field_tag name, value, opts
72
+ end
73
+
74
+ def select_filter_tag field, default, options, multiple: false, disabled: false
75
+ klasses = "_filter_input_field filter-input filter-input-#{field} " \
76
+ "_submit-on-change form-control " \
77
+ "pointer-#{'multi' if multiple}select"
78
+ # not sure form-control does much here?
79
+ klasses << " _no-select2" if @compact_filter_form # select2 initiated once active
80
+
81
+ select_tag filter_input_name(field, multi: multiple),
82
+ options_for_select(options, (filter_param(field) || default)),
83
+ id: "filter-input-#{unique_id}",
84
+ multiple: multiple,
85
+ class: klasses,
86
+ disabled: disabled
87
+ end
88
+
89
+ def range_sign side
90
+ dir = side == :from ? "greater" : "less"
91
+ icon_tag("#{dir}_than", class: "input-group-text range-sign")
92
+ end
93
+
94
+ def sub_text_filter field, subfield, default={}, opts={}
95
+ name = filter_input_name field, subfield: subfield
96
+ value = filter_hash.dig(field, subfield) || default[subfield]
97
+ text_filter_with_name_and_value name, value, opts
98
+ end
99
+
100
+ def filter_input_name field, subfield: nil, multi: false
101
+ parts = [filter_prefix, "[#{field}]"]
102
+ parts << "[#{subfield}]" if subfield
103
+ parts << "[]" if multi
104
+ parts.join
105
+ end
106
+
107
+ # for override
108
+ def filter_prefix
109
+ "filter"
110
+ end
111
+ end
@@ -0,0 +1,131 @@
1
+ include_set Abstract::BsBadge
2
+
3
+ format do
4
+ def filter_class
5
+ Card::FilterQuery
6
+ end
7
+
8
+ def filter_map
9
+ [{ key: :name, open: true }]
10
+ end
11
+
12
+ def filter_keys_from_params
13
+ filter_hash.keys.map(&:to_sym) - [:not_ids]
14
+ end
15
+
16
+ def sort_options
17
+ { "Alphabetical": :name, "Recently Added": :create }
18
+ end
19
+
20
+ # all filter keys in the order they were selected
21
+ def all_filter_keys
22
+ @all_filter_keys ||= filter_keys_from_params | filter_keys
23
+ end
24
+
25
+ def current_sort
26
+ sort_param || default_sort_option
27
+ end
28
+
29
+ def default_sort_option
30
+ cql = card.cql_content || {}
31
+ cql[:sort_by] || cql[:sort]
32
+ end
33
+
34
+ def filter_param field
35
+ filter_hash[field.to_sym]
36
+ end
37
+
38
+ def filter_hash
39
+ @filter_hash ||= filter_hash_from_params || voo.filter || default_filter_hash
40
+ end
41
+
42
+ def filter_hash_from_params
43
+ param = Env.params[:filter]
44
+ if param.blank?
45
+ nil
46
+ elsif param.to_s == "empty"
47
+ {}
48
+ else
49
+ Env.hash(param).deep_symbolize_keys
50
+ end
51
+ end
52
+
53
+ def sort_param
54
+ @sort_param ||= safe_sql_param :sort_by
55
+ end
56
+
57
+ def safe_sql_param key
58
+ param = Env.params[key]
59
+ param.blank? ? nil : Card::Query.safe_sql(param)
60
+ end
61
+
62
+ def filter_keys
63
+ filter_keys_from_map_list(filter_map).flatten.compact
64
+ end
65
+
66
+ def filter_keys_from_map_list list
67
+ list.map do |item|
68
+ case item
69
+ when Symbol then item
70
+ when Hash then filter_keys_from_map_hash item
71
+ end
72
+ end
73
+ end
74
+
75
+ def filter_keys_from_map_hash item
76
+ item[:filters] ? filter_keys_from_map_list(item[:filters]) : item[:key]
77
+ end
78
+
79
+ def filter_keys_with_values
80
+ filter_keys.map do |key|
81
+ values = filter_param(key)
82
+ values.present? ? [key, values] : next
83
+ end.compact
84
+ end
85
+
86
+ # initial values for filtered search
87
+ def default_filter_hash
88
+ {}
89
+ end
90
+
91
+ def filter_hash_without key, value
92
+ filter_hash.clone.tap do |hash|
93
+ case hash[key]
94
+ when Array
95
+ hash[key] = hash[key] - Array.wrap(value)
96
+ else
97
+ hash.delete key
98
+ end
99
+ end
100
+ end
101
+
102
+ def removable_filters
103
+ each_removable_filter do |key, value, array|
104
+ if value.is_a? Array
105
+ value.each { |v| array << [key, v] }
106
+ elsif !empty_filter_value_hash? value
107
+ array << [key, value]
108
+ end
109
+ end
110
+ end
111
+
112
+ def empty_filter_value_hash? value
113
+ value.is_a?(Hash) && value.values.present? && !value.values.select(&:present?).any?
114
+ end
115
+
116
+ def each_removable_filter
117
+ filter_hash&.each_with_object([]) do |(key, val), arr|
118
+ yield key, val, arr if val.present? && filter_config(key)[:default] != val
119
+ end
120
+ end
121
+
122
+ def extra_paging_path_args
123
+ super.merge filter_and_sort_hash
124
+ end
125
+
126
+ def filter_and_sort_hash
127
+ { filter: filter_hash }.tap do |hash|
128
+ hash[:sort_by] = sort_param if sort_param
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,8 @@
1
+ # in FormHelper, #type_options has a special caching for the type options in filters.
2
+ #
3
+ # If this mod is included in the type set, it will clear that cache when items are
4
+ # added or deleted.
5
+
6
+ event :clear_cached_type_options, :integrate do
7
+ Card.cache.delete "#{type_code}-TYPE-OPTIONS"
8
+ end
@@ -0,0 +1,33 @@
1
+ # needs to be included here because Abstract::Search does not have Abstract::Filter
2
+ # when Abstract::CqlSearch includes Abstract::Search.
3
+ include_set Abstract::Filter
4
+
5
+ format do
6
+ def search_params
7
+ super.merge filter_and_sort_cql
8
+ end
9
+
10
+ def filter_and_sort_cql
11
+ filter_cql.merge sort_cql
12
+ end
13
+
14
+ def filter_cql
15
+ return {} if filter_hash.empty?
16
+
17
+ filter_cql_from_params
18
+ end
19
+
20
+ # separate method is needed for tests
21
+ def filter_cql_from_params
22
+ filter_class.new(filter_keys_with_values, blocked_id_cql).to_cql
23
+ end
24
+
25
+ def sort_cql
26
+ { sort_by: current_sort }
27
+ end
28
+
29
+ def blocked_id_cql
30
+ not_ids = filter_param :not_ids
31
+ not_ids.present? ? { id: ["not in", not_ids.split(",")] } : {}
32
+ end
33
+ end
@@ -0,0 +1,15 @@
1
+
2
+
3
+ format :html do
4
+ def filterable filter_hash={}, html_opts={}
5
+ add_class html_opts, "_filterable _noFilterUrlUpdates"
6
+ html_opts[:data] ||= {}
7
+ html_opts[:data][:filter] = filter_hash
8
+ wrap_with :div, yield, html_opts
9
+ end
10
+
11
+ def filtering selector=nil
12
+ selector ||= "._compact-filter:visible"
13
+ wrap_with :div, yield, class: "_filtering", "data-filter-selector": selector
14
+ end
15
+ end
@@ -0,0 +1,16 @@
1
+ # Makes it so that a List can be viewed (and filtered) as a Search
2
+
3
+ include_set Abstract::CqlSearch
4
+ include_set Abstract::SearchViews
5
+
6
+ def cql_content
7
+ { referred_to_by: "_" }
8
+ end
9
+
10
+ def item_cards args={}
11
+ standard_item_cards args
12
+ end
13
+
14
+ def count
15
+ item_strings.size
16
+ end
@@ -0,0 +1,28 @@
1
+ ._filter-items.container-fluid.nodblclick._noFilterUrlUpdates{ data: filter_items_data }
2
+ .row
3
+ = nest filter_card, view: :compact_filter_form
4
+ ._unselected.col-6.border.mt-2.px-0
5
+ = nest filter_card, view: :select_item,
6
+ items: { view: implicit_item_view },
7
+ cql: { limit: 10 }
8
+ ._selected.col-6.border.mt-2.ps-0.pe-0
9
+ .selected-box
10
+ .card-header
11
+ %h5
12
+ Selected
13
+ .badge.bg-secondary
14
+ %span._selected-items 0
15
+ ._selected-item-list{ style: "display:none" }
16
+ .deselector.p-2
17
+ %input#deselect-all._deselect-all{ type: "checkbox", checked: true, disabled: true }
18
+ %label{ for: "deselect-all" }
19
+ deselect
20
+ %span._selected-items 0
21
+ following
22
+ ._selected-bin.p-2
23
+ ._filter-help.alert.alert-secondary
24
+ Filter and select items to add them here.
25
+ .form-group
26
+ .selected-item-buttons.p-2
27
+ = button_tag "Cancel", class: "cancel-modal", data: { dismiss: :modal }
28
+ = render :add_selected_link
@@ -0,0 +1,7 @@
1
+ .filtered-list-editor
2
+ %ul.pointer-list.filtered-list-review._filtered-list.list-group.vertical.p-0
3
+ - card.item_cards(context: :raw).each do |item_card|
4
+ = nest_item item_card, view: filtered_item_view, wrap: filtered_item_wrap
5
+ %br
6
+ .clearfix
7
+ = add_item_modal_link
@@ -0,0 +1,105 @@
1
+ def unique_items?
2
+ true
3
+ end
4
+
5
+ def not_ids_value
6
+ unique_items? ? item_ids.map(&:to_s).join(",") : ""
7
+ end
8
+
9
+ format :html do
10
+ view :filtered_list, unknown: true do
11
+ filtered_list_input
12
+ end
13
+
14
+ view :filter_items_modal, unknown: true, wrap: :modal do
15
+ render_filter_items
16
+ end
17
+
18
+ view :filter_items, unknown: true, wrap: :slot, template: :haml
19
+
20
+ def filtered_item_view
21
+ implicit_item_view
22
+ end
23
+
24
+ def filtered_item_wrap
25
+ :filtered_list_item
26
+ end
27
+
28
+ def filtered_list_input
29
+ with_nest_mode :normal do
30
+ wrap { haml :filtered_list_input }
31
+ end
32
+ end
33
+
34
+ def add_item_modal_link text=nil
35
+ modal_link (text || "Add Item"),
36
+ size: :xl,
37
+ class: "btn btn-sm btn-outline-secondary _add-item-link",
38
+ path: {
39
+ view: :filter_items_modal,
40
+ slot: filter_items_modal_slot,
41
+ filter: filter_items_default_filter,
42
+ # each key value below is there to help support new cards configured
43
+ # by type_plus_right sets. do not remove without testing that case
44
+ # (not currently covered by specs)
45
+ type: :list.cardname,
46
+ filter_card: filter_card.name,
47
+ filter_items: filter_item_config
48
+ }
49
+ end
50
+
51
+ def filter_items_default_filter
52
+ { not_ids: card.not_ids_value }
53
+ end
54
+
55
+ def filter_items_data
56
+ {
57
+ "slot-selector": "modal-origin",
58
+ "item-selector": "._filtered-list-item",
59
+ item: filter_item_config
60
+ }
61
+ end
62
+
63
+ def filter_item_config
64
+ %i[view wrap duplicable].each_with_object({}) do |key, hash|
65
+ hash[key] = params.dig(:filter_items, key) || send("filtered_item_#{key}")
66
+ end
67
+ end
68
+
69
+ def filtered_item_duplicable
70
+ !card.unique_items?
71
+ end
72
+
73
+ def filter_items_modal_slot
74
+ { hide: [:modal_footer] }
75
+ end
76
+
77
+ view :add_selected_link, unknown: true do
78
+ button_tag "Add Selected", class: "_add-selected btn btn-primary", disabled: true
79
+ end
80
+
81
+ def filtered_list_item item_card
82
+ nest_item item_card do |rendered, item_view|
83
+ wrap_item rendered, item_view
84
+ end
85
+ end
86
+
87
+ # for override
88
+ # @return [Card] search card on which filtering is based
89
+ def filter_card
90
+ filter_card_from_params || default_filter_card
91
+ end
92
+
93
+ def default_filter_card
94
+ fcard = card.options_card
95
+ return fcard if fcard.respond_to? :cql_hash
96
+
97
+ fcard.fetch :referred_to_by, new: {}
98
+ end
99
+
100
+ def filter_card_from_params
101
+ return unless params[:filter_card]&.present?
102
+
103
+ Card.fetch params[:filter_card], new: {}
104
+ end
105
+ end
@@ -0,0 +1 @@
1
+ include_set Abstract::FilteredList
@@ -0,0 +1,7 @@
1
+ # this file is a little strange.
2
+ #
3
+ # It is necessary to have a new file in the search directory rather than adding
4
+ # #include_set directly to search.rb, because this makes it so that
5
+ # extra_paging_path_args overrides the code in search/search_params.
6
+
7
+ include_set Filter
@@ -0,0 +1,8 @@
1
+ ._filtered-list-item.row{ data: wrap_data }
2
+ .col-1._handle
3
+ = icon_tag :reorder
4
+ .col-10
5
+ = interior
6
+ .col-1.filtered-list-item-button
7
+ %button._filtered-list-item-delete.btn.btn-secondary.btn-sm.m-2{ type: "button"}
8
+ = icon_tag :remove
@@ -0,0 +1,11 @@
1
+ basket[:list_input_options] << :filtered_list
2
+
3
+ def supports_content_option_view?
4
+ super || (item_name == "filtered list")
5
+ end
6
+
7
+ format :html do
8
+ wrapper :filtered_list_item, template: :haml do
9
+ haml :filtered_list_item
10
+ end
11
+ end
data/set/type/list.rb ADDED
@@ -0,0 +1,4 @@
1
+ def input_type_content_options
2
+ # TODO: need to make super work here somehow or refactor around baskets
3
+ %w[multiselect checkbox list] << "filtered list"
4
+ end
metadata ADDED
@@ -0,0 +1,116 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: card-mod-filter
3
+ version: !ruby/object:Gem::Version
4
+ version: '0.2'
5
+ platform: ruby
6
+ authors:
7
+ - Philipp Kühl
8
+ - Ethan McCutchen
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2023-05-02 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: card
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '0'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: '0'
28
+ - !ruby/object:Gem::Dependency
29
+ name: card-mod-search
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: '0'
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: '0'
42
+ - !ruby/object:Gem::Dependency
43
+ name: card-mod-bootstrap
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ type: :runtime
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ description: ''
57
+ email:
58
+ - info@decko.org
59
+ executables: []
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - README.md
64
+ - lib/card/filter_query.rb
65
+ - set/abstract/0_filter.rb
66
+ - set/abstract/0_filter/filter_fields.rb
67
+ - set/abstract/0_filter/filter_form.rb
68
+ - set/abstract/0_filter/filter_form/_compact_filter_input.haml
69
+ - set/abstract/0_filter/filter_form/compact_filter_form.haml
70
+ - set/abstract/0_filter/filter_form/compact_quick_filters.haml
71
+ - set/abstract/0_filter/filter_form/filter_bars.haml
72
+ - set/abstract/0_filter/filter_form/filter_sort_dropdown.haml
73
+ - set/abstract/0_filter/filter_form/filtered_results_header.haml
74
+ - set/abstract/0_filter/filter_form/offcanvas_filters.haml
75
+ - set/abstract/0_filter/filter_form/open_filter_button.haml
76
+ - set/abstract/0_filter/filter_form/select_item.haml
77
+ - set/abstract/0_filter/filter_form/selectable_filtered_content.haml
78
+ - set/abstract/0_filter/filter_input_fields.rb
79
+ - set/abstract/0_filter/filter_input_fields/check_filter.haml
80
+ - set/abstract/cached_type_options.rb
81
+ - set/abstract/cql_search/filter.rb
82
+ - set/abstract/filter_link.rb
83
+ - set/abstract/filterable_list.rb
84
+ - set/abstract/filtered_list.rb
85
+ - set/abstract/filtered_list/filter_items.haml
86
+ - set/abstract/filtered_list/filtered_list_input.haml
87
+ - set/abstract/list/filter.rb
88
+ - set/abstract/search/filter_inclusion.rb
89
+ - set/all/filtered_list_item.haml
90
+ - set/all/filtered_list_item.rb
91
+ - set/type/list.rb
92
+ homepage: http://decko.org
93
+ licenses:
94
+ - GPL-3.0
95
+ metadata:
96
+ card-mod: filter
97
+ post_install_message:
98
+ rdoc_options: []
99
+ require_paths:
100
+ - lib
101
+ required_ruby_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: '2.5'
106
+ required_rubygems_version: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ requirements: []
112
+ rubygems_version: 3.2.33
113
+ signing_key:
114
+ specification_version: 4
115
+ summary: filter
116
+ test_files: []