ransack_search_element 0.0.2.pre.alpha
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/MIT-LICENSE +20 -0
- data/README.md +50 -0
- data/Rakefile +18 -0
- data/app/assets/config/ransack_search_element_manifest.js +3 -0
- data/app/assets/stylesheets/ransack_search_element/application.scss +19 -0
- data/app/controllers/ransack_search_element/application_controller.rb +4 -0
- data/app/helpers/ransack_search_element/application_helper.rb +10 -0
- data/app/javascript/ransack-search-element/components/FilterDropdown.tsx +226 -0
- data/app/javascript/ransack-search-element/components/FilterRow.tsx +115 -0
- data/app/javascript/ransack-search-element/components/FormInput.tsx +98 -0
- data/app/javascript/ransack-search-element/index.ts +44 -0
- data/app/javascript/ransack-search-element/utilities/QueryBuilder.ts +134 -0
- data/app/javascript/ransack-search-element/utilities/dateConverter.ts +5 -0
- data/app/javascript/ransack-search-element/utilities/registerNestedDropdown.ts +65 -0
- data/app/jobs/ransack_search_element/application_job.rb +4 -0
- data/app/mailers/ransack_search_element/application_mailer.rb +6 -0
- data/app/models/ransack_search_element/application_record.rb +5 -0
- data/app/views/layouts/ransack_search_element/application.html.erb +15 -0
- data/config/routes.rb +2 -0
- data/lib/ransack_search_element/engine.rb +21 -0
- data/lib/ransack_search_element/version.rb +3 -0
- data/lib/ransack_search_element.rb +5 -0
- data/lib/tasks/ransack_search_element_tasks.rake +4 -0
- metadata +84 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a6231963831824c4d45beb2710387ff159f2d8ceff31dfe15905678e95c8a62e
|
|
4
|
+
data.tar.gz: 158c5cb3a5c577d2969030635ad93ff61c25d3820ba76629a147eaa9ad233f9d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e8e734658728bfc5cacafec2e3483191f761d1429f68ba2c44b7377b72b7817a1df39ba47fa1e70d9f778903589e32a9daabc73b490fae96c7369425bdbec11b
|
|
7
|
+
data.tar.gz: 8ccae8b4e6d1c4b164d73f9ed88882bdcfc1c9ebaf95ab940f5bdb4a6dd5fabd8273babcaf71911dc6dcaf3c9ffe072b1af5cfc0d3b0e13334035f6f472b6aef
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright 2021 Arthur Dzieniszewski
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# RansackSearchElement
|
|
2
|
+
|
|
3
|
+
Generic [Ransack](https://github.com/activerecord-hackery/ransack) backed element
|
|
4
|
+
to give a dynamic frontend for advanced searching and filtering, such as for
|
|
5
|
+
exploratory joins. Still early in development (refactoring?), so contributions
|
|
6
|
+
welcome. This is originally based on a set of React and Bootstrap elements.
|
|
7
|
+
|
|
8
|
+
## Todo
|
|
9
|
+
|
|
10
|
+
- [x] Write wrapper Rails tags
|
|
11
|
+
- [ ] Rip out React and replace with Web Components
|
|
12
|
+
- [ ] Rip out Bootstrap and replace with leaner default styles (possibly
|
|
13
|
+
customizable with sass?)
|
|
14
|
+
- [ ] Decouple javascript further
|
|
15
|
+
- [ ] Write adapters to interface with other search backends? (big maybe)
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
Use `ransack_search_tag` in your views, and supply it with more options
|
|
19
|
+
as of yet to be defined.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
gem 'ransack_search_element'
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
And then execute:
|
|
28
|
+
```bash
|
|
29
|
+
$ bundle
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Or install it yourself as:
|
|
33
|
+
```bash
|
|
34
|
+
$ gem install ransack_search_element
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
You will also need to install the accompanying npm package,
|
|
38
|
+
`ransack-search-element`:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
yarn add ransack-search-element
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Contributing
|
|
45
|
+
Yeah just send it.
|
|
46
|
+
|
|
47
|
+

|
|
48
|
+
|
|
49
|
+
## License
|
|
50
|
+
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,18 @@
|
|
|
1
|
+
require "bundler/setup"
|
|
2
|
+
|
|
3
|
+
APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
|
|
4
|
+
load "rails/tasks/engine.rake"
|
|
5
|
+
|
|
6
|
+
load "rails/tasks/statistics.rake"
|
|
7
|
+
|
|
8
|
+
require "bundler/gem_tasks"
|
|
9
|
+
|
|
10
|
+
require "rake/testtask"
|
|
11
|
+
|
|
12
|
+
Rake::TestTask.new(:test) do |t|
|
|
13
|
+
t.libs << 'test'
|
|
14
|
+
t.pattern = 'test/**/*_test.rb'
|
|
15
|
+
t.verbose = false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
task default: :test
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This is a manifest file that'll be compiled into application.css, which will include all the files
|
|
3
|
+
* listed below.
|
|
4
|
+
*
|
|
5
|
+
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
|
|
6
|
+
* or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
|
|
7
|
+
*
|
|
8
|
+
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
|
|
9
|
+
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
|
|
10
|
+
* files in this directory. Styles in this file should be added after the last require_* statement.
|
|
11
|
+
* It is generally better to create a new file per style scope.
|
|
12
|
+
*
|
|
13
|
+
*= require_tree .
|
|
14
|
+
*= require_self
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
html {
|
|
18
|
+
background-color: red;
|
|
19
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import React, { useMemo, useState } from "react";
|
|
2
|
+
import QueryBuilder, {
|
|
3
|
+
AttributeInfo,
|
|
4
|
+
Filter,
|
|
5
|
+
options,
|
|
6
|
+
} from "../utilities/QueryBuilder";
|
|
7
|
+
|
|
8
|
+
import FilterRow from "./FilterRow";
|
|
9
|
+
|
|
10
|
+
export type FilterDropdownProps = {
|
|
11
|
+
columns: {
|
|
12
|
+
root: {
|
|
13
|
+
[key: string]: AttributeInfo;
|
|
14
|
+
};
|
|
15
|
+
associations: {
|
|
16
|
+
[key: string]: {
|
|
17
|
+
[key: string]: AttributeInfo;
|
|
18
|
+
}[];
|
|
19
|
+
}[];
|
|
20
|
+
};
|
|
21
|
+
queries: {
|
|
22
|
+
[key: string]: string;
|
|
23
|
+
};
|
|
24
|
+
base: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* This component handles the generation of filters for searching a model.
|
|
29
|
+
*
|
|
30
|
+
* @param props
|
|
31
|
+
* @returns {JSX.Element}
|
|
32
|
+
*/
|
|
33
|
+
export default (props: FilterDropdownProps): JSX.Element => {
|
|
34
|
+
const { columns, queries } = props;
|
|
35
|
+
|
|
36
|
+
const { root: colNames, associations } = columns;
|
|
37
|
+
|
|
38
|
+
const possible_values = Object.keys(colNames).concat(
|
|
39
|
+
associations.map((v) => Object.keys(v)[0]).flat()
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const queryBuilder = useMemo(() => {
|
|
43
|
+
return new QueryBuilder(colNames, associations);
|
|
44
|
+
}, [columns, queries]);
|
|
45
|
+
|
|
46
|
+
const [filters, setFilters] = useState<{
|
|
47
|
+
[key: string]: Filter[];
|
|
48
|
+
}>(queryBuilder.parseQueries(queries));
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The constructed query string to replace the current location when
|
|
52
|
+
* the user "filters". Consists of compounding attribute value with its
|
|
53
|
+
* comparison in the case of a real attribute for the model, or by compounding
|
|
54
|
+
* the association entity's name, with the attribute, and its comparison in
|
|
55
|
+
* the case of an association.
|
|
56
|
+
*/
|
|
57
|
+
const searchValue =
|
|
58
|
+
window.location.pathname +
|
|
59
|
+
"?" +
|
|
60
|
+
Object.entries(filters)
|
|
61
|
+
.map(([k, v]) => {
|
|
62
|
+
return v
|
|
63
|
+
.map((x) => {
|
|
64
|
+
if (x["ass"] && k.indexOf(x["ass"]) !== 0) {
|
|
65
|
+
return (
|
|
66
|
+
`q[${x["ass"] + "_" + k + "_" + x["query"]}]` +
|
|
67
|
+
"=" +
|
|
68
|
+
(x["value"] || "")
|
|
69
|
+
);
|
|
70
|
+
} else {
|
|
71
|
+
return `q[${k + "_" + x["query"]}]` + "=" + (x["value"] || "");
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
.join("&");
|
|
75
|
+
})
|
|
76
|
+
.join("&");
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="card shadow-sm mb-2" style={{ minHeight: "150px" }}>
|
|
80
|
+
<div className="card-body">
|
|
81
|
+
<div className="card-title">
|
|
82
|
+
<h4>Filters</h4>
|
|
83
|
+
</div>
|
|
84
|
+
{Object.keys(filters).map((v) => {
|
|
85
|
+
return filters[v].map((x, i) => (
|
|
86
|
+
<FilterRow
|
|
87
|
+
key={x}
|
|
88
|
+
idx={i}
|
|
89
|
+
attribute={v}
|
|
90
|
+
filters={filters}
|
|
91
|
+
setFilters={setFilters}
|
|
92
|
+
options={options}
|
|
93
|
+
/>
|
|
94
|
+
));
|
|
95
|
+
})}
|
|
96
|
+
</div>
|
|
97
|
+
<div className="card-footer d-flex align-items-center">
|
|
98
|
+
<div className="d-flex">
|
|
99
|
+
<div className="me-1">
|
|
100
|
+
<a role="button" className="btn btn-primary" href={searchValue}>
|
|
101
|
+
Filter
|
|
102
|
+
</a>
|
|
103
|
+
</div>
|
|
104
|
+
<div className="dropdown me-1">
|
|
105
|
+
<button
|
|
106
|
+
className="btn btn-secondary dropdown-toggle"
|
|
107
|
+
type="button"
|
|
108
|
+
id="dropdownMenuButton"
|
|
109
|
+
data-bs-toggle="dropdown"
|
|
110
|
+
aria-haspopup="true"
|
|
111
|
+
aria-expanded="false"
|
|
112
|
+
>
|
|
113
|
+
Add Filter
|
|
114
|
+
</button>
|
|
115
|
+
<ul className="dropdown-menu" aria-labelledby="dropdownMenuButton">
|
|
116
|
+
{Object.keys(colNames).map((v) => (
|
|
117
|
+
<li key={v}>
|
|
118
|
+
<a
|
|
119
|
+
href=""
|
|
120
|
+
className="dropdown-item"
|
|
121
|
+
onClick={(e) => {
|
|
122
|
+
e.preventDefault();
|
|
123
|
+
let copy = { ...filters };
|
|
124
|
+
const t = {};
|
|
125
|
+
t["query"] = "eq";
|
|
126
|
+
t["type"] = colNames[v]?.type || associations[v]?.type;
|
|
127
|
+
t["label"] = colNames[v]?.label || associations[v]?.label;
|
|
128
|
+
|
|
129
|
+
if (copy[v] instanceof Array) {
|
|
130
|
+
copy[v].push(t);
|
|
131
|
+
} else {
|
|
132
|
+
copy[v] = [];
|
|
133
|
+
copy[v].push(t);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
setFilters(copy);
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
{colNames[v]?.label || associations[v]?.label}
|
|
140
|
+
</a>
|
|
141
|
+
</li>
|
|
142
|
+
))}
|
|
143
|
+
{associations?.length > 0 ? (
|
|
144
|
+
<li>
|
|
145
|
+
<hr className="dropdown-divider" />
|
|
146
|
+
</li>
|
|
147
|
+
) : null}
|
|
148
|
+
{associations?.map((x, i) => {
|
|
149
|
+
const association = Object.keys(associations[i])[0];
|
|
150
|
+
const attributes = associations[i][association];
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div className="dropdown dropend">
|
|
154
|
+
<a
|
|
155
|
+
className="dropdown-item dropdown-toggle"
|
|
156
|
+
href="#"
|
|
157
|
+
id="dropdown-layouts"
|
|
158
|
+
data-bs-toggle="dropdown"
|
|
159
|
+
aria-haspopup="true"
|
|
160
|
+
aria-expanded="false"
|
|
161
|
+
>
|
|
162
|
+
{association}
|
|
163
|
+
</a>
|
|
164
|
+
<div
|
|
165
|
+
className="dropdown-menu"
|
|
166
|
+
aria-labelledby="dropdown-layouts"
|
|
167
|
+
>
|
|
168
|
+
{attributes.map((v) => {
|
|
169
|
+
const attr = Object.keys(v)[0];
|
|
170
|
+
const values = Object.values(v)[0];
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<li key={attr}>
|
|
174
|
+
<a
|
|
175
|
+
href=""
|
|
176
|
+
className="dropdown-item"
|
|
177
|
+
onClick={(e) => {
|
|
178
|
+
e.preventDefault();
|
|
179
|
+
let copy = { ...filters };
|
|
180
|
+
|
|
181
|
+
const t = {};
|
|
182
|
+
t["query"] = "eq";
|
|
183
|
+
t["type"] = values.type;
|
|
184
|
+
t["label"] = values.label;
|
|
185
|
+
t["ass"] = values.ass;
|
|
186
|
+
|
|
187
|
+
if (copy[attr] instanceof Array) {
|
|
188
|
+
copy[attr].push(t);
|
|
189
|
+
} else {
|
|
190
|
+
copy[attr] = [];
|
|
191
|
+
copy[attr].push(t);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
setFilters(copy);
|
|
195
|
+
}}
|
|
196
|
+
>
|
|
197
|
+
{values.label}
|
|
198
|
+
</a>
|
|
199
|
+
</li>
|
|
200
|
+
);
|
|
201
|
+
})}
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
})}
|
|
206
|
+
</ul>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
<div>
|
|
210
|
+
<a href={window.location.pathname}>
|
|
211
|
+
<button className="btn btn-danger">Reset</button>
|
|
212
|
+
</a>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/*
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
*/
|
|
223
|
+
|
|
224
|
+
/*
|
|
225
|
+
|
|
226
|
+
*/
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import FormInput from "./FormInput";
|
|
3
|
+
|
|
4
|
+
function getOptions(type) {
|
|
5
|
+
switch (type) {
|
|
6
|
+
case "datetime":
|
|
7
|
+
return [
|
|
8
|
+
["Equals", "eq"],
|
|
9
|
+
["Before", "lt"],
|
|
10
|
+
["After", "gt"],
|
|
11
|
+
["Before/on", "lteq"],
|
|
12
|
+
["After/on", "gteq"],
|
|
13
|
+
];
|
|
14
|
+
case "text":
|
|
15
|
+
return [
|
|
16
|
+
["Contains", "cont"],
|
|
17
|
+
["Equals", "eq"],
|
|
18
|
+
];
|
|
19
|
+
default:
|
|
20
|
+
return [
|
|
21
|
+
["Contains", "cont"],
|
|
22
|
+
["Equals", "eq"],
|
|
23
|
+
["Less than", "lt"],
|
|
24
|
+
["Greater than", "gt"],
|
|
25
|
+
["Less than/equal", "lteq"],
|
|
26
|
+
["Greater than/equal", "gteq"],
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default (props) => {
|
|
32
|
+
const { filters, setFilters, attribute, idx } = props;
|
|
33
|
+
|
|
34
|
+
const { query, value, type, label } = filters[attribute][idx];
|
|
35
|
+
|
|
36
|
+
const options = getOptions(type);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div
|
|
40
|
+
className="d-flex align-items-center filter-row w-100 mb-3"
|
|
41
|
+
style={{ maxWidth: "650px" }}
|
|
42
|
+
key={attribute}
|
|
43
|
+
>
|
|
44
|
+
<a
|
|
45
|
+
className="border rounded-circle me-4"
|
|
46
|
+
style={{
|
|
47
|
+
height: "35px",
|
|
48
|
+
width: "35px",
|
|
49
|
+
display: "flex",
|
|
50
|
+
justifyContent: "center",
|
|
51
|
+
alignItems: "center",
|
|
52
|
+
}}
|
|
53
|
+
href=""
|
|
54
|
+
onClick={(e) => {
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
let copy = { ...filters };
|
|
57
|
+
delete copy[attribute][idx];
|
|
58
|
+
|
|
59
|
+
setFilters(copy);
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
<i className="fas fa-times" />
|
|
63
|
+
</a>
|
|
64
|
+
<div
|
|
65
|
+
className="d-flex justify-content-start align-items-center me-3 mb-0"
|
|
66
|
+
style={{ minWidth: "100px" }}
|
|
67
|
+
>
|
|
68
|
+
<b>{label}</b>
|
|
69
|
+
</div>
|
|
70
|
+
<div className="w-auto me-3 mb-0">
|
|
71
|
+
<select
|
|
72
|
+
defaultValue={filters[attribute][idx]["query"]}
|
|
73
|
+
className="form-select rounded-pill"
|
|
74
|
+
style={{ minWidth: "100px" }}
|
|
75
|
+
onInput={(e) => {
|
|
76
|
+
const copy = { ...filters };
|
|
77
|
+
copy[attribute][idx] = copy[attribute][idx]
|
|
78
|
+
? copy[attribute][idx]
|
|
79
|
+
: {};
|
|
80
|
+
copy[attribute][idx]["query"] = e.target.value;
|
|
81
|
+
setFilters(copy);
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
{options.map(([k, val]) => (
|
|
85
|
+
<option
|
|
86
|
+
key={`${k}${val}`}
|
|
87
|
+
defaultValue={`${filters[attribute][idx]["query"] === val}`}
|
|
88
|
+
value={val}
|
|
89
|
+
>
|
|
90
|
+
{k}
|
|
91
|
+
</option>
|
|
92
|
+
))}
|
|
93
|
+
</select>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<div className="d-flex mb-0" style={{ minWidth: "100px" }}>
|
|
97
|
+
<FormInput
|
|
98
|
+
idx={idx}
|
|
99
|
+
name={attribute}
|
|
100
|
+
query={filters[attribute][idx]["query"]}
|
|
101
|
+
colType={filters[attribute][idx]["type"]}
|
|
102
|
+
className="form-control me-2"
|
|
103
|
+
filters={filters}
|
|
104
|
+
setFilters={setFilters}
|
|
105
|
+
onInput={(e) => {
|
|
106
|
+
let copy = { ...filters };
|
|
107
|
+
copy[attribute][idx]["value"] = e.target.value;
|
|
108
|
+
setFilters(copy);
|
|
109
|
+
}}
|
|
110
|
+
value={filters[attribute][idx]["value"]}
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import DatePicker from "react-datepicker";
|
|
4
|
+
import { convertToDate } from "../utilities/dateConverter";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tries to handle the inputs generically.
|
|
8
|
+
* Unfortunately, lot of timezone stuff because we need to shift to and from the local time zone.
|
|
9
|
+
* @param props
|
|
10
|
+
* @returns {JSX.Element}
|
|
11
|
+
*/
|
|
12
|
+
export default (props) => {
|
|
13
|
+
const {
|
|
14
|
+
className,
|
|
15
|
+
style,
|
|
16
|
+
colType,
|
|
17
|
+
value,
|
|
18
|
+
onInput,
|
|
19
|
+
filters,
|
|
20
|
+
setFilters,
|
|
21
|
+
name,
|
|
22
|
+
query,
|
|
23
|
+
idx,
|
|
24
|
+
} = props;
|
|
25
|
+
|
|
26
|
+
const type = useMemo(() => {
|
|
27
|
+
if (colType === "datetime") return "datetime-local";
|
|
28
|
+
if (colType === "integer") return "number";
|
|
29
|
+
if (colType === "text") return "text";
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
const ref = useRef();
|
|
33
|
+
|
|
34
|
+
const [date, setDate] = useState(
|
|
35
|
+
(() => {
|
|
36
|
+
const d = new Date();
|
|
37
|
+
d.setHours(d.getHours() - d.getTimezoneOffset() / 60);
|
|
38
|
+
return d;
|
|
39
|
+
})()
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (colType === "datetime" && value) {
|
|
44
|
+
let copy = { ...filters };
|
|
45
|
+
const d = new Date(value);
|
|
46
|
+
d.setHours(d.getHours() + d.getTimezoneOffset() / 60);
|
|
47
|
+
copy[name][idx]["value"] = convertToDate(d);
|
|
48
|
+
setFilters(copy);
|
|
49
|
+
const adjusted_time = new Date(value);
|
|
50
|
+
adjusted_time.setHours(
|
|
51
|
+
adjusted_time.getHours() + adjusted_time.getTimezoneOffset() / 60
|
|
52
|
+
);
|
|
53
|
+
setDate(adjusted_time);
|
|
54
|
+
} else if (colType === "datetime") {
|
|
55
|
+
let copy = { ...filters };
|
|
56
|
+
copy[name][idx]["value"] = convertToDate(date);
|
|
57
|
+
setFilters(copy);
|
|
58
|
+
const adjusted_time = new Date(date);
|
|
59
|
+
adjusted_time.setHours(
|
|
60
|
+
adjusted_time.getHours() + adjusted_time.getTimezoneOffset() / 60
|
|
61
|
+
);
|
|
62
|
+
setDate(adjusted_time);
|
|
63
|
+
}
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
switch (type) {
|
|
67
|
+
case "datetime-local":
|
|
68
|
+
return (
|
|
69
|
+
<DatePicker
|
|
70
|
+
ref={ref}
|
|
71
|
+
className={`${className} filter-input`}
|
|
72
|
+
selected={date}
|
|
73
|
+
onChange={(e: Date) => {
|
|
74
|
+
let copy = { ...filters };
|
|
75
|
+
copy[name][idx]["value"] = convertToDate(e);
|
|
76
|
+
setFilters(copy);
|
|
77
|
+
setDate(e);
|
|
78
|
+
}}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
break;
|
|
82
|
+
|
|
83
|
+
default:
|
|
84
|
+
return (
|
|
85
|
+
<input
|
|
86
|
+
value={value}
|
|
87
|
+
onInput={(e) => {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
onInput(e);
|
|
90
|
+
}}
|
|
91
|
+
style={style}
|
|
92
|
+
className={className}
|
|
93
|
+
type={type}
|
|
94
|
+
/>
|
|
95
|
+
);
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import FilterDropdown, {
|
|
3
|
+
FilterDropdownProps,
|
|
4
|
+
} from "./components/FilterDropdown";
|
|
5
|
+
import ReactDOM from "react-dom";
|
|
6
|
+
import registerNestedDropdown from "./utilities/registerNestedDropdown";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Class to handle wrapping of the
|
|
10
|
+
*
|
|
11
|
+
* TODO: remove dependency on React and Bootstrap
|
|
12
|
+
* NOTE: possibly remove dependency on React by just wrapping around a form
|
|
13
|
+
* with a Stimulus controller. Refactor to expose ERB partials.
|
|
14
|
+
*/
|
|
15
|
+
class RansackSearch extends HTMLDivElement {
|
|
16
|
+
constructor() {
|
|
17
|
+
super();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
connectedCallback() {
|
|
21
|
+
const data: FilterDropdownProps = {
|
|
22
|
+
columns: {
|
|
23
|
+
root: {},
|
|
24
|
+
associations: [],
|
|
25
|
+
},
|
|
26
|
+
queries: [],
|
|
27
|
+
...(JSON.parse(this.getAttribute("data")) || {}),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
ReactDOM.render(
|
|
31
|
+
[
|
|
32
|
+
React.createElement(FilterDropdown, {
|
|
33
|
+
...data,
|
|
34
|
+
}),
|
|
35
|
+
],
|
|
36
|
+
this,
|
|
37
|
+
() => {
|
|
38
|
+
registerNestedDropdown(this);
|
|
39
|
+
}
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
customElements.define("ransack-search", RansackSearch, { extends: "div" });
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
export type Filter = {
|
|
2
|
+
[key: string]: {
|
|
3
|
+
label: string;
|
|
4
|
+
query: string;
|
|
5
|
+
type: string;
|
|
6
|
+
value: string | number;
|
|
7
|
+
ass?: string;
|
|
8
|
+
};
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type AttributeInfo = {
|
|
12
|
+
type: string;
|
|
13
|
+
label: string;
|
|
14
|
+
ass?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const options = [
|
|
18
|
+
["Contains", "cont"],
|
|
19
|
+
["Equals", "eq"],
|
|
20
|
+
["Less than", "lt"],
|
|
21
|
+
["Greater than", "gt"],
|
|
22
|
+
["Less than/equal", "lteq"],
|
|
23
|
+
["Greater than/equal", "gteq"],
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const options_values = options.map((v) => v[1]);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Recursively iterate through relations with matching prefixes.
|
|
30
|
+
* Attempts to get the accompanying AttributeInfo given a full query.
|
|
31
|
+
* @param full_query
|
|
32
|
+
* @param ass
|
|
33
|
+
*/
|
|
34
|
+
const get_association = (
|
|
35
|
+
full_query,
|
|
36
|
+
ass
|
|
37
|
+
): {
|
|
38
|
+
[association: string]: AttributeInfo;
|
|
39
|
+
} => {
|
|
40
|
+
if (ass instanceof Array) {
|
|
41
|
+
return ass.find((dict) => {
|
|
42
|
+
return full_query.includes(Object.keys(dict)[0]);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const key = Object.keys(ass).find((k) => full_query.includes(k));
|
|
47
|
+
|
|
48
|
+
if (typeof key === "undefined") return null;
|
|
49
|
+
|
|
50
|
+
const remainder = full_query.slice(full_query.indexOf(key) + key.length + 1);
|
|
51
|
+
|
|
52
|
+
return get_association(remainder, ass[key]);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export default class QueryBuilder {
|
|
56
|
+
attributes = {};
|
|
57
|
+
associations;
|
|
58
|
+
possible_values = [];
|
|
59
|
+
|
|
60
|
+
constructor(attributes, associations) {
|
|
61
|
+
this.attributes = attributes;
|
|
62
|
+
this.associations = associations;
|
|
63
|
+
|
|
64
|
+
this.possible_values = Object.keys(attributes).concat(
|
|
65
|
+
associations.map((v) => Object.keys(v)[0]).flat()
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
parseQueries(queries) {
|
|
70
|
+
let s = queries || {};
|
|
71
|
+
|
|
72
|
+
let f = {};
|
|
73
|
+
|
|
74
|
+
if (Object.entries(s)?.length > 0) {
|
|
75
|
+
for (const [k, v] of Object.entries(s)) {
|
|
76
|
+
// sorting parameter
|
|
77
|
+
if (k === "s") continue;
|
|
78
|
+
|
|
79
|
+
const query = options_values
|
|
80
|
+
.filter((v) => k.includes(v))
|
|
81
|
+
.reduce((x, y) => (x.length > y.length ? x : y), "");
|
|
82
|
+
|
|
83
|
+
const attribute = this.possible_values
|
|
84
|
+
.filter((v) => k.includes(v))
|
|
85
|
+
.reduce((x, y) => (x.length > y.length ? x : y), "");
|
|
86
|
+
|
|
87
|
+
const asses = get_association(
|
|
88
|
+
k,
|
|
89
|
+
this.associations.reduce((acc, cur) => ({ ...acc, ...cur }), {})
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
if (asses) {
|
|
93
|
+
const full_ass_key = Object.keys(asses)[0];
|
|
94
|
+
const full_ass_values = Object.values(asses)[0];
|
|
95
|
+
|
|
96
|
+
const t = {};
|
|
97
|
+
t["query"] = query;
|
|
98
|
+
t["value"] = v;
|
|
99
|
+
t["type"] = full_ass_values?.type;
|
|
100
|
+
t["label"] = full_ass_values?.label;
|
|
101
|
+
t["ass"] = attribute;
|
|
102
|
+
|
|
103
|
+
if (query.length > 0 && attribute.length > 0) {
|
|
104
|
+
if (f[`${attribute}_${full_ass_key}`] instanceof Array) {
|
|
105
|
+
f[`${attribute}_${full_ass_key}`].push(t);
|
|
106
|
+
} else {
|
|
107
|
+
f[`${attribute}_${full_ass_key}`] = [];
|
|
108
|
+
f[`${attribute}_${full_ass_key}`].push(t);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
const t = {};
|
|
113
|
+
t["query"] = query;
|
|
114
|
+
t["value"] = v;
|
|
115
|
+
t["type"] = this.attributes[attribute]?.type;
|
|
116
|
+
t["label"] = this.attributes[attribute]?.label;
|
|
117
|
+
|
|
118
|
+
if (query.length > 0 && attribute.length > 0) {
|
|
119
|
+
if (f[`${attribute}`] instanceof Array) {
|
|
120
|
+
f[`${attribute}`].push(t);
|
|
121
|
+
} else {
|
|
122
|
+
f[`${attribute}`] = [];
|
|
123
|
+
f[`${attribute}`].push(t);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
return {};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return f;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Dropdown } from "bootstrap";
|
|
2
|
+
|
|
3
|
+
export default function (root: Document | HTMLElement) {
|
|
4
|
+
const CLASS_NAME = "has-child-dropdown-show";
|
|
5
|
+
Dropdown.prototype.toggle = (function (_orginal) {
|
|
6
|
+
return function () {
|
|
7
|
+
root.querySelectorAll("." + CLASS_NAME).forEach(function (e) {
|
|
8
|
+
e.classList.remove(CLASS_NAME);
|
|
9
|
+
});
|
|
10
|
+
let dd = this._element
|
|
11
|
+
.closest(".dropdown")
|
|
12
|
+
.parentNode.closest(".dropdown");
|
|
13
|
+
for (; dd && dd !== root; dd = dd.parentNode.closest(".dropdown")) {
|
|
14
|
+
dd.classList.add(CLASS_NAME);
|
|
15
|
+
}
|
|
16
|
+
return _orginal.call(this);
|
|
17
|
+
};
|
|
18
|
+
})(Dropdown.prototype.toggle);
|
|
19
|
+
|
|
20
|
+
root.querySelectorAll(".dropdown").forEach(function (dd) {
|
|
21
|
+
dd.addEventListener("hide.bs.dropdown", function (e) {
|
|
22
|
+
if (this.classList.contains(CLASS_NAME)) {
|
|
23
|
+
this.classList.remove(CLASS_NAME);
|
|
24
|
+
e.preventDefault();
|
|
25
|
+
}
|
|
26
|
+
if (
|
|
27
|
+
e.clickEvent &&
|
|
28
|
+
e.clickEvent
|
|
29
|
+
.composedPath()
|
|
30
|
+
.some(
|
|
31
|
+
(el) => el.classList && el.classList.contains("dropdown-toggle")
|
|
32
|
+
)
|
|
33
|
+
) {
|
|
34
|
+
e.preventDefault();
|
|
35
|
+
}
|
|
36
|
+
e.stopPropagation(); // do not need pop in multi level mode
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// for hover
|
|
41
|
+
function getDropdown(element) {
|
|
42
|
+
return Dropdown.getInstance(element) || new Dropdown(element);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
root
|
|
46
|
+
.querySelectorAll(".dropdown-hover, .dropdown-hover-all .dropdown")
|
|
47
|
+
.forEach(function (dd) {
|
|
48
|
+
dd.addEventListener("mouseenter", function (e) {
|
|
49
|
+
let toggle = e.target.querySelector(
|
|
50
|
+
':scope>[data-bs-toggle="dropdown"]'
|
|
51
|
+
);
|
|
52
|
+
if (!toggle.classList.contains("show")) {
|
|
53
|
+
getDropdown(toggle).toggle();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
dd.addEventListener("mouseleave", function (e) {
|
|
57
|
+
let toggle = e.target.querySelector(
|
|
58
|
+
':scope>[data-bs-toggle="dropdown"]'
|
|
59
|
+
);
|
|
60
|
+
if (toggle.classList.contains("show")) {
|
|
61
|
+
getDropdown(toggle).toggle();
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Ransack search element</title>
|
|
5
|
+
<%= csrf_meta_tags %>
|
|
6
|
+
<%= csp_meta_tag %>
|
|
7
|
+
|
|
8
|
+
<%= stylesheet_link_tag "ransack_search_element/application", media: "all" %>
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
|
|
12
|
+
<%= yield %>
|
|
13
|
+
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module RansackSearchElement
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
isolate_namespace RansackSearchElement
|
|
4
|
+
|
|
5
|
+
config.autoload_once_paths = %W(
|
|
6
|
+
#{root}/app/channels
|
|
7
|
+
#{root}/app/controllers
|
|
8
|
+
#{root}/app/controllers/concerns
|
|
9
|
+
#{root}/app/helpers
|
|
10
|
+
#{root}/app/models
|
|
11
|
+
#{root}/app/models/concerns
|
|
12
|
+
#{root}/app/jobs
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
initializer "ransack_search_element.helpers", before: :load_config_initializers do
|
|
16
|
+
ActiveSupport.on_load(:action_controller_base) do
|
|
17
|
+
helper RansackSearchElement::Engine.helpers
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ransack_search_element
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.2.pre.alpha
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Arthur Dzieniszewski
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2021-09-05 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: rails
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: 6.1.4
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: 6.1.4
|
|
27
|
+
description: Generic, advanced search element for Ransack.
|
|
28
|
+
email:
|
|
29
|
+
- arthurdzieniszewski@gmail.com
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- MIT-LICENSE
|
|
35
|
+
- README.md
|
|
36
|
+
- Rakefile
|
|
37
|
+
- app/assets/config/ransack_search_element_manifest.js
|
|
38
|
+
- app/assets/stylesheets/ransack_search_element/application.scss
|
|
39
|
+
- app/controllers/ransack_search_element/application_controller.rb
|
|
40
|
+
- app/helpers/ransack_search_element/application_helper.rb
|
|
41
|
+
- app/javascript/ransack-search-element/components/FilterDropdown.tsx
|
|
42
|
+
- app/javascript/ransack-search-element/components/FilterRow.tsx
|
|
43
|
+
- app/javascript/ransack-search-element/components/FormInput.tsx
|
|
44
|
+
- app/javascript/ransack-search-element/index.ts
|
|
45
|
+
- app/javascript/ransack-search-element/utilities/QueryBuilder.ts
|
|
46
|
+
- app/javascript/ransack-search-element/utilities/dateConverter.ts
|
|
47
|
+
- app/javascript/ransack-search-element/utilities/registerNestedDropdown.ts
|
|
48
|
+
- app/jobs/ransack_search_element/application_job.rb
|
|
49
|
+
- app/mailers/ransack_search_element/application_mailer.rb
|
|
50
|
+
- app/models/ransack_search_element/application_record.rb
|
|
51
|
+
- app/views/layouts/ransack_search_element/application.html.erb
|
|
52
|
+
- config/routes.rb
|
|
53
|
+
- lib/ransack_search_element.rb
|
|
54
|
+
- lib/ransack_search_element/engine.rb
|
|
55
|
+
- lib/ransack_search_element/version.rb
|
|
56
|
+
- lib/tasks/ransack_search_element_tasks.rake
|
|
57
|
+
homepage: https://github.com/adzienis/ransack-search-element
|
|
58
|
+
licenses:
|
|
59
|
+
- MIT
|
|
60
|
+
metadata:
|
|
61
|
+
allowed_push_host: https://rubygems.org/
|
|
62
|
+
homepage_uri: https://github.com/adzienis/ransack-search-element
|
|
63
|
+
source_code_uri: https://github.com/adzienis/ransack-search-element
|
|
64
|
+
changelog_uri: https://github.com/adzienis/ransack-search-element/CHANGELOG.md
|
|
65
|
+
post_install_message:
|
|
66
|
+
rdoc_options: []
|
|
67
|
+
require_paths:
|
|
68
|
+
- lib
|
|
69
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - ">="
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '0'
|
|
74
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
75
|
+
requirements:
|
|
76
|
+
- - ">"
|
|
77
|
+
- !ruby/object:Gem::Version
|
|
78
|
+
version: 1.3.1
|
|
79
|
+
requirements: []
|
|
80
|
+
rubygems_version: 3.2.15
|
|
81
|
+
signing_key:
|
|
82
|
+
specification_version: 4
|
|
83
|
+
summary: Generic, advanced search element for Ransack.
|
|
84
|
+
test_files: []
|