iron-cms 0.17.2 → 0.18.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 (142) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +88 -4
  3. data/app/assets/builds/iron.css +255 -106
  4. data/app/assets/tailwind/iron/application.css +1 -0
  5. data/app/assets/tailwind/iron/components/file-upload.css +26 -0
  6. data/app/assets/tailwind/iron/lexxy.css +111 -87
  7. data/app/controllers/concerns/iron/schema_editing.rb +19 -0
  8. data/app/controllers/iron/api/schema/base_controller.rb +25 -0
  9. data/app/controllers/iron/api/schema/block_definitions_controller.rb +49 -0
  10. data/app/controllers/iron/api/schema/content_types_controller.rb +64 -0
  11. data/app/controllers/iron/api/schema/field_definitions_controller.rb +88 -0
  12. data/app/controllers/iron/api/schema/locales_controller.rb +52 -0
  13. data/app/controllers/iron/application_controller.rb +1 -1
  14. data/app/controllers/iron/block_definitions_controller.rb +1 -0
  15. data/app/controllers/iron/content_types_controller.rb +1 -0
  16. data/app/controllers/iron/field_definitions_controller.rb +2 -1
  17. data/app/controllers/iron/first_runs_controller.rb +1 -1
  18. data/app/controllers/iron/locales_controller.rb +1 -0
  19. data/app/controllers/iron/sessions_controller.rb +1 -1
  20. data/app/helpers/iron/avatar_helper.rb +24 -2
  21. data/app/javascript/iron/controllers/file_upload_controller.js +38 -7
  22. data/app/models/iron/account.rb +1 -1
  23. data/app/models/iron/api/openapi_spec.rb +37 -15
  24. data/app/models/iron/block_definition/exportable.rb +1 -1
  25. data/app/models/iron/block_definition.rb +3 -1
  26. data/app/models/iron/content.rb +176 -0
  27. data/app/models/iron/content_type/exportable.rb +3 -1
  28. data/app/models/iron/content_type/importable.rb +1 -1
  29. data/app/models/iron/content_type.rb +6 -1
  30. data/app/models/iron/entry/content_assignable.rb +10 -2
  31. data/app/models/iron/exporter.rb +1 -26
  32. data/app/models/iron/field/length_constrained.rb +17 -0
  33. data/app/models/iron/field/validatable.rb +16 -0
  34. data/app/models/iron/field.rb +1 -1
  35. data/app/models/iron/field_definition/exportable.rb +3 -4
  36. data/app/models/iron/field_definition/importable.rb +16 -9
  37. data/app/models/iron/field_definition/ranked.rb +1 -1
  38. data/app/models/iron/field_definition/searchable.rb +2 -0
  39. data/app/models/iron/field_definition/validatable.rb +40 -0
  40. data/app/models/iron/field_definition/validations.rb +175 -0
  41. data/app/models/iron/field_definition.rb +1 -1
  42. data/app/models/iron/field_definitions/date.rb +2 -0
  43. data/app/models/iron/field_definitions/file.rb +3 -11
  44. data/app/models/iron/field_definitions/number.rb +2 -0
  45. data/app/models/iron/field_definitions/reference.rb +2 -0
  46. data/app/models/iron/field_definitions/rich_text_area.rb +1 -0
  47. data/app/models/iron/field_definitions/text_area.rb +2 -0
  48. data/app/models/iron/field_definitions/text_field.rb +1 -9
  49. data/app/models/iron/fields/block.rb +5 -1
  50. data/app/models/iron/fields/block_list.rb +5 -1
  51. data/app/models/iron/fields/date.rb +19 -2
  52. data/app/models/iron/fields/file.rb +5 -3
  53. data/app/models/iron/fields/number.rb +28 -1
  54. data/app/models/iron/fields/reference.rb +2 -0
  55. data/app/models/iron/fields/rich_text_area.rb +2 -0
  56. data/app/models/iron/fields/text_area.rb +4 -0
  57. data/app/models/iron/fields/text_field.rb +9 -5
  58. data/app/models/iron/importer.rb +1 -54
  59. data/app/models/iron/locale.rb +2 -0
  60. data/app/models/iron/schema/auto_dumpable.rb +26 -0
  61. data/app/models/iron/schema/diff.rb +194 -0
  62. data/app/models/iron/schema/validation.rb +214 -0
  63. data/app/models/iron/schema.rb +282 -0
  64. data/app/models/iron/seed.rb +5 -3
  65. data/app/models/iron/system.rb +7 -0
  66. data/app/models/iron/user/deactivatable.rb +5 -0
  67. data/app/models/iron/user.rb +18 -1
  68. data/app/views/iron/api/fields/_rich_text_area.json.jbuilder +2 -2
  69. data/app/views/iron/api/schema/block_definitions/_block_definition.json.jbuilder +4 -0
  70. data/app/views/iron/api/schema/block_definitions/index.json.jbuilder +1 -0
  71. data/app/views/iron/api/schema/block_definitions/show.json.jbuilder +1 -0
  72. data/app/views/iron/api/schema/content_types/_content_type.json.jbuilder +5 -0
  73. data/app/views/iron/api/schema/content_types/index.json.jbuilder +1 -0
  74. data/app/views/iron/api/schema/content_types/show.json.jbuilder +1 -0
  75. data/app/views/iron/api/schema/field_definitions/_field_definition.json.jbuilder +7 -0
  76. data/app/views/iron/api/schema/field_definitions/show.json.jbuilder +1 -0
  77. data/app/views/iron/api/schema/locales/_locale.json.jbuilder +4 -0
  78. data/app/views/iron/api/schema/locales/index.json.jbuilder +1 -0
  79. data/app/views/iron/api/schema/locales/show.json.jbuilder +1 -0
  80. data/app/views/iron/block_definitions/_empty_state.html.erb +5 -3
  81. data/app/views/iron/block_definitions/_form.html.erb +3 -1
  82. data/app/views/iron/block_definitions/edit.html.erb +19 -17
  83. data/app/views/iron/block_definitions/index.html.erb +7 -3
  84. data/app/views/iron/block_definitions/show.html.erb +14 -8
  85. data/app/views/iron/content_types/_content_type.html.erb +1 -1
  86. data/app/views/iron/content_types/_empty_state.html.erb +5 -3
  87. data/app/views/iron/content_types/_form.html.erb +12 -8
  88. data/app/views/iron/content_types/edit.html.erb +19 -17
  89. data/app/views/iron/content_types/index.html.erb +7 -3
  90. data/app/views/iron/content_types/show.html.erb +14 -8
  91. data/app/views/iron/entries/_empty_state.html.erb +1 -1
  92. data/app/views/iron/entries/edit.html.erb +1 -1
  93. data/app/views/iron/entries/entry.html.erb +1 -1
  94. data/app/views/iron/entries/fields/_block.html.erb +4 -0
  95. data/app/views/iron/entries/fields/_block_list.html.erb +2 -0
  96. data/app/views/iron/entries/fields/_boolean.html.erb +1 -0
  97. data/app/views/iron/entries/fields/_date.html.erb +3 -2
  98. data/app/views/iron/entries/fields/_field_errors.html.erb +7 -0
  99. data/app/views/iron/entries/fields/_field_label.html.erb +8 -0
  100. data/app/views/iron/entries/fields/_file.html.erb +11 -19
  101. data/app/views/iron/entries/fields/_number.html.erb +6 -2
  102. data/app/views/iron/entries/fields/_reference.html.erb +2 -1
  103. data/app/views/iron/entries/fields/_reference_list.html.erb +2 -0
  104. data/app/views/iron/entries/fields/_rich_text_area.html.erb +2 -1
  105. data/app/views/iron/entries/fields/_text_area.html.erb +6 -2
  106. data/app/views/iron/entries/fields/_text_field.html.erb +5 -12
  107. data/app/views/iron/field_definitions/_field_definition.html.erb +51 -29
  108. data/app/views/iron/field_definitions/date/_form.html.erb +5 -1
  109. data/app/views/iron/field_definitions/edit.html.erb +10 -8
  110. data/app/views/iron/field_definitions/file/_form.html.erb +6 -0
  111. data/app/views/iron/field_definitions/new.html.erb +5 -3
  112. data/app/views/iron/field_definitions/number/_form.html.erb +23 -1
  113. data/app/views/iron/field_definitions/reference/_form.html.erb +5 -0
  114. data/app/views/iron/field_definitions/rich_text_area/_form.html.erb +5 -0
  115. data/app/views/iron/field_definitions/text_area/_form.html.erb +23 -0
  116. data/app/views/iron/field_definitions/text_field/_form.html.erb +20 -1
  117. data/app/views/iron/home/_content_types.html.erb +1 -1
  118. data/app/views/iron/locales/_form.html.erb +5 -3
  119. data/app/views/iron/locales/edit.html.erb +1 -1
  120. data/app/views/iron/locales/index.html.erb +12 -6
  121. data/app/views/iron/shared/_schema_lock_badge.html.erb +19 -0
  122. data/config/locales/en.yml +13 -1
  123. data/config/locales/it.yml +18 -1
  124. data/config/routes.rb +11 -0
  125. data/db/migrate/20260612131538_create_iron_systems.rb +9 -0
  126. data/exe/iron +5 -0
  127. data/lib/generators/iron/agents/agents_generator.rb +52 -0
  128. data/lib/generators/iron/agents/templates/AGENTS.md +24 -0
  129. data/lib/generators/iron/agents/templates/SKILL.md +423 -0
  130. data/lib/generators/iron/install/install_generator.rb +118 -0
  131. data/lib/generators/iron/install/templates/iron_release.rake +5 -0
  132. data/lib/generators/iron/install/templates/schema.json +12 -0
  133. data/lib/generators/iron/install/templates/seeds.rb +13 -0
  134. data/lib/generators/iron/pages/pages_generator.rb +5 -0
  135. data/lib/generators/iron/pages/templates/pages_controller.rb +1 -1
  136. data/lib/generators/iron/pages/templates/show.html.erb +1 -1
  137. data/lib/install/template.rb +9 -0
  138. data/lib/iron/cli.rb +43 -0
  139. data/lib/iron/version.rb +1 -1
  140. data/lib/tasks/iron_content.rake +82 -0
  141. data/lib/tasks/iron_schema.rake +45 -0
  142. metadata +62 -3
