Skip to content

Commit b3a151c

Browse files
authored
Add Attributes Concern with flex_attribute method and memorable_date type (#42)
- Add Flex::Attributes Concern that defines flex_attribute class method - Add memorable_date type to flex_attribute options - Add tests to test the ability to convert
1 parent 32bb7bd commit b3a151c

4 files changed

Lines changed: 179 additions & 1 deletion

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
module Flex
2+
module Attributes
3+
extend ActiveSupport::Concern
4+
5+
# This class defines a string that represents a date in the format "<YEAR>-<MM>-<DD>"
6+
# and is designed to also allow invalid dates such as "2020-13-32" to facilitate
7+
# storing user input before validation.
8+
# Validation is handled separately to ensure that the date is valid
9+
#
10+
# The class also has nested attributes for year, month, and day to facilitate
11+
# treating the date as a structured value object which is useful for form building.
12+
class DateString < ::String
13+
attr_reader :year, :month, :day
14+
15+
def initialize(year, month, day)
16+
@year, @month, @day = year, month, day
17+
super("#{year}-#{month.rjust(2, "0")}-#{day.rjust(2, "0")}")
18+
end
19+
end
20+
21+
# A custom ActiveRecord type that allows storing a date as a string.
22+
# The attribute accepts a Date, a Hash with keys :year, :month, :day,
23+
# or a String in the format "YYYY-MM-DD".
24+
class DateStringType < ActiveRecord::Type::String
25+
# Accept a Date, a Hash of with keys :year, :month, :day,
26+
# or a String in the format "YYYY-MM-DD"
27+
# (the parts of the string don't have to be numeric or represent valid years/months/days
28+
# since the date will be validated separately)
29+
def cast(value)
30+
return nil if value.nil?
31+
32+
year, month, day = case value
33+
when Date
34+
[ value.year.to_s, value.month.to_s, value.day.to_s ]
35+
when Hash
36+
[ value[:year].to_s, value[:month].to_s, value[:day].to_s ]
37+
when String
38+
if match = value.match(/(\w+)-(\w+)-(\w+)/)
39+
match.captures
40+
else
41+
raise ArgumentError, "Invalid date string format: #{value.inspect}. Expected format is '<YEAR>-<MONTH>-<DAY>'."
42+
end
43+
else
44+
raise ArgumentError, "Invalid value for #{name}: #{value.inspect}. Expected Date, Hash, or String."
45+
end
46+
47+
DateString.new(year, month, day)
48+
end
49+
50+
def type
51+
:date_string
52+
end
53+
end
54+
55+
class_methods do
56+
def flex_attribute(name, type, options = {})
57+
if type == :memorable_date
58+
memorable_date_attribute name, options
59+
else
60+
raise ArgumentError, "Unsupported attribute type: #{type}"
61+
end
62+
end
63+
64+
private
65+
66+
def memorable_date_attribute(name, options)
67+
attribute name, DateStringType.new
68+
69+
validate :"validate_#{name}"
70+
71+
if options[:presence]
72+
validates name, presence: true
73+
end
74+
75+
# Create a validation method that checks if the value is a valid date
76+
define_method "validate_#{name}" do
77+
value = send(name)
78+
return if value.nil?
79+
80+
begin
81+
Date.strptime(value, "%Y-%m-%d")
82+
rescue Date::Error
83+
errors.add(name, :invalid_date, message: "is not a valid date")
84+
end
85+
end
86+
end
87+
end
88+
end
89+
end

template/{{app_name}}/engines/flex/spec/dummy/app/models/passport_application_form.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
class PassportApplicationForm < Flex::ApplicationForm
2+
include Flex::Attributes
3+
24
before_create :create_passport_case, unless: -> { has_case_id? }
35

46
attribute :first_name, :string
57
attribute :last_name, :string
6-
attribute :date_of_birth, :date
8+
9+
flex_attribute :date_of_birth, :memorable_date
710

811
attribute :case_id, :integer
912
private def case_id=(value)
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
require "rails_helper"
2+
3+
RSpec.describe Flex::Attributes do
4+
before do
5+
test_model = Class.new do
6+
include ActiveModel::Attributes
7+
include ActiveModel::Validations
8+
include Flex::Attributes
9+
10+
flex_attribute :test_date, :memorable_date
11+
end
12+
stub_const "TestModel", test_model
13+
end
14+
15+
let(:object) { TestModel.new }
16+
17+
describe "memorable_date attribute" do
18+
it "allows setting a Date" do
19+
object.test_date = Date.new(2020, 1, 2)
20+
expect(object.test_date).to eq("2020-01-02")
21+
expect(object.test_date.year).to eq("2020")
22+
expect(object.test_date.month).to eq("1")
23+
expect(object.test_date.day).to eq("2")
24+
end
25+
26+
[
27+
[ { year: 2020, month: 1, day: 2 }, "2020-01-02", "2020", "1", "2" ],
28+
[ { year: "2020", month: "1", day: "2" }, "2020-01-02", "2020", "1", "2" ],
29+
[ { year: "2020", month: "01", day: "02" }, "2020-01-02", "2020", "01", "02" ],
30+
[ { year: "badyear", month: "badmonth", day: "badday" }, "badyear-badmonth-badday", "badyear", "badmonth", "badday" ]
31+
].each do |input_hash, expected, expected_year, expected_month, expected_day|
32+
it "allows setting a Hash with year, month, and day [#{expected}]" do
33+
object.test_date = input_hash
34+
expect(object.test_date).to eq(expected)
35+
expect(object.test_date.year).to eq(expected_year)
36+
expect(object.test_date.month).to eq(expected_month)
37+
expect(object.test_date.day).to eq(expected_day)
38+
end
39+
end
40+
41+
[
42+
[ "2020-1-2", "2020-01-02", "2020", "1", "2" ],
43+
[ "2020-01-02", "2020-01-02", "2020", "01", "02" ],
44+
[ "badyear-badmonth-badday", "badyear-badmonth-badday", "badyear", "badmonth", "badday" ]
45+
].each do |input_string, expected, expected_year, expected_month, expected_day|
46+
it "allows setting string in format <YEAR>-<MONTH>-<DAY> [#{expected}]" do
47+
object.test_date = input_string
48+
expect(object.test_date).to eq(expected)
49+
expect(object.test_date.year).to eq(expected_year)
50+
expect(object.test_date.month).to eq(expected_month)
51+
expect(object.test_date.day).to eq(expected_day)
52+
end
53+
end
54+
55+
[
56+
{ year: 2020, month: 1, day: -1 },
57+
{ year: 2020, month: 1, day: 0 },
58+
{ year: 2020, month: 1, day: 32 },
59+
{ year: 2020, month: -1, day: 1 },
60+
{ year: 2020, month: 0, day: 1 },
61+
{ year: 2020, month: 13, day: 1 },
62+
{ year: 2020, month: 2, day: 30 }
63+
].each do |date|
64+
it "validates that date is a valid date" do
65+
object.test_date = date
66+
expect(object.test_date).to eq("%04d-%02d-%02d" % [ date[:year], date[:month], date[:day] ])
67+
expect(object).not_to be_valid
68+
expect(object.errors["test_date"]).to include("is not a valid date")
69+
end
70+
end
71+
end
72+
end

template/{{app_name}}/engines/flex/spec/dummy/spec/models/flex/passport_application_form_spec.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,20 @@ def generate_random_date_of_birth
2121
passport_application_form.save!
2222
end
2323

24+
describe "saving and loading" do
25+
it "saves the form with valid attributes" do
26+
expect(passport_application_form).to be_valid
27+
expect(passport_application_form).to be_persisted
28+
end
29+
30+
it "loads the form with correct attributes" do
31+
loaded_form = described_class.find(passport_application_form.id)
32+
expect(loaded_form.first_name).to eq("John")
33+
expect(loaded_form.last_name).to eq("Doe")
34+
expect(loaded_form.date_of_birth).to eq(passport_application_form.date_of_birth)
35+
end
36+
end
37+
2438
context "when attempting to update case_id" do
2539
it "prevents direct status updates when setting status directly" do
2640
expect { passport_application_form.case_id = 22 }.to raise_error(NoMethodError)

0 commit comments

Comments
 (0)