warped 0.2.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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 +44 -6
@@ -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