warped 0.2.0 → 1.0.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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +6 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +104 -0
  5. data/app/assets/config/warped_manifest.js +2 -0
  6. data/app/assets/javascript/warped/controllers/filter_controller.js +76 -0
  7. data/app/assets/javascript/warped/controllers/filters_controller.js +21 -0
  8. data/app/assets/javascript/warped/index.js +2 -0
  9. data/app/assets/stylesheets/warped/application.css +15 -0
  10. data/app/assets/stylesheets/warped/base.css +23 -0
  11. data/app/assets/stylesheets/warped/filters.css +115 -0
  12. data/app/assets/stylesheets/warped/pagination.css +74 -0
  13. data/app/assets/stylesheets/warped/search.css +33 -0
  14. data/app/assets/stylesheets/warped/table.css +114 -0
  15. data/app/views/warped/_actions.html.erb +9 -0
  16. data/app/views/warped/_cell.html.erb +3 -0
  17. data/app/views/warped/_column.html.erb +35 -0
  18. data/app/views/warped/_filters.html.erb +21 -0
  19. data/app/views/warped/_hidden_fields.html.erb +19 -0
  20. data/app/views/warped/_pagination.html.erb +34 -0
  21. data/app/views/warped/_row.html.erb +19 -0
  22. data/app/views/warped/_search.html.erb +21 -0
  23. data/app/views/warped/_table.html.erb +52 -0
  24. data/app/views/warped/filters/_filter.html.erb +40 -0
  25. data/config/importmap.rb +3 -0
  26. data/docs/controllers/FILTERABLE.md +82 -3
  27. data/docs/controllers/views/PARTIALS.md +285 -0
  28. data/lib/warped/api/filter/base/value.rb +52 -0
  29. data/lib/warped/api/filter/base.rb +84 -0
  30. data/lib/warped/api/filter/boolean.rb +41 -0
  31. data/lib/warped/api/filter/date.rb +26 -0
  32. data/lib/warped/api/filter/date_time.rb +32 -0
  33. data/lib/warped/api/filter/decimal.rb +31 -0
  34. data/lib/warped/api/filter/factory.rb +38 -0
  35. data/lib/warped/api/filter/integer.rb +38 -0
  36. data/lib/warped/api/filter/string.rb +25 -0
  37. data/lib/warped/api/filter/time.rb +25 -0
  38. data/lib/warped/api/filter.rb +14 -0
  39. data/lib/warped/api/sort/value.rb +40 -0
  40. data/lib/warped/api/sort.rb +65 -0
  41. data/lib/warped/controllers/filterable/ui.rb +10 -32
  42. data/lib/warped/controllers/filterable.rb +75 -42
  43. data/lib/warped/controllers/pageable/ui.rb +13 -3
  44. data/lib/warped/controllers/pageable.rb +1 -1
  45. data/lib/warped/controllers/searchable/ui.rb +3 -1
  46. data/lib/warped/controllers/sortable/ui.rb +21 -26
  47. data/lib/warped/controllers/sortable.rb +53 -33
  48. data/lib/warped/controllers/tabulatable/ui.rb +4 -0
  49. data/lib/warped/controllers/tabulatable.rb +6 -9
  50. data/lib/warped/engine.rb +19 -0
  51. data/lib/warped/queries/filter.rb +3 -3
  52. data/lib/warped/table/action.rb +33 -0
  53. data/lib/warped/table/column.rb +34 -0
  54. data/lib/warped/version.rb +1 -1
  55. data/lib/warped.rb +1 -0
  56. data/warped.gemspec +1 -1
  57. metadata +43 -11
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/blank"
4
+ require "active_support/core_ext/module/delegation"
5
+
6
+ module Warped
7
+ module Filter
8
+ class Base
9
+ class Value
10
+ attr_reader :filter
11
+
12
+ delegate :name, :alias_name, :parameter_name, :kind, to: :filter
13
+
14
+ # @param filter [Warped::Filter::Base] The filter object
15
+ # @param relation [String] The filter relation.
16
+ # @param value [String] The filter value.
17
+ def initialize(filter, relation, value)
18
+ @filter = filter
19
+ @relation = relation
20
+ @value = value
21
+ end
22
+
23
+ # @return [String] The casted filter value.
24
+ def value
25
+ filter.cast(@value)
26
+ end
27
+
28
+ # @return [String] The validated filter relation.
29
+ def relation
30
+ filter.relation(@relation)
31
+ end
32
+
33
+ # @return [Boolean] Whether the filter is empty.
34
+ def empty?
35
+ value.nil? && !%w[is_null is_not_null].include?(relation)
36
+ end
37
+
38
+ def to_h
39
+ {
40
+ field: filter.name,
41
+ relation:,
42
+ value:
43
+ }
44
+ end
45
+
46
+ # Some filters may need to be parsed/formatted differently for the HTML input value.
47
+ # This method can be overridden in the filter value class to provide a different value.
48
+ alias html_value value
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/module/delegation"
4
+ require "active_support/core_ext/object/blank"
5
+ require "active_support/core_ext/string/inflections"
6
+
7
+ module Warped
8
+ module Filter
9
+ class Base
10
+ RELATIONS = %w[eq neq gt gte lt lte between in not_in starts_with ends_with contains is_null is_not_null].freeze
11
+
12
+ attr_reader :name, :strict, :alias_name, :options
13
+
14
+ delegate :[], to: :options
15
+
16
+ # @return [Symbol, nil] The filter kind.
17
+ def self.kind
18
+ filter_type = name.demodulize.underscore.to_sym
19
+
20
+ filter_type == :filter ? nil : filter_type
21
+ end
22
+
23
+ # @param name [String] The name of the filter.
24
+ # @param alias_name [String] The alias name of the filter, used for renaming the filter key in the URL params
25
+ # @param options [Hash] The filter options.
26
+ def initialize(name, strict:, alias_name: nil, **options)
27
+ raise ArgumentError, "name cannot be nil" if name.nil?
28
+
29
+ @name = name.to_s
30
+ @strict = strict
31
+ @alias_name = alias_name&.to_s
32
+ @options = options
33
+ end
34
+
35
+ # @return [Symbol, nil] The filter kind.
36
+ def kind
37
+ self.class.kind
38
+ end
39
+
40
+ # @return [Array<String>] The valid filter relations.
41
+ def relations
42
+ self.class::RELATIONS
43
+ end
44
+
45
+ # @param relation [Object] The filter relation.
46
+ # @return [Object] The casted value.
47
+ # @raise [Warped::Filter::RelationError] If the relation is invalid and strict is true.
48
+ def cast(value)
49
+ return if value.nil?
50
+
51
+ value
52
+ end
53
+
54
+ # @param relation [String] The validated filter relation.
55
+ # @return [String] The validated filter relation.
56
+ # @raise [Warped::Filter::RelationError] If the relation is invalid and strict is true.
57
+ def relation(relation)
58
+ if valid_relation?(relation)
59
+ relation
60
+ else
61
+ raise RelationError, "Invalid relation: #{relation}" unless strict
62
+
63
+ "eq"
64
+ end
65
+ end
66
+
67
+ # @return [String] The name to use in the URL params.
68
+ def parameter_name
69
+ alias_name.presence || name
70
+ end
71
+
72
+ # @return [String] The HTML input type.
73
+ def html_type
74
+ raise NotImplementedError, "#{self.class.name}#html_type not implemented"
75
+ end
76
+
77
+ private
78
+
79
+ def valid_relation?(relation)
80
+ relations.include?(relation.to_s)
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Warped
4
+ module Filter
5
+ class Boolean < Base
6
+ RELATIONS = %w[eq neq is_null is_not_null].freeze
7
+
8
+ def cast(value)
9
+ return if value.nil?
10
+
11
+ casted_value = case value
12
+ when true, false
13
+ value
14
+ when "true", "1", "t", 1
15
+ true
16
+ when "false", "0", "f", 0
17
+ false
18
+ end
19
+
20
+ casted_value.tap do |casted|
21
+ raise ValueError, "#{value} cannot be casted to #{kind}" if casted.nil? && strict
22
+ end
23
+ end
24
+
25
+ def html_type
26
+ "text"
27
+ end
28
+
29
+ class Value < Value
30
+ def html_value
31
+ case value.class
32
+ when TrueClass
33
+ "true"
34
+ when FalseClass
35
+ "false"
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "active_support/core_ext/object/blank"
5
+
6
+ module Warped
7
+ module Filter
8
+ class Date < Base
9
+ RELATIONS = %w[eq neq gt gte lt lte between is_null is_not_null].freeze
10
+
11
+ def cast(value)
12
+ return if value.blank?
13
+
14
+ ::Date.parse(value)
15
+ rescue ::Date::Error
16
+ raise ValueError, "#{value} cannot be casted to #{kind}" if strict
17
+
18
+ nil
19
+ end
20
+
21
+ def html_type
22
+ "date"
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "active_support/core_ext/object/blank"
5
+
6
+ module Warped
7
+ module Filter
8
+ class DateTime < Base
9
+ RELATIONS = %w[eq neq gt gte lt lte between is_null is_not_null].freeze
10
+
11
+ def cast(value)
12
+ return if value.blank?
13
+
14
+ ::DateTime.parse(value)
15
+ rescue ::Date::Error
16
+ raise ValueError, "#{value} cannot be casted to #{kind}" if strict
17
+
18
+ nil
19
+ end
20
+
21
+ def html_type
22
+ "datetime-local"
23
+ end
24
+
25
+ class Value < Value
26
+ def html_value
27
+ value.strftime("%Y-%m-%dT%H:%M:%S")
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "active_support/core_ext/object/blank"
5
+
6
+ module Warped
7
+ module Filter
8
+ class Decimal < Base
9
+ RELATIONS = %w[eq neq gt gte lt lte between in not_in is_null is_not_null].freeze
10
+
11
+ def cast(value)
12
+ return if value.blank?
13
+
14
+ casted_value = case value
15
+ when ::BigDecimal
16
+ value
17
+ when ::Integer, ::Float, ::String
18
+ value.to_d
19
+ end
20
+
21
+ casted_value.tap do |casted|
22
+ raise ValueError, "#{value} cannot be casted to #{kind}" if casted.nil? && strict
23
+ end
24
+ end
25
+
26
+ def html_type
27
+ "number"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Warped
4
+ module Filter
5
+ class Factory
6
+ TYPES = %i[string integer float decimal date time date_time boolean].freeze
7
+
8
+ def self.build(kind, *args, **kwargs)
9
+ new(kind).build(*args, **kwargs)
10
+ end
11
+
12
+ def initialize(kind = nil)
13
+ @kind = kind
14
+ validate_kind!
15
+ end
16
+
17
+ def build(*args, **kwargs)
18
+ filter_class.new(*args, **kwargs)
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :kind
24
+
25
+ def validate_kind!
26
+ return if kind.nil?
27
+
28
+ raise ArgumentError, "#{kind} is not a valid filter type" unless TYPES.include?(kind)
29
+ end
30
+
31
+ def filter_class
32
+ return Base if kind.nil?
33
+
34
+ Filter.const_get(kind.to_s.camelize)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/blank"
4
+
5
+ module Warped
6
+ module Filter
7
+ class Integer < Base
8
+ RELATIONS = %w[eq neq gt gte lt lte between in not_in is_null is_not_null].freeze
9
+
10
+ def cast(value)
11
+ return if value.blank?
12
+
13
+ casted_value = case value
14
+ when ::Integer
15
+ value
16
+ when ::String
17
+ value.to_i if value.match?(/\A-?\d+\z/)
18
+ when ::Float, ::BigDecimal
19
+ value.to_i
20
+ end
21
+
22
+ check_casted_value!(casted_value)
23
+ end
24
+
25
+ def html_type
26
+ "number"
27
+ end
28
+
29
+ private
30
+
31
+ def check_casted_value!(value)
32
+ raise ValueError, "#{value} cannot be casted to #{kind}" if value.nil? && strict
33
+
34
+ value
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/blank"
4
+
5
+ module Warped
6
+ module Filter
7
+ class String < Base
8
+ RELATIONS = Queries::Filter::RELATIONS
9
+
10
+ def cast(value)
11
+ return if value.blank?
12
+
13
+ value.to_s
14
+ rescue StandardError
15
+ raise ValueError, "#{value} cannot be casted to #{kind}" if strict
16
+
17
+ nil
18
+ end
19
+
20
+ def html_type
21
+ "text"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/blank"
4
+
5
+ module Warped
6
+ module Filter
7
+ class Time < Base
8
+ RELATIONS = %w[eq neq gt gte lt lte between is_null is_not_null].freeze
9
+
10
+ def cast(value)
11
+ return if value.blank?
12
+
13
+ ::Time.parse(value)
14
+ rescue StandardError
15
+ raise ValueError, "#{value} cannot be casted to #{kind}" if strict
16
+
17
+ nil
18
+ end
19
+
20
+ def html_type
21
+ "time"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/module/delegation"
4
+
5
+ module Warped
6
+ module Filter
7
+ class ValueError < StandardError; end
8
+ class RelationError < StandardError; end
9
+
10
+ class << self
11
+ delegate :build, to: Factory
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/module/delegation"
4
+
5
+ module Warped
6
+ class Sort
7
+ class Value
8
+ attr_reader :sort
9
+
10
+ delegate :name, :alias_name, :parameter_name, to: :sort
11
+
12
+ # @param sort [Warped::Sort] The sort object
13
+ # @param direction [String] The sort direction.
14
+ def initialize(sort, direction)
15
+ @sort = sort
16
+ @direction = direction
17
+ end
18
+
19
+ # @return [String] The sort direction.
20
+ def direction
21
+ sort.direction!(@direction)
22
+ end
23
+
24
+ # @return [String] The opposite sort direction.
25
+ def opposite_direction
26
+ sort.opposite_direction(direction)
27
+ end
28
+
29
+ # @return [Boolean] Whether the sort is ascending.
30
+ def asc?
31
+ %w[asc asc_nulls_first asc_nulls_last].include?(direction)
32
+ end
33
+
34
+ # @return [Boolean] Whether the sort is descending.
35
+ def desc?
36
+ %w[desc desc_nulls_first desc_nulls_last].include?(direction)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/blank"
4
+
5
+ module Warped
6
+ class Sort
7
+ class DirectionError < StandardError; end
8
+
9
+ SORT_DIRECTIONS = %w[asc desc].freeze
10
+ NULLS_SORT_DIRECTION = %w[asc_nulls_first asc_nulls_last desc_nulls_first desc_nulls_last].freeze
11
+
12
+ attr_accessor :name, :alias_name
13
+
14
+ # @return [Array<String>] The valid sort directions.
15
+ def self.directions
16
+ @directions ||= SORT_DIRECTIONS + NULLS_SORT_DIRECTION
17
+ end
18
+
19
+ # @param name [String] The name of the sort.
20
+ # @param alias_name [String] The alias name of the sort, used for renaming the sort key in the URL params
21
+ def initialize(name, alias_name: nil)
22
+ raise ArgumentError, "name cannot be nil" if name.nil?
23
+
24
+ @name = name.to_s
25
+ @alias_name = alias_name&.to_s
26
+ end
27
+
28
+ # @return [String] The name to use in the URL params.
29
+ def parameter_name
30
+ alias_name.presence || name
31
+ end
32
+
33
+ # @param direction [String] The sort direction.
34
+ # @return [String] The sort direction.
35
+ # @raise [DirectionError] If the direction is invalid.
36
+ def direction!(direction)
37
+ raise DirectionError, "Invalid direction: #{direction}" unless valid_direction?(direction.to_s)
38
+
39
+ direction.to_s
40
+ end
41
+
42
+ # @param direction [String] The sort direction.
43
+ # @return [String] The opposite sort direction.
44
+ def opposite_direction(direction)
45
+ opposite_directions[direction]
46
+ end
47
+
48
+ private
49
+
50
+ def valid_direction?(relation)
51
+ self.class.directions.include?(relation.to_s)
52
+ end
53
+
54
+ def opposite_directions
55
+ @opposite_directions ||= {
56
+ "asc" => "desc",
57
+ "desc" => "asc",
58
+ "asc_nulls_first" => "desc_nulls_last",
59
+ "asc_nulls_last" => "desc_nulls_first",
60
+ "desc_nulls_first" => "asc_nulls_last",
61
+ "desc_nulls_last" => "asc_nulls_first"
62
+ }.freeze
63
+ end
64
+ end
65
+ end
@@ -10,57 +10,35 @@ module Warped
10
10
  include Filterable
