sn_filterable 0.1.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
+ SHA256:
3
+ metadata.gz: 4789bdaca59f8fe8a8aa1adfc7725d69ec7fdded65ee794b13cf155c8ff3ad09
4
+ data.tar.gz: a172fe3b19ce43a61ffb511c66a224f0caf1a533b7bd3414cdc752bbe2fd59c0
5
+ SHA512:
6
+ metadata.gz: 90af89cc97840f7f5c3cb53632ddbf3e99e197c01fcbf1f8e96a82daa0b7ff7d2a5c7e884aaeedb17f553a8a1e16ef73c298b7b9257ba9591e2a3020337321c0
7
+ data.tar.gz: 5e6fa1d8e79e29abd6683e3d8756949feeebc23f1e99f92d57f39b55b3e162ef8d3f4233f2846e8a271e0f62f6fd9e6b515c9c85d7a982c2ed57c3584f52869a
data/README.md ADDED
@@ -0,0 +1,199 @@
1
+ # SnFilterable
2
+
3
+ Welcome to the Skills Network Filterable gem!
4
+
5
+ This gem provides a method for developers to quickly implement a customizable search and filter for their data with live-reloading.
6
+
7
+ Live examples of the gem's use can be viewed at [Skills Network's Author Workbench](https://author.skills.network), primarily under the [organizations tab](https://author.skills.network/organizations)
8
+
9
+ ![](sn_filterable_demo.gif)
10
+
11
+ ## Requirements
12
+
13
+ There are a couple key requirements for your app to be compatible with this gem:
14
+
15
+ 1. You need to have [AlpineJS](https://alpinejs.dev/essentials/installation) loaded into the page where you plan to use SnFilterable
16
+ 2. Your app needs to be running [TailwindCSS](https://tailwindcss.com/docs/guides/ruby-on-rails)
17
+
18
+ ## Installation
19
+
20
+ Add this line to your application's Gemfile:
21
+
22
+ ```ruby
23
+ gem "sn_filterable", git: "https://github.com/ibm-skills-network/sn_filterable.git"
24
+ ```
25
+
26
+ And then execute:
27
+ ```bash
28
+ bundle install
29
+ ```
30
+
31
+ ##### Make the following adjustments to your codebase
32
+
33
+ 1. Add the necessary translations and customize as desired
34
+ ```yaml
35
+ # en.yml
36
+ en:
37
+ # Other translations
38
+ shared:
39
+ filterable:
40
+ view_filter_button: "View filters"
41
+ results_per_page: "Results per page"
42
+ clear_all: "Clear all"
43
+ pagination:
44
+ previous_page: "Previous"
45
+ next_page: "Next"
46
+ ```
47
+
48
+ 2. Require the necessary JavaScript (dependent on AlpineJS being included with your App)
49
+ ```javascript
50
+ // application.js converted to application.js.erb
51
+
52
+ // other imports
53
+ <%= SnFilterable.load_js %>
54
+ ```
55
+
56
+ 3. Configure your app's Tailwind to scan the gem
57
+ ```javascript
58
+ // tailwind.config.js
59
+ const execSync = require('child_process').execSync;
60
+ const output = execSync('bundle show sn_filterable', { encoding: 'utf-8' });
61
+
62
+ module.exports = {
63
+ // other config settings
64
+ content: [
65
+ // other content
66
+ output.trim() + '/app/**/*.{erb,rb}'
67
+ ]
68
+ // other config settings
69
+ };
70
+ ```
71
+
72
+ ## Usage
73
+
74
+
75
+ #### The MainComponent: Search Bar and sidebar
76
+
77
+ The MainComponent is what is demo'd in the introduction. It consists of the search bar and a sidebar for filters.
78
+
79
+ If you only wish to use the Search bar an optional `show_sidebar: false` parameter can be passed to `SnFilterable::MainComponent` in the view.
80
+
81
+ There are three components which work to provide the text search functionality:
82
+
83
+ 1. Filters in the given model:
84
+ ```ruby
85
+ # model.rb
86
+ class Model < ApplicationRecord
87
+ include SnFilterable::Filterable
88
+
89
+ FILTER_SCOPE_MAPPINGS = {
90
+ "search_name": :filter_by_name
91
+ # 'search_name' will be referenced from the view
92
+ }.freeze
93
+
94
+
95
+ SORT_SCOPE_MAPPINGS = {
96
+ "sort_name": :sort_by_name
97
+ # 'sort_name' will be referenced from the controller
98
+ }.freeze
99
+
100
+ scope :filter_by_name, ->(search) { where(SnFilterable::WhereHelper.contains("name", search)) }
101
+ scope :sort_by_name, -> { order :name }
102
+ # 'name' is a string column defined on the Model
103
+
104
+ # Model code...
105
+ end
106
+ ```
107
+
108
+ 2. Setting up the controller
109
+ * While `:default_sort` is an optional parameter it is recommended
110
+ ```ruby
111
+ # models_controller.rb
112
+ @search = Model.filter(params:, default_sort: ["sort_name", :asc].freeze)
113
+ @models = @search.items
114
+ ```
115
+
116
+ 3. Rendering the ViewComponent
117
+ ```html
118
+ <%= render SnFilterable::MainComponent.new(frame_id: "some_unique_id", filtered: @search, filters: [], search_filter_name: "search_name") do %>
119
+ <% @models.each do |model| %>
120
+ <%= model.name %>
121
+ <% end %>
122
+ <%= filtered_paginate @search %> # Kaminari Gem Pagination
123
+ <% end %>
124
+ ```
125
+
126
+ #### The MainComponent: Adding filters to the sidebar
127
+
128
+ Adding filters to the sidebar requires changes to two files though we recommend storing the data across three files and will demsontrate as such.
129
+
130
+ 1. Add filters to Model
131
+ ```ruby
132
+ # app/models/model.rb
133
+ class Model < ApplicationRecord
134
+ # inclusion statement from introduction
135
+
136
+ FILTER_SCOPE_MAPPINGS = {
137
+ # other filter scopes...
138
+ "model_type_filter": :filter_by_type
139
+ }.freeze
140
+ # 'model_type_filter' will be referenced in step 2
141
+
142
+ ARRAY_FILTER_SCOPES = %i[model_type_filter].freeze
143
+ # safelist of all filters we will be rendering in our sidebar
144
+
145
+ scope :filter_by_type, ->(model_type_input) { where(model_type: model_type_input) }
146
+ # where 'model_type' is an attribute defined on Model
147
+ end
148
+ ```
149
+
150
+ 2. Create filter options
151
+ ```ruby
152
+ # app/models/filter.rb
153
+ # We store in a filter.rb model, but you can store as desired
154
+ class Filter
155
+ MODEL_FILTERS = [
156
+ {
157
+ multi: true,
158
+ title: "Type",
159
+ filter_name: "model_type_filter",
160
+ filters: %w(Special Normal).map { |type| { name: type, value: type } }
161
+ # Allows us to filter between 'Special' and 'Normal' model types
162
+ # Note we recommend storing the %w(Special Normal) array at a central location for easier validation and manipulation
163
+ }
164
+ ].freeze
165
+ end
166
+ ```
167
+
168
+ 3. Render as part of our MainComponent
169
+ ```html
170
+ <!-- Notice the addition of a non-empty 'filters' argument which references step 2 -->
171
+ <%= render SnFilterable::MainComponent.new(frame_id: "some_unique_id", filtered: @search, filters: Filter::MODEL_FILTERS, search_filter_name: "search_name") do %>
172
+ <!-- display code.. -->
173
+ <% end %>
174
+ ```
175
+
176
+ ## Testing / Development
177
+
178
+ This gem using [RSpec](https://rspec.info) for testing. Tests can be running locally by first setting up the dummy database/app as follows:
179
+
180
+ ```bash
181
+ docker compose up -d
182
+ cd spec/dummy
183
+ rails db:create
184
+ rails db:schema:load
185
+ ```
186
+
187
+
188
+ Now the test suite can be run from the project root using
189
+ ```bash
190
+ bundle exec rspec
191
+ ```
192
+
193
+ ## Contributing
194
+
195
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/ibm-skills-network/sn_filterable).
196
+
197
+ ## License
198
+
199
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,63 @@
1
+ document.addEventListener("alpine:init", () => {
2
+ Alpine.data("filteringSearchInput", (searchKey) => ({
3
+ currentTimeout: null,
4
+ hasValue: false,
5
+ onInit: function () {
6
+ this.onValueUpdate()
7
+ },
8
+ onUpdate: function (force) {
9
+ this.clearTimeout();
10
+ this.onValueUpdate();
11
+
12
+ this.$data.currentTimeout = setTimeout(() => {
13
+ this.$data.currentTimeout = null;
14
+
15
+ this.$data.filteringForm.requestSubmit();
16
+ }, force ? 0 : 500);
17
+ },
18
+ onValueUpdate: function () {
19
+ this.$data.hasValue = this.$el.value.length > 0;
20
+ },
21
+ clearTimeout: function () {
22
+ if (this.$data.currentTimeout != null) {
23
+ clearInterval(this.$data.currentTimeout);
24
+ this.$data.currentTimeout = null;
25
+ }
26
+ }
27
+ }));
28
+ Alpine.data("filteringChipContainer", () => ({
29
+ updateSidebarGap: function () {
30
+ this.$data.sidebarGapTarget.style.paddingTop = `${this.$el.clientHeight}px`;
31
+ }
32
+ }));
33
+ Alpine.data("filteringChip", (filter) => ({
34
+ onClick: function (chipElem) {
35
+ const inputElem = this.$data.filteringForm.querySelector(`input[data-filter-name=${JSON.stringify(filter.parent)}][value=${JSON.stringify(filter.value)}]`);
36
+ if (filter.multi) {
37
+ inputElem.click()
38
+ } else {
39
+ inputElem.checked = false;
40
+ this.$data.filteringForm.requestSubmit();
41
+ }
42
+
43
+ if (this.$el.closest(".app-filter-chips-container").children.length == 1) {
44
+ this.$el.closest(".app-filter-chips-content").remove();
45
+ } else {
46
+ this.$el.closest(".app-filter-chip").remove();
47
+ }
48
+
49
+ this.$data.updateSidebarGap();
50
+ }
51
+ }));
52
+ Alpine.data("filteringClear", (filter) => ({
53
+ onClick: function (chipElem) {
54
+ const chips = Array.from(this.$data.entireComponenet.querySelector(".app-filter-chips-container").querySelectorAll(".app-filter-chip"));
55
+
56
+ for (const chip of chips) {
57
+ chip.querySelector("a").click();
58
+ }
59
+
60
+ this.$el.closest(".app-filter-chips-content").remove();
61
+ }
62
+ }));
63
+ });
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+ require "view_component"
3
+ require "rails"
4
+
5
+ class SnFilterable::BaseComponents::BaseComponent < ViewComponent::Base
6
+ include ClassNameHelper
7
+ include FetchOrFallbackHelper
8
+
9
+ def initialize(tag:, classes: nil, **system_arguments)
10
+ @tag = tag
11
+ @system_arguments = system_arguments
12
+
13
+ # TODO
14
+ # Implement a more native way of adding custom CSS class instead of stupid string concat
15
+ # Similar to the Claasifier Primer build
16
+ # https://github.com/primer/view_components/blob/41b277aa047ba7d1a669a48dc392115bf4948435/app/components/primer/base_component.rb
17
+ @system_arguments[:class] = class_names(
18
+ system_arguments[:class],
19
+ classes
20
+ )
21
+ end
22
+
23
+ def call
24
+ content_tag(@tag, content, **@system_arguments)
25
+ end
26
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+ require_relative "base_component"
3
+
4
+ class SnFilterable::BaseComponents::ButtonComponent < SnFilterable::BaseComponents::BaseComponent
5
+ DEFAULT_BUTTON_TYPE = :default
6
+ BUTTON_TYPE_MAPPINGS = {
7
+ DEFAULT_BUTTON_TYPE => "shadow-sm border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:ring-indigo-500",
8
+ :primary => "shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:ring-indigo-500",
9
+ :danger => "border-transparent text-red-700 bg-red-100 hover:bg-red-200 focus:ring-red-500",
10
+ :disabled => "shadow-sm border-gray-300 text-gray-700 bg-gray-200 cursor-default"
11
+ }.freeze
12
+ BUTTON_TYPE_OPTIONS = BUTTON_TYPE_MAPPINGS.keys
13
+
14
+ DEFAULT_VARIANT = :medium
15
+ VARIANT_MAPPINGS = {
16
+ :small => "px-3 py-2 text-sm leading-4 font-medium",
17
+ DEFAULT_VARIANT => "px-4 py-2 text-sm font-medium",
18
+ :large => "px-4 py-2 text-base font-medium"
19
+ }.freeze
20
+ VARIANT_OPTIONS = VARIANT_MAPPINGS.keys
21
+
22
+ DEFAULT_TAG = :button
23
+ TAG_OPTIONS = [DEFAULT_TAG, :a, :summary].freeze
24
+
25
+ DEFAULT_TYPE = :button
26
+ TYPE_OPTIONS = [DEFAULT_TYPE, :reset, :submit].freeze
27
+
28
+ # @example 50|Button types
29
+ # <%= render(ButtonComponent.new) { "Default" } %>
30
+ # <%= render(ButtonComponent.new(button_type: :primary)) { "Primary" } %>
31
+ # <%= render(ButtonComponent.new(button_type: :danger)) { "Danger" } %>
32
+ #
33
+ # @example 50|Variants
34
+ # <%= render(ButtonComponent.new(variant: :small)) { "Small" } %>
35
+ # <%= render(ButtonComponent.new(variant: :medium)) { "Medium" } %>
36
+ # <%= render(ButtonComponent.new(variant: :large)) { "Large" } %>
37
+ #
38
+ # @param button_type [Symbol] <%= one_of(ButtonComponent::BUTTON_TYPE_OPTIONS) %>
39
+ # @param variant [Symbol] <%= one_of(ButtonComponent::VARIANT_OPTIONS) %>
40
+ # @param tag [Symbol] <%= one_of(ButtonComponent::TAG_OPTIONS) %>
41
+ # @param type [Symbol] <%= one_of(ButtonComponent::TYPE_OPTIONS) %>
42
+ def initialize(
43
+ button_type: DEFAULT_BUTTON_TYPE,
44
+ variant: DEFAULT_VARIANT,
45
+ tag: DEFAULT_TAG,
46
+ type: DEFAULT_TYPE,
47
+ **arguments
48
+ )
49
+ @arguments = arguments
50
+ @arguments[:tag] = tag || DEFAULT_TAG
51
+
52
+ if @arguments[:tag] == :a
53
+ @arguments[:role] = :button
54
+ else
55
+ @arguments[:type] = type
56
+ end
57
+
58
+ show_focus_ring = arguments[:show_focus_ring].nil? ? true : arguments[:show_focus_ring]
59
+ focus_ring_class = show_focus_ring ? "focus:ring-2 focus:ring-offset-2" : ""
60
+
61
+ @arguments[:classes] = class_names(
62
+ "inline-flex items-center border rounded-md #{focus_ring_class} focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed",
63
+ BUTTON_TYPE_MAPPINGS[fetch_or_fallback(BUTTON_TYPE_OPTIONS, button_type, DEFAULT_BUTTON_TYPE)],
64
+ VARIANT_MAPPINGS[fetch_or_fallback(VARIANT_OPTIONS, variant, DEFAULT_VARIANT)]
65
+ )
66
+ end
67
+
68
+ def call
69
+ render(SnFilterable::BaseComponents::BaseComponent.new(**@arguments)) { content }
70
+ end
71
+ end
@@ -0,0 +1,11 @@
1
+ <%= content_tag :fieldset, class: "w-full text-xs lg:text-sm app-word-break",
2
+ "x-data": { open: @open }.to_json do %>
3
+ <button class="flex border-b justify-between w-full px-4 py-3 font-medium text-left text-gray-900 transition-colors hover:bg-gray-200 rounded-sm focus:outline-none focus-visible:ring focus-visible:ring-gray-500 focus-visible:ring-opacity-75" type="button" @click="open = !open">
4
+ <span><%= @title %></span>
5
+ <%= heroicon "chevron-down", options: { class: "transform w-5 h-5 text-gray-500 transition ease-out", ":class": "open && 'rotate-180'"} %>
6
+ </button>
7
+ <div class="pb-2 text-gray-500" x-show="open" x-cloak>
8
+ <%= filter %>
9
+ <%= content %>
10
+ </div>
11
+ <% end %>
@@ -0,0 +1,18 @@
1
+ require_relative "filter_category_component"
2
+
3
+ module SnFilterable
4
+ # Renders a category to be displayed in the filtering sidebar/popup.
5
+ # Simple view component, logic to display filters should render a [:filter] (see [Filterable::FilterCategoryComponent])
6
+ class CategoryComponent < ViewComponent::Base
7
+ include Heroicon::Engine.helpers
8
+
9
+ renders_one :filter, SnFilterable::FilterCategoryComponent
10
+
11
+ # @param [String] title Optional, the title of the category, will default to the filter's title (if specified)
12
+ # @param [Boolean] open Optional, determines if the category should be opened by default
13
+ def initialize(title: nil, open: false)
14
+ @title = title
15
+ @open = open
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,37 @@
1
+ <% unless @known_filters.empty? %>
2
+ <div class="relative app-filter-chips-content bg-gray-50 -mx-8 mt-4" x-data="filteringChipContainer" x-init="updateSidebarGap()">
3
+ <div class="mx-auto py-3 px-4 flex items-center px-4 sm:px-6 lg:px-8">
4
+ <h3 class="sm:pr-0 text-xs font-semibold uppercase tracking-wide text-gray-500">
5
+ <span>Filters</span>
6
+ <span class="sr-only">, active</span>
7
+ </h3>
8
+
9
+ <div aria-hidden="true" class="w-px h-5 bg-gray-300 block ml-2 sm:ml-4"></div>
10
+
11
+ <div class="ml-2 sm:ml-4 flex-1">
12
+ <div class="app-filter-chips-container -m-1 flex flex-wrap items-center">
13
+ <% @known_filters.each do |filter| %>
14
+ <span class="app-filter-chip m-1 inline-flex rounded-full border border-gray-200 items-center py-1.5 pl-3 pr-2 text-xs sm:text-sm font-medium bg-white text-gray-900" x-data>
15
+ <span><%= filter[:name] %></span>
16
+ <%= content_tag :a,
17
+ href: filter[:multi] ? remove_sub_filter_url(@filtered, filter[:parent], filter[:value], url: @url) : remove_filter_url(@filtered, filter[:parent], url: @url),
18
+ class: "flex-shrink-0 ml-1 h-4 w-4 p-1 rounded-full inline-flex text-gray-400 hover:bg-gray-200 hover:text-gray-500",
19
+ "x-data": "filteringChip(#{filter.to_json})",
20
+ "@click": "$event.preventDefault(); onClick()" do %>
21
+ <span class="sr-only">Remove filter for Objects</span>
22
+ <svg class="h-2 w-2" stroke="currentColor" fill="none" viewBox="0 0 8 8">
23
+ <path stroke-linecap="round" stroke-width="1.5" d="M1 1l6 6m0-6L1 7" />
24
+ </svg>
25
+ <% end %>
26
+ </span>
27
+ <% end %>
28
+ </div>
29
+ </div>
30
+ <div class="pl-2 sm:pl-6">
31
+ <%= link_to t("shared.filterable.clear_all"), clear_filter_url(@filtered, url: @url), class: "text-sm text-gray-500", "x-data": "filteringClear", "@click": "$event.preventDefault(); onClick()" %>
32
+ </div>
33
+ </div>
34
+ </div>
35
+ <% else %>
36
+ <div class="hidden" x-data="filteringChipContainer" x-init="updateSidebarGap()"></div>
37
+ <% end %>
@@ -0,0 +1,78 @@
1
+ module SnFilterable
2
+ # Handles rendering of the chips for the filters
3
+ class ChipsComponent < ViewComponent::Base
4
+ include FilteredHelper
5
+
6
+ # @param [Filtered] filtered The filtered instance
7
+ # @param [Array<Hash>] filters An array of the filters' info
8
+ # @param [String] url The base URL of where the filters are displayed
9
+ def initialize(filtered:, filters:, url:)
10
+ @filtered = filtered
11
+ @filters = filters
12
+ @url = url
13
+ @known_filters = parsed_filters
14
+ end
15
+
16
+ private
17
+
18
+ # Parses all the queries and builds a [Hash] array that contains info about each filter used
19
+ def parsed_filters
20
+ filters = []
21
+
22
+ @filtered.queries["filter"].each do |filter_name, filter_value|
23
+ filter = @filters.find { |x| x[:filter_name] == filter_name }
24
+ next if filter.nil?
25
+
26
+ if filter_value.is_a?(Array)
27
+ filter_value.each do |sub_filter|
28
+ value = parse_multi_filter(filter, sub_filter)
29
+
30
+ filters.append(value) if value.present?
31
+ end
32
+ else
33
+ value = parse_single_filter(filter, filter_value)
34
+
35
+ filters.append(value) if value.present?
36
+ end
37
+ end
38
+
39
+ filters
40
+ end
41
+
42
+ # Parses a value from filter that supports multiple values
43
+ #
44
+ # @param [Hash] filter The filter info
45
+ # @param [String] sub_filter_value The sub filter's value
46
+ # @return [Hash]
47
+ def parse_multi_filter(filter, sub_filter_value)
48
+ value = filter[:filters].find { |x| x[:value].to_s == sub_filter_value }
49
+
50
+ return nil if value.blank?
51
+
52
+ {
53
+ multi: true,
54
+ parent: filter[:filter_name],
55
+ name: value[:name],
56
+ value: sub_filter_value
57
+ }
58
+ end
59
+
60
+ # Parses a filter that supports a single value
61
+ #
62
+ # @param [Hash] filter The filter info
63
+ # @param [String] filter_value The filter's value
64
+ # @return [Hash]
65
+ def parse_single_filter(filter, filter_value)
66
+ value = filter[:filters].find { |x| x[:value].to_s == filter_value }
67
+
68
+ return nil if value.blank?
69
+
70
+ {
71
+ multi: false,
72
+ parent: filter[:filter_name],
73
+ name: value[:name],
74
+ value: filter_value
75
+ }
76
+ end
77
+ end
78
+ end
@@ -0,0 +1 @@
1
+ <%= render(SnFilterable::BaseComponents::ButtonComponent.new("@click": "filtersPopupOpen = !filtersPopupOpen", "class": "after:content-[attr(data-active)] before:block", "data-active": @count)) { t("shared.filterable.view_filter_button") } %>
@@ -0,0 +1,29 @@
1
+ module SnFilterable
2
+ # Handles rendering of the `View filters` button for mobile layouts
3
+ class FilterButtonComponent < ViewComponent::Base
4
+ # @param [Filtered] filtered The filtered instance
5
+ # @param [Array<Hash>] filters An array of the filters' info
6
+ def initialize(filtered:, filters:)
7
+ @filtered = filtered
8
+ @filters = filters
9
+ @count = active_filter_count
10
+ end
11
+
12
+ private
13
+
14
+ # Generates the count for the button suffix
15
+ #
16
+ # @return [String] a string of format: ` (COUNT)` or nil if no filters applied
17
+ def active_filter_count
18
+ count = 0
19
+
20
+ @filtered.queries["filter"].each do |key, value|
21
+ count += (value.is_a?(Array) ? value.length : 1) if @filters.any? { |x| x[:filter_name] == key }
22
+ end
23
+
24
+ return nil if count.zero?
25
+
26
+ "\xA0(#{count})"
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,32 @@
1
+ <% @filter[:filters].each_with_index do |sub_filter, index| %>
2
+ <div class="relative flex items-start px-4 transition-colors hover:bg-gray-200 text-gray-700 hover:text-gray-900">
3
+ <div class="min-w-0 flex-1 flex-grow">
4
+ <%= content_tag :label, sub_filter[:name], class: "block py-2 pr-4 text-gray-600 select-none w-full cursor-pointer", for: "filter-#{@filter[:filter_name]}-#{index}" %>
5
+ </div>
6
+ <div class="my-2 flex items-center">
7
+ <% if @filter[:multi] %>
8
+ <%= content_tag :input, "",
9
+ class: "focus:ring-purple-400 cursor-pointer h-5 w-5 text-purple-500 bg-purple-100 border-0 rounded",
10
+ type: "checkbox",
11
+ name: "filter[#{@filter[:filter_name]}][]",
12
+ value: sub_filter[:value],
13
+ id: "filter-#{@filter[:filter_name]}-#{index}",
14
+ checked: sub_filter_checked?(sub_filter),
15
+ "data-filter-name": @filter[:filter_name],
16
+ "@click": "filteringForm.requestSubmit()"
17
+ %>
18
+ <% else %>
19
+ <%= content_tag :input, "",
20
+ class: "focus:ring-purple-400 cursor-pointer h-5 w-5 text-purple-500 bg-purple-100 border-0",
21
+ type: "radio",
22
+ name: "filter[#{@filter[:filter_name]}]",
23
+ value: sub_filter[:value],
24
+ id: "filter-#{@filter[:filter_name]}-#{index}",
25
+ checked: sub_filter_checked?(sub_filter),
26
+ "data-filter-name": @filter[:filter_name],
27
+ "@click": "filteringForm.requestSubmit()"
28
+ %>
29
+ <% end %>
30
+ </div>
31
+ </div>
32
+ <% end %>
@@ -0,0 +1,30 @@
1
+ module SnFilterable
2
+ # Component for a filter's category for the filters sidebar
3
+ class FilterCategoryComponent < ViewComponent::Base
4
+ include Heroicon::Engine.helpers
5
+ include FilteredHelper
6
+
7
+ # @param [Filtered] filtered The filtered instance
8
+ # @param [Hash] filters The filter's info
9
+ def initialize(filtered:, filter:)
10
+ @filtered = filtered
11
+ @filter = filter
12
+ end
13
+
14
+ # Returns if this sub filter should be checked, denoting that the filter is active
15
+ #
16
+ # @param [String] sub_filter The sub filter's info
17
+ # @return [Boolean] True if the filter should be checked
18
+ def sub_filter_checked?(sub_filter)
19
+ filter_query_value = @filtered.queries.dig("filter", @filter[:filter_name])
20
+
21
+ return false if filter_query_value.blank?
22
+
23
+ if filter_query_value.is_a?(Array)
24
+ filter_query_value.include?((sub_filter[:value]).to_s)
25
+ else
26
+ filter_query_value == (sub_filter[:value]).to_s
27
+ end
28
+ end
29
+ end
30
+ end