ruflet_rails 0.0.13 → 0.0.14

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.
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fileutils"
4
3
  require "rbconfig"
5
- require "active_support/core_ext/string/inflections"
6
4
 
7
5
  module Ruflet
8
6
  module Rails
@@ -13,9 +11,8 @@ module Ruflet
13
11
  <<~RUBY
14
12
  # frozen_string_literal: true
15
13
 
16
- # The home screen for this Ruflet app. You own this file declare your
17
- # screens here; nothing is auto-discovered. It is mounted explicitly in
18
- # config/routes.rb (the install generator adds this line):
14
+ # The home screen for this Ruflet app. You own this file; nothing is
15
+ # auto-discovered. It is mounted explicitly in config/routes.rb:
19
16
  # match "/ws", to: Ruflet::Rails.app(Rails.root.join("app/views/ruflet/main.rb")), via: :all
20
17
  Ruflet.run do |page|
21
18
  page.title = #{app_title.inspect}
@@ -39,637 +36,6 @@ module Ruflet
39
36
  RUBY
40
37
  end
41
38
 
42
- def application_component_template
43
- template = <<~RUBY
44
- # frozen_string_literal: true
45
-
46
- # ApplicationComponent is the base class for all Ruflet UI components in
47
- # this Rails app. It explicitly includes Ruflet::UI::SharedControlForwarders
48
- # so that every subclass has the full ruflet widget DSL available as
49
- # instance methods (text, column, row, container, safe_area, filled_button,
50
- # icon, data_table, alert_dialog, and every other ruflet widget).
51
- # This is the same DSL that showcase uses — explicit, no Kernel magic.
52
- class ApplicationComponent
53
- include Ruflet::UI::SharedControlForwarders
54
-
55
- attr_reader :page
56
-
57
- def self.render(page, *args, **kwargs, &block)
58
- new(page).render(*args, **kwargs, &block)
59
- end
60
-
61
- def initialize(page)
62
- @page = page
63
- end
64
-
65
- private
66
-
67
- # Widget builder calls on this component delegate to Ruflet::DSL,
68
- # the same target used by the showcase App and by Kernel.
69
- # Override in a subclass to scope builds to a local WidgetBuilder.
70
- def control_delegate
71
- Ruflet::DSL
72
- end
73
-
74
- def platform
75
- page.client_details["platform"].to_s
76
- end
77
-
78
- def desktop?
79
- %w[macos windows linux].include?(platform)
80
- end
81
-
82
- def web?
83
- platform == "web"
84
- end
85
-
86
- def mobile?
87
- !desktop? && !web?
88
- end
89
-
90
- def screen_width
91
- page.client_details["width"].to_f
92
- end
93
-
94
- # Returns true when the client is narrower than 600 logical pixels
95
- # (phones and small tablets), enabling compact list layouts.
96
- def compact?
97
- screen_width > 0 && screen_width < 600
98
- end
99
- end
100
- RUBY
101
- template.gsub(/^ /, " ")
102
- end
103
-
104
- def application_component_path
105
- File.join("app", "views", "ruflet", "components", "application_component.rb")
106
- end
107
-
108
- def model_names(model_name)
109
- raw = model_name.to_s.strip
110
- class_name = raw.camelize
111
- singular = raw.underscore.singularize
112
- plural = singular.pluralize
113
- {
114
- class_name: class_name,
115
- singular: singular,
116
- plural: plural,
117
- title: plural.humanize.titleize
118
- }
119
- end
120
-
121
- def form_view_path(model_name)
122
- names = model_names(model_name)
123
-
124
- File.join("app", "views", "ruflet", "components", names[:plural], "#{names[:singular]}_form.rb")
125
- end
126
-
127
- def scaffold_component_path(model_name)
128
- names = model_names(model_name)
129
-
130
- File.join("app", "views", "ruflet", "components", names[:plural], "#{names[:singular]}_component.rb")
131
- end
132
-
133
- # A `case` with no `when` clause is a syntax error, so models without
134
- # date/time attributes get the plain fallback body instead.
135
- def scaffold_display_value_body(display_value_cases, indent)
136
- return "#{indent}record.public_send(field).to_s" if display_value_cases.to_s.strip.empty?
137
-
138
- reindented_cases = display_value_cases.gsub(/^ /, indent)
139
- <<~RUBY.chomp.gsub(/^/, indent).gsub(/^#{Regexp.escape(indent)}__CASES__$/, reindented_cases)
140
- case field
141
- __CASES__
142
- else
143
- record.public_send(field).to_s
144
- end
145
- RUBY
146
- end
147
-
148
- def scaffold_component_template(model_name:, attributes: [])
149
- names = model_names(model_name)
150
- model_class = names[:class_name]
151
- component_class = "#{model_class}Component"
152
- attrs = normalized_form_attributes(attributes)
153
- control_locals = scaffold_control_locals(attrs)
154
- control_list = scaffold_control_list(attrs)
155
- attributes_hash = scaffold_attributes_hash(attrs)
156
-
157
- <<~RUBY
158
- # frozen_string_literal: true
159
-
160
- require "date"
161
- require "ruflet_rails"
162
-
163
- # The model (#{model_class}) is inferred from the class name and the route
164
- # ("/#{names[:plural]}") is declared in config/routes.rb. This file is YOURS:
165
- # the whole CRUD UI (index table, detail screen, create/edit form) and the
166
- # database calls (#{model_class}#update, #destroy!, .new) are explicit below
167
- # so you can change the UI or logic however you like. The base class only
168
- # provides reusable helpers: record loading, field inference, dialog
169
- # open/close, the date/time picker value helpers, and refresh.
170
- #
171
- # The same component renders on web and on mobile/desktop.
172
- class #{component_class} < Ruflet::Rails::ResourceComponent
173
- def render
174
- safe_area(
175
- container(
176
- expand: true,
177
- padding: { left: 24, top: 16, right: 24, bottom: 24 },
178
- content: column(
179
- expand: true,
180
- spacing: 16,
181
- children: [
182
- index_header,
183
- compact? ? record_list(records) : record_table(records)
184
- ]
185
- )
186
- ),
187
- expand: true
188
- )
189
- end
190
-
191
- def show(record)
192
- safe_area(
193
- container(
194
- expand: true,
195
- padding: { left: 24, top: 16, right: 24, bottom: 24 },
196
- content: column(
197
- expand: true,
198
- spacing: 16,
199
- children: [
200
- show_header(record),
201
- column(
202
- spacing: 8,
203
- children: resource_fields.map { |field| field_row(field.humanize, display_value(record, field)) }
204
- )
205
- ]
206
- )
207
- ),
208
- expand: true
209
- )
210
- end
211
-
212
- private
213
-
214
- def show_header(record)
215
- row(
216
- alignment: "spaceBetween",
217
- vertical_alignment: "center",
218
- children: [
219
- container(expand: true, content: text("\#{singular_title} ##\#{record_id(record)}", size: 24, weight: "bold")),
220
- row(
221
- tight: true,
222
- spacing: 8,
223
- children: [
224
- outlined_button(content: text("Back"), on_click: ->(_event) { render_index }),
225
- filled_button(content: text("Edit"), on_click: ->(_event) { open_form(record) })
226
- ]
227
- )
228
- ]
229
- )
230
- end
231
-
232
- def index_header
233
- row(
234
- alignment: "spaceBetween",
235
- vertical_alignment: "center",
236
- children: [
237
- container(expand: true, content: text(resource_title, size: 24, weight: "bold")),
238
- filled_button(content: text("New \#{singular_title}"), on_click: ->(_event) { open_form(model_class.new) })
239
- ]
240
- )
241
- end
242
-
243
- def record_table(items)
244
- row(
245
- scroll: "auto",
246
- children: [
247
- data_table(
248
- table_columns,
249
- rows: items.map { |record| table_row(record) },
250
- column_spacing: 24,
251
- horizontal_margin: 12,
252
- show_bottom_border: true
253
- )
254
- ]
255
- )
256
- end
257
-
258
- def table_columns
259
- display_fields.map { |field| data_column(field.humanize) } + [
260
- data_column("Actions"),
261
- data_column(""),
262
- data_column("")
263
- ]
264
- end
265
-
266
- def table_row(record)
267
- data_row(
268
- display_fields.map { |field| data_cell(display_value(record, field), on_tap: ->(_event) { open_show(record) }) } +
269
- [
270
- data_cell(icon("visibility", tooltip: "Show"), on_tap: ->(_event) { open_show(record) }),
271
- data_cell(icon("edit", tooltip: "Edit"), on_tap: ->(_event) { open_form(record) }),
272
- data_cell(icon("delete", tooltip: "Delete"), on_tap: ->(_event) { open_delete(record) })
273
- ]
274
- )
275
- end
276
-
277
- def record_list(items)
278
- column(spacing: 4, children: items.map { |record| record_tile(record) })
279
- end
280
-
281
- def record_tile(record)
282
- list_tile(
283
- title: text(primary_label(record)),
284
- subtitle: secondary_label(record) ? text(secondary_label(record)) : nil,
285
- trailing: row(
286
- tight: true,
287
- spacing: 0,
288
- children: [
289
- icon_button("edit", tooltip: "Edit", on_click: ->(_event) { open_form(record) }),
290
- icon_button("delete", tooltip: "Delete", on_click: ->(_event) { open_delete(record) })
291
- ]
292
- ),
293
- on_click: ->(_event) { open_show(record) }
294
- )
295
- end
296
-
297
- def open_show(record)
298
- show_record(record)
299
- end
300
-
301
- def open_form(record)
302
- #{control_locals}
303
-
304
- attributes = lambda do
305
- {
306
- #{attributes_hash}
307
- }
308
- end
309
-
310
- dialog = nil
311
- dialog = alert_dialog(
312
- open: false,
313
- modal: true,
314
- scrollable: true,
315
- title: text(record.persisted? ? "Edit \#{singular_title}" : "New \#{singular_title}"),
316
- content: container(
317
- width: dialog_width,
318
- content: column(
319
- spacing: 8,
320
- horizontal_alignment: "stretch",
321
- children: [
322
- #{control_list}
323
- ]
324
- )
325
- ),
326
- actions: [
327
- text_button(content: text("Cancel"), on_click: ->(_event) { close_dialog(dialog) }),
328
- filled_button(content: text("Save"), on_click: ->(_event) {
329
- # Persist with the model (this is your code — change it freely).
330
- if record.update(attributes.call)
331
- close_dialog(dialog)
332
- refresh
333
- show_snackbar("\#{singular_title} saved")
334
- else
335
- show_errors(record)
336
- end
337
- })
338
- ],
339
- actions_alignment: "end"
340
- )
341
- open_dialog(dialog)
342
- end
343
-
344
- def open_delete(record)
345
- dialog = nil
346
- dialog = alert_dialog(
347
- open: false,
348
- modal: true,
349
- title: text("Delete \#{singular_title}?"),
350
- content: text("Permanently remove \#{singular_title} #\#{record_id(record)}?", no_wrap: false),
351
- actions: [
352
- text_button(content: text("Cancel"), on_click: ->(_event) { close_dialog(dialog) }),
353
- filled_button(content: text("Delete"), on_click: ->(_event) {
354
- # Destroy with the model (this is your code — change it freely).
355
- record.destroy!
356
- close_dialog(dialog)
357
- refresh
358
- show_snackbar("\#{singular_title} deleted")
359
- })
360
- ],
361
- actions_alignment: "end"
362
- )
363
- open_dialog(dialog)
364
- end
365
-
366
- def field_row(label, value)
367
- row(
368
- children: [
369
- container(width: 140, content: text(label, weight: "bold")),
370
- container(expand: true, content: text(value, no_wrap: false))
371
- ]
372
- )
373
- end
374
- end
375
- RUBY
376
- end
377
-
378
- def scaffold_control_locals(attrs)
379
- attrs.map { |field| scaffold_control_local(field) }.join("\n ")
380
- end
381
-
382
- def scaffold_resource_fields(attrs)
383
- attrs.map { |field| field[:name] }.inspect
384
- end
385
-
386
- def scaffold_display_fields(attrs)
387
- fields = attrs.reject { |field| field[:type].to_s == "text" }
388
- fields = attrs if fields.empty?
389
- fields.first(3).map { |field| field[:name] }.inspect
390
- end
391
-
392
- def scaffold_display_value_cases(attrs)
393
- attrs.filter_map do |field|
394
- next unless %w[date datetime timestamp time date_range daterange].include?(field[:type].to_s)
395
-
396
- name = field[:name]
397
- formatter =
398
- case field[:type].to_s
399
- when "time"
400
- "value.respond_to?(:strftime) ? value.strftime(\"%H:%M\") : value.to_s"
401
- when "date_range", "daterange"
402
- "value.respond_to?(:begin) && value.respond_to?(:end) ? \"\#{value.begin} - \#{value.end}\" : value.to_s"
403
- when "date"
404
- "value.respond_to?(:to_date) ? value.to_date.iso8601 : value.to_s"
405
- else
406
- "value.respond_to?(:iso8601) ? value.iso8601 : value.to_s"
407
- end
408
- [
409
- " when #{name.inspect}",
410
- " value = record.public_send(#{name.inspect})",
411
- " #{formatter}"
412
- ].join("\n")
413
- end.join("\n")
414
- end
415
-
416
- def scaffold_control_list(attrs)
417
- attrs.map { |field| scaffold_control_view_name(field) }.join(",\n ")
418
- end
419
-
420
- def scaffold_attributes_hash(attrs)
421
- attrs.map { |field| scaffold_attribute_pair(field) }.join(",\n ")
422
- end
423
-
424
- def scaffold_control_local(field)
425
- name = field[:name]
426
- type = field[:type].to_s
427
- control = scaffold_control_name(field)
428
- label = name.humanize
429
- value = "record.public_send(#{name.inspect})"
430
-
431
- case type
432
- when "boolean"
433
- "#{control} = checkbox(label: #{label.inspect}, value: !!#{value})"
434
- when "date", "datetime", "timestamp"
435
- display_control = "#{control}_display"
436
- picker_value_helper = type == "date" ? "date_picker_value" : "datetime_picker_value"
437
- <<~RUBY.chomp
438
- #{control}_value = #{picker_value_helper}(#{value})
439
- #{display_control} = text(date_display_value(#{control}_value))
440
- #{control} = date_picker(
441
- value: #{control}_value,
442
- help_text: #{label.inspect},
443
- on_change: ->(_event) do
444
- close_dialogs(#{control})
445
- page.update(#{display_control}, value: date_display_value(#{control}.value))
446
- end
447
- )
448
- #{control}_field = column(
449
- spacing: 6,
450
- children: [
451
- text(#{label.inspect}),
452
- row(
453
- spacing: 8,
454
- children: [
455
- container(expand: true, content: #{display_control}),
456
- outlined_button(content: text("Choose #{label}"), on_click: ->(_event) { open_dialog(#{control}) })
457
- ]
458
- )
459
- ]
460
- )
461
- RUBY
462
- when "time"
463
- display_control = "#{control}_display"
464
- <<~RUBY.chomp
465
- #{control}_value = time_picker_value(#{value})
466
- #{display_control} = text(time_display_value(#{control}_value))
467
- #{control} = time_picker(
468
- value: #{control}_value,
469
- help_text: #{label.inspect},
470
- on_change: ->(_event) do
471
- close_dialogs(#{control})
472
- page.update(#{display_control}, value: time_display_value(#{control}.value))
473
- end
474
- )
475
- #{control}_field = column(
476
- spacing: 6,
477
- children: [
478
- text(#{label.inspect}),
479
- row(
480
- spacing: 8,
481
- children: [
482
- container(expand: true, content: #{display_control}),
483
- outlined_button(content: text("Choose #{label}"), on_click: ->(_event) { open_dialog(#{control}) })
484
- ]
485
- )
486
- ]
487
- )
488
- RUBY
489
- when "date_range", "daterange"
490
- display_control = "#{control}_display"
491
- <<~RUBY.chomp
492
- #{control}_start_value, #{control}_end_value = date_range_picker_values(#{value})
493
- #{display_control} = text(date_range_display_value(#{control}_start_value, #{control}_end_value))
494
- #{control} = date_range_picker(
495
- start_value: #{control}_start_value,
496
- end_value: #{control}_end_value,
497
- help_text: #{label.inspect},
498
- on_change: ->(_event) do
499
- close_dialogs(#{control})
500
- page.update(
501
- #{display_control},
502
- value: date_range_display_value(#{control}.start_value, #{control}.end_value)
503
- )
504
- end
505
- )
506
- #{control}_field = column(
507
- spacing: 6,
508
- children: [
509
- text(#{label.inspect}),
510
- row(
511
- spacing: 8,
512
- children: [
513
- container(expand: true, content: #{display_control}),
514
- outlined_button(content: text("Choose #{label}"), on_click: ->(_event) { open_dialog(#{control}) })
515
- ]
516
- )
517
- ]
518
- )
519
- RUBY
520
- when "text"
521
- "#{control} = text_field(value: #{value}.to_s, label: #{label.inspect}, multiline: true, min_lines: 3)"
522
- when "integer", "float", "decimal"
523
- "#{control} = text_field(value: #{value}.to_s, label: #{label.inspect}, keyboard_type: \"number\")"
524
- else
525
- "#{control} = text_field(value: #{value}.to_s, label: #{label.inspect})"
526
- end
527
- end
528
-
529
- def scaffold_attribute_pair(field)
530
- name = field[:name]
531
- type = field[:type].to_s
532
- control = scaffold_control_name(field)
533
- value =
534
- case type
535
- when "boolean"
536
- "!!#{control}.value"
537
- when "date"
538
- "#{control}.value.to_s.split(\"T\", 2).first"
539
- when "date_range", "daterange"
540
- "Range.new(Date.parse(#{control}.start_value.to_s), Date.parse(#{control}.end_value.to_s))"
541
- else
542
- "#{control}.value.to_s"
543
- end
544
-
545
- "#{name.inspect} => #{value}"
546
- end
547
-
548
- def scaffold_control_name(field)
549
- "#{field[:name].gsub(/[^a-zA-Z0-9_]/, '_')}_control"
550
- end
551
-
552
- def scaffold_control_view_name(field)
553
- control = scaffold_control_name(field)
554
- %w[date datetime timestamp time date_range daterange].include?(field[:type].to_s) ? "#{control}_field" : control
555
- end
556
-
557
- def form_view_template(model_name:, attributes:)
558
- names = model_names(model_name)
559
- attrs = normalized_form_attributes(attributes)
560
- fields_literal = attrs.map { |field| form_field_literal(field) }.join(", ")
561
- model_class = names[:class_name]
562
- singular_title = names[:singular].humanize.titleize
563
-
564
- <<~RUBY
565
- # frozen_string_literal: true
566
-
567
- require "ruflet_rails"
568
-
569
- class #{model_class}Form < ApplicationComponent
570
- include Ruflet::Rails::FormHelpers
571
-
572
- def render(record:, title: nil, on_save: nil, on_cancel: nil)
573
- title ||= record.persisted? ? "Edit #{singular_title}" : "New #{singular_title}"
574
- fields = ruflet_form_bindings(record, form_fields)
575
-
576
- column(
577
- expand: true,
578
- spacing: 12,
579
- children: [
580
- text(title, size: 24, weight: "bold"),
581
- column(spacing: 8, horizontal_alignment: "stretch", children: ruflet_form_controls(fields)),
582
- row(
583
- spacing: 8,
584
- children: [
585
- outlined_button(
586
- content: text("Cancel"),
587
- on_click: ->(_e) { on_cancel ? on_cancel.call(page, record) : nil }
588
- ),
589
- filled_button(
590
- content: text(record.persisted? ? "Update #{singular_title}" : "Create #{singular_title}"),
591
- on_click: ->(_e) { save(record, fields, on_save: on_save) }
592
- )
593
- ]
594
- )
595
- ]
596
- )
597
- end
598
-
599
- def save(record, fields, on_save: nil)
600
- if record.update(ruflet_form_attributes(fields, form_fields))
601
- on_save ? on_save.call(page, record) : record
602
- else
603
- show_errors(record)
604
- false
605
- end
606
- end
607
-
608
- def form_fields
609
- [#{fields_literal}]
610
- end
611
-
612
- def show_errors(record)
613
- show_snackbar(error_message(record))
614
- end
615
-
616
- def show_snackbar(message)
617
- page.snackbar = snackbar(text(message), open: true)
618
- end
619
-
620
- def error_message(record)
621
- messages = record.errors.full_messages
622
- messages.respond_to?(:to_sentence) ? messages.to_sentence : messages.join(", ")
623
- end
624
- end
625
- RUBY
626
- end
627
-
628
- def normalized_form_attributes(attributes)
629
- attrs = Array(attributes).map { |field| normalize_form_attribute(field) }.reject { |field| field[:name].empty? }
630
- attrs.empty? ? [{ name: "name", type: "string" }] : attrs
631
- end
632
-
633
- def attributes_from_model(model_class)
634
- return [] unless model_class.respond_to?(:columns)
635
-
636
- model_class.columns.reject { |column|
637
- %w[id created_at updated_at].include?(column.name)
638
- }.map { |column| "#{column.name}:#{column.type}" }
639
- end
640
-
641
- def form_field_literal(field)
642
- parts = [
643
- "name: #{field[:name].inspect}",
644
- "type: #{field[:type].inspect}"
645
- ]
646
- parts << "class_name: #{field[:class_name].inspect}" if field[:class_name]
647
- "{ #{parts.join(', ')} }"
648
- end
649
-
650
- def normalize_form_attribute(value)
651
- raw = value.to_s.strip
652
- name, type = raw.split(":", 2)
653
- name = name.to_s.underscore.gsub(/[^a-z0-9_]/, "")
654
- type = type.to_s.strip
655
- type = "string" if type.empty?
656
- name = "#{name}_id" if %w[references belongs_to association].include?(type) && !name.end_with?("_id")
657
- association = association_class_name_for(name, type)
658
- {
659
- name: name,
660
- type: association ? "association" : type
661
- }.tap do |field|
662
- field[:class_name] = association if association
663
- end
664
- end
665
-
666
- def association_class_name_for(name, type)
667
- return name.sub(/_id\z/, "").camelize if name.end_with?("_id")
668
- return name.camelize if %w[references belongs_to association].include?(type)
669
-
670
- nil
671
- end
672
-
673
39
  def default_ruflet_yaml(app_name:)
674
40
  <<~YAML
675
41
  app:
@@ -684,6 +50,23 @@ module Ruflet
684
50
  YAML
685
51
  end
686
52
 
53
+ def default_initializer(app_name:)
54
+ <<~RUBY
55
+ # frozen_string_literal: true
56
+
57
+ Ruflet::Rails.configure do |config|
58
+ config.app_name = #{app_name.inspect}
59
+ config.backend_url = ENV.fetch("RUFLET_BACKEND_URL", #{default_backend_url.inspect})
60
+
61
+ config.services = []
62
+
63
+ # Build assets. Replace these with your app artwork when ready.
64
+ config.splash_screen = Rails.root.join("app/assets/images/splash.png")
65
+ config.icon_launcher = Rails.root.join("app/assets/images/icon.png")
66
+ end
67
+ RUBY
68
+ end
69
+
687
70
  def ruby_desktop_flag_bootstrap
688
71
  <<~RUBY
689
72
  # ruflet_rails desktop flag
@@ -756,7 +139,6 @@ module Ruflet
756
139
  File.join("app", "views", "ruflet", "main.rb")
757
140
  end
758
141
 
759
- # Kept for backward compatibility with apps that use manual mount.
760
142
  def route_snippet(entrypoint: default_entrypoint_path, mount_path: "/ws", helper: "app")
761
143
  %(match "#{mount_path}", to: Ruflet::Rails.#{helper}(Rails.root.join("#{entrypoint}")), via: :all)
762
144
  end