11
11
 
12
12
  included do
13
- helper_method :filters, :filtered?, :current_filters, :filter_url_params, :filterable_by
13
+ helper_method :filters, :filtered?, :filter_url_params, :filterable_by
14
14
  end
15
15
 
16
+ # @see Filterable#filter
16
17
  def filter(...)
17
18
  @filtered = true
18
19
 
19
20
  super
20
21
  end
21
22
 
23
+ # @return [Boolean] Whether the current action is filtered.
22
24
  def filtered?
23
25
  @filtered ||= false
24
26
  end
25
27
 
28
+ # @return [Hash] The filters for the current action.
26
29
  def filter_url_params(**options)
27
30
  url_params = {}
28
- current_filters.each_with_object(url_params) do |filter, hsh|
29
- if filter[:value].is_a?(Array)
30
- filter[:value].each { |value| hsh["#{filter[:name]}[]"] = value }
31
+ current_action_filter_values.each_with_object(url_params) do |filter_value, hsh|
32
+ if filter_value.value.is_a?(Array)
33
+ filter_value.value.each { |value| hsh["#{filter_value.parameter_name}[]"] = value }
31
34
  else
32
- hsh[filter[:name]] = filter[:value]
35
+ hsh[filter_value.parameter_name] = filter_value.value
33
36
  end
34
37
 
35
- hsh["#{filter[:name]}.rel"] = filter[:relation]
38
+ hsh["#{filter_value.parameter_name}.rel"] = filter_value.relation
36
39
  end
37
40
 
38
- url_params.tap { _1.merge!(options) }
39
- end
40
-
41
- def filters
42
- (filter_fields | mapped_filter_fields).map do |field|
43
- {
44
- name: filter_mapped_name(field),
45
- value: filter_value(field),
46
- relation: filter_rel_value(field)
47
- }
48
- end
49
- end
50
-
51
- def current_filters
52
- (filter_fields | mapped_filter_fields).filter_map do |field|
53
- filter_value = filter_value(field)
54
- filter_rel_value = filter_rel_value(field)
55
-
56
- next if filter_value.blank? && %w[is_null is_not_null].exclude?(filter_rel_value)
57
-
58
- {
59
- name: filter_mapped_name(field),
60
- value: filter_value,
61
- relation: filter_rel_value
62
- }
63
- end
41
+ url_params.merge!(options)
64
42
  end
65
43
  end
66
44
  end