headmin 0.4.0 → 0.5.0

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.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/.lock-487e157d270f3062a98b7b2a012753708-1272821827 +0 -0
  3. data/CHANGELOG.md +16 -2
  4. data/Gemfile.lock +77 -79
  5. data/app/assets/javascripts/headmin/controllers/autocomplete_controller.js +84 -21
  6. data/app/assets/javascripts/headmin/controllers/date_range_controller.js +12 -6
  7. data/app/assets/javascripts/headmin/controllers/filter_controller.js +61 -11
  8. data/app/assets/javascripts/headmin/controllers/filter_row_controller.js +50 -0
  9. data/app/assets/javascripts/headmin/controllers/flatpickr_controller.js +2 -6
  10. data/app/assets/javascripts/headmin/controllers/popup_controller.js +14 -5
  11. data/app/assets/javascripts/headmin/controllers/table_actions_controller.js +16 -21
  12. data/app/assets/javascripts/headmin/index.js +2 -0
  13. data/app/assets/javascripts/headmin.js +186 -56
  14. data/app/assets/stylesheets/headmin/filter.scss +74 -0
  15. data/app/assets/stylesheets/headmin/general.scss +8 -0
  16. data/app/assets/stylesheets/headmin/layout/body.scss +5 -0
  17. data/app/assets/stylesheets/headmin/popup.scss +0 -1
  18. data/app/assets/stylesheets/headmin.css +70 -1
  19. data/app/controllers/concerns/headmin/filterable.rb +27 -0
  20. data/app/models/concerns/headmin/field.rb +4 -2
  21. data/app/models/concerns/headmin/fieldable.rb +138 -44
  22. data/app/models/headmin/filter/base.rb +238 -0
  23. data/app/models/headmin/filter/base_view.rb +64 -0
  24. data/app/models/headmin/filter/boolean.rb +15 -0
  25. data/app/models/headmin/filter/boolean_view.rb +61 -0
  26. data/app/models/headmin/filter/button_view.rb +25 -0
  27. data/app/models/headmin/filter/conditional_view.rb +16 -0
  28. data/app/models/headmin/filter/date.rb +19 -0
  29. data/app/models/headmin/filter/date_view.rb +52 -0
  30. data/app/models/headmin/filter/flatpickr_view.rb +54 -0
  31. data/app/models/headmin/filter/menu_item_view.rb +6 -0
  32. data/app/models/headmin/filter/money.rb +13 -0
  33. data/app/models/headmin/filter/number.rb +27 -0
  34. data/app/models/headmin/filter/number_view.rb +54 -0
  35. data/app/models/headmin/filter/operator_view.rb +30 -0
  36. data/app/models/headmin/filter/options_view.rb +61 -0
  37. data/app/models/headmin/filter/row_view.rb +13 -0
  38. data/app/models/headmin/filter/search.rb +18 -0
  39. data/app/models/headmin/filter/search_view.rb +31 -0
  40. data/app/models/headmin/filter/text.rb +25 -0
  41. data/app/models/headmin/filter/text_view.rb +53 -0
  42. data/app/models/headmin/filters.rb +29 -0
  43. data/app/models/headmin/form/blocks_view.rb +1 -1
  44. data/app/models/headmin/form/checkbox_view.rb +3 -3
  45. data/app/models/headmin/form/date_range_view.rb +2 -2
  46. data/app/models/headmin/form/date_view.rb +5 -5
  47. data/app/models/headmin/form/datetime_range_view.rb +25 -0
  48. data/app/models/headmin/form/datetime_view.rb +45 -0
  49. data/app/models/headmin/form/email_view.rb +7 -7
  50. data/app/models/headmin/form/file_view.rb +6 -6
  51. data/app/models/headmin/form/flatpickr_range_view.rb +11 -22
  52. data/app/models/headmin/form/flatpickr_view.rb +4 -13
  53. data/app/models/headmin/form/input_group_view.rb +1 -1
  54. data/app/models/headmin/form/label_view.rb +1 -1
  55. data/app/models/headmin/form/number_view.rb +5 -5
  56. data/app/models/headmin/form/password_view.rb +5 -5
  57. data/app/models/headmin/form/redactorx_view.rb +2 -2
  58. data/app/models/headmin/form/search_view.rb +7 -7
  59. data/app/models/headmin/form/select_view.rb +6 -6
  60. data/app/models/headmin/form/switch_view.rb +1 -1
  61. data/app/models/headmin/form/text_view.rb +7 -7
  62. data/app/models/headmin/form/textarea_view.rb +5 -5
  63. data/app/models/headmin/form/url_view.rb +7 -7
  64. data/app/models/headmin/form/wrapper_view.rb +1 -1
  65. data/app/models/headmin/form/wysiwyg_view.rb +1 -1
  66. data/app/models/view_model.rb +1 -1
  67. data/app/views/examples/admin.html.erb +13 -13
  68. data/app/views/examples/auth.html.erb +1 -1
  69. data/app/views/headmin/_filters.html.erb +6 -6
  70. data/app/views/headmin/_form.html.erb +2 -2
  71. data/app/views/headmin/_index.html.erb +1 -1
  72. data/app/views/headmin/_pagination.html.erb +1 -1
  73. data/app/views/headmin/_popup.html.erb +2 -2
  74. data/app/views/headmin/_table.html.erb +1 -1
  75. data/app/views/headmin/dropdown/_devise.html.erb +8 -8
  76. data/app/views/headmin/dropdown/_locale.html.erb +4 -4
  77. data/app/views/headmin/filters/_base.html.erb +95 -0
  78. data/app/views/headmin/filters/_boolean.html.erb +23 -0
  79. data/app/views/headmin/filters/_date.html.erb +14 -38
  80. data/app/views/headmin/filters/_flatpickr.html.erb +15 -48
  81. data/app/views/headmin/filters/_number.html.erb +23 -0
  82. data/app/views/headmin/filters/_options.html.erb +24 -0
  83. data/app/views/headmin/filters/_search.html.erb +14 -12
  84. data/app/views/headmin/filters/_text.html.erb +23 -0
  85. data/app/views/headmin/filters/filter/_button.html.erb +9 -10
  86. data/app/views/headmin/filters/filter/_conditional.html.erb +18 -0
  87. data/app/views/headmin/filters/filter/_menu_item.html.erb +5 -2
  88. data/app/views/headmin/filters/filter/_null_select.html.erb +8 -0
  89. data/app/views/headmin/filters/filter/_operator.html.erb +16 -0
  90. data/app/views/headmin/filters/filter/_row.html.erb +11 -0
  91. data/app/views/headmin/forms/_blocks.html.erb +1 -1
  92. data/app/views/headmin/forms/_date_range.html.erb +3 -3
  93. data/app/views/headmin/forms/_datetime.html.erb +41 -0
  94. data/app/views/headmin/forms/_datetime_range.html.erb +40 -0
  95. data/app/views/headmin/forms/_file.html.erb +3 -3
  96. data/app/views/headmin/forms/_flatpickr.html.erb +1 -1
  97. data/app/views/headmin/forms/_flatpickr_range.html.erb +3 -4
  98. data/app/views/headmin/forms/_label.html.erb +1 -1
  99. data/app/views/headmin/forms/_repeater.html.erb +12 -12
  100. data/app/views/headmin/forms/fields/_base.html.erb +1 -1
  101. data/app/views/headmin/forms/fields/_file.html.erb +3 -3
  102. data/app/views/headmin/forms/fields/_files.html.erb +17 -0
  103. data/app/views/headmin/forms/fields/_group.html.erb +10 -5
  104. data/app/views/headmin/forms/fields/_list.html.erb +4 -4
  105. data/app/views/headmin/forms/fields/_text.html.erb +2 -2
  106. data/app/views/headmin/layout/_footer.html.erb +1 -1
  107. data/app/views/headmin/layout/_main.html.erb +1 -1
  108. data/app/views/headmin/nav/_dropdown.html.erb +3 -3
  109. data/app/views/headmin/nav/_item.html.erb +2 -2
  110. data/app/views/headmin/nav/item/_devise.html.erb +8 -8
  111. data/app/views/headmin/nav/item/_locale.html.erb +4 -4
  112. data/app/views/headmin/table/_actions.html.erb +3 -6
  113. data/app/views/headmin/table/actions/_export.html.erb +1 -1
  114. data/app/views/headmin/table/body/_row.html.erb +3 -3
  115. data/app/views/headmin/table/foot/_id.html.erb +1 -1
  116. data/app/views/headmin/views/devise/confirmations/_new.html.erb +1 -1
  117. data/app/views/headmin/views/devise/passwords/_edit.html.erb +2 -2
  118. data/app/views/headmin/views/devise/passwords/_new.html.erb +1 -1
  119. data/app/views/headmin/views/devise/registrations/_edit.html.erb +4 -4
  120. data/app/views/headmin/views/devise/registrations/_new.html.erb +3 -3
  121. data/app/views/headmin/views/devise/sessions/_new.html.erb +3 -3
  122. data/app/views/headmin/views/devise/unlocks/_new.html.erb +1 -1
  123. data/config/locales/en.yml +4 -0
  124. data/config/locales/headmin/dropdown/en.yml +6 -0
  125. data/config/locales/headmin/dropdown/nl.yml +6 -0
  126. data/config/locales/headmin/filters/en.yml +26 -1
  127. data/config/locales/headmin/filters/nl.yml +26 -1
  128. data/config/locales/headmin/forms/en.yml +1 -1
  129. data/config/locales/headmin/forms/nl.yml +1 -1
  130. data/config/locales/headmin/layout/en.yml +0 -9
  131. data/config/locales/headmin/layout/nl.yml +0 -9
  132. data/config/locales/headmin/nav/en.yml +7 -0
  133. data/config/locales/headmin/nav/nl.yml +7 -0
  134. data/config/locales/nl.yml +4 -0
  135. data/lib/generators/templates/views/layouts/auth.html.erb +1 -1
  136. data/lib/headmin/version.rb +1 -1
  137. data/package.json +1 -1
  138. metadata +44 -7
  139. data/app/controllers/concerns/headmin/filter.rb +0 -5
  140. data/app/controllers/concerns/headmin/searchable.rb +0 -15
  141. data/app/views/headmin/filters/_select.html.erb +0 -45
  142. data/app/views/headmin/filters/filter/_template.html.erb +0 -13
  143. data/app/views/headmin/forms/fields/_image.html.erb +0 -17
