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,370 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "date"
4
- require "time"
5
- require "active_model"
6
- require "active_support/core_ext/string/inflections"
7
-
8
- module Ruflet
9
- module Rails
10
- # Base class for a Ruflet CRUD resource.
11
- #
12
- # The generated subclass owns the explicit CRUD UI (render, show, the
13
- # create/edit form) AND the database calls (record.update, record.destroy!,
14
- # model_class.new) — so a developer can read and change anything. This base
15
- # provides reusable helpers: model resolution, record loading,
16
- # field inference (resource_fields/display_fields/display_value), navigation
17
- # (render_index/render_show/refresh), dialog management, snackbars, and the
18
- # date/time picker value helpers.
19
- #
20
- # The same subclass renders on web and on mobile/desktop. It is mounted from
21
- # config/routes.rb (the route path is declared there, never in the
22
- # component):
23
- #
24
- # mount Ruflet::Rails.web_app(view: "ProductComponent"), at: "/products"
25
- #
26
- # web_app(view:) calls `.render(page)` on the class, which is why the class
27
- # method below is the entrypoint. The model is inferred from the class name
28
- # (ProductComponent -> Product); override `model_class` to customize.
29
- class ResourceComponent
30
- include Ruflet::UI::SharedControlForwarders
31
-
32
- attr_reader :page, :controller
33
-
34
- class << self
35
- # Entrypoint used by web_app(view: "...") on each new session.
36
- def render(page, *_args)
37
- new(page).render_index
38
- end
39
-
40
- # Declare or read the managed model: `model Product` or inferred from
41
- # the component class name.
42
- def model(value = nil)
43
- @model_class = value if value
44
- @model_class || inferred_model_class
45
- end
46
-
47
- # Declare or read the plural resource title shown on the index screen.
48
- def title(value = nil)
49
- @resource_title = value if value
50
- @resource_title || (model_name ? model_name.plural.humanize.titleize : "Resources")
51
- end
52
-
53
- def singular_title
54
- model_name ? model_name.human.titleize : "Record"
55
- end
56
-
57
- private
58
-
59
- def inferred_model_class
60
- base = name.to_s.split("::").last.to_s.sub(/Component\z/, "")
61
- base.empty? ? nil : base.safe_constantize
62
- end
63
-
64
- def model_name
65
- klass = model
66
- klass.respond_to?(:model_name) ? klass.model_name : nil
67
- rescue StandardError
68
- nil
69
- end
70
- end
71
-
72
- def initialize(page, controller: nil)
73
- @page = page
74
- @controller = controller
75
- end
76
-
77
- # --- Resource configuration (override in the generated subclass) --------
78
-
79
- def model_class
80
- self.class.model
81
- end
82
-
83
- def resource_title
84
- self.class.title
85
- end
86
-
87
- def singular_title
88
- model_class.respond_to?(:model_name) ? model_class.model_name.human.titleize : self.class.singular_title
89
- end
90
-
91
- # Fields rendered on the detail (show) screen. The default uses the
92
- # model's own attribute names; subclasses can override it.
93
- def resource_fields
94
- default_resource_fields
95
- end
96
-
97
- # Columns rendered in the index table / list tiles.
98
- def display_fields
99
- resource_fields
100
- end
101
-
102
- # Formats a single field for display. Override for custom rendering.
103
- def display_value(record, field)
104
- record.public_send(field).to_s
105
- end
106
-
107
- # --- Record loading & navigation ---------------------------------------
108
-
109
- def records
110
- scope = model_class.respond_to?(:limit) ? model_class.limit(50) : model_class.all
111
- scope.respond_to?(:limit) ? scope.limit(50) : scope.to_a.first(50)
112
- end
113
-
114
- def render_index
115
- page.title = resource_title
116
- page.views = []
117
- page.add(render)
118
- end
119
-
120
- def render_show(record)
121
- page.views = []
122
- page.add(show(record))
123
- page.update
124
- end
125
-
126
- def show_record(record)
127
- render_show(record)
128
- end
129
-
130
- # Re-render the index after a create/update/destroy. The generated
131
- # component calls this from its own (explicit) save/destroy code.
132
- def refresh
133
- render_index
134
- end
135
-
136
- # --- Index labels -------------------------------------------------------
137
-
138
- def primary_label(record)
139
- field = display_fields.first
140
- field ? display_value(record, field) : "##{record_id(record)}"
141
- end
142
-
143
- def secondary_label(record)
144
- field = display_fields[1]
145
- field ? display_value(record, field) : nil
146
- end
147
-
148
- private
149
-
150
- def default_resource_fields
151
- return [] unless model_class.respond_to?(:attribute_names)
152
-
153
- ignored = %w[id created_at updated_at]
154
- model_class.attribute_names.map(&:to_s) - ignored
155
- rescue StandardError
156
- []
157
- end
158
-
159
- def control_delegate
160
- Ruflet::DSL
161
- end
162
-
163
- # --- Layout helpers shared by every resource UI ------------------------
164
-
165
- def compact?
166
- width = page.client_details["width"].to_f
167
- width > 0 && width < 600
168
- end
169
-
170
- def dialog_width
171
- width = page.client_details["width"].to_f
172
- return 520 if width <= 0
173
-
174
- [[width - 64, 280].max, 520].min
175
- end
176
-
177
- def record_id(record)
178
- record.respond_to?(:id) ? record.id : nil
179
- end
180
-
181
- def show_errors(record)
182
- show_snackbar(error_message(record))
183
- end
184
-
185
- def show_snackbar(message)
186
- page.snackbar = snackbar(text(message), open: true)
187
- end
188
-
189
- def error_message(record)
190
- messages = record.errors.full_messages
191
- messages.respond_to?(:to_sentence) ? messages.to_sentence : messages.join(", ")
192
- end
193
-
194
- # --- Dialog management --------------------------------------------------
195
-
196
- def open_dialog(dialog)
197
- prune_closed_dialogs
198
- return open_primary_dialog(dialog) if primary_dialog?(dialog) && !latest_stack_dialog
199
-
200
- preserve_rendered_open_dialog_state
201
- page.show_dialog(dialog)
202
- dialog
203
- end
204
-
205
- def close_dialog(dialog)
206
- close_open_dialogs_above(dialog)
207
- close_tracked_dialog(dialog)
208
- end
209
-
210
- def close_dialogs(*dialogs)
211
- dialogs.flatten.compact.each do |dialog|
212
- close_tracked_dialog(dialog)
213
- end
214
- end
215
-
216
- def date_picker_value(value, fallback: Date.today)
217
- return value.to_date.iso8601 if value.respond_to?(:to_date)
218
- return fallback.iso8601 if value.to_s.empty?
219
-
220
- Date.parse(value.to_s).iso8601
221
- rescue ArgumentError
222
- fallback.iso8601
223
- end
224
-
225
- def datetime_picker_value(value, fallback: Time.now)
226
- return value.iso8601 if value.respond_to?(:iso8601)
227
- return fallback.iso8601 if value.to_s.empty?
228
-
229
- Time.parse(value.to_s).iso8601
230
- rescue ArgumentError
231
- fallback.iso8601
232
- end
233
-
234
- def time_picker_value(value)
235
- return value.strftime("%H:%M") if value.respond_to?(:strftime)
236
-
237
- value.to_s
238
- end
239
-
240
- def date_range_picker_values(value)
241
- if value.respond_to?(:begin) && value.respond_to?(:end)
242
- return [date_picker_value(value.begin), date_picker_value(value.end)]
243
- end
244
- if value.respond_to?(:to_a) && value.to_a.length >= 2
245
- first, last = value.to_a.first(2)
246
- return [date_picker_value(first), date_picker_value(last)]
247
- end
248
-
249
- raw = value.to_s.strip.gsub(/\A[\[(]|[\])]\z/, "")
250
- start_value, end_value =
251
- if raw.include?("...")
252
- raw.split("...", 2)
253
- elsif raw.include?("..")
254
- raw.split("..", 2)
255
- else
256
- raw.split(",", 2)
257
- end
258
- [date_picker_value(start_value), date_picker_value(end_value || start_value)]
259
- end
260
-
261
- def date_display_value(value)
262
- visible = value.to_s.split("T", 2).first
263
- visible.empty? ? "Not selected" : visible
264
- end
265
-
266
- def date_range_display_value(start_value, end_value)
267
- "#{date_display_value(start_value)} - #{date_display_value(end_value)}"
268
- end
269
-
270
- def time_display_value(value)
271
- visible = value.to_s
272
- visible.empty? ? "Not selected" : visible
273
- end
274
-
275
- def close_open_dialogs_above(dialog)
276
- return false unless page.respond_to?(:latest_open_dialog, true)
277
-
278
- while (latest = page.__send__(:latest_open_dialog)) && !latest.equal?(dialog)
279
- close_tracked_dialog(latest)
280
- end
281
- rescue StandardError
282
- false
283
- end
284
-
285
- def close_tracked_dialog(dialog)
286
- return unless dialog
287
-
288
- close_dialog_stack_entry(dialog)
289
- end
290
-
291
- def close_dialog_stack_entry(dialog)
292
- return close_primary_dialog(dialog) if primary_dialog_slot?(dialog)
293
-
294
- unless page.respond_to?(:remove_dialog_tracking, true)
295
- page.close_dialog(dialog)
296
- return
297
- end
298
-
299
- page.update(dialog, open: false) if dialog.wire_id
300
- page.__send__(:remove_dialog_tracking, dialog)
301
- sync_dialogs_container
302
- end
303
-
304
- def sync_dialogs_container
305
- dialogs_container = page.instance_variable_get(:@dialogs_container) if page.instance_variable_defined?(:@dialogs_container)
306
- return page.update unless dialogs_container&.wire_id
307
-
308
- page.update(dialogs_container, controls: dialogs_container.props["controls"])
309
- end
310
-
311
- def open_primary_dialog(dialog)
312
- dialog.props["open"] = true
313
- page.dialog = dialog
314
- dialog
315
- end
316
-
317
- def close_primary_dialog(dialog)
318
- page.update(dialog, open: false) if dialog.wire_id
319
- end
320
-
321
- def primary_dialog_slot?(dialog)
322
- page.instance_variable_defined?(:@dialog) && page.instance_variable_get(:@dialog).equal?(dialog)
323
- end
324
-
325
- def latest_stack_dialog
326
- page.__send__(:latest_open_dialog) if page.respond_to?(:latest_open_dialog, true)
327
- end
328
-
329
- def primary_dialog?(dialog)
330
- dialog.type.to_s.tr("_", "").casecmp("alertdialog").zero?
331
- end
332
-
333
- def preserve_rendered_open_dialog_state
334
- tracked_dialogs.each do |dialog|
335
- next if dialog.props["open"] == false
336
-
337
- dialog.props["_open"] = true
338
- end
339
- end
340
-
341
- def tracked_dialogs
342
- dialogs = []
343
- dialogs << page.instance_variable_get(:@dialog) if page.instance_variable_defined?(:@dialog)
344
- dialogs.concat(Array(page.instance_variable_get(:@dialogs))) if page.instance_variable_defined?(:@dialogs)
345
- dialogs.compact.uniq
346
- end
347
-
348
- def prune_closed_dialogs
349
- return unless page.respond_to?(:remove_dialog_tracking, true)
350
-
351
- dialogs = page.instance_variable_get(:@dialogs) if page.instance_variable_defined?(:@dialogs)
352
- Array(dialogs).select { |dialog| dialog.props["open"] == false }.each do |dialog|
353
- page.__send__(:remove_dialog_tracking, dialog)
354
- end
355
- end
356
-
357
- # Methods not found on the component fall back to an optional controller,
358
- # kept for apps that still pair a component with a separate host object.
359
- def method_missing(name, *args, **kwargs, &block)
360
- return controller.__send__(name, *args, **kwargs, &block) if controller&.respond_to?(name, true)
361
-
362
- super
363
- end
364
-
365
- def respond_to_missing?(name, include_private = false)
366
- (controller&.respond_to?(name, true) || false) || super
367
- end
368
- end
369
- end
370
- end
@@ -1,90 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ruflet
4
- module Rails
5
- # Flet-style routed navigation stack for complex, multi-screen apps.
6
- #
7
- # It wires up the boilerplate that Flet's routing examples spell out by hand
8
- # — on_route_change, on_view_pop and the initial page.go — so an app only has
9
- # to describe, per route, how to build that screen's View stack. The stack is
10
- # rebuilt from the current route on every navigation, and the client back
11
- # button (AppBar arrow / system back) pops it automatically.
12
- #
13
- # Block form — one builder receives the current route and the stack:
14
- #
15
- # Ruflet::Rails.routed(page) do |route, nav|
16
- # nav.push(home_view)
17
- # nav.push(store_view) if route == "/store"
18
- # end
19
- #
20
- # Map form — declare a builder per route:
21
- #
22
- # Ruflet::Rails::RouteStack.new(page)
23
- # .on("/") { |nav| nav.push(home_view) }
24
- # .on("/store") { |nav| nav.push(home_view); nav.push(store_view) }
25
- # .start
26
- #
27
- # Navigate with page.go("/store"); pushing onto the stack happens by
28
- # rebuilding for the new route, exactly like Flet.
29
- class RouteStack
30
- def initialize(page, &builder)
31
- @page = page
32
- @builder = builder
33
- @routes = {}
34
- @stack = []
35
- wire_page_handlers!
36
- end
37
-
38
- # Register a per-route stack builder. The block receives this RouteStack so
39
- # it can #push one or more views for that route.
40
- def on(route, &block)
41
- @routes[normalize(route)] = block
42
- self
43
- end
44
-
45
- # Append a View (or anything responding to #route) onto the current stack.
46
- def push(view)
47
- @stack << view
48
- view
49
- end
50
-
51
- # Render the current route's stack and begin listening for navigation.
52
- def start
53
- @page.go(@page.route.to_s.empty? ? "/" : @page.route)
54
- self
55
- end
56
-
57
- private
58
-
59
- def wire_page_handlers!
60
- @page.on_route_change = ->(_event) { rebuild }
61
- @page.on_view_pop = lambda do |_event|
62
- @page.views.pop
63
- top = @page.views.last
64
- @page.go(top.respond_to?(:route) ? top.route : "/")
65
- end
66
- end
67
-
68
- def rebuild
69
- @stack = []
70
- route = normalize(@page.route.to_s.split("?", 2).first)
71
-
72
- if @builder
73
- @builder.call(route, self)
74
- elsif (block = @routes[route])
75
- block.call(self)
76
- end
77
-
78
- @page.views = @stack
79
- @page.update
80
- end
81
-
82
- def normalize(route)
83
- value = route.to_s.strip
84
- return "/" if value.empty? || value == "/"
85
-
86
- "/#{value.gsub(%r{\A/+|/+\z}, "")}"
87
- end
88
- end
89
- end
90
- end
@@ -1,54 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ruflet
4
- module Rails
5
- # Build a "webview app" shell: a native screen whose body is a WebView, with
6
- # a native AppBar on top and a native NavigationBar (or bottom AppBar) below.
7
- # The chrome stays native while the body shows your website.
8
- #
9
- # Ruflet.run do |page|
10
- # Ruflet::Rails.routed(page) do |route, nav|
11
- # if route == "/"
12
- # nav.push(
13
- # Ruflet::Rails.webview_app(
14
- # url: "https://myapp.com",
15
- # appbar: app_bar(title: text("My App")),
16
- # navigation_bar: navigation_bar(destinations: [...]),
17
- # # Following a link inside the page opens a NATIVE view instead:
18
- # on_navigate: ->(url) { page.go("/details") if url.include?("/product/") }
19
- # )
20
- # )
21
- # elsif route == "/details"
22
- # nav.push(detail_view) # native screen, pushed on top; back returns to the shell
23
- # end
24
- # end
25
- # end
26
- #
27
- # on_navigate fires from the webview's url change as the user navigates, with
28
- # the target URL — map it to a native route with page.go. (prevent_links can
29
- # additionally stop the webview from ever loading matching URLs.) Pass a block
30
- # to capture the WebView control itself (e.g. to run_javascript on it later).
31
- module_function
32
-
33
- def webview_app(url:, appbar: nil, navigation_bar: nil, bottom_appbar: nil,
34
- route: "/", prevent_links: nil, on_navigate: nil,
35
- on_page_started: nil, on_page_ended: nil, **webview_props)
36
- webview_args = { url: url, method: "get", expand: true }
37
- webview_args[:prevent_links] = prevent_links unless prevent_links.nil?
38
- webview_args[:on_url_change] = ->(event) { on_navigate.call(event.data) } if on_navigate
39
- webview_args[:on_page_started] = on_page_started if on_page_started
40
- webview_args[:on_page_ended] = on_page_ended if on_page_ended
41
- webview_args.merge!(webview_props)
42
-
43
- body = Ruflet::UI::ControlFactory.build(:webview, **webview_args)
44
- yield body if block_given?
45
-
46
- view_args = { route: route, controls: [body] }
47
- view_args[:appbar] = appbar unless appbar.nil?
48
- view_args[:navigation_bar] = navigation_bar unless navigation_bar.nil?
49
- view_args[:bottom_appbar] = bottom_appbar unless bottom_appbar.nil?
50
-
51
- Ruflet::UI::ControlFactory.build(:view, **view_args)
52
- end
53
- end
54
- end