sandals 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/LICENSE +21 -0
- data/README.md +604 -0
- data/bin/sandals +8 -0
- data/lib/sandals/action_error.rb +41 -0
- data/lib/sandals/application.rb +114 -0
- data/lib/sandals/assets/TURBO-LICENSE +20 -0
- data/lib/sandals/assets/turbo.es2017-umd.js +7298 -0
- data/lib/sandals/binding_resolver.rb +77 -0
- data/lib/sandals/cli.rb +75 -0
- data/lib/sandals/code_reloader.rb +80 -0
- data/lib/sandals/event_dispatcher.rb +35 -0
- data/lib/sandals/html_renderer.rb +686 -0
- data/lib/sandals/server.rb +179 -0
- data/lib/sandals/snapshot_history.rb +34 -0
- data/lib/sandals/test_session.rb +352 -0
- data/lib/sandals/uploaded_file.rb +18 -0
- data/lib/sandals/version.rb +5 -0
- data/lib/sandals/view.rb +568 -0
- data/lib/sandals.rb +34 -0
- metadata +74 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "webrick"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module Sandals
|
|
8
|
+
class Server
|
|
9
|
+
SESSION_COOKIE = "sandals_session"
|
|
10
|
+
MAX_UPLOAD_SIZE = 10 * 1024 * 1024
|
|
11
|
+
|
|
12
|
+
attr_reader :port
|
|
13
|
+
|
|
14
|
+
def initialize(application, port: 0, reload_path: nil)
|
|
15
|
+
@application_factory = application
|
|
16
|
+
@reloader = CodeReloader.new(reload_path) if reload_path
|
|
17
|
+
@applications = {}
|
|
18
|
+
@applications_mutex = Mutex.new
|
|
19
|
+
@server = WEBrick::HTTPServer.new(
|
|
20
|
+
BindAddress: "127.0.0.1",
|
|
21
|
+
Port: port,
|
|
22
|
+
AccessLog: [],
|
|
23
|
+
Logger: WEBrick::Log.new(File::NULL)
|
|
24
|
+
)
|
|
25
|
+
@port = @server.listeners.first.addr[1]
|
|
26
|
+
@server.mount_proc("/") { |request, response| render(request, response) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def start
|
|
30
|
+
@server.start
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def stop
|
|
34
|
+
@server.shutdown
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def url
|
|
38
|
+
"http://127.0.0.1:#{port}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def render(request, response)
|
|
44
|
+
if request.request_method == "GET" && request.path == HTMLRenderer::TURBO_URL
|
|
45
|
+
render_turbo(response)
|
|
46
|
+
return
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
if request.request_method == "GET" && request.path == "/__sandals/reload"
|
|
50
|
+
render_reload(response)
|
|
51
|
+
return
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
application = application_for(request, response)
|
|
55
|
+
if request.request_method == "POST" && request.path == "/events"
|
|
56
|
+
render_event(request, response, application)
|
|
57
|
+
elsif request.request_method == "POST" && request.path == "/files"
|
|
58
|
+
render_file(request, response, application)
|
|
59
|
+
else
|
|
60
|
+
response.status = 200
|
|
61
|
+
response["content-type"] = "text/html; charset=utf-8"
|
|
62
|
+
response.body = application.render(development_reload: !@reloader.nil?)
|
|
63
|
+
end
|
|
64
|
+
rescue StandardError => error
|
|
65
|
+
response.status = 500
|
|
66
|
+
response["content-type"] = "text/plain; charset=utf-8"
|
|
67
|
+
response.body = "#{error.class}: #{error.message}\n#{error.backtrace&.first}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def render_turbo(response)
|
|
71
|
+
response.status = 200
|
|
72
|
+
response["cache-control"] = "public, max-age=31536000, immutable"
|
|
73
|
+
response["content-type"] = "text/javascript; charset=utf-8"
|
|
74
|
+
response.body = File.binread(HTMLRenderer::TURBO_PATH)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def render_event(request, response, application)
|
|
78
|
+
message = JSON.parse(request.body)
|
|
79
|
+
revision, content = application.dispatch(
|
|
80
|
+
revision: message.fetch("revision"),
|
|
81
|
+
oldest_pending_revision: message.fetch("oldest_pending_revision"),
|
|
82
|
+
events: message.fetch("events")
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
render_update(response, revision, content)
|
|
86
|
+
rescue ActionError => error
|
|
87
|
+
render_action_error(response, message.fetch("revision"), error)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def render_file(request, response, application)
|
|
91
|
+
form = request.query
|
|
92
|
+
revision = Integer(form.fetch("revision"))
|
|
93
|
+
file_data = form.fetch("file")
|
|
94
|
+
content = file_data.to_s.b
|
|
95
|
+
if content.bytesize > MAX_UPLOAD_SIZE
|
|
96
|
+
response.status = 413
|
|
97
|
+
response["content-type"] = "text/plain; charset=utf-8"
|
|
98
|
+
response.body = "uploaded file exceeds the 10 MB limit"
|
|
99
|
+
return
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
file = UploadedFile.new(
|
|
103
|
+
name: file_data.filename,
|
|
104
|
+
content_type: file_data["content-type"] || "application/octet-stream",
|
|
105
|
+
content:
|
|
106
|
+
)
|
|
107
|
+
next_revision, rendered_view = application.dispatch(
|
|
108
|
+
revision:,
|
|
109
|
+
oldest_pending_revision: Integer(form.fetch("oldest_pending_revision")),
|
|
110
|
+
events: [
|
|
111
|
+
{
|
|
112
|
+
"target" => form.fetch("target"),
|
|
113
|
+
"event" => "change",
|
|
114
|
+
"value" => file
|
|
115
|
+
}
|
|
116
|
+
]
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
render_update(response, next_revision, rendered_view)
|
|
120
|
+
rescue ActionError => error
|
|
121
|
+
render_action_error(response, revision, error)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def render_update(response, revision, content)
|
|
125
|
+
response.status = 200
|
|
126
|
+
response["content-type"] = "text/vnd.turbo-stream.html; charset=utf-8"
|
|
127
|
+
response["X-Sandals-Revision"] = revision.to_s
|
|
128
|
+
response.body = HTMLRenderer.new.clear_action_error + <<~HTML
|
|
129
|
+
<turbo-stream action="update" method="morph" target="sandals-view">
|
|
130
|
+
<template>#{content}</template>
|
|
131
|
+
</turbo-stream>
|
|
132
|
+
HTML
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def render_action_error(response, revision, error)
|
|
136
|
+
response.status = 500
|
|
137
|
+
response["content-type"] = "text/vnd.turbo-stream.html; charset=utf-8"
|
|
138
|
+
response["X-Sandals-Revision"] = revision.to_s
|
|
139
|
+
response.body = HTMLRenderer.new.render_action_error(error)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def render_reload(response)
|
|
143
|
+
unless @reloader
|
|
144
|
+
response.status = 404
|
|
145
|
+
response.body = "development reload is not enabled"
|
|
146
|
+
return
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
result = @reloader.check
|
|
150
|
+
if result.application
|
|
151
|
+
@applications_mutex.synchronize do
|
|
152
|
+
@application_factory = result.application
|
|
153
|
+
@applications.clear
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
response.status = 200
|
|
158
|
+
response["cache-control"] = "no-store"
|
|
159
|
+
response["content-type"] = "application/json; charset=utf-8"
|
|
160
|
+
response.body = JSON.generate(
|
|
161
|
+
version: result.version,
|
|
162
|
+
error: result.error
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def application_for(request, response)
|
|
167
|
+
session_id = request.cookies.find { |cookie| cookie.name == SESSION_COOKIE }&.value
|
|
168
|
+
application = @applications_mutex.synchronize { @applications[session_id] }
|
|
169
|
+
return application if application
|
|
170
|
+
|
|
171
|
+
session_id = SecureRandom.hex(16)
|
|
172
|
+
application = @application_factory.new_session
|
|
173
|
+
@applications_mutex.synchronize { @applications[session_id] = application }
|
|
174
|
+
response["Set-Cookie"] =
|
|
175
|
+
"#{SESSION_COOKIE}=#{session_id}; Path=/; HttpOnly; SameSite=Lax"
|
|
176
|
+
application
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sandals
|
|
4
|
+
class SnapshotHistory
|
|
5
|
+
Snapshot = Data.define(:revision, :view, :controls)
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@revision = 0
|
|
9
|
+
@snapshots = []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def publish(view, controls:)
|
|
13
|
+
@revision += 1
|
|
14
|
+
snapshot = Snapshot.new(@revision, view, controls)
|
|
15
|
+
@snapshots << snapshot
|
|
16
|
+
snapshot
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def fetch(revision)
|
|
20
|
+
snapshot = @snapshots.find { |candidate| candidate.revision == revision }
|
|
21
|
+
return snapshot if snapshot
|
|
22
|
+
|
|
23
|
+
raise ArgumentError, "unknown revision: #{revision}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def current
|
|
27
|
+
@snapshots.last
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def discard_before(revision)
|
|
31
|
+
@snapshots.reject! { |snapshot| snapshot.revision < revision }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sandals
|
|
4
|
+
class TestSession
|
|
5
|
+
class AssertionError < StandardError; end
|
|
6
|
+
|
|
7
|
+
class FieldLocator
|
|
8
|
+
attr_reader :label
|
|
9
|
+
|
|
10
|
+
def initialize(session, label)
|
|
11
|
+
@session = session
|
|
12
|
+
@label = label.to_s
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def fill(value)
|
|
16
|
+
@session.fill(label, with: value)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def check
|
|
20
|
+
@session.check(label)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def uncheck
|
|
24
|
+
@session.uncheck(label)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def select(option)
|
|
28
|
+
@session.select(option, from: label)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def choose(option)
|
|
32
|
+
@session.choose(option, from: label)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def value
|
|
36
|
+
@session.field_value(label)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def attach(file)
|
|
40
|
+
@session.attach(label, file:)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def inspect
|
|
44
|
+
%(#<#{self.class} label="#{label}">)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
FIELD_TYPES = [
|
|
49
|
+
TextField,
|
|
50
|
+
NumberField,
|
|
51
|
+
TextArea,
|
|
52
|
+
Checkbox,
|
|
53
|
+
Select,
|
|
54
|
+
Radio,
|
|
55
|
+
FileField
|
|
56
|
+
].freeze
|
|
57
|
+
FILLABLE_TYPES = [TextField, NumberField, TextArea].freeze
|
|
58
|
+
|
|
59
|
+
def initialize(application)
|
|
60
|
+
@application = application
|
|
61
|
+
use_snapshot(@application.__send__(:publish_view))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def field(label)
|
|
65
|
+
find_field(label)
|
|
66
|
+
FieldLocator.new(self, label)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def fill(label, with:)
|
|
70
|
+
control = find_field(label)
|
|
71
|
+
unless FILLABLE_TYPES.any? { |type| control.is_a?(type) }
|
|
72
|
+
raise AssertionError, %(field "#{label}" cannot be filled)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
dispatch(control, "change", "value" => serialize(with))
|
|
76
|
+
self
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def field_value(label_or_locator)
|
|
80
|
+
label =
|
|
81
|
+
if label_or_locator.is_a?(FieldLocator)
|
|
82
|
+
label_or_locator.label
|
|
83
|
+
else
|
|
84
|
+
label_or_locator
|
|
85
|
+
end
|
|
86
|
+
control = find_field(label)
|
|
87
|
+
return control.checked? if control.is_a?(Checkbox)
|
|
88
|
+
|
|
89
|
+
control.value
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def click(content)
|
|
93
|
+
button = find_one(
|
|
94
|
+
controls.select do |control|
|
|
95
|
+
control.is_a?(Button) && control.content == content.to_s
|
|
96
|
+
end,
|
|
97
|
+
%(button named "#{content}")
|
|
98
|
+
)
|
|
99
|
+
dispatch(button, button.is_a?(Submit) ? "submit" : "click")
|
|
100
|
+
self
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def attach(label, file:)
|
|
104
|
+
control = find_field(label)
|
|
105
|
+
unless control.is_a?(FileField)
|
|
106
|
+
raise AssertionError, %(field "#{label}" is not a file field)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
uploaded_file =
|
|
110
|
+
if file.is_a?(UploadedFile)
|
|
111
|
+
file
|
|
112
|
+
else
|
|
113
|
+
path = File.expand_path(file.to_s)
|
|
114
|
+
UploadedFile.new(
|
|
115
|
+
name: File.basename(path),
|
|
116
|
+
content_type: content_type_for(path),
|
|
117
|
+
content: File.binread(path)
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
dispatch(control, "change", "value" => uploaded_file)
|
|
122
|
+
self
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def check(label)
|
|
126
|
+
change_checkbox(label, true)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def uncheck(label)
|
|
130
|
+
change_checkbox(label, false)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def select(option, from:)
|
|
134
|
+
control = find_field(from)
|
|
135
|
+
unless control.instance_of?(Select)
|
|
136
|
+
raise AssertionError, %(field "#{from}" is not a select)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
change_option(control, option)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def choose(option, from:)
|
|
143
|
+
control = find_field(from)
|
|
144
|
+
unless control.is_a?(Radio)
|
|
145
|
+
raise AssertionError, %(field "#{from}" is not a radio)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
change_option(control, option)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def assert_text(expected)
|
|
152
|
+
return self if text?(expected)
|
|
153
|
+
|
|
154
|
+
raise AssertionError,
|
|
155
|
+
%(expected the view to include "#{expected}", but it rendered:\n#{visible_text})
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def refute_text(unexpected)
|
|
159
|
+
return self unless text?(unexpected)
|
|
160
|
+
|
|
161
|
+
raise AssertionError,
|
|
162
|
+
%(expected the view not to include "#{unexpected}", but it rendered:\n#{visible_text})
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def text?(expected)
|
|
166
|
+
visible_text.include?(expected.to_s)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def notice?(expected)
|
|
170
|
+
messages(Notice).include?(expected.to_s)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def error?(expected)
|
|
174
|
+
messages(Error).include?(expected.to_s)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def assert_notice(expected)
|
|
178
|
+
assert_message(Notice, "notice", expected)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def refute_notice(unexpected)
|
|
182
|
+
refute_message(Notice, "notice", unexpected)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def assert_error(expected)
|
|
186
|
+
assert_message(Error, "error", expected)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def refute_error(unexpected)
|
|
190
|
+
refute_message(Error, "error", unexpected)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def field_error(label_or_locator)
|
|
194
|
+
label =
|
|
195
|
+
if label_or_locator.is_a?(FieldLocator)
|
|
196
|
+
label_or_locator.label
|
|
197
|
+
else
|
|
198
|
+
label_or_locator
|
|
199
|
+
end
|
|
200
|
+
find_field(label).error
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def assert_field_error(label_or_locator, expected)
|
|
204
|
+
actual = field_error(label_or_locator)
|
|
205
|
+
return self if actual == expected.to_s
|
|
206
|
+
|
|
207
|
+
label =
|
|
208
|
+
if label_or_locator.is_a?(FieldLocator)
|
|
209
|
+
label_or_locator.label
|
|
210
|
+
else
|
|
211
|
+
label_or_locator
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
raise AssertionError,
|
|
215
|
+
"expected field \"#{label}\" to have error \"#{expected}\", " \
|
|
216
|
+
"but its error was #{actual.inspect}"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
private
|
|
220
|
+
|
|
221
|
+
def find_field(label)
|
|
222
|
+
find_one(
|
|
223
|
+
controls.select do |control|
|
|
224
|
+
FIELD_TYPES.any? { |type| control.is_a?(type) } &&
|
|
225
|
+
control.label == label.to_s
|
|
226
|
+
end,
|
|
227
|
+
%(field labeled "#{label}")
|
|
228
|
+
)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def find_one(matches, description)
|
|
232
|
+
return matches.first if matches.one?
|
|
233
|
+
|
|
234
|
+
raise AssertionError,
|
|
235
|
+
"expected one #{description}, but found #{matches.length}"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def assert_message(type, name, expected)
|
|
239
|
+
return self if messages(type).include?(expected.to_s)
|
|
240
|
+
|
|
241
|
+
raise AssertionError,
|
|
242
|
+
%(expected #{name} "#{expected}", but found #{messages(type).inspect})
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def refute_message(type, name, unexpected)
|
|
246
|
+
return self unless messages(type).include?(unexpected.to_s)
|
|
247
|
+
|
|
248
|
+
raise AssertionError,
|
|
249
|
+
%(expected not to find #{name} "#{unexpected}", but found #{messages(type).inspect})
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def messages(type)
|
|
253
|
+
descendants(@view)
|
|
254
|
+
.select { |element| element.is_a?(type) }
|
|
255
|
+
.map(&:content)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def controls
|
|
259
|
+
descendants(@view).select do |element|
|
|
260
|
+
FIELD_TYPES.any? { |type| element.is_a?(type) } ||
|
|
261
|
+
element.is_a?(Button)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def descendants(container)
|
|
266
|
+
container.children.flat_map do |element|
|
|
267
|
+
element.is_a?(Container) ? [element, *descendants(element)] : element
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def dispatch(control, event, attributes = {})
|
|
272
|
+
revision, = @application.dispatch(
|
|
273
|
+
revision: @revision,
|
|
274
|
+
events: [
|
|
275
|
+
{
|
|
276
|
+
"target" => control.id,
|
|
277
|
+
"event" => event,
|
|
278
|
+
**attributes
|
|
279
|
+
}
|
|
280
|
+
]
|
|
281
|
+
)
|
|
282
|
+
snapshot = @application.__send__(:current_snapshot)
|
|
283
|
+
raise "snapshot revision mismatch" unless snapshot.revision == revision
|
|
284
|
+
|
|
285
|
+
use_snapshot(snapshot)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def change_checkbox(label, checked)
|
|
289
|
+
control = find_field(label)
|
|
290
|
+
unless control.is_a?(Checkbox)
|
|
291
|
+
raise AssertionError, %(field "#{label}" is not a checkbox)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
dispatch(control, "change", "value" => checked)
|
|
295
|
+
self
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def change_option(control, option_label)
|
|
299
|
+
option = find_one(
|
|
300
|
+
control.options.select { |candidate| candidate.label == option_label.to_s },
|
|
301
|
+
%(option named "#{option_label}" in field "#{control.label}")
|
|
302
|
+
)
|
|
303
|
+
dispatch(control, "change", "value" => option.value.to_s)
|
|
304
|
+
self
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def use_snapshot(snapshot)
|
|
308
|
+
@revision = snapshot.revision
|
|
309
|
+
@view = snapshot.view
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def serialize(value)
|
|
313
|
+
value.nil? ? "" : value.to_s
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def content_type_for(path)
|
|
317
|
+
File.extname(path).downcase == ".csv" ? "text/csv" : "application/octet-stream"
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def visible_text
|
|
321
|
+
text_for(@view).reject(&:empty?).join("\n")
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def text_for(element)
|
|
325
|
+
case element
|
|
326
|
+
when View, Container
|
|
327
|
+
element.children.flat_map { |child| text_for(child) }
|
|
328
|
+
when Title, Subtitle, Error, Notice, Strong, Emphasis
|
|
329
|
+
[element.content]
|
|
330
|
+
when Para
|
|
331
|
+
[element.parts.flat_map { |part| text_for(part) }.join]
|
|
332
|
+
when Image
|
|
333
|
+
[element.alt]
|
|
334
|
+
when Link
|
|
335
|
+
[element.content]
|
|
336
|
+
when Table
|
|
337
|
+
[
|
|
338
|
+
*element.headers,
|
|
339
|
+
*element.rows.flat_map { |row| row.flat_map { |cell| text_for(cell) } }
|
|
340
|
+
]
|
|
341
|
+
when TextField, NumberField, TextArea, Checkbox, Select, FileField
|
|
342
|
+
[element.label, element.error.to_s]
|
|
343
|
+
when Button
|
|
344
|
+
[element.content]
|
|
345
|
+
when String
|
|
346
|
+
[element]
|
|
347
|
+
else
|
|
348
|
+
[]
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sandals
|
|
4
|
+
class UploadedFile
|
|
5
|
+
attr_reader :name, :content_type, :size
|
|
6
|
+
|
|
7
|
+
def initialize(name:, content_type:, content:)
|
|
8
|
+
@name = File.basename(name.to_s)
|
|
9
|
+
@content_type = content_type.to_s
|
|
10
|
+
@content = content.to_s.b.freeze
|
|
11
|
+
@size = @content.bytesize
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def read
|
|
15
|
+
@content.dup
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|