sn_filterable 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
![](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,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
|