@@ -0,0 +1,40 @@
1
+ module Iron
2
+ module FieldDefinition::Validatable
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ class_attribute :supported_validations, instance_writer: false, default: [].freeze
7
+
8
+ validate :declared_validations_are_well_formed
9
+ end
10
+
11
+ class_methods do
12
+ def supports_validations(*keys)
13
+ keys = keys.map(&:to_s)
14
+ self.supported_validations = keys.freeze
15
+ store_accessor :metadata, *keys
16
+
17
+ keys.each do |key|
18
+ kind = FieldDefinition::Validations::RULES.fetch(key).fetch(:kind)
19
+ define_method(key) { FieldDefinition::Validations.cast(kind, super()) }
20
+ define_method("#{key}=") { |value| super(FieldDefinition::Validations.cast(kind, value)) }
21
+ end
22
+ end
23
+ end
24
+
25
+ def required?
26
+ ActiveModel::Type::Boolean.new.cast(metadata&.dig("required")) || false
27
+ end
28
+
29
+ def metadata=(config)
30
+ super(FieldDefinition::Validations.cast_rules(config))
31
+ end
32
+
33
+ private
34
+ def declared_validations_are_well_formed
35
+ FieldDefinition::Validations.violations(type_handle, metadata).each do |violation|
36
+ errors.add(violation.attribute, violation.message)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,175 @@
1
+ module Iron
2
+ module FieldDefinition::Validations
3
+ Violation = Data.define(:attribute, :message)
4
+
5
+ RULES = {
6
+ "required" => { kind: :boolean },
7
+ "allowed_values" => { kind: :string_list },
8
+ "min_length" => { kind: :positive_integer, ceiling: "max_length" },
9
+ "max_length" => { kind: :positive_integer },
10
+ "min" => { kind: :number, ceiling: "max" },
11
+ "max" => { kind: :number },
12
+ "file_scope" => { kind: :file_scope },
13
+ "selected_presets" => { kind: :preset_list }
14
+ }.freeze
15
+
16
+ class << self
17
+ def keys_for(type_handle)
18
+ FieldDefinition.classify_type(type_handle).constantize.supported_validations
19
+ end
20
+
21
+ def violations(type_handle, metadata)
22
+ return [ Violation.new(attribute: :metadata, message: "must be an object") ] unless metadata.nil? || metadata.is_a?(Hash)
23
+
24
+ rules = (metadata || {}).transform_keys(&:to_s).slice(*RULES.keys).reject { |_key, value| absent?(value) }
25
+ return [] if rules.empty?
26
+
27
+ allowed = keys_for(type_handle)
28
+ return [ Violation.new(attribute: :metadata, message: "type #{type_handle} supports no validations") ] if allowed.empty?
29
+
30
+ rules.flat_map { |key, value| rule_violations(type_handle, allowed, key, value) } + bound_violations(rules, allowed)
31
+ end
32
+
33
+ def cast_rules(config)
34
+ return config unless config.is_a?(Hash)
35
+
36
+ config.each_with_object({}) do |(key, value), typed|
37
+ key = key.to_s
38
+ rule = RULES[key]
39
+ typed[key] = rule ? cast(rule.fetch(:kind), value) : value
40
+ end
41
+ end
42
+
43
+ def cast(kind, value)
44
+ return nil if value.nil?
45
+
46
+ case kind
47
+ when :boolean then cast_boolean(value)
48
+ when :positive_integer then cast_positive_integer(value)
49
+ when :number then cast_number(value)
50
+ when :string_list then cast_string_list(value)
51
+ when :file_scope then cast_file_scope(value)
52
+ when :preset_list then cast_preset_list(value)
53
+ end
54
+ end
55
+
56
+ private
57
+ # Blank rules read as absent: existing databases store allowed_values: [] for blank textareas.
58
+ def absent?(value)
59
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
60
+ end
61
+
62
+ def rule_violations(type_handle, allowed, key, value)
63
+ unless allowed.include?(key)
64
+ return [ Violation.new(attribute: :metadata, message: %(unknown validation "#{key}" for #{type_handle} (valid: #{allowed.join(', ')}))) ]
65
+ end
66
+
67
+ value_violations(key, value)
68
+ end
69
+
70
+ def value_violations(key, value)
71
+ case RULES.fetch(key).fetch(:kind)
72
+ when :boolean
73
+ [ Violation.new(attribute: key.to_sym, message: "must be true or false") ] unless value == true || value == false
74
+ when :positive_integer
75
+ [ Violation.new(attribute: key.to_sym, message: "must be a positive integer (omit the key instead of 0)") ] unless positive_integer?(value)
76
+ when :number
77
+ [ Violation.new(attribute: key.to_sym, message: "must be a number") ] unless value.is_a?(Numeric)
78
+ when :string_list
79
+ [ Violation.new(attribute: key.to_sym, message: "must be a non-empty array of distinct, non-blank strings") ] unless clean_string_list?(value)
80
+ when :file_scope
81
+ [ Violation.new(attribute: key.to_sym, message: %(must be "all" or "specific")) ] unless %w[all specific].include?(value)
82
+ when :preset_list
83
+ preset_list_violations(key, value)
84
+ end || []
85
+ end
86
+
87
+ def preset_list_violations(key, value)
88
+ unless value.is_a?(Array) && value.all? { |item| item.is_a?(String) }
89
+ return [ Violation.new(attribute: key.to_sym, message: "must be an array of file type presets (#{preset_keys.join(', ')})") ]
90
+ end
91
+
92
+ (value.uniq - preset_keys).map do |unknown|
93
+ Violation.new(attribute: key.to_sym, message: %(contains unknown file type "#{unknown}" (valid: #{preset_keys.join(', ')})))
94
+ end
95
+ end
96
+
97
+ def bound_violations(rules, allowed)
98
+ RULES.filter_map do |floor_key, rule|
99
+ ceiling_key = rule[:ceiling]
100
+ next unless ceiling_key && allowed.include?(floor_key)
101
+
102
+ floor, ceiling = rules[floor_key], rules[ceiling_key]
103
+ next unless comparable?(rule.fetch(:kind), floor) && comparable?(rule.fetch(:kind), ceiling)
104
+ next unless floor > ceiling
105
+
106
+ Violation.new(attribute: floor_key.to_sym, message: "(#{floor}) cannot exceed #{ceiling_key} (#{ceiling})")
107
+ end
108
+ end
109
+
110
+ def comparable?(kind, value)
111
+ kind == :positive_integer ? positive_integer?(value) : value.is_a?(Numeric)
112
+ end
113
+
114
+ def positive_integer?(value)
115
+ value.is_a?(Integer) && value >= 1
116
+ end
117
+
118
+ def clean_string_list?(value)
119
+ value.is_a?(Array) && value.any? &&
120
+ value.all? { |item| item.is_a?(String) && !item.empty? && item == item.strip } &&
121
+ value.uniq.size == value.size
122
+ end
123
+
124
+ def cast_boolean(value)
125
+ case value.to_s
126
+ when "" then nil
127
+ when "0", "f", "F", "false", "FALSE", "False", "off", "OFF" then false
128
+ when "1", "t", "T", "true", "TRUE", "True", "on", "ON" then true
129
+ else value
130
+ end
131
+ end
132
+
133
+ def cast_positive_integer(value)
134
+ case value
135
+ when Integer then value
136
+ when Float then value % 1 == 0 ? value.to_i : value
137
+ when String then value.strip.empty? ? nil : (Integer(value, exception: false) || value)
138
+ else value
139
+ end
140
+ end
141
+
142
+ def cast_number(value)
143
+ case value
144
+ when Numeric then value.to_f
145
+ when String then value.strip.empty? ? nil : (Float(value, exception: false) || value)
146
+ else value
147
+ end
148
+ end
149
+
150
+ def cast_string_list(value)
151
+ return value unless value.is_a?(Array) && value.all? { |item| item.is_a?(String) }
152
+
153
+ value.map(&:strip).reject(&:empty?).presence
154
+ end
155
+
156
+ def cast_file_scope(value)
157
+ scope = value.is_a?(Symbol) ? value.to_s : value
158
+ scope.is_a?(String) && scope.strip.empty? ? nil : scope
159
+ end
160
+
161
+ def cast_preset_list(value)
162
+ return value unless value.is_a?(Array) && value.all? { |item| item.is_a?(String) }
163
+
164
+ members = value.map(&:strip).reject(&:empty?)
165
+ return value unless (members - preset_keys).empty?
166
+
167
+ preset_keys & members
168
+ end
169
+
170
+ def preset_keys
171
+ FieldDefinitions::File::FILE_TYPE_PRESETS.keys
172
+ end
173
+ end
174
+ end
175
+ end
@@ -1,6 +1,6 @@
1
1
  module Iron
2
2
  class FieldDefinition < ApplicationRecord
3
- include Ranked, Searchable, Exportable, Importable
3
+ include Ranked, Searchable, Validatable, Exportable, Importable, Schema::AutoDumpable
4
4
 
5
5
  TYPES = %w[text_field text_area rich_text_area number file boolean date block block_list reference_list reference].freeze
6
6
 
@@ -1,5 +1,7 @@
1
1
  module Iron
2
2
  class FieldDefinitions::Date < FieldDefinition
3
+ supports_validations :required
4
+
3
5
  def value_column
4
6
  :value_datetime
5
7
  end
@@ -28,7 +28,7 @@ module Iron
28
28
  }
29
29
  }.freeze
30
30
 
31
- store_accessor :metadata, :file_scope, :selected_presets
31
+ supports_validations :required, :file_scope, :selected_presets
32
32
 
33
33
  def file_scope
34
34
  super || "all"
@@ -41,21 +41,13 @@ module Iron
41
41
  def accepted_extensions
42
42
  return nil if file_scope == "all"
43
43
 
44
- extensions = []
45
- selected_presets.each do |preset|
46
- extensions.concat(FILE_TYPE_PRESETS[preset][:extensions]) if FILE_TYPE_PRESETS[preset]
47
- end
48
- extensions.uniq
44
+ selected_presets.flat_map { |preset| FILE_TYPE_PRESETS.dig(preset, :extensions) || [] }.uniq
49
45
  end
50
46
 
51
47
  def accepted_mime_types
52
48
  return nil if file_scope == "all"
53
49
 
54
- mime_types = []
55
- selected_presets.each do |preset|
56
- mime_types.concat(FILE_TYPE_PRESETS[preset][:mime_types]) if FILE_TYPE_PRESETS[preset]
57
- end
58
- mime_types.uniq
50
+ selected_presets.flat_map { |preset| FILE_TYPE_PRESETS.dig(preset, :mime_types) || [] }.uniq
59
51
  end
60
52
 
61
53
  def validation_description
@@ -1,5 +1,7 @@
1
1
  module Iron
2
2
  class FieldDefinitions::Number < FieldDefinition
3
+ supports_validations :required, :min, :max
4
+
3
5
  def value_column
4
6
  :value_float
5
7
  end
@@ -1,5 +1,7 @@
1
1
  module Iron
2
2
  class FieldDefinitions::Reference < FieldDefinition
3
+ supports_validations :required
4
+
3
5
  has_and_belongs_to_many :supported_content_types,
4
6
  class_name: "::Iron::ContentType",
5
7
  foreign_key: "field_definition_id",
@@ -1,4 +1,5 @@
1
1
  module Iron
2
2
  class FieldDefinitions::RichTextArea < FieldDefinition
3
+ supports_validations :required
3
4
  end
4
5
  end
@@ -1,5 +1,7 @@
1
1
  module Iron
2
2
  class FieldDefinitions::TextArea < FieldDefinition
3
+ supports_validations :required, :min_length, :max_length
4
+
3
5
  def value_column
4
6
  :value_text
5
7
  end
@@ -1,6 +1,6 @@
1
1
  module Iron
2
2
  class FieldDefinitions::TextField < FieldDefinition
3
- store_accessor :metadata, :allowed_values, :required
3
+ supports_validations :required, :allowed_values, :min_length, :max_length
4
4
 
5
5
  def allowed_values_text
6
6
  Array(allowed_values).join("\n")
@@ -10,14 +10,6 @@ module Iron
10
10
  self.allowed_values = text.to_s.lines.map(&:strip).reject(&:blank?)
11
11
  end
12
12
 
13
- def required
14
- ActiveModel::Type::Boolean.new.cast(super)
15
- end
16
-
17
- def required=(value)
18
- super(ActiveModel::Type::Boolean.new.cast(value))
19
- end
20
-
21
13
  def value_column
22
14
  :value_string
23
15
  end
@@ -22,7 +22,11 @@ module Iron
22
22
 
23
23
  block_definition.field_definitions.each do |nested_def|
24
24
  raw = Field.content_fetch(value, nested_def.handle)
25
- next if raw == CONTENT_MISSING
25
+
26
+ if raw == CONTENT_MISSING
27
+ fields.build(type: nested_def.field_type, entry: entry, definition: nested_def) if nested_def.required?
28
+ next
29
+ end
26
30
 
27
31
  field = fields.build(type: nested_def.field_type, entry: entry, definition: nested_def)
28
32
  field.content_value = raw
@@ -32,7 +32,11 @@ module Iron
32
32
 
33
33
  block_def.field_definitions.each do |nested_def|
34
34
  raw = Field.content_fetch(block_data, nested_def.handle)
35
- next if raw == CONTENT_MISSING
35
+
36
+ if raw == CONTENT_MISSING
37
+ block.fields.build(type: nested_def.field_type, entry: entry, definition: nested_def) if nested_def.required?
38
+ next
39
+ end
36
40
 
37
41
  field = block.fields.build(type: nested_def.field_type, entry: entry, definition: nested_def)
38
42
  field.content_value = raw
@@ -1,16 +1,20 @@
1
1
  module Iron
2
2
  class Fields::Date < Field
3
+ TIME_DESIGNATOR = /[Tt ]/
4
+
3
5
  def content_value=(value)
4
6
  @content_errors = nil
5
7
  if value.blank?
6
8
  self.value_datetime = nil
7
9
  else
8
- self.value_datetime = Time.iso8601(value.to_s)
10
+ self.value_datetime = parse_iso8601(value.to_s)
9
11
  end
10
12
  rescue ArgumentError
11
- add_content_error("must be a valid ISO8601 datetime")
13
+ add_content_error("must be a valid ISO8601 date or datetime")
12
14
  end
13
15
 
16
+ def filled? = !value_datetime.nil?
17
+
14
18
  def value
15
19
  value_datetime
16
20
  end
@@ -18,5 +22,18 @@ module Iron
18
22
  def export_value
19
23
  { type: "date", value: value_datetime&.iso8601 }
20
24
  end
25
+
26
+ private
27
+
28
+ # A failed parse of a string with a time designator is a malformed
29
+ # datetime, not a date-only value — re-raise rather than letting the
30
+ # date fallback silently drop its time part.
31
+ def parse_iso8601(value)
32
+ Time.iso8601(value)
33
+ rescue ArgumentError
34
+ raise if value.match?(TIME_DESIGNATOR)
35
+
36
+ ::Date.iso8601(value).in_time_zone
37
+ end
21
38
  end
22
39
  end
@@ -8,6 +8,8 @@ module Iron
8
8
  @content_errors = nil
9
9
  if value.nil?
10
10
  self.file = nil
11
+ elsif !value.is_a?(String)
12
+ add_content_error("must be an upload token (signed id)")
11
13
  elsif value.present?
12
14
  self.file = ActiveStorage::Blob.find_signed!(value)
13
15
  end
@@ -15,6 +17,8 @@ module Iron
15
17
  add_content_error("has an invalid upload token")
16
18
  end
17
19
 
20
+ def filled? = file.attached?
21
+
18
22
  def value
19
23
  file.attached? ? file : nil
20
24
  end
@@ -42,9 +46,7 @@ module Iron
42
46
  end
43
47
 
44
48
  def definition_restricts_file_type?
45
- file.attached? &&
46
- definition.file_scope == "specific" &&
47
- definition.accepted_mime_types.present?
49
+ file.attached? && definition.accepted_mime_types.present?
48
50
  end
49
51
 
50
52
  def file_type_matches_restrictions
@@ -1,9 +1,19 @@
1
1
  module Iron
2
2
  class Fields::Number < Field
3
+ validate :value_within_bounds
4
+
3
5
  def content_value=(value)
4
- self.value_float = value
6
+ @content_errors = nil
7
+
8
+ if value.nil? || value.is_a?(Numeric) || numeric_string?(value)
9
+ self.value_float = value
10
+ else
11
+ add_content_error("must be a number")
12
+ end
5
13
  end
6
14
 
15
+ def filled? = !value_float.nil?
16
+
7
17
  def value
8
18
  value_float
9
19
  end
@@ -11,5 +21,22 @@ module Iron
11
21
  def export_value
12
22
  { type: "number", value: value_float }
13
23
  end
24
+
25
+ private
26
+
27
+ def value_within_bounds
28
+ return if value_float.nil?
29
+
30
+ errors.add(:base, :greater_than_or_equal_to, count: displayed_bound(definition.min)) if definition.min && value_float < definition.min
31
+ errors.add(:base, :less_than_or_equal_to, count: displayed_bound(definition.max)) if definition.max && value_float > definition.max
32
+ end
33
+
34
+ def numeric_string?(value)
35
+ value.is_a?(String) && !Float(value, exception: false).nil?
36
+ end
37
+
38
+ def displayed_bound(bound)
39
+ bound % 1 == 0 ? bound.to_i : bound
40
+ end
14
41
  end
15
42
  end
@@ -21,6 +21,8 @@ module Iron
21
21
  end
22
22
  end
23
23
 
24
+ def filled? = referenced_entry_id.present?
25
+
24
26
  def value
25
27
  if referenced_entry.present?
26
28
  OpenStruct.new(
@@ -8,6 +8,8 @@ module Iron
8
8
  self.rich_text = value
9
9
  end
10
10
 
11
+ def filled? = rich_text&.body.present?
12
+
11
13
  def searchable_text
12
14
  rich_text&.to_plain_text
13
15
  end
@@ -1,9 +1,13 @@
1
1
  module Iron
2
2
  class Fields::TextArea < Field
3
+ include LengthConstrained
4
+
3
5
  def content_value=(value)
4
6
  self.value_text = value
5
7
  end
6
8
 
9
+ def filled? = value_text.present?
10
+
7
11
  def searchable_text
8
12
  value
9
13
  end
@@ -1,11 +1,15 @@
1
1
  module Iron
2
2
  class Fields::TextField < Field
3
- validate :validate_required_field
3
+ include LengthConstrained
4
+
5
+ validate :value_is_an_allowed_value
4
6
 
5
7
  def content_value=(value)
6
8
  self.value_string = value
7
9
  end
8
10
 
11
+ def filled? = value_string.present?
12
+
9
13
  def searchable_text
10
14
  value
11
15
  end
@@ -20,11 +24,11 @@ module Iron
20
24
 
21
25
  private
22
26
 
23
- def validate_required_field
24
- return unless definition.required
27
+ def value_is_an_allowed_value
28
+ return if value_string.blank? || definition.allowed_values.blank?
25
29
 
26
- if value_string.blank?
27
- errors.add(:base, :blank)
30
+ unless definition.allowed_values.include?(value_string)
31
+ errors.add(:base, "must be one of: #{definition.allowed_values.join(', ')}")
28
32
  end
29
33
  end
30
34
  end
@@ -39,16 +39,7 @@ module Iron
39
39
  def import_schema(zip)
40
40
  return unless zip.find_entry("schema.json")
41
41
 
42
- schema = read_schema(zip)
43
-
44
- ActiveRecord::Base.transaction do
45
- import_locales(schema[:locales] || [])
46
- import_block_definitions(schema[:block_definitions] || [])
47
- populate_block_field_definitions(schema[:block_definitions] || [])
48
- import_content_types(schema[:content_types] || [])
49
- resolve_content_type_references(schema[:content_types] || [])
50
- restore_default_locale(schema[:default_locale])
51
- end
42
+ Schema.import(read_schema(zip))
52
43
  end
53
44
 
54
45
  def import_content(zip, files_dir)
@@ -163,49 +154,5 @@ module Iron
163
154
  def parse_json(content)
164
155
  JSON.parse(content, symbolize_names: true)
165
156
  end
166
-
167
- def import_locales(locales)
168
- locales.each do |attrs|
169
- Locale.find_or_create_by!(code: attrs[:code]) do |locale|
170
- locale.name = attrs[:name]
171
- end
172
- end
173
- end
174
-
175
- def restore_default_locale(locale_code)
176
- return unless locale_code
177
-
178
- locale = Locale.find_by(code: locale_code)
179
- Current.account.update!(default_locale: locale) if locale
180
- end
181
-
182
- def import_block_definitions(definitions)
183
- definitions.each { |attrs| BlockDefinition.import_from_attributes(attrs) }
184
- end
185
-
186
- def populate_block_field_definitions(definitions)
187
- definitions.each { |attrs| BlockDefinition.populate_field_definitions(attrs) }
188
- end
189
-
190
- def import_content_types(definitions)
191
- definitions.each { |attrs| ContentType.import_from_attributes(attrs) }
192
- end
193
-
194
- def resolve_content_type_references(definitions)
195
- definitions.each do |attrs|
196
- content_type = ContentType.find_by(handle: attrs[:handle])
197
- next unless content_type
198
-
199
- if attrs[:title_field_handle].present?
200
- field = content_type.field_definitions.find_by(handle: attrs[:title_field_handle])
201
- content_type.update!(title_field_definition: field) if field
202
- end
203
-
204
- if attrs[:web_page_title_field_handle].present?
205
- field = content_type.field_definitions.find_by(handle: attrs[:web_page_title_field_handle])
206
- content_type.update!(web_page_title_field_definition: field) if field
207
- end
208
- end
209
- end
210
157
  end
211
158
  end
@@ -1,5 +1,7 @@
1
1
  module Iron
2
2
  class Locale < ApplicationRecord
3
+ include Schema::AutoDumpable
4
+
3
5
  validates :code, presence: true
4
6
  validates :name, presence: true
5
7
 
@@ -0,0 +1,26 @@
1
+ module Iron
2
+ module Schema::AutoDumpable
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ after_save :note_iron_schema_change
7
+ after_destroy :note_iron_schema_change
8
+ after_commit :auto_dump_iron_schema
9
+ after_rollback :forget_iron_schema_change
10
+ end
11
+
12
+ private
13
+
14
+ def note_iron_schema_change
15
+ Iron::Schema.note_change
16
+ end
17
+
18
+ def auto_dump_iron_schema
19
+ Iron::Schema.auto_dump
20
+ end
21
+
22
+ def forget_iron_schema_change
23
+ Iron::Schema.forget_change
24
+ end
25
+ end
26
+ end