sn_filterable 0.1.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
+ 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