open_rosa 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/CLAUDE.md +1 -0
- data/LICENSE.txt +21 -0
- data/README.md +285 -0
- data/Rakefile +12 -0
- data/examples/authentication.rb +80 -0
- data/examples/rack_app.rb +178 -0
- data/examples/rails_integration.rb +152 -0
- data/lib/open_rosa/field_context.rb +59 -0
- data/lib/open_rosa/fields/base.rb +18 -0
- data/lib/open_rosa/fields/boolean.rb +17 -0
- data/lib/open_rosa/fields/group.rb +19 -0
- data/lib/open_rosa/fields/input.rb +32 -0
- data/lib/open_rosa/fields/range.rb +34 -0
- data/lib/open_rosa/fields/repeat.rb +20 -0
- data/lib/open_rosa/fields/select.rb +28 -0
- data/lib/open_rosa/fields/select1.rb +27 -0
- data/lib/open_rosa/fields/trigger.rb +17 -0
- data/lib/open_rosa/fields/upload.rb +18 -0
- data/lib/open_rosa/form.rb +146 -0
- data/lib/open_rosa/form_dsl.rb +70 -0
- data/lib/open_rosa/form_list.rb +97 -0
- data/lib/open_rosa/manifest.rb +105 -0
- data/lib/open_rosa/media_file.rb +97 -0
- data/lib/open_rosa/middleware.rb +352 -0
- data/lib/open_rosa/submission.rb +195 -0
- data/lib/open_rosa/version.rb +5 -0
- data/lib/open_rosa/xform.rb +250 -0
- data/lib/open_rosa.rb +27 -0
- data/sig/openrosa.rbs +4 -0
- metadata +105 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Example of how to integrate OpenRosa middleware in a Rails application
|
|
4
|
+
#
|
|
5
|
+
# Add this to your Rails config/application.rb or config/environments/production.rb
|
|
6
|
+
|
|
7
|
+
# Step 1: Define your forms (perhaps in app/forms/)
|
|
8
|
+
class MySurveyForm < OpenRosa::Form
|
|
9
|
+
form_id "my_survey"
|
|
10
|
+
version "1.0.0"
|
|
11
|
+
name "My Survey"
|
|
12
|
+
download_url "https://myapp.com/openrosa/forms/my_survey"
|
|
13
|
+
|
|
14
|
+
input :name, label: "Name", type: :string, required: true
|
|
15
|
+
input :age, label: "Age", type: :int
|
|
16
|
+
select1 :gender, label: "Gender", choices: ["Male", "Female", "Other"]
|
|
17
|
+
|
|
18
|
+
# Option A: Form-specific submission handler
|
|
19
|
+
on_submit do |submission|
|
|
20
|
+
# Handle submissions for this specific form
|
|
21
|
+
Survey.create!(
|
|
22
|
+
instance_id: submission.instance_id,
|
|
23
|
+
name: submission.data["name"],
|
|
24
|
+
age: submission.data["age"],
|
|
25
|
+
gender: submission.data["gender"],
|
|
26
|
+
submitted_at: submission.metadata[:timeEnd]
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
"Thank you for completing the survey!"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Step 2: Add middleware to your Rails application
|
|
34
|
+
# In config/application.rb:
|
|
35
|
+
#
|
|
36
|
+
# module MyApp
|
|
37
|
+
# class Application < Rails::Application
|
|
38
|
+
# # ... other config ...
|
|
39
|
+
#
|
|
40
|
+
# config.middleware.use OpenRosa::Middleware do |config|
|
|
41
|
+
# config.forms = [MySurveyForm]
|
|
42
|
+
# config.mount_path = "/openrosa"
|
|
43
|
+
#
|
|
44
|
+
# # Option B: Global submission handler (for all forms)
|
|
45
|
+
# config.on_submission do |submission|
|
|
46
|
+
# # Generic handler for all form submissions
|
|
47
|
+
# FormSubmission.create!(
|
|
48
|
+
# form_id: submission.form_id,
|
|
49
|
+
# instance_id: submission.instance_id,
|
|
50
|
+
# data: submission.data,
|
|
51
|
+
# metadata: submission.metadata,
|
|
52
|
+
# raw_xml: submission.raw_xml
|
|
53
|
+
# )
|
|
54
|
+
#
|
|
55
|
+
# # Handle file attachments
|
|
56
|
+
# submission.attachments.each do |attachment|
|
|
57
|
+
# # Save to ActiveStorage or file system
|
|
58
|
+
# record = FormSubmission.find_by(instance_id: submission.instance_id)
|
|
59
|
+
# record.attachments.attach(
|
|
60
|
+
# io: StringIO.new(attachment.read),
|
|
61
|
+
# filename: attachment.filename,
|
|
62
|
+
# content_type: attachment.content_type
|
|
63
|
+
# )
|
|
64
|
+
# end
|
|
65
|
+
#
|
|
66
|
+
# "Submission received successfully!"
|
|
67
|
+
# end
|
|
68
|
+
# end
|
|
69
|
+
# end
|
|
70
|
+
# end
|
|
71
|
+
|
|
72
|
+
# Alternative: Use an initializer
|
|
73
|
+
# Create config/initializers/openrosa.rb:
|
|
74
|
+
#
|
|
75
|
+
# Rails.application.config.middleware.use OpenRosa::Middleware do |config|
|
|
76
|
+
# # Load all form classes from app/forms
|
|
77
|
+
# config.forms = Dir[Rails.root.join("app/forms/**/*.rb")].map do |file|
|
|
78
|
+
# require file
|
|
79
|
+
# # Extract class name from file and constantize
|
|
80
|
+
# File.basename(file, ".rb").camelize.constantize
|
|
81
|
+
# end
|
|
82
|
+
#
|
|
83
|
+
# config.mount_path = "/openrosa"
|
|
84
|
+
#
|
|
85
|
+
# # Global submission handler
|
|
86
|
+
# config.on_submission do |submission|
|
|
87
|
+
# # Process submission...
|
|
88
|
+
# Rails.logger.info "Received submission for form: #{submission.form_id}"
|
|
89
|
+
#
|
|
90
|
+
# FormSubmission.create!(
|
|
91
|
+
# form_id: submission.form_id,
|
|
92
|
+
# instance_id: submission.instance_id,
|
|
93
|
+
# payload: submission.data
|
|
94
|
+
# )
|
|
95
|
+
# end
|
|
96
|
+
# end
|
|
97
|
+
|
|
98
|
+
# Step 3: Endpoints will be available at:
|
|
99
|
+
# GET /openrosa/formList - List all forms
|
|
100
|
+
# GET /openrosa/forms/:id - Download specific form XForm XML
|
|
101
|
+
# HEAD /openrosa/submission - Pre-flight check for submissions
|
|
102
|
+
# POST /openrosa/submission - Receive form submissions
|
|
103
|
+
#
|
|
104
|
+
# These endpoints will properly return XML with OpenRosa headers:
|
|
105
|
+
# - Content-Type: text/xml; charset=utf-8
|
|
106
|
+
# - X-OpenRosa-Version: 1.0
|
|
107
|
+
# - Date: [current date in HTTP format]
|
|
108
|
+
|
|
109
|
+
# Step 4: (Optional) Add authentication
|
|
110
|
+
# You can add authentication by placing another middleware before OpenRosa:
|
|
111
|
+
#
|
|
112
|
+
# Rails.application.config.middleware.insert_before(
|
|
113
|
+
# OpenRosa::Middleware,
|
|
114
|
+
# MyAuthMiddleware
|
|
115
|
+
# )
|
|
116
|
+
#
|
|
117
|
+
# Or use Rails controllers instead for more control:
|
|
118
|
+
# Create app/controllers/openrosa_controller.rb:
|
|
119
|
+
#
|
|
120
|
+
# class OpenrosaController < ApplicationController
|
|
121
|
+
# before_action :authenticate_user!
|
|
122
|
+
#
|
|
123
|
+
# def form_list
|
|
124
|
+
# form_id = params[:formID]
|
|
125
|
+
# verbose = params[:verbose] == "true"
|
|
126
|
+
#
|
|
127
|
+
# forms = [MySurveyForm.new]
|
|
128
|
+
# form_list = OpenRosa::FormList.new(forms, form_id: form_id, verbose: verbose)
|
|
129
|
+
#
|
|
130
|
+
# render xml: form_list.to_xml, content_type: "text/xml; charset=utf-8"
|
|
131
|
+
# response.headers["X-OpenRosa-Version"] = "1.0"
|
|
132
|
+
# end
|
|
133
|
+
#
|
|
134
|
+
# def form_download
|
|
135
|
+
# form_class = find_form_class(params[:id])
|
|
136
|
+
# return head :not_found unless form_class
|
|
137
|
+
#
|
|
138
|
+
# form = form_class.new
|
|
139
|
+
# render xml: form.to_xml, content_type: "text/xml; charset=utf-8"
|
|
140
|
+
# response.headers["X-OpenRosa-Version"] = "1.0"
|
|
141
|
+
# end
|
|
142
|
+
#
|
|
143
|
+
# private
|
|
144
|
+
#
|
|
145
|
+
# def find_form_class(form_id)
|
|
146
|
+
# [MySurveyForm].find { |fc| fc.new.form_id == form_id }
|
|
147
|
+
# end
|
|
148
|
+
# end
|
|
149
|
+
#
|
|
150
|
+
# And in config/routes.rb:
|
|
151
|
+
# get "/openrosa/formList", to: "openrosa#form_list"
|
|
152
|
+
# get "/openrosa/forms/:id", to: "openrosa#form_download"
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenRosa
|
|
4
|
+
# Helper class for evaluating blocks in group/repeat DSL
|
|
5
|
+
# This allows for clean nested field definitions within groups and repeats
|
|
6
|
+
class FieldContext
|
|
7
|
+
def initialize(fields_array)
|
|
8
|
+
@fields = fields_array
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def input(name, options = {})
|
|
12
|
+
@fields << Fields::Input.new(name, options)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def select1(name, options = {})
|
|
16
|
+
@fields << Fields::Select1.new(name, options)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def select(name, options = {})
|
|
20
|
+
@fields << Fields::Select.new(name, options)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def boolean(name, options = {})
|
|
24
|
+
@fields << Fields::Boolean.new(name, options)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def upload(name, options = {})
|
|
28
|
+
@fields << Fields::Upload.new(name, options)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def range(name, options = {})
|
|
32
|
+
@fields << Fields::Range.new(name, options)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def trigger(name, options = {})
|
|
36
|
+
@fields << Fields::Trigger.new(name, options)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def group(name, options = {}, &)
|
|
40
|
+
nested_fields = []
|
|
41
|
+
if block_given?
|
|
42
|
+
context = FieldContext.new(nested_fields)
|
|
43
|
+
context.instance_eval(&)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
@fields << Fields::Group.new(name, options.merge(fields: nested_fields))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def repeat(name, options = {}, &)
|
|
50
|
+
nested_fields = []
|
|
51
|
+
if block_given?
|
|
52
|
+
context = FieldContext.new(nested_fields)
|
|
53
|
+
context.instance_eval(&)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
@fields << Fields::Repeat.new(name, options.merge(fields: nested_fields))
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenRosa
|
|
4
|
+
module Fields
|
|
5
|
+
# Base class for all form field types
|
|
6
|
+
class Base
|
|
7
|
+
attr_reader :name, :label, :hint, :required, :default
|
|
8
|
+
|
|
9
|
+
def initialize(name, options = {})
|
|
10
|
+
@name = name
|
|
11
|
+
@label = options[:label]
|
|
12
|
+
@hint = options[:hint]
|
|
13
|
+
@required = options.fetch(:required, false)
|
|
14
|
+
@default = options[:default]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenRosa
|
|
4
|
+
module Fields
|
|
5
|
+
# Boolean field for true/false values
|
|
6
|
+
# Maps to XForms select1 element with true/false options
|
|
7
|
+
# Data type: boolean (stored as "true" or "false" strings in XForms)
|
|
8
|
+
class Boolean < Base
|
|
9
|
+
attr_reader :appearance
|
|
10
|
+
|
|
11
|
+
def initialize(name, options = {})
|
|
12
|
+
super
|
|
13
|
+
@appearance = options[:appearance]
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenRosa
|
|
4
|
+
module Fields
|
|
5
|
+
# Group field for logical grouping of fields
|
|
6
|
+
# Maps to XForms <group> element
|
|
7
|
+
# Can contain nested fields and supports conditional display
|
|
8
|
+
class Group < Base
|
|
9
|
+
attr_reader :fields, :relevant, :appearance
|
|
10
|
+
|
|
11
|
+
def initialize(name, options = {})
|
|
12
|
+
super
|
|
13
|
+
@fields = options.fetch(:fields, [])
|
|
14
|
+
@relevant = options[:relevant]
|
|
15
|
+
@appearance = options[:appearance]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenRosa
|
|
4
|
+
module Fields
|
|
5
|
+
# Input field for text, numbers, dates, and other basic input types
|
|
6
|
+
class Input < Base
|
|
7
|
+
attr_reader :type, :constraint, :constraint_message
|
|
8
|
+
|
|
9
|
+
VALID_TYPES = %i[
|
|
10
|
+
string int decimal date time dateTime
|
|
11
|
+
geopoint geotrace geoshape barcode binary intent
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(name, options = {})
|
|
15
|
+
super
|
|
16
|
+
@type = options.fetch(:type, :string)
|
|
17
|
+
@constraint = options[:constraint]
|
|
18
|
+
@constraint_message = options[:constraint_message]
|
|
19
|
+
|
|
20
|
+
validate!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def validate!
|
|
26
|
+
return if VALID_TYPES.include?(@type)
|
|
27
|
+
|
|
28
|
+
raise ArgumentError, "type must be one of: #{VALID_TYPES.join(", ")}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenRosa
|
|
4
|
+
module Fields
|
|
5
|
+
# Range field for numeric sliders/pickers
|
|
6
|
+
# Maps to XForms <range> element
|
|
7
|
+
# Data type: int or decimal
|
|
8
|
+
class Range < Base
|
|
9
|
+
attr_reader :start, :end, :step, :type
|
|
10
|
+
|
|
11
|
+
VALID_TYPES = %i[int decimal].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(name, options = {})
|
|
14
|
+
super
|
|
15
|
+
@start = options[:start]
|
|
16
|
+
@end = options[:end]
|
|
17
|
+
@step = options.fetch(:step, 1)
|
|
18
|
+
@type = options.fetch(:type, :int)
|
|
19
|
+
|
|
20
|
+
validate!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def validate!
|
|
26
|
+
raise ArgumentError, "start is required for Range field" if @start.nil?
|
|
27
|
+
raise ArgumentError, "end is required for Range field" if @end.nil?
|
|
28
|
+
raise ArgumentError, "start must be less than end" if @start >= @end
|
|
29
|
+
raise ArgumentError, "step must be greater than 0" if @step <= 0
|
|
30
|
+
raise ArgumentError, "type must be :int or :decimal" unless VALID_TYPES.include?(@type)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenRosa
|
|
4
|
+
module Fields
|
|
5
|
+
# Repeat field for repeating sections
|
|
6
|
+
# Maps to XForms <repeat> element
|
|
7
|
+
# All fields within repeat can occur multiple times
|
|
8
|
+
class Repeat < Base
|
|
9
|
+
attr_reader :fields, :count, :appearance, :relevant
|
|
10
|
+
|
|
11
|
+
def initialize(name, options = {})
|
|
12
|
+
super
|
|
13
|
+
@fields = options.fetch(:fields, [])
|
|
14
|
+
@count = options[:count]
|
|
15
|
+
@appearance = options[:appearance]
|
|
16
|
+
@relevant = options[:relevant]
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenRosa
|
|
4
|
+
module Fields
|
|
5
|
+
# Multiple select field (checkboxes)
|
|
6
|
+
# Maps to XForms <select> element
|
|
7
|
+
# Values are stored as space-separated strings in submissions
|
|
8
|
+
class Select < Base
|
|
9
|
+
attr_reader :choices, :appearance
|
|
10
|
+
|
|
11
|
+
def initialize(name, options = {})
|
|
12
|
+
super
|
|
13
|
+
@choices = options[:choices]
|
|
14
|
+
@appearance = options[:appearance]
|
|
15
|
+
|
|
16
|
+
validate!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def validate!
|
|
22
|
+
raise ArgumentError, "choices is required for Select field" if @choices.nil?
|
|
23
|
+
raise ArgumentError, "choices must be an Array or Hash" unless @choices.is_a?(Array) || @choices.is_a?(Hash)
|
|
24
|
+
raise ArgumentError, "choices cannot be empty" if @choices.empty?
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenRosa
|
|
4
|
+
module Fields
|
|
5
|
+
# Single select field (radio buttons or dropdown)
|
|
6
|
+
# Maps to XForms <select1> element
|
|
7
|
+
class Select1 < Base
|
|
8
|
+
attr_reader :choices, :appearance
|
|
9
|
+
|
|
10
|
+
def initialize(name, options = {})
|
|
11
|
+
super
|
|
12
|
+
@choices = options[:choices]
|
|
13
|
+
@appearance = options[:appearance]
|
|
14
|
+
|
|
15
|
+
validate!
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def validate!
|
|
21
|
+
raise ArgumentError, "choices is required for Select1 field" if @choices.nil?
|
|
22
|
+
raise ArgumentError, "choices must be an Array or Hash" unless @choices.is_a?(Array) || @choices.is_a?(Hash)
|
|
23
|
+
raise ArgumentError, "choices cannot be empty" if @choices.empty?
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenRosa
|
|
4
|
+
module Fields
|
|
5
|
+
# Trigger field for confirmation buttons/checkboxes
|
|
6
|
+
# Maps to XForms <trigger> element
|
|
7
|
+
# Adds "OK" value when confirmed
|
|
8
|
+
class Trigger < Base
|
|
9
|
+
attr_reader :appearance
|
|
10
|
+
|
|
11
|
+
def initialize(name, options = {})
|
|
12
|
+
super
|
|
13
|
+
@appearance = options[:appearance]
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenRosa
|
|
4
|
+
module Fields
|
|
5
|
+
# Upload field for file/media uploads
|
|
6
|
+
# Maps to XForms <upload> element
|
|
7
|
+
# Data type: binary
|
|
8
|
+
class Upload < Base
|
|
9
|
+
attr_reader :mediatype, :max_pixels
|
|
10
|
+
|
|
11
|
+
def initialize(name, options = {})
|
|
12
|
+
super
|
|
13
|
+
@mediatype = options[:mediatype]
|
|
14
|
+
@max_pixels = options[:max_pixels]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenRosa
|
|
4
|
+
# Base class for defining OpenRosa forms with a clean DSL
|
|
5
|
+
class Form
|
|
6
|
+
class << self
|
|
7
|
+
include FormDSL
|
|
8
|
+
|
|
9
|
+
def form_id(id = nil)
|
|
10
|
+
if id
|
|
11
|
+
@form_id = id
|
|
12
|
+
else
|
|
13
|
+
@form_id
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def version(ver = nil)
|
|
18
|
+
if ver
|
|
19
|
+
@version = ver
|
|
20
|
+
else
|
|
21
|
+
@version
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def name(form_name = nil)
|
|
26
|
+
if form_name
|
|
27
|
+
@name = form_name
|
|
28
|
+
else
|
|
29
|
+
@name
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def description_text(text = nil)
|
|
34
|
+
if text
|
|
35
|
+
@description_text = text
|
|
36
|
+
else
|
|
37
|
+
@description_text
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def description_url(url = nil)
|
|
42
|
+
if url
|
|
43
|
+
@description_url = url
|
|
44
|
+
else
|
|
45
|
+
@description_url
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def download_url(url = nil)
|
|
50
|
+
if url
|
|
51
|
+
@download_url = url
|
|
52
|
+
else
|
|
53
|
+
@download_url
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def manifest_url(url = nil)
|
|
58
|
+
if url
|
|
59
|
+
@manifest_url = url
|
|
60
|
+
else
|
|
61
|
+
@manifest_url
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Calculates MD5 hash of the form's XForm XML content
|
|
66
|
+
# Returns format: "md5:abc123..."
|
|
67
|
+
def form_hash
|
|
68
|
+
require "digest"
|
|
69
|
+
xml = to_xml
|
|
70
|
+
hash = Digest::MD5.hexdigest(xml)
|
|
71
|
+
"md5:#{hash}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Generates XForm XML from the form definition
|
|
75
|
+
# Returns the complete XForm XML as a string
|
|
76
|
+
def to_xml
|
|
77
|
+
XForm.new(self).to_xml
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Define a handler for form submissions
|
|
81
|
+
#
|
|
82
|
+
# @yield [submission] The parsed submission
|
|
83
|
+
# @yieldparam submission [Submission] The submission object
|
|
84
|
+
# @yieldreturn [String, nil] Optional success message
|
|
85
|
+
#
|
|
86
|
+
# Example:
|
|
87
|
+
# class MyForm < OpenRosa::Form
|
|
88
|
+
# on_submit do |submission|
|
|
89
|
+
# MyModel.create!(data: submission.data)
|
|
90
|
+
# "Thanks for submitting!"
|
|
91
|
+
# end
|
|
92
|
+
# end
|
|
93
|
+
def on_submit(&block)
|
|
94
|
+
@submission_handler = block
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Handle a submission (called by middleware)
|
|
98
|
+
#
|
|
99
|
+
# @param submission [Submission] The parsed submission
|
|
100
|
+
# @return [String, nil] Success message or nil
|
|
101
|
+
def handle_submission(submission)
|
|
102
|
+
@submission_handler&.call(submission)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def form_id
|
|
107
|
+
self.class.form_id
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def version
|
|
111
|
+
self.class.version
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def name
|
|
115
|
+
self.class.name
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def description_text
|
|
119
|
+
self.class.description_text
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def description_url
|
|
123
|
+
self.class.description_url
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def download_url
|
|
127
|
+
self.class.download_url
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def manifest_url
|
|
131
|
+
self.class.manifest_url
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def form_hash
|
|
135
|
+
self.class.form_hash
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def fields
|
|
139
|
+
self.class.fields
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def to_xml
|
|
143
|
+
self.class.to_xml
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenRosa
|
|
4
|
+
# DSL methods for defining form fields
|
|
5
|
+
module FormDSL
|
|
6
|
+
# Returns array of all fields defined in this form
|
|
7
|
+
def fields
|
|
8
|
+
@fields ||= []
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# DSL method to add an input field
|
|
12
|
+
def input(name, options = {})
|
|
13
|
+
fields << Fields::Input.new(name, options)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# DSL method to add a select1 field (single select)
|
|
17
|
+
def select1(name, options = {})
|
|
18
|
+
fields << Fields::Select1.new(name, options)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# DSL method to add a select field (multiple select)
|
|
22
|
+
def select(name, options = {})
|
|
23
|
+
fields << Fields::Select.new(name, options)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# DSL method to add a boolean field
|
|
27
|
+
def boolean(name, options = {})
|
|
28
|
+
fields << Fields::Boolean.new(name, options)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# DSL method to add an upload field
|
|
32
|
+
def upload(name, options = {})
|
|
33
|
+
fields << Fields::Upload.new(name, options)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# DSL method to add a range field
|
|
37
|
+
def range(name, options = {})
|
|
38
|
+
fields << Fields::Range.new(name, options)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# DSL method to add a trigger field
|
|
42
|
+
def trigger(name, options = {})
|
|
43
|
+
fields << Fields::Trigger.new(name, options)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# DSL method to add a group field with nested fields
|
|
47
|
+
def group(name, options = {}, &)
|
|
48
|
+
# Create a temporary context to collect nested fields
|
|
49
|
+
nested_fields = []
|
|
50
|
+
if block_given?
|
|
51
|
+
context = FieldContext.new(nested_fields)
|
|
52
|
+
context.instance_eval(&)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
fields << Fields::Group.new(name, options.merge(fields: nested_fields))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# DSL method to add a repeat field with nested fields
|
|
59
|
+
def repeat(name, options = {}, &)
|
|
60
|
+
# Create a temporary context to collect nested fields
|
|
61
|
+
nested_fields = []
|
|
62
|
+
if block_given?
|
|
63
|
+
context = FieldContext.new(nested_fields)
|
|
64
|
+
context.instance_eval(&)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
fields << Fields::Repeat.new(name, options.merge(fields: nested_fields))
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|