@@ -13104,6 +13104,9 @@ input[type=search] {
13104
13104
  html {
13105
13105
  height: 100%;
13106
13106
  }
13107
+ .list-group-item.active .text-muted {
13108
+ color: #fff !important;
13109
+ }
13107
13110
  mark,
13108
13111
  .mark {
13109
13112
  padding: 0;
@@ -13113,6 +13116,11 @@ mark,
13113
13116
  background: rgba(0, 0, 0, 0.03);
13114
13117
  height: 100vh;
13115
13118
  overflow-y: scroll;
13119
+ display: flex;
13120
+ flex-direction: column;
13121
+ }
13122
+ .content {
13123
+ flex-grow: 1;
13116
13124
  }
13117
13125
  .sidebar {
13118
13126
  font-size: 0.99rem;
@@ -13306,6 +13314,54 @@ body.empty {
13306
13314
  .h-filter-popup.closed {
13307
13315
  display: none;
13308
13316
  }
13317
+ .h-filter-row {
13318
+ padding: 0.5rem 0 0.5rem 0;
13319
+ position: relative;
13320
+ flex-wrap: unset;
13321
+ display: flex;
13322
+ gap: 10px;
13323
+ }
13324
+ .h-filter-row:first-child {
13325
+ padding-top: 0;
13326
+ }
13327
+ .h-filter-row:hover .h-filter-add-input,
13328
+ .h-filter-row:hover .h-filter-remove-input {
13329
+ display: block;
13330
+ }
13331
+ .h-filter-row input,
13332
+ .h-filter-row select:not(.h-filter-operator) {
13333
+ min-width: 200px;
13334
+ }
13335
+ .h-filter-operator {
13336
+ width: 62px;
13337
+ }
13338
+ .h-filter-conditional {
13339
+ position: relative;
13340
+ display: flex;
13341
+ justify-content: center;
13342
+ align-items: center;
13343
+ }
13344
+ .h-filter-conditional select {
13345
+ width: 50px;
13346
+ }
13347
+ .h-filter-conditional::before {
13348
+ content: "";
13349
+ position: absolute;
13350
+ top: calc(50% - 1px);
13351
+ left: 0;
13352
+ width: calc(50% - 35px);
13353
+ height: 1px;
13354
+ background: rgba(0, 0, 0, 0.2);
13355
+ }
13356
+ .h-filter-conditional::after {
13357
+ content: "";
13358
+ position: absolute;
13359
+ top: calc(50% - 1px);
13360
+ left: calc(50% + 35px);
13361
+ width: calc(50% - 35px);
13362
+ height: 1px;
13363
+ background: rgba(0, 0, 0, 0.2);
13364
+ }
13309
13365
  .h-filter-remove {
13310
13366
  border-left: 1px solid #d1d5db;
13311
13367
  padding-left: 8px;
@@ -13314,11 +13370,24 @@ body.empty {
13314
13370
  .h-filter-remove i::before {
13315
13371
  font-size: 0.8em;
13316
13372
  }
13373
+ .h-filter-add-input {
13374
+ position: absolute !important;
13375
+ top: calc(100% - 12px);
13376
+ left: calc(50% - 17px);
13377
+ z-index: 9;
13378
+ display: none;
13379
+ }
13380
+ .h-filter-remove-input {
13381
+ position: absolute !important;
13382
+ top: calc(50% - 15px);
13383
+ left: calc(100% - 4px);
13384
+ z-index: 9;
13385
+ display: none;
13386
+ }
13317
13387
  .h-popup {
13318
13388
  padding: 10px;
13319
13389
  background: #ffffff;
13320
13390
  z-index: 1070;
13321
- width: 276px;
13322
13391
  font-family: var(--bs-font-sans-serif);
13323
13392
  font-style: normal;
13324
13393
  font-weight: 400;
@@ -0,0 +1,27 @@
1
+ module Headmin
2
+ module Filterable
3
+ # Will create a Headmin::Filters object with a default configuration for "id" and "search"
4
+ #
5
+ # Example:
6
+ #
7
+ # orders = Order
8
+ # orders = filter(orders, {
9
+ # status: :text,
10
+ # price: :number,
11
+ # in_stock: :boolean
12
+ # })
13
+ # @orders = orders.all
14
+
15
+ def filter(collection, filter_types = {})
16
+ type_hash = default_filter_types.merge(filter_types)
17
+ Headmin::Filters.new(params, type_hash).query(collection)
18
+ end
19
+
20
+ def default_filter_types
21
+ {
22
+ id: :number,
23
+ search: :search
24
+ }
25
+ end
26
+ end
27
+ end
@@ -11,8 +11,10 @@ module Headmin
11
11
  belongs_to :field, optional: true, touch: true
12
12
  has_many :fields, foreign_key: "parent_id"
13
13
  accepts_nested_attributes_for :fields, allow_destroy: true
14
- has_one_attached :file
15
- accepts_nested_attributes_for :file_attachment
14
+
15
+ # field_type: :files, :file
16
+ has_many_attached :files
17
+ accepts_nested_attributes_for :files_attachments, allow_destroy: true
16
18
  end
17
19
  end
18
20
  end
@@ -6,68 +6,162 @@ module Headmin
6
6
  has_many :fields, as: :fieldable, dependent: :destroy
7
7
  accepts_nested_attributes_for :fields, allow_destroy: true
8
8
 
9
+ # Callbacks
10
+ before_validation :build_fields
11
+
9
12
  def fields_hash
10
- fields.order(position: :asc).map do |field|
11
- parse_hash_tree(field.hash_tree)
12
- end.reduce({}, :merge)
13
+ @fields_hash ||= parse_fields
13
14
  end
14
15
 
15
16
  def fields_hash=(hash)
16
- parse_fields_hash(hash)
17
+ @fields_hash = hash
17
18
  end
18
19
 
19
20
  private
20
21
 
21
- def parse_fields_hash(hash)
22
+ def parse_fields
23
+ fields.where(parent: nil).order(position: :asc).map { |field| parse_hash_tree(field.hash_tree) }.reduce({}, :merge)
24
+ end
25
+
26
+ def build_fields
27
+ return unless @fields_hash
28
+ self.fields = fields_for(@fields_hash)
29
+ end
30
+
31
+ def fields_for(hash)
22
32
  hash.map do |key, value|
23
33
  case value
24
34
  when Hash
25
- fields.build(
26
- name: key,
27
- field_type: "group",
28
- fields: parse_fields_hash(value)
29
- )
35
+ field_for_hash(key, value)
30
36
  when Array
31
- fields.build(
32
- name: key,
33
- field_type: "list",
34
- fields: value.map do |item|
35
- fields.new(name: "item", field_type: "group", fields: parse_fields_hash(item))
36
- end
37
- )
38
- when String
39
- fields.build(
40
- name: key,
41
- field_type: "text",
42
- value: value
43
- )
37
+ field_for_array(key, value)
38
+ when File
39
+ field_for_file(key, value)
44
40
  else
45
- fields.build(
46
- name: key,
47
- field_type: "file",
48
- file: value
49
- )
41
+ field_for_string(key, value)
50
42
  end
51
43
  end
52
44
  end
53
45
 
46
+ def field_for_hash(name, hash)
47
+ ::Field.new(
48
+ fieldable: self,
49
+ name: name,
50
+ field_type: "group",
51
+ fields: fields_for(hash)
52
+ )
53
+ end
54
+
55
+ def field_for_array(name, array)
56
+ if array.all? { |item| item.is_a?(File) }
57
+ field_for_files(name, array)
58
+ elsif array.all? { |item| item.is_a?(Hash) }
59
+ field_for_group_list(name, array)
60
+ else
61
+ field_for_simple_list(name, array)
62
+ end
63
+ end
64
+
65
+ def field_for_simple_list(name, array)
66
+ ::Field.new(
67
+ fieldable: self,
68
+ name: name,
69
+ field_type: "list",
70
+ fields: array.map { |item| fields_for({item: item}) }.flatten
71
+ )
72
+ end
73
+
74
+ def field_for_group_list(name, array)
75
+ ::Field.new(
76
+ fieldable: self,
77
+ name: name,
78
+ field_type: "list",
79
+ fields: array.map { |item| ::Field.new(fieldable: self, name: "item", field_type: "group", fields: fields_for(item)) }
80
+ )
81
+ end
82
+
83
+ def field_for_string(name, string)
84
+ ::Field.new(
85
+ fieldable: self,
86
+ name: name,
87
+ field_type: "text",
88
+ value: string
89
+ )
90
+ end
91
+
92
+ def field_for_file(name, file)
93
+ ::Field.new(
94
+ fieldable: self,
95
+ name: name,
96
+ field_type: "file",
97
+ value: nil,
98
+ files: [ActiveStorage::Blob.create_and_upload!(
99
+ io: file,
100
+ filename: File.basename(file.path)
101
+ )]
102
+ )
103
+ end
104
+
105
+ def field_for_files(name, files)
106
+ ::Field.new(
107
+ fieldable: self,
108
+ name: name,
109
+ field_type: "files",
110
+ value: nil,
111
+ files: files.map { |file|
112
+ ActiveStorage::Blob.create_and_upload!(
113
+ io: file,
114
+ filename: File.basename(file.path)
115
+ )
116
+ }
117
+ )
118
+ end
119
+
120
+ # From hash tree to hash
54
121
  def parse_hash_tree(hash_tree)
55
- hash_tree.map do |field, children|
56
- case field.field_type.to_sym
57
- when :group
58
- children = children.any? ? children : field.hash_tree
59
- [field.name, parse_hash_tree(children)]
60
- when :list
61
- children = children.any? ? children : field.hash_tree
62
- [field.name, children.map { |child, grand_children| parse_hash_tree(grand_children) }]
63
- when :image
64
- [field.name, field.file]
65
- when :file
66
- [field.name, field.file]
67
- else
68
- [field.name, field.value]
69
- end
70
- end.to_h
122
+ new_hash = {}
123
+ hash_tree.each do |field, children|
124
+ new_hash[field.name.to_sym] = parse_field(field, children)
125
+ end
126
+ new_hash
127
+ end
128
+
129
+ # Parse value for given field
130
+ def parse_field(field, children)
131
+ case field.field_type.to_sym
132
+ when :group
133
+ parse_group_field(field, children)
134
+ when :list
135
+ parse_list_field(field, children)
136
+ when :files
137
+ parse_files_field(field)
138
+ when :file
139
+ parse_file_field(field)
140
+ else
141
+ parse_text_field(field)
142
+ end
143
+ end
144
+
145
+ def parse_group_field(field, children)
146
+ parse_hash_tree(children)
147
+ end
148
+
149
+ def parse_list_field(field, children)
150
+ children.map do |child, grand_children|
151
+ parse_field(child, grand_children)
152
+ end
153
+ end
154
+
155
+ def parse_files_field(field)
156
+ field.files.all
157
+ end
158
+
159
+ def parse_file_field(field)
160
+ field.files.last
161
+ end
162
+
163
+ def parse_text_field(field)
164
+ field.value
71
165
  end
72
166
  end
73
167
  end
@@ -0,0 +1,238 @@
1
+ module Headmin
2
+ module Filter
3
+ class Base
4
+ # Constants
5
+ OPERATORS = %w[eq not_eq gt gteq lt lteq between not_between in not_in matches does_not_match is_null is_not_null starts_with ends_with]
6
+
7
+ OPERATORS_CONVERT_TO = {
8
+ convert_to_range: %w[between not_between],
9
+ convert_to_array: %w[in not_in],
10
+ convert_to_value: %w[eq not_eq gt gteq lt lteq matches does_not_match is_null is_not_null starts_with ends_with]
11
+ }
12
+
13
+ QUERY_VALUE_CONVERT_TO = {
14
+ convert_value_to_match: %w[matches does_not_match],
15
+ convert_to_starts: %w[starts_with],
16
+ convert_to_ends: %w[ends_with]
17
+ }
18
+
19
+ QUERY_OPERATOR_CONVERT_TO = {
20
+ matches: %w[starts_with ends_with]
21
+ }
22
+
23
+ # Methods
24
+ def initialize(attribute, params)
25
+ @raw_value = params[attribute]
26
+ @attribute = attribute
27
+ @instructions = []
28
+
29
+ if params.key?(attribute)
30
+ parse(@raw_value)
31
+ end
32
+ end
33
+
34
+ attr_reader :raw_value
35
+ attr_reader :attribute
36
+
37
+ def values
38
+ @instructions.map { |instruction| instruction[:value] }.compact
39
+ end
40
+
41
+ def operators
42
+ @instructions.map { |instruction| instruction[:operator] }.compact
43
+ end
44
+
45
+ def conditionals
46
+ @instructions.map { |instruction| instruction[:conditional] }.compact
47
+ end
48
+
49
+ def string(display_values = [])
50
+ build_instructions_string(display_values)
51
+ end
52
+
53
+ attr_reader :instructions
54
+
55
+ def query(collection)
56
+ return collection unless @instructions.any?
57
+
58
+ query = nil
59
+
60
+ @instructions.each do |instruction|
61
+ query = build_query(query, collection, instruction)
62
+ end
63
+
64
+ collection.where(query)
65
+ end
66
+
67
+ def cast_value(value)
68
+ # This method has to be implemented by the child.
69
+
70
+ raise NotImplementedMethodError, "Method cast_value has to be implemented by its child."
71
+ end
72
+
73
+ def display_value(value)
74
+ value
75
+ end
76
+
77
+ private
78
+
79
+ def parse(string)
80
+ instructions = split_instructions(string)
81
+ parse_instructions(instructions)
82
+ end
83
+
84
+ def split_instructions(string)
85
+ # Regex: It divides the string into separate parts:
86
+ # - Parts starting with +AND+ OR +OR+ until the next occurrence of +
87
+ # - First part of the string till the first occurrence of +
88
+ string.scan(/\+AND\+[^+]* | \+OR\+[^+]* | ^[^+]*/x)
89
+ end
90
+
91
+ def parse_instructions(instructions)
92
+ instructions.map { |instruction| parse_instruction(instruction) }
93
+ end
94
+
95
+ def parse_instruction(string)
96
+ conditional_raw, operator_raw, value_raw = split_instruction(string)
97
+
98
+ conditional = parse_conditional(conditional_raw)
99
+ operator = parse_operator(operator_raw, value_raw)
100
+ value = parse_value(value_raw, operator)
101
+
102
+ @instructions << {conditional: conditional, operator: operator, value: value}
103
+ end
104
+
105
+ def split_instruction(string)
106
+ # Regex: takes out the +AND+ / +OR+, if not found returns nil
107
+ conditional_raw = string.scan(/\+AND\+|\+OR\+/)[0]
108
+
109
+ string = string.remove("+AND+").remove("+OR+")
110
+
111
+ # Regex: takes out the value before the : character
112
+ operator_raw = string.match(/^[^:]*/)[0] == string ? nil : string.match(/^[^:]*/)[0]
113
+ value_raw = string.remove("#{operator_raw}:")
114
+
115
+ [conditional_raw, operator_raw, value_raw]
116
+ end
117
+
118
+ def parse_conditional(string)
119
+ string.present? ? string.delete("+").downcase : nil
120
+ end
121
+
122
+ def parse_operator(string, value)
123
+ if self.class::OPERATORS.include?(string)
124
+ # Operator is recognized, return the operator
125
+ string
126
+ elsif string.nil?
127
+ # There was no operator present, default operator is eq
128
+ "eq"
129
+ else
130
+ # There was an operator passed but it was not recognized, raise exception
131
+ raise OperatorNotSupportedError, "This operator is not supported by this filter."
132
+ end
133
+ end
134
+
135
+ def parse_value(string, operator)
136
+ if operator == "is_null" || operator == "is_not_null"
137
+ return string.to_i
138
+ end
139
+ process_value(string, operator)
140
+ end
141
+
142
+ def build_query(query, collection, instruction)
143
+ query_operator = convert_to_query_operator(instruction[:operator])
144
+ query_value = convert_to_query_value(instruction[:value], instruction[:operator])
145
+
146
+ query_operator, query_value = process_null_operators(query_operator, query_value)
147
+
148
+ model = collection.is_a?(Class) ? collection : collection.model
149
+ new_query = model.arel_table[attribute].send(query_operator, query_value)
150
+
151
+ query ? query.send(instruction[:conditional], new_query) : new_query
152
+ end
153
+
154
+ def process_null_operators(operator, value)
155
+ # In case of null operators (is_null and is_not_null), we have to intercept the operator and value values
156
+ # and transform them to the correct operator (eq or not_eq) and value (nil)
157
+ if operator == "is_null" && value == 1
158
+ ["eq", nil]
159
+ elsif operator == "is_null" && value == 0
160
+ ["not_eq", nil]
161
+ elsif operator == "is_not_null" && value == 1
162
+ ["not_eq", nil]
163
+ elsif operator == "is_not_null" && value == 0
164
+ ["eq", nil]
165
+ else
166
+ [operator, value]
167
+ end
168
+ end
169
+
170
+ def convert_to_range(value)
171
+ array = value.split(",")
172
+ cast_value(array[0])..cast_value(array[1])
173
+ end
174
+
175
+ def convert_to_array(value)
176
+ array = value.split(",")
177
+ array.map { |e| cast_value(e) }
178
+ end
179
+
180
+ def convert_to_value(value)
181
+ cast_value(value)
182
+ end
183
+
184
+ def convert_to_query_value(value, operator)
185
+ value_converter = self.class::QUERY_VALUE_CONVERT_TO.find { |key, values| values.include?(operator) }&.first
186
+
187
+ if value_converter.present?
188
+ send(value_converter, value)
189
+ else
190
+ value
191
+ end
192
+ end
193
+
194
+ def convert_value_to_match(value)
195
+ "%#{value}%"
196
+ end
197
+
198
+ def convert_to_query_operator(operator)
199
+ self.class::QUERY_OPERATOR_CONVERT_TO.find { |key, values| values.include?(operator) }&.first || operator
200
+ end
201
+
202
+ def match_operator_with_convert(operator)
203
+ self.class::OPERATORS_CONVERT_TO.find { |key, values| values.include?(operator) }&.first
204
+ end
205
+
206
+ def process_value(value, operator)
207
+ send(match_operator_with_convert(operator), value)
208
+ end
209
+
210
+ def build_instructions_string(display_values)
211
+ strings = @instructions.map { |instruction| build_instruction_string(instruction, display_values) }
212
+ strings.join(" ")
213
+ end
214
+
215
+ def build_instruction_string(instruction, display_values)
216
+ conditional = instruction[:conditional].present? ? "#{I18n.t("headmin.filters.conditionals.#{instruction[:conditional]}")} " : nil
217
+ operator = I18n.t("headmin.filters.operators.#{instruction[:operator]}")
218
+ value = display_values[:display_values].find { |item| item.second == instruction[:value] }
219
+ value = value.present? ? value.first : instruction[:value]
220
+
221
+ value = if instruction[:operator] == "is_null" || instruction[:operator] == "is_not_null"
222
+ # In case of special operators (is_null & is_not_null), we intercept the value
223
+ value == 1 ? I18n.t("headmin.filters.values.yes") : I18n.t("headmin.filters.values.no")
224
+ else
225
+ display_value(value)
226
+ end
227
+
228
+ "#{conditional}#{operator} &ldquo;#{value}&rdquo;"
229
+ end
230
+ end
231
+
232
+ class OperatorNotSupportedError < StandardError
233
+ end
234
+
235
+ class NotImplementedMethodError < StandardError
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,64 @@
1
+ module Headmin
2
+ module Filter
3
+ class BaseView < ViewModel
4
+ def menu_item_options
5
+ keys = %i[name label]
6
+ options = to_h.slice(*keys)
7
+ default_menu_item_options.merge(options)
8
+ end
9
+
10
+ def filter_template_options
11
+ keys = %i[name label]
12
+ options = to_h.slice(*keys)
13
+ default_filter_template_options.merge(options)
14
+ end
15
+
16
+ def filter_operator_options
17
+ keys = %i[allowed_operators]
18
+ options = to_h.slice(*keys)
19
+ default_filter_operator_options.merge(options)
20
+ end
21
+
22
+ def filter_button_options
23
+ keys = %i[name label display_values filter]
24
+ options = to_h.slice(*keys)
25
+ default_filter_button_options.merge(options)
26
+ end
27
+
28
+ def label
29
+ @label || name.to_s.humanize
30
+ end
31
+
32
+ private
33
+
34
+ def default_menu_item_options
35
+ {
36
+ name: nil,
37
+ label: label
38
+ }
39
+ end
40
+
41
+ def default_filter_template_options
42
+ {
43
+ name: nil,
44
+ label: label
45
+ }
46
+ end
47
+
48
+ def default_filter_operator_options
49
+ {
50
+ allowed_operators: []
51
+ }
52
+ end
53
+
54
+ def default_filter_button_options
55
+ {
56
+ name: nil,
57
+ label: label,
58
+ display_values: [],
59
+ filter: nil
60
+ }
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,15 @@
1
+ module Headmin
2
+ module Filter
3
+ class Boolean < Headmin::Filter::Base
4
+ OPERATORS = %w[eq not_eq is_null is_not_null]
5
+
6
+ def cast_value(value)
7
+ ActiveModel::Type::Boolean.new.cast(value)
8
+ end
9
+
10
+ def to_s
11
+ string
12
+ end
13
+ end
14
+ end
15
+ end