ruflet_rails 0.0.7 → 0.0.8
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 +4 -4
- data/README.md +152 -5
- data/lib/generators/ruflet/form/form_generator.rb +55 -0
- data/lib/generators/ruflet/install/install_generator.rb +111 -22
- data/lib/generators/ruflet/scaffold/scaffold_generator.rb +60 -0
- data/lib/ruflet/rails/desktop_launcher.rb +116 -0
- data/lib/ruflet/rails/form_helpers.rb +161 -0
- data/lib/ruflet/rails/install_support.rb +911 -133
- data/lib/ruflet/rails/protocol/endpoint.rb +21 -5
- data/lib/ruflet/rails/protocol/local_server.rb +55 -12
- data/lib/ruflet/rails/protocol/runner.rb +22 -9
- data/lib/ruflet/rails/protocol/web_socket_connection.rb +3 -118
- data/lib/ruflet/rails/protocol/wire_codec.rb +3 -243
- data/lib/ruflet/rails/railtie.rb +63 -4
- data/lib/ruflet/rails/resource_component.rb +191 -0
- data/lib/ruflet/rails/resource_view.rb +124 -0
- data/lib/ruflet/rails/session_registry.rb +94 -0
- data/lib/ruflet/rails/view.rb +57 -0
- data/lib/ruflet/rails.rb +167 -2
- data/lib/ruflet/version.rb +1 -1
- data/lib/ruflet_rails.rb +8 -1
- metadata +13 -5
|
@@ -1,71 +1,750 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
-
require "
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
require "active_support/core_ext/string/inflections"
|
|
5
6
|
|
|
6
7
|
module Ruflet
|
|
7
8
|
module Rails
|
|
8
9
|
module InstallSupport
|
|
9
|
-
CLIENT_EXTENSION_MAP = {
|
|
10
|
-
"ads" => { package: "flet_ads", alias: "ruflet_ads" },
|
|
11
|
-
"audio" => { package: "flet_audio", alias: "ruflet_audio" },
|
|
12
|
-
"audio_recorder" => { package: "flet_audio_recorder", alias: "ruflet_audio_recorder" },
|
|
13
|
-
"camera" => { package: "flet_camera", alias: "ruflet_camera" },
|
|
14
|
-
"charts" => { package: "flet_charts", alias: "ruflet_charts" },
|
|
15
|
-
"code_editor" => { package: "flet_code_editor", alias: "ruflet_code_editor" },
|
|
16
|
-
"color_pickers" => { package: "flet_color_pickers", alias: "ruflet_color_picker" },
|
|
17
|
-
"datatable2" => { package: "flet_datatable2", alias: "ruflet_datatable2" },
|
|
18
|
-
"flashlight" => { package: "flet_flashlight", alias: "ruflet_flashlight" },
|
|
19
|
-
"geolocator" => { package: "flet_geolocator", alias: "ruflet_geolocator" },
|
|
20
|
-
"lottie" => { package: "flet_lottie", alias: "ruflet_lottie" },
|
|
21
|
-
"map" => { package: "flet_map", alias: "ruflet_map" },
|
|
22
|
-
"permission_handler" => { package: "flet_permission_handler", alias: "ruflet_permission_handler" },
|
|
23
|
-
"secure_storage" => { package: "flet_secure_storage", alias: "ruflet_secure_storage" },
|
|
24
|
-
"video" => { package: "flet_video", alias: "ruflet_video" },
|
|
25
|
-
"webview" => { package: "flet_webview", alias: "ruflet_webview" }
|
|
26
|
-
}.freeze
|
|
27
|
-
|
|
28
10
|
module_function
|
|
29
11
|
|
|
30
|
-
def
|
|
31
|
-
<<~RUBY
|
|
12
|
+
def default_app_template(app_title:)
|
|
13
|
+
template = <<~RUBY
|
|
32
14
|
require "ruflet"
|
|
15
|
+
require "ruflet_rails"
|
|
16
|
+
|
|
17
|
+
Ruflet::Rails.load_views(__dir__)
|
|
33
18
|
|
|
34
19
|
Ruflet.run do |page|
|
|
35
20
|
page.title = #{app_title.inspect}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
21
|
+
Ruflet::Rails.render(page)
|
|
22
|
+
end
|
|
23
|
+
RUBY
|
|
24
|
+
template.gsub(/^ /, " ")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def application_component_template
|
|
28
|
+
template = <<~RUBY
|
|
29
|
+
# frozen_string_literal: true
|
|
30
|
+
|
|
31
|
+
# ApplicationComponent is the base class for all Ruflet UI components in
|
|
32
|
+
# this Rails app. It explicitly includes Ruflet::UI::SharedControlForwarders
|
|
33
|
+
# so that every subclass has the full ruflet widget DSL available as
|
|
34
|
+
# instance methods (text, column, row, container, safe_area, filled_button,
|
|
35
|
+
# icon, data_table, alert_dialog, and every other ruflet widget).
|
|
36
|
+
# This is the same DSL that showcase uses — explicit, no Kernel magic.
|
|
37
|
+
class ApplicationComponent
|
|
38
|
+
include Ruflet::UI::SharedControlForwarders
|
|
39
|
+
|
|
40
|
+
attr_reader :page
|
|
41
|
+
|
|
42
|
+
def self.render(page, *args, **kwargs, &block)
|
|
43
|
+
new(page).render(*args, **kwargs, &block)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def initialize(page)
|
|
47
|
+
@page = page
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# Widget builder calls on this component delegate to Ruflet::DSL,
|
|
53
|
+
# the same target used by the showcase App and by Kernel.
|
|
54
|
+
# Override in a subclass to scope builds to a local WidgetBuilder.
|
|
55
|
+
def control_delegate
|
|
56
|
+
Ruflet::DSL
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def platform
|
|
60
|
+
page.client_details["platform"].to_s
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def desktop?
|
|
64
|
+
%w[macos windows linux].include?(platform)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def web?
|
|
68
|
+
platform == "web"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def mobile?
|
|
72
|
+
!desktop? && !web?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def screen_width
|
|
76
|
+
page.client_details["width"].to_f
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Returns true when the client is narrower than 600 logical pixels
|
|
80
|
+
# (phones and small tablets), enabling compact list layouts.
|
|
81
|
+
def compact?
|
|
82
|
+
screen_width > 0 && screen_width < 600
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
RUBY
|
|
86
|
+
template.gsub(/^ /, " ")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def application_component_path
|
|
90
|
+
File.join("app", "views", "ruflet", "components", "application_component.rb")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def default_mobile_app_template(app_title:)
|
|
94
|
+
default_app_template(app_title: app_title)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def model_names(model_name)
|
|
98
|
+
raw = model_name.to_s.strip
|
|
99
|
+
class_name = raw.camelize
|
|
100
|
+
singular = raw.underscore.singularize
|
|
101
|
+
plural = singular.pluralize
|
|
102
|
+
{
|
|
103
|
+
class_name: class_name,
|
|
104
|
+
singular: singular,
|
|
105
|
+
plural: plural,
|
|
106
|
+
title: plural.humanize.titleize
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def form_view_path(model_name)
|
|
111
|
+
names = model_names(model_name)
|
|
112
|
+
|
|
113
|
+
File.join("app", "views", "ruflet", "components", names[:plural], "#{names[:singular]}_form.rb")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def scaffold_view_path(model_name)
|
|
117
|
+
names = model_names(model_name)
|
|
118
|
+
|
|
119
|
+
File.join("app", "views", "ruflet", "#{names[:plural]}_view.rb")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def scaffold_component_path(model_name)
|
|
123
|
+
names = model_names(model_name)
|
|
124
|
+
|
|
125
|
+
File.join("app", "views", "ruflet", "components", names[:plural], "#{names[:singular]}_component.rb")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def scaffold_view_template(model_name:, attributes: [])
|
|
129
|
+
names = model_names(model_name)
|
|
130
|
+
model_class = names[:class_name]
|
|
131
|
+
view_class = "#{model_class}View"
|
|
132
|
+
component_class = "#{model_class}Component"
|
|
133
|
+
title = names[:title]
|
|
134
|
+
attrs = normalized_form_attributes(attributes)
|
|
135
|
+
resource_fields = scaffold_resource_fields(attrs)
|
|
136
|
+
display_fields = scaffold_display_fields(attrs)
|
|
137
|
+
display_value_cases = scaffold_display_value_cases(attrs)
|
|
138
|
+
|
|
139
|
+
template = <<~RUBY
|
|
140
|
+
# frozen_string_literal: true
|
|
141
|
+
|
|
142
|
+
require "ruflet_rails"
|
|
143
|
+
require_relative "components/#{names[:plural]}/#{names[:singular]}_component"
|
|
144
|
+
|
|
145
|
+
class #{view_class} < Ruflet::Rails::ResourceView
|
|
146
|
+
route #{("/" + names[:plural]).inspect}
|
|
147
|
+
|
|
148
|
+
def render
|
|
149
|
+
page.title = resource_title
|
|
150
|
+
render_index
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def model_class
|
|
156
|
+
#{model_class}
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def resource_title
|
|
160
|
+
#{title.inspect}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def singular_title
|
|
164
|
+
model_class.model_name.human.titleize
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def records
|
|
168
|
+
scope = model_class.respond_to?(:limit) ? model_class.limit(50) : model_class.all
|
|
169
|
+
scope.respond_to?(:limit) ? scope.limit(50) : scope.to_a.first(50)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def render_index
|
|
173
|
+
page.views = []
|
|
174
|
+
page.add(component.render)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def render_show(record)
|
|
178
|
+
page.views = []
|
|
179
|
+
page.add(component.show(record))
|
|
180
|
+
page.update
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def component
|
|
184
|
+
@component ||= #{component_class}.new(page, controller: self)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def show_record(record)
|
|
188
|
+
render_show(record)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def save_record(record, attributes, dialog)
|
|
192
|
+
if record.update(attributes)
|
|
193
|
+
close_dialog(dialog)
|
|
194
|
+
render_index
|
|
195
|
+
show_snackbar("\#{singular_title} saved")
|
|
196
|
+
else
|
|
197
|
+
show_errors(record)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def destroy_record(record, dialog)
|
|
202
|
+
record.destroy!
|
|
203
|
+
close_dialog(dialog)
|
|
204
|
+
render_index
|
|
205
|
+
show_snackbar("\#{singular_title} deleted")
|
|
206
|
+
rescue StandardError => e
|
|
207
|
+
show_snackbar(e.message)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def resource_fields
|
|
211
|
+
#{resource_fields}
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def display_fields
|
|
215
|
+
#{display_fields}
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def display_value(record, field)
|
|
219
|
+
case field
|
|
220
|
+
__DISPLAY_VALUE_CASES__
|
|
221
|
+
else
|
|
222
|
+
record.public_send(field).to_s
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def primary_label(record)
|
|
227
|
+
field = display_fields.first
|
|
228
|
+
field ? display_value(record, field) : "##\#{record_id(record)}"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def secondary_label(record)
|
|
232
|
+
field = display_fields[1]
|
|
233
|
+
field ? display_value(record, field) : nil
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
end
|
|
237
|
+
RUBY
|
|
238
|
+
template.gsub(/^[ \t]*__DISPLAY_VALUE_CASES__$/, display_value_cases)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def scaffold_component_template(model_name:, attributes: [])
|
|
242
|
+
names = model_names(model_name)
|
|
243
|
+
model_class = names[:class_name]
|
|
244
|
+
component_class = "#{model_class}Component"
|
|
245
|
+
attrs = normalized_form_attributes(attributes)
|
|
246
|
+
control_locals = scaffold_control_locals(attrs)
|
|
247
|
+
control_list = scaffold_control_list(attrs)
|
|
248
|
+
attributes_hash = scaffold_attributes_hash(attrs)
|
|
249
|
+
|
|
250
|
+
<<~RUBY
|
|
251
|
+
# frozen_string_literal: true
|
|
252
|
+
|
|
253
|
+
require "date"
|
|
254
|
+
require "ruflet_rails"
|
|
255
|
+
|
|
256
|
+
class #{component_class} < Ruflet::Rails::ResourceComponent
|
|
257
|
+
def render
|
|
258
|
+
safe_area(
|
|
259
|
+
container(
|
|
260
|
+
expand: true,
|
|
261
|
+
padding: { left: 24, top: 16, right: 24, bottom: 24 },
|
|
262
|
+
content: column(
|
|
263
|
+
expand: true,
|
|
264
|
+
spacing: 16,
|
|
265
|
+
children: [
|
|
266
|
+
index_header,
|
|
267
|
+
compact? ? record_list(records) : record_table(records)
|
|
268
|
+
]
|
|
269
|
+
)
|
|
270
|
+
),
|
|
271
|
+
expand: true
|
|
272
|
+
)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def show(record)
|
|
276
|
+
safe_area(
|
|
277
|
+
container(
|
|
278
|
+
expand: true,
|
|
279
|
+
padding: { left: 24, top: 16, right: 24, bottom: 24 },
|
|
280
|
+
content: column(
|
|
281
|
+
expand: true,
|
|
282
|
+
spacing: 16,
|
|
283
|
+
children: [
|
|
284
|
+
show_header(record),
|
|
285
|
+
column(
|
|
286
|
+
spacing: 8,
|
|
287
|
+
children: resource_fields.map { |field| field_row(field.humanize, display_value(record, field)) }
|
|
288
|
+
)
|
|
289
|
+
]
|
|
290
|
+
)
|
|
291
|
+
),
|
|
292
|
+
expand: true
|
|
293
|
+
)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
private
|
|
297
|
+
|
|
298
|
+
def show_header(record)
|
|
299
|
+
row(
|
|
300
|
+
alignment: "spaceBetween",
|
|
301
|
+
vertical_alignment: "center",
|
|
302
|
+
children: [
|
|
303
|
+
container(expand: true, content: text("\#{singular_title} ##\#{record_id(record)}", size: 24, weight: "bold")),
|
|
304
|
+
row(
|
|
305
|
+
tight: true,
|
|
306
|
+
spacing: 8,
|
|
307
|
+
children: [
|
|
308
|
+
outlined_button(content: text("Back"), on_click: ->(_event) { render_index }),
|
|
309
|
+
filled_button(content: text("Edit"), on_click: ->(_event) { open_form(record) })
|
|
310
|
+
]
|
|
311
|
+
)
|
|
312
|
+
]
|
|
313
|
+
)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def index_header
|
|
317
|
+
row(
|
|
318
|
+
alignment: "spaceBetween",
|
|
319
|
+
vertical_alignment: "center",
|
|
320
|
+
children: [
|
|
321
|
+
container(expand: true, content: text(resource_title, size: 24, weight: "bold")),
|
|
322
|
+
filled_button(content: text("New \#{singular_title}"), on_click: ->(_event) { open_form(model_class.new) })
|
|
323
|
+
]
|
|
324
|
+
)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def record_table(items)
|
|
328
|
+
row(
|
|
329
|
+
scroll: "auto",
|
|
330
|
+
children: [
|
|
331
|
+
data_table(
|
|
332
|
+
table_columns,
|
|
333
|
+
rows: items.map { |record| table_row(record) },
|
|
334
|
+
column_spacing: 24,
|
|
335
|
+
horizontal_margin: 12,
|
|
336
|
+
show_bottom_border: true
|
|
337
|
+
)
|
|
338
|
+
]
|
|
339
|
+
)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def table_columns
|
|
343
|
+
display_fields.map { |field| data_column(field.humanize) } + [
|
|
344
|
+
data_column("Actions"),
|
|
345
|
+
data_column(""),
|
|
346
|
+
data_column("")
|
|
347
|
+
]
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def table_row(record)
|
|
351
|
+
data_row(
|
|
352
|
+
display_fields.map { |field| data_cell(display_value(record, field), on_tap: ->(_event) { open_show(record) }) } +
|
|
353
|
+
[
|
|
354
|
+
data_cell(icon("visibility", tooltip: "Show"), on_tap: ->(_event) { open_show(record) }),
|
|
355
|
+
data_cell(icon("edit", tooltip: "Edit"), on_tap: ->(_event) { open_form(record) }),
|
|
356
|
+
data_cell(icon("delete", tooltip: "Delete"), on_tap: ->(_event) { open_delete(record) })
|
|
357
|
+
]
|
|
358
|
+
)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def record_list(items)
|
|
362
|
+
column(spacing: 4, children: items.map { |record| record_tile(record) })
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def record_tile(record)
|
|
366
|
+
list_tile(
|
|
367
|
+
title: text(primary_label(record)),
|
|
368
|
+
subtitle: secondary_label(record) ? text(secondary_label(record)) : nil,
|
|
369
|
+
trailing: row(
|
|
370
|
+
tight: true,
|
|
371
|
+
spacing: 0,
|
|
372
|
+
children: [
|
|
373
|
+
icon_button("edit", tooltip: "Edit", on_click: ->(_event) { open_form(record) }),
|
|
374
|
+
icon_button("delete", tooltip: "Delete", on_click: ->(_event) { open_delete(record) })
|
|
375
|
+
]
|
|
376
|
+
),
|
|
377
|
+
on_click: ->(_event) { open_show(record) }
|
|
378
|
+
)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def open_show(record)
|
|
382
|
+
show_record(record)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def open_form(record)
|
|
386
|
+
#{control_locals}
|
|
387
|
+
|
|
388
|
+
attributes = lambda do
|
|
389
|
+
{
|
|
390
|
+
#{attributes_hash}
|
|
391
|
+
}
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
dialog = nil
|
|
395
|
+
dialog = alert_dialog(
|
|
396
|
+
open: false,
|
|
397
|
+
modal: true,
|
|
398
|
+
scrollable: true,
|
|
399
|
+
title: text(record.persisted? ? "Edit \#{singular_title}" : "New \#{singular_title}"),
|
|
400
|
+
content: container(
|
|
401
|
+
width: dialog_width,
|
|
402
|
+
content: column(
|
|
403
|
+
spacing: 8,
|
|
404
|
+
children: [
|
|
405
|
+
#{control_list}
|
|
406
|
+
]
|
|
407
|
+
)
|
|
408
|
+
),
|
|
409
|
+
actions: [
|
|
410
|
+
text_button(content: text("Cancel"), on_click: ->(_event) { close_dialog(dialog) }),
|
|
411
|
+
filled_button(content: text("Save"), on_click: ->(_event) {
|
|
412
|
+
save_record(record, attributes.call, dialog)
|
|
413
|
+
})
|
|
414
|
+
],
|
|
415
|
+
actions_alignment: "end"
|
|
416
|
+
)
|
|
417
|
+
open_dialog(dialog)
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def open_delete(record)
|
|
421
|
+
dialog = nil
|
|
422
|
+
dialog = alert_dialog(
|
|
423
|
+
open: false,
|
|
424
|
+
modal: true,
|
|
425
|
+
title: text("Delete \#{singular_title}?"),
|
|
426
|
+
content: text("Permanently remove \#{singular_title} #\#{record_id(record)}?", no_wrap: false),
|
|
427
|
+
actions: [
|
|
428
|
+
text_button(content: text("Cancel"), on_click: ->(_event) { close_dialog(dialog) }),
|
|
429
|
+
filled_button(content: text("Delete"), on_click: ->(_event) { destroy_record(record, dialog) })
|
|
430
|
+
],
|
|
431
|
+
actions_alignment: "end"
|
|
432
|
+
)
|
|
433
|
+
open_dialog(dialog)
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def field_row(label, value)
|
|
437
|
+
row(
|
|
438
|
+
children: [
|
|
439
|
+
container(width: 140, content: text(label, weight: "bold")),
|
|
440
|
+
container(expand: true, content: text(value, no_wrap: false))
|
|
441
|
+
]
|
|
442
|
+
)
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
RUBY
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def scaffold_control_locals(attrs)
|
|
449
|
+
attrs.map { |field| scaffold_control_local(field) }.join("\n ")
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def scaffold_resource_fields(attrs)
|
|
453
|
+
attrs.map { |field| field[:name] }.inspect
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def scaffold_display_fields(attrs)
|
|
457
|
+
fields = attrs.reject { |field| field[:type].to_s == "text" }
|
|
458
|
+
fields = attrs if fields.empty?
|
|
459
|
+
fields.first(3).map { |field| field[:name] }.inspect
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def scaffold_display_value_cases(attrs)
|
|
463
|
+
attrs.filter_map do |field|
|
|
464
|
+
next unless %w[date datetime timestamp time date_range daterange].include?(field[:type].to_s)
|
|
465
|
+
|
|
466
|
+
name = field[:name]
|
|
467
|
+
formatter =
|
|
468
|
+
case field[:type].to_s
|
|
469
|
+
when "time"
|
|
470
|
+
"value.respond_to?(:strftime) ? value.strftime(\"%H:%M\") : value.to_s"
|
|
471
|
+
when "date_range", "daterange"
|
|
472
|
+
"value.respond_to?(:begin) && value.respond_to?(:end) ? \"\#{value.begin} - \#{value.end}\" : value.to_s"
|
|
473
|
+
when "date"
|
|
474
|
+
"value.respond_to?(:to_date) ? value.to_date.iso8601 : value.to_s"
|
|
475
|
+
else
|
|
476
|
+
"value.respond_to?(:iso8601) ? value.iso8601 : value.to_s"
|
|
477
|
+
end
|
|
478
|
+
[
|
|
479
|
+
" when #{name.inspect}",
|
|
480
|
+
" value = record.public_send(#{name.inspect})",
|
|
481
|
+
" #{formatter}"
|
|
482
|
+
].join("\n")
|
|
483
|
+
end.join("\n")
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def scaffold_control_list(attrs)
|
|
487
|
+
attrs.map { |field| scaffold_control_view_name(field) }.join(",\n ")
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def scaffold_attributes_hash(attrs)
|
|
491
|
+
attrs.map { |field| scaffold_attribute_pair(field) }.join(",\n ")
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def scaffold_control_local(field)
|
|
495
|
+
name = field[:name]
|
|
496
|
+
type = field[:type].to_s
|
|
497
|
+
control = scaffold_control_name(field)
|
|
498
|
+
label = name.humanize
|
|
499
|
+
value = "record.public_send(#{name.inspect})"
|
|
500
|
+
|
|
501
|
+
case type
|
|
502
|
+
when "boolean"
|
|
503
|
+
"#{control} = checkbox(label: #{label.inspect}, value: !!#{value})"
|
|
504
|
+
when "date", "datetime", "timestamp"
|
|
505
|
+
display_control = "#{control}_display"
|
|
506
|
+
picker_value_helper = type == "date" ? "date_picker_value" : "datetime_picker_value"
|
|
507
|
+
<<~RUBY.chomp
|
|
508
|
+
#{control}_value = #{picker_value_helper}(#{value})
|
|
509
|
+
#{display_control} = text(date_display_value(#{control}_value))
|
|
510
|
+
#{control} = date_picker(
|
|
511
|
+
value: #{control}_value,
|
|
512
|
+
help_text: #{label.inspect},
|
|
513
|
+
on_change: ->(_event) do
|
|
514
|
+
close_dialogs(#{control})
|
|
515
|
+
page.update(#{display_control}, value: date_display_value(#{control}.props["value"]))
|
|
516
|
+
end
|
|
517
|
+
)
|
|
518
|
+
#{control}_field = column(
|
|
519
|
+
spacing: 6,
|
|
520
|
+
children: [
|
|
521
|
+
text(#{label.inspect}),
|
|
522
|
+
row(
|
|
523
|
+
spacing: 8,
|
|
524
|
+
children: [
|
|
525
|
+
container(expand: true, content: #{display_control}),
|
|
526
|
+
outlined_button(content: text("Choose #{label}"), on_click: ->(_event) { open_dialog(#{control}) })
|
|
527
|
+
]
|
|
528
|
+
)
|
|
529
|
+
]
|
|
530
|
+
)
|
|
531
|
+
RUBY
|
|
532
|
+
when "time"
|
|
533
|
+
display_control = "#{control}_display"
|
|
534
|
+
<<~RUBY.chomp
|
|
535
|
+
#{control}_value = time_picker_value(#{value})
|
|
536
|
+
#{display_control} = text(time_display_value(#{control}_value))
|
|
537
|
+
#{control} = time_picker(
|
|
538
|
+
value: #{control}_value,
|
|
539
|
+
help_text: #{label.inspect},
|
|
540
|
+
on_change: ->(_event) do
|
|
541
|
+
close_dialogs(#{control})
|
|
542
|
+
page.update(#{display_control}, value: time_display_value(#{control}.props["value"]))
|
|
543
|
+
end
|
|
544
|
+
)
|
|
545
|
+
#{control}_field = column(
|
|
546
|
+
spacing: 6,
|
|
547
|
+
children: [
|
|
548
|
+
text(#{label.inspect}),
|
|
549
|
+
row(
|
|
550
|
+
spacing: 8,
|
|
551
|
+
children: [
|
|
552
|
+
container(expand: true, content: #{display_control}),
|
|
553
|
+
outlined_button(content: text("Choose #{label}"), on_click: ->(_event) { open_dialog(#{control}) })
|
|
554
|
+
]
|
|
555
|
+
)
|
|
556
|
+
]
|
|
557
|
+
)
|
|
558
|
+
RUBY
|
|
559
|
+
when "date_range", "daterange"
|
|
560
|
+
display_control = "#{control}_display"
|
|
561
|
+
<<~RUBY.chomp
|
|
562
|
+
#{control}_start_value, #{control}_end_value = date_range_picker_values(#{value})
|
|
563
|
+
#{display_control} = text(date_range_display_value(#{control}_start_value, #{control}_end_value))
|
|
564
|
+
#{control} = date_range_picker(
|
|
565
|
+
start_value: #{control}_start_value,
|
|
566
|
+
end_value: #{control}_end_value,
|
|
567
|
+
help_text: #{label.inspect},
|
|
568
|
+
on_change: ->(_event) do
|
|
569
|
+
close_dialogs(#{control})
|
|
570
|
+
page.update(
|
|
571
|
+
#{display_control},
|
|
572
|
+
value: date_range_display_value(#{control}.props["start_value"], #{control}.props["end_value"])
|
|
573
|
+
)
|
|
574
|
+
end
|
|
575
|
+
)
|
|
576
|
+
#{control}_field = column(
|
|
577
|
+
spacing: 6,
|
|
578
|
+
children: [
|
|
579
|
+
text(#{label.inspect}),
|
|
580
|
+
row(
|
|
581
|
+
spacing: 8,
|
|
582
|
+
children: [
|
|
583
|
+
container(expand: true, content: #{display_control}),
|
|
584
|
+
outlined_button(content: text("Choose #{label}"), on_click: ->(_event) { open_dialog(#{control}) })
|
|
585
|
+
]
|
|
586
|
+
)
|
|
587
|
+
]
|
|
588
|
+
)
|
|
589
|
+
RUBY
|
|
590
|
+
when "text"
|
|
591
|
+
"#{control} = text_field(value: #{value}.to_s, label: #{label.inspect}, multiline: true, min_lines: 3)"
|
|
592
|
+
when "integer", "float", "decimal"
|
|
593
|
+
"#{control} = text_field(value: #{value}.to_s, label: #{label.inspect}, keyboard_type: \"number\")"
|
|
594
|
+
else
|
|
595
|
+
"#{control} = text_field(value: #{value}.to_s, label: #{label.inspect})"
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def scaffold_attribute_pair(field)
|
|
600
|
+
name = field[:name]
|
|
601
|
+
type = field[:type].to_s
|
|
602
|
+
control = scaffold_control_name(field)
|
|
603
|
+
value =
|
|
604
|
+
case type
|
|
605
|
+
when "boolean"
|
|
606
|
+
"!!#{control}.props[\"value\"]"
|
|
607
|
+
when "date"
|
|
608
|
+
"#{control}.props[\"value\"].to_s.split(\"T\", 2).first"
|
|
609
|
+
when "date_range", "daterange"
|
|
610
|
+
"Range.new(Date.parse(#{control}.props[\"start_value\"].to_s), Date.parse(#{control}.props[\"end_value\"].to_s))"
|
|
611
|
+
else
|
|
612
|
+
"#{control}.props[\"value\"].to_s"
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
"#{name.inspect} => #{value}"
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def scaffold_control_name(field)
|
|
619
|
+
"#{field[:name].gsub(/[^a-zA-Z0-9_]/, '_')}_control"
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
def scaffold_control_view_name(field)
|
|
623
|
+
control = scaffold_control_name(field)
|
|
624
|
+
%w[date datetime timestamp time date_range daterange].include?(field[:type].to_s) ? "#{control}_field" : control
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
def form_view_template(model_name:, attributes:)
|
|
628
|
+
names = model_names(model_name)
|
|
629
|
+
attrs = normalized_form_attributes(attributes)
|
|
630
|
+
fields_literal = attrs.map { |field| form_field_literal(field) }.join(", ")
|
|
631
|
+
model_class = names[:class_name]
|
|
632
|
+
singular_title = names[:singular].humanize.titleize
|
|
633
|
+
|
|
634
|
+
<<~RUBY
|
|
635
|
+
# frozen_string_literal: true
|
|
636
|
+
|
|
637
|
+
require "ruflet_rails"
|
|
638
|
+
|
|
639
|
+
class #{model_class}Form < ApplicationComponent
|
|
640
|
+
include Ruflet::Rails::FormHelpers
|
|
641
|
+
|
|
642
|
+
def render(record:, title: nil, on_save: nil, on_cancel: nil)
|
|
643
|
+
title ||= record.persisted? ? "Edit #{singular_title}" : "New #{singular_title}"
|
|
644
|
+
fields = ruflet_form_bindings(record, form_fields)
|
|
645
|
+
|
|
646
|
+
column(
|
|
647
|
+
expand: true,
|
|
648
|
+
spacing: 12,
|
|
46
649
|
children: [
|
|
47
|
-
text(
|
|
48
|
-
|
|
650
|
+
text(title, size: 24, weight: "bold"),
|
|
651
|
+
column(spacing: 8, children: ruflet_form_controls(fields)),
|
|
652
|
+
row(
|
|
653
|
+
spacing: 8,
|
|
654
|
+
children: [
|
|
655
|
+
outlined_button(
|
|
656
|
+
content: text("Cancel"),
|
|
657
|
+
on_click: ->(_e) { on_cancel ? on_cancel.call(page, record) : nil }
|
|
658
|
+
),
|
|
659
|
+
filled_button(
|
|
660
|
+
content: text(record.persisted? ? "Update #{singular_title}" : "Create #{singular_title}"),
|
|
661
|
+
on_click: ->(_e) { save(record, fields, on_save: on_save) }
|
|
662
|
+
)
|
|
663
|
+
]
|
|
664
|
+
)
|
|
49
665
|
]
|
|
50
666
|
)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
def save(record, fields, on_save: nil)
|
|
670
|
+
if record.update(ruflet_form_attributes(fields, form_fields))
|
|
671
|
+
on_save ? on_save.call(page, record) : record
|
|
672
|
+
else
|
|
673
|
+
show_errors(record)
|
|
674
|
+
false
|
|
57
675
|
end
|
|
58
|
-
|
|
59
|
-
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
def form_fields
|
|
679
|
+
[#{fields_literal}]
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
def show_errors(record)
|
|
683
|
+
show_snackbar(error_message(record))
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def show_snackbar(message)
|
|
687
|
+
page.snackbar = snackbar(text(message), open: true)
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
def error_message(record)
|
|
691
|
+
messages = record.errors.full_messages
|
|
692
|
+
messages.respond_to?(:to_sentence) ? messages.to_sentence : messages.join(", ")
|
|
693
|
+
end
|
|
60
694
|
end
|
|
61
695
|
RUBY
|
|
62
696
|
end
|
|
63
697
|
|
|
698
|
+
def normalized_form_attributes(attributes)
|
|
699
|
+
attrs = Array(attributes).map { |field| normalize_form_attribute(field) }.reject { |field| field[:name].empty? }
|
|
700
|
+
attrs.empty? ? [{ name: "name", type: "string" }] : attrs
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
def attributes_from_model(model_class)
|
|
704
|
+
return [] unless model_class.respond_to?(:columns)
|
|
705
|
+
|
|
706
|
+
model_class.columns.reject { |column|
|
|
707
|
+
%w[id created_at updated_at].include?(column.name)
|
|
708
|
+
}.map { |column| "#{column.name}:#{column.type}" }
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
def form_field_literal(field)
|
|
712
|
+
parts = [
|
|
713
|
+
"name: #{field[:name].inspect}",
|
|
714
|
+
"type: #{field[:type].inspect}"
|
|
715
|
+
]
|
|
716
|
+
parts << "class_name: #{field[:class_name].inspect}" if field[:class_name]
|
|
717
|
+
"{ #{parts.join(', ')} }"
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
def normalize_form_attribute(value)
|
|
721
|
+
raw = value.to_s.strip
|
|
722
|
+
name, type = raw.split(":", 2)
|
|
723
|
+
name = name.to_s.underscore.gsub(/[^a-z0-9_]/, "")
|
|
724
|
+
type = type.to_s.strip
|
|
725
|
+
type = "string" if type.empty?
|
|
726
|
+
name = "#{name}_id" if %w[references belongs_to association].include?(type) && !name.end_with?("_id")
|
|
727
|
+
association = association_class_name_for(name, type)
|
|
728
|
+
{
|
|
729
|
+
name: name,
|
|
730
|
+
type: association ? "association" : type
|
|
731
|
+
}.tap do |field|
|
|
732
|
+
field[:class_name] = association if association
|
|
733
|
+
end
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
def association_class_name_for(name, type)
|
|
737
|
+
return name.sub(/_id\z/, "").camelize if name.end_with?("_id")
|
|
738
|
+
return name.camelize if %w[references belongs_to association].include?(type)
|
|
739
|
+
|
|
740
|
+
nil
|
|
741
|
+
end
|
|
742
|
+
|
|
64
743
|
def default_ruflet_yaml(app_name:)
|
|
65
744
|
<<~YAML
|
|
66
745
|
app:
|
|
67
746
|
name: #{app_name}
|
|
68
|
-
|
|
747
|
+
backend_url: #{default_backend_url}
|
|
69
748
|
|
|
70
749
|
services: []
|
|
71
750
|
|
|
@@ -75,129 +754,228 @@ module Ruflet
|
|
|
75
754
|
YAML
|
|
76
755
|
end
|
|
77
756
|
|
|
78
|
-
def
|
|
79
|
-
|
|
757
|
+
def desktop_initializer_path
|
|
758
|
+
File.join("config", "initializers", "ruflet_desktop.rb")
|
|
80
759
|
end
|
|
81
760
|
|
|
82
|
-
def
|
|
83
|
-
|
|
84
|
-
|
|
761
|
+
def desktop_initializer_template
|
|
762
|
+
<<~RUBY
|
|
763
|
+
# frozen_string_literal: true
|
|
85
764
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
765
|
+
# Set this to true when you intentionally want the Rails server process to
|
|
766
|
+
# launch the server-driven Ruflet desktop client.
|
|
767
|
+
Rails.application.configure do
|
|
768
|
+
config.x.ruflet_rails.desktop = false
|
|
769
|
+
end
|
|
770
|
+
RUBY
|
|
91
771
|
end
|
|
92
772
|
|
|
93
|
-
def
|
|
94
|
-
|
|
95
|
-
|
|
773
|
+
def ruby_desktop_flag_bootstrap
|
|
774
|
+
<<~RUBY
|
|
775
|
+
# ruflet_rails desktop flag
|
|
776
|
+
ruflet_rails_desktop = ARGV.include?("--desktop")
|
|
777
|
+
ruflet_rails_command = ARGV.find { |value| !value.to_s.start_with?("-") }
|
|
778
|
+
if ruflet_rails_desktop && %w[server s].include?(ruflet_rails_command.to_s)
|
|
779
|
+
ENV["RUFLET_RAILS_DESKTOP"] = "true"
|
|
780
|
+
ENV["RUFLET_RAILS_DESKTOP_SERVER"] = "true"
|
|
781
|
+
end
|
|
782
|
+
ARGV.delete("--desktop")
|
|
96
783
|
|
|
97
|
-
|
|
98
|
-
|
|
784
|
+
RUBY
|
|
785
|
+
end
|
|
99
786
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
787
|
+
def ruby_dev_desktop_flag_bootstrap
|
|
788
|
+
<<~RUBY
|
|
789
|
+
# ruflet_rails desktop flag
|
|
790
|
+
if ARGV.delete("--desktop")
|
|
791
|
+
ENV["RUFLET_RAILS_DESKTOP"] = "true"
|
|
792
|
+
ENV["RUFLET_RAILS_DESKTOP_SERVER"] = "true"
|
|
793
|
+
end
|
|
794
|
+
|
|
795
|
+
RUBY
|
|
103
796
|
end
|
|
104
797
|
|
|
105
|
-
def
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
android/.gradle
|
|
116
|
-
android/.kotlin
|
|
117
|
-
android/local.properties
|
|
118
|
-
].each do |path|
|
|
119
|
-
full = File.join(target, path)
|
|
120
|
-
FileUtils.rm_rf(full) if File.exist?(full)
|
|
121
|
-
end
|
|
798
|
+
def shell_desktop_flag_bootstrap
|
|
799
|
+
<<~SH
|
|
800
|
+
# ruflet_rails desktop flag
|
|
801
|
+
if [ "$1" = "--desktop" ]; then
|
|
802
|
+
export RUFLET_RAILS_DESKTOP=true
|
|
803
|
+
export RUFLET_RAILS_DESKTOP_SERVER=true
|
|
804
|
+
shift
|
|
805
|
+
fi
|
|
806
|
+
|
|
807
|
+
SH
|
|
122
808
|
end
|
|
123
809
|
|
|
124
|
-
def
|
|
125
|
-
|
|
126
|
-
|
|
810
|
+
def default_backend_url
|
|
811
|
+
"http://localhost:3000"
|
|
812
|
+
end
|
|
127
813
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
814
|
+
def host_desktop_platform
|
|
815
|
+
host_os = RbConfig::CONFIG["host_os"]
|
|
816
|
+
return "macos" if host_os.match?(/darwin/i)
|
|
817
|
+
return "linux" if host_os.match?(/linux/i)
|
|
818
|
+
return "windows" if host_os.match?(/mswin|mingw|cygwin/i)
|
|
132
819
|
|
|
133
|
-
|
|
134
|
-
|
|
820
|
+
nil
|
|
821
|
+
end
|
|
135
822
|
|
|
136
|
-
|
|
137
|
-
|
|
823
|
+
def normalize_build_platform(platform)
|
|
824
|
+
value = platform.to_s.strip.downcase
|
|
825
|
+
return host_desktop_platform if value == "desktop"
|
|
826
|
+
|
|
827
|
+
value
|
|
138
828
|
end
|
|
139
829
|
|
|
140
|
-
def
|
|
141
|
-
|
|
142
|
-
return
|
|
830
|
+
def build_args_for_platform(platform)
|
|
831
|
+
normalized = normalize_build_platform(platform)
|
|
832
|
+
return [] if normalized.to_s.empty?
|
|
143
833
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
key.gsub!(/\Acontrol_/, "")
|
|
148
|
-
key = "file_picker" if key == "filepicker"
|
|
149
|
-
key
|
|
834
|
+
args = [normalized]
|
|
835
|
+
args += ["--base-href", web_base_href] if normalized == "web"
|
|
836
|
+
args
|
|
150
837
|
end
|
|
151
838
|
|
|
152
|
-
def
|
|
153
|
-
|
|
839
|
+
def default_entrypoint_path
|
|
840
|
+
File.join("app", "views", "ruflet", "main.rb")
|
|
841
|
+
end
|
|
154
842
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
next unless name.start_with?("flet_")
|
|
159
|
-
next if name == "flet"
|
|
160
|
-
next if selected_packages.include?(name)
|
|
843
|
+
def route_snippet(entrypoint: default_entrypoint_path, mount_path: "/ws", helper: "app")
|
|
844
|
+
%(match "#{mount_path}", to: Ruflet::Rails.#{helper}(Rails.root.join("#{entrypoint}")), via: :all)
|
|
845
|
+
end
|
|
161
846
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
847
|
+
def default_web_public_path
|
|
848
|
+
"app"
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
def web_base_href(public_path = default_web_public_path)
|
|
852
|
+
normalized = public_path.to_s.strip.gsub(%r{\A/+|/+\z}, "")
|
|
853
|
+
normalized.empty? ? "/" : "/#{normalized}/"
|
|
166
854
|
end
|
|
167
855
|
|
|
168
|
-
def
|
|
169
|
-
|
|
856
|
+
def publish_web_build(root, public_path: default_web_public_path)
|
|
857
|
+
publish_web_client(root, source: File.join(root, "build", "web"), public_path: public_path)
|
|
858
|
+
end
|
|
859
|
+
|
|
860
|
+
def publish_prebuilt_web_client(root, platform: host_desktop_platform, public_path: default_web_public_path)
|
|
861
|
+
source = prebuilt_web_client_path(platform: platform)
|
|
862
|
+
return false unless source
|
|
863
|
+
|
|
864
|
+
publish_web_client(root, source: source, public_path: public_path)
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
def prebuilt_web_client_path(platform: host_desktop_platform)
|
|
868
|
+
return nil if platform.to_s.empty?
|
|
170
869
|
|
|
171
|
-
|
|
172
|
-
|
|
870
|
+
source = File.join(prebuilt_client_cache_root(platform: platform), "web")
|
|
871
|
+
return nil unless Dir.exist?(source)
|
|
872
|
+
return nil unless File.file?(File.join(source, "index.html"))
|
|
173
873
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
874
|
+
source
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
def prebuilt_client_cache_root(platform: host_desktop_platform)
|
|
878
|
+
require "ruflet/cli"
|
|
879
|
+
|
|
880
|
+
if Ruflet::CLI.respond_to?(:client_cache_root_for, true)
|
|
881
|
+
Ruflet::CLI.send(:client_cache_root_for, platform)
|
|
882
|
+
else
|
|
883
|
+
File.join(Dir.home, ".ruflet", "client", Ruflet::VERSION, platform.to_s)
|
|
177
884
|
end
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
def publish_web_client(root, source:, public_path: default_web_public_path)
|
|
888
|
+
return false unless Dir.exist?(source)
|
|
889
|
+
return false unless File.file?(File.join(source, "index.html"))
|
|
178
890
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
891
|
+
target = File.join(root, "public", public_path.to_s.gsub(%r{\A/+|/+\z}, ""))
|
|
892
|
+
FileUtils.rm_rf(target)
|
|
893
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
894
|
+
FileUtils.cp_r(source, target)
|
|
895
|
+
rewrite_web_base_href(target, public_path: public_path)
|
|
896
|
+
inject_web_client_bootstrap(target)
|
|
897
|
+
true
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
def rewrite_web_base_href(target, public_path:)
|
|
901
|
+
index_path = File.join(target, "index.html")
|
|
902
|
+
return unless File.file?(index_path)
|
|
903
|
+
|
|
904
|
+
content = File.read(index_path)
|
|
905
|
+
base_href = web_base_href(public_path)
|
|
906
|
+
updated =
|
|
907
|
+
if content.match?(%r{<base\s+href=["'][^"']*["']\s*/?>}i)
|
|
908
|
+
content.sub(%r{<base\s+href=["'][^"']*["']\s*/?>}i, %(<base href="#{base_href}">))
|
|
909
|
+
else
|
|
910
|
+
content.sub(%r{<head([^>]*)>}i, %(<head\\1>\n <base href="#{base_href}">))
|
|
186
911
|
end
|
|
912
|
+
File.write(index_path, updated)
|
|
913
|
+
end
|
|
187
914
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
915
|
+
def inject_web_client_bootstrap(target)
|
|
916
|
+
index_path = File.join(target, "index.html")
|
|
917
|
+
return unless File.file?(index_path)
|
|
918
|
+
|
|
919
|
+
content = File.read(index_path)
|
|
920
|
+
return if content.include?('id="ruflet-rails-bootstrap"')
|
|
921
|
+
|
|
922
|
+
script = <<~HTML
|
|
923
|
+
<script id="ruflet-rails-bootstrap">
|
|
924
|
+
if (window.location.search === "" && window.location.hash === "") {
|
|
925
|
+
const rufletServerUrl = window.location.origin + "/";
|
|
926
|
+
window.history.replaceState(
|
|
927
|
+
null,
|
|
928
|
+
document.title,
|
|
929
|
+
window.location.pathname + "?url=" + encodeURIComponent(rufletServerUrl)
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
</script>
|
|
933
|
+
HTML
|
|
934
|
+
|
|
935
|
+
updated =
|
|
936
|
+
if content.include?('<script src="flutter_bootstrap.js"')
|
|
937
|
+
content.sub(%r{<script src="flutter_bootstrap\.js"[^>]*></script>}i) { |match| "#{script} #{match}" }
|
|
938
|
+
else
|
|
939
|
+
content.sub(%r{</body>}i, "#{script}</body>")
|
|
195
940
|
end
|
|
941
|
+
File.write(index_path, updated)
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
def install_next_steps(target:, entrypoint:, client:, web_published:, mount_path: "/ws")
|
|
945
|
+
web_path = default_web_public_path
|
|
946
|
+
lines = [
|
|
947
|
+
"Ruflet Rails installed.",
|
|
948
|
+
"Generated entrypoint: #{entrypoint}",
|
|
949
|
+
"Mounted websocket: #{mount_path}",
|
|
950
|
+
"Next steps:",
|
|
951
|
+
" 1. Start Rails: bin/rails server",
|
|
952
|
+
" 2. Open the Ruflet web client: /#{web_path}/"
|
|
953
|
+
]
|
|
954
|
+
|
|
955
|
+
if web_published
|
|
956
|
+
lines << "Web client copied to public/#{web_path}."
|
|
957
|
+
elsif target.to_s == "ruflet" || %w[web all].include?(client.to_s)
|
|
958
|
+
lines += [
|
|
959
|
+
"Web client was not copied because no built/prebuilt web index.html was found.",
|
|
960
|
+
"To download the prebuilt client from GitHub: bin/rails ruflet:update[web]",
|
|
961
|
+
"To build the WASM web client yourself, install the ruflet CLI globally first:",
|
|
962
|
+
" gem install ruflet",
|
|
963
|
+
"Then build and copy build/web into public/#{web_path}:",
|
|
964
|
+
" bin/rails ruflet:build[web]"
|
|
965
|
+
]
|
|
966
|
+
end
|
|
196
967
|
|
|
197
|
-
|
|
968
|
+
if %w[desktop all].include?(client.to_s)
|
|
969
|
+
lines += [
|
|
970
|
+
"Desktop clients are server-driven and connect to this Rails app.",
|
|
971
|
+
"Plain bin/dev, bin/rails server, and bin/rails s do not launch desktop.",
|
|
972
|
+
"To launch desktop for a dev server run: bin/rails s --desktop or bin/dev --desktop",
|
|
973
|
+
"To download the prebuilt desktop client: bin/rails ruflet:update[desktop]",
|
|
974
|
+
"To build the host desktop client: bin/rails ruflet:build[desktop]"
|
|
975
|
+
]
|
|
198
976
|
end
|
|
199
977
|
|
|
200
|
-
|
|
978
|
+
lines
|
|
201
979
|
end
|
|
202
980
|
end
|
|
203
981
|
end
|