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 +7 -0
- data/README.md +199 -0
- data/Rakefile +12 -0
- data/app/assets/javascripts/sn_filtering.js +63 -0
- data/app/components/sn_filterable/base_components/base_component.rb +26 -0
- data/app/components/sn_filterable/base_components/button_component.rb +71 -0
- data/app/components/sn_filterable/category_component.html.erb +11 -0
- data/app/components/sn_filterable/category_component.rb +18 -0
- data/app/components/sn_filterable/chips_component.html.erb +37 -0
- data/app/components/sn_filterable/chips_component.rb +78 -0
- data/app/components/sn_filterable/filter_button_component.html.erb +1 -0
- data/app/components/sn_filterable/filter_button_component.rb +29 -0
- data/app/components/sn_filterable/filter_category_component.html.erb +32 -0
- data/app/components/sn_filterable/filter_category_component.rb +30 -0
- data/app/components/sn_filterable/main_component.html.erb +97 -0
- data/app/components/sn_filterable/main_component.rb +39 -0
- data/app/components/sn_filterable/search_component.html.erb +16 -0
- data/app/components/sn_filterable/search_component.rb +13 -0
- data/lib/models/filtered.rb +171 -0
- data/lib/sn_filterable/engine.rb +10 -0
- data/lib/sn_filterable/filterable.rb +292 -0
- data/lib/sn_filterable/railtie.rb +11 -0
- data/lib/sn_filterable/version.rb +5 -0
- data/lib/sn_filterable.rb +182 -0
- metadata +294 -0
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
|
+

|
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,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
|