glib-web 5.1.5 → 6.2.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.
Files changed (24) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/glib/json/ui.rb +10 -0
  3. data/app/controllers/glib/errors_controller.rb +57 -1
  4. data/app/helpers/glib/json_ui/action_builder/logics.rb +14 -0
  5. data/app/helpers/glib/json_ui/action_builder/windows.rb +20 -5
  6. data/app/helpers/glib/json_ui/json_logic_lint.rb +82 -0
  7. data/app/helpers/glib/json_ui/view_builder/panels.rb +14 -0
  8. data/app/views/json_ui/garage/test_page/fields_upload.json.jbuilder +1 -1
  9. data/app/views/json_ui/garage/test_page/form_dirty_hooks.json.jbuilder +31 -0
  10. data/app/views/json_ui/garage/test_page/form_dynamic_dirty.json.jbuilder +41 -0
  11. data/app/views/json_ui/garage/test_page/form_removed_field.json.jbuilder +55 -0
  12. data/app/views/json_ui/garage/test_page/form_removed_field_onload.json.jbuilder +34 -0
  13. data/app/views/json_ui/garage/test_page/form_touched_validation.json.jbuilder +35 -0
  14. data/app/views/json_ui/garage/test_page/logics_run.json.jbuilder +79 -0
  15. data/app/views/json_ui/garage/test_page/logics_run_multi_form.json.jbuilder +45 -0
  16. data/app/views/json_ui/garage/test_page/logics_set.json.jbuilder +19 -0
  17. data/app/views/json_ui/garage/test_page/panels_bulkEdit2.json.jbuilder +3 -0
  18. data/app/views/json_ui/garage/test_page/run_multiple.json.jbuilder +31 -0
  19. data/app/views/json_ui/garage/test_page/windows_reload.json.jbuilder +57 -0
  20. data/lib/glib/rubocop.rb +0 -1
  21. data/lib/glib/snapshot.rb +1 -1
  22. data/lib/glib/test/parallel_coverage.rb +16 -0
  23. metadata +11 -2
  24. data/lib/glib/rubocop/cops/multiline_method_call_style.rb +0 -250
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9663b2eb594fba65030c257cc3e343228a2d3a32857e11db73310a5ae12a264b
4
- data.tar.gz: 577f3c5be804d1efd40e2e9de4bfa629216f4514a3bd14ca92beef6cfbd6cedb
3
+ metadata.gz: 52b6b62180ee0cd668652772110774bc915d8789f1fd65e9ef22fadd781977a4
4
+ data.tar.gz: 3d47de6a06859cce937834478bbef460e2cac999bbfbdf72cef10a781399e68f
5
5
  SHA512:
6
- metadata.gz: 37260aff5b03ad55ed5ffd904bc349d8a256d11ceb824380b4c541db0765237b82b05d376cb0835d8d83948460be97bacbfe176b6a442603496626d7ddf2fd83
7
- data.tar.gz: ff4072d9f930e5a8da3ee7084c6a4be66da62d06b9fe49e3160876564c5ea3b251671832d68d44b9551b046b1182f3e7e3e3edabba9c07879b58a59026f23c96
6
+ metadata.gz: c6537178f38fa7703d8c3e0436f1c3d4ac1171c340bcda27d18aad936a7ff40e329488c0cbf618dd0974e4e7a7926a3b209eb3c88f29447416b1000db9fee37d
7
+ data.tar.gz: c532e338ce8890c52a5efc9c07c734fd99f4adba46f244f5a5c9c160b188b7630d0d18b080ad599a528ad9bb9a68fc5697c84137ec5383524308fc58b53c512b
@@ -32,6 +32,16 @@ module Glib::Json::Ui
32
32
  @__json_ui_activated
33
33
  end
34
34
 
35
+ # Call this after `render`-ing a json_ui response from a HALTING callback,
36
+ # e.g. a `rate_limit` handler or any before_action that renders. Halting
37
+ # skips the rest of the callback chain, including the after_action that
38
+ # swaps the JSON body for the HTML shell on browser page loads — without
39
+ # this, an HTML request would receive raw JSON. Safe to call
40
+ # unconditionally: the swap is a no-op for JSON/XHR requests.
41
+ def json_ui_commit_now
42
+ __json_ui_commit(self.class.__json_libs_init_options)
43
+ end
44
+
35
45
  def __json_ui_rendering?
36
46
  @__json_ui_rendering != nil
37
47
  end
@@ -1,7 +1,63 @@
1
1
  module Glib
2
+ # Receives browser-side JS error reports (see the npm package's
3
+ # `useGlibErrorReporter`) and forwards them to the app's error tracker
4
+ # server-side -- no client-side token or SDK needed. The client dedupes and
5
+ # caps reports per page load, so this endpoint is not expected to see
6
+ # sustained traffic.
2
7
  class ErrorsController < ::ActionController::Base
8
+ # The reporter sends the CSRF token when available, but a stale token
9
+ # must not turn error reporting itself into an error source (and a forged
10
+ # report is harmless telemetry).
11
+ skip_forgery_protection
12
+
13
+ # Abuse ceilings (Rails' built-in limiter, backed by the host app's cache
14
+ # store), TIERED by whether the report's CSRF token verifies: a valid
15
+ # token proves the report came from a page this app served to this
16
+ # session, so crafted floods live in the low tier while real browsers get
17
+ # headroom. Stale tabs (expired sessions -- often the very tabs that are
18
+ # erroring) also land in the low tier, which is SIZED TOGETHER with the
19
+ # client's self-caps (glib-web-npm utils/log.js budgets: 10 errors + 10
20
+ # warnings per page load, deduped) -- change them together or stale-tab
21
+ # reports get dropped. Excess reports are DROPPED with the same 204 as accepted ones,
22
+ # so an abuser learns nothing about the threshold or the tiers. NOTE:
23
+ # inert where the cache store is :null_store (typical test envs).
24
+ rate_limit to: 10, within: 1.minute, name: 'untrusted', with: -> { head :no_content }, unless: :trusted_report?
25
+ rate_limit to: 60, within: 1.minute, name: 'trusted', with: -> { head :no_content }, if: :trusted_report?
26
+ rate_limit to: 300, within: 1.hour, name: 'hourly', with: -> { head :no_content }
27
+
3
28
  def create
4
- Rollbar.error(params[:stack], params[:message])
29
+ if defined?(Rollbar)
30
+ # `GLib.log.warn` reports arrive with level=warning; everything else
31
+ # (uncaught errors, `GLib.log.error`) is an error. Prefixed so
32
+ # browser-side reports are distinguishable from server exceptions;
33
+ # the details ride along as custom data for grouping.
34
+ rollbar_level = params[:level] == 'warning' ? :warning : :error
35
+ Rollbar.public_send(
36
+ rollbar_level,
37
+ "JS: #{params[:message]}",
38
+ stack: params[:stack],
39
+ url: params[:url],
40
+ context: params[:context],
41
+ user_agent: request.user_agent
42
+ )
43
+ end
44
+ head :no_content
5
45
  end
46
+
47
+ private
48
+ # CSRF enforcement stays skipped (stale tabs must still be able to
49
+ # report), but VERIFYING the token it sends anyway upgrades the
50
+ # rate-limit tier. Must never raise: a malformed token is simply
51
+ # untrusted.
52
+ def trusted_report?
53
+ return @trusted_report if defined?(@trusted_report)
54
+
55
+ @trusted_report =
56
+ begin
57
+ valid_authenticity_token?(session, params[:authenticity_token].to_s)
58
+ rescue StandardError
59
+ false
60
+ end
61
+ end
6
62
  end
7
63
  end
@@ -10,6 +10,14 @@ class Glib::JsonUi::ActionBuilder
10
10
  bool :cacheData
11
11
  bool :skipOnChange
12
12
 
13
+ # Wrap the generated setter to lint the JsonLogic rules at render
14
+ # time (see JsonLogicLint). The rules are hashes keyed by target prop.
15
+ alias_method :conditionalData_without_lint, :conditionalData
16
+ def conditionalData(value)
17
+ Glib::JsonUi::JsonLogicLint.validate!(value) unless Rails.env.production?
18
+ conditionalData_without_lint(value)
19
+ end
20
+
13
21
  def dataBuilder(block)
14
22
  json.data do
15
23
  block.call(page.sview_builder)
@@ -22,6 +30,12 @@ class Glib::JsonUi::ActionBuilder
22
30
  hash :variables
23
31
  action :onTrue
24
32
  action :onFalse
33
+
34
+ alias_method :condition_without_lint, :condition
35
+ def condition(value)
36
+ Glib::JsonUi::JsonLogicLint.validate!(value) unless Rails.env.production?
37
+ condition_without_lint(value)
38
+ end
25
39
  end
26
40
 
27
41
  # Under consideration, likely not a good idea.
@@ -9,6 +9,13 @@ class Glib::JsonUi::ActionBuilder
9
9
  action :onClose
10
10
  end
11
11
 
12
+ # "You OPEN a page you're not on; you RELOAD the page you're on" (see
13
+ # Reload below for the other half of the rule). `updateExisting: true`
14
+ # REPLACES the current history entry instead of pushing (matching
15
+ # `dialogs_open updateExisting`) -- for forward moves that should
16
+ # collapse history, e.g. wizard steps and post-filter landings. Landing
17
+ # is always fresh: scroll resets to the top, and `loaderViews` skeletons
18
+ # can cover the fetch.
12
19
  class Open < Action
13
20
  string :url, cache: true
14
21
  bool :updateExisting
@@ -46,13 +53,21 @@ class Glib::JsonUi::ActionBuilder
46
53
  end
47
54
  end
48
55
 
56
+ # Contract (see the npm package's actions/windows/reload.js): re-renders
57
+ # the current page from the server in place. User input is discarded
58
+ # (guarded by the dirty prompt), scroll position is preserved, and the
59
+ # history entry is REPLACED, never pushed. Partial updates that must
60
+ # keep user input belong to `logics_set`.
49
61
  class Reload < Action
50
- string :url, cache: true # deprecated
51
- bool :disableDirtyCheck
62
+ # "You RELOAD the page you're on; you OPEN a page you're not on":
63
+ # `url` is for reloading a VARIANT of the current page (sort order,
64
+ # active tab, tweaked params) while the user stays where they are --
65
+ # Back skips the pre-reload state, so sorting/tab-switching doesn't
66
+ # pollute history. To navigate to a DIFFERENT page while replacing
67
+ # history (wizard steps, post-filter landings), use `windows_open
68
+ # updateExisting: true`, which lands fresh at the top.
69
+ string :url, cache: true
52
70
  action :onReload
53
-
54
- # deprecated
55
- bool :skipUpdateKey
56
71
  end
57
72
 
58
73
  class CloseWithReload < Action
@@ -0,0 +1,82 @@
1
+ # Render-time sanity check for JsonLogic rules that compare form-field values.
2
+ #
3
+ # Form values always travel as strings (or arrays of strings for multi-value
4
+ # fields), so a rule that compares a form-field `var` against a typed constant
5
+ # (integer, date, boolean, ...) depends on client-side coercion at best and
6
+ # silently never matches at worst -- e.g. JsonLogic's `==` cannot equate
7
+ # arrays at all. These bugs don't crash anything; the affected condition just
8
+ # evaluates wrongly forever.
9
+ #
10
+ # This lint runs while the rule is being rendered into the page JSON (outside
11
+ # production), so any page exercised by tests -- including crawler-style tests
12
+ # that visit every route -- surfaces the mistake as a hard failure.
13
+ #
14
+ # A `var` is treated as a form field when its name uses the nested-param
15
+ # convention (`model[attr]`), which is how form inputs are named. Ordering
16
+ # operators (>, <, ...) and vars without that shape are left alone: numeric
17
+ # comparisons against action variables or computed values are legitimate.
18
+ module Glib
19
+ module JsonUi
20
+ module JsonLogicLint
21
+ EQUALITY_OPS = %w[== != === !== setEq in].freeze
22
+
23
+ def self.validate!(rule)
24
+ walk(rule)
25
+ end
26
+
27
+ def self.walk(node)
28
+ case node
29
+ when Array
30
+ node.each { |child| walk(child) }
31
+ when Hash
32
+ node.each do |op, operands|
33
+ check_comparison(op.to_s, operands) if EQUALITY_OPS.include?(op.to_s) && operands.is_a?(Array)
34
+ walk(operands)
35
+ end
36
+ end
37
+ end
38
+
39
+ def self.check_comparison(op, operands)
40
+ return unless operands.any? { |operand| form_field_var?(operand) }
41
+
42
+ operands.each do |operand|
43
+ next unless non_string_constant?(operand)
44
+
45
+ field = operands.find { |o| form_field_var?(o) }
46
+ raise "JsonLogic rule compares form field #{var_name(field).inspect} against " \
47
+ "non-string constant #{operand.inspect} (`#{op}`).\n" \
48
+ 'Why strings: rules never read the field spec (which may legitimately carry ' \
49
+ 'integers); a `var` reads the LIVE form data, which the client collects from ' \
50
+ 'DOM inputs -- and DOM input values are always strings (arrays of strings for ' \
51
+ "multi-value fields). The comparison would rely on JS coercion or fail outright.\n" \
52
+ 'Fix: serialize the constant as a string, e.g. `record.id.to_s`, arrays via ' \
53
+ '`.map(&:to_s)`. For array comparisons, prefer the `setEq` operator, which ' \
54
+ 'string-normalizes both sides.'
55
+ end
56
+ end
57
+
58
+ def self.form_field_var?(operand)
59
+ operand.is_a?(Hash) && (name = var_name(operand)) && name.include?('[')
60
+ end
61
+
62
+ def self.var_name(operand)
63
+ value = operand['var'] || operand[:var]
64
+ value&.to_s
65
+ end
66
+
67
+ # What matters is the JSON wire type, not the Ruby type: symbols and
68
+ # dates serialize to JSON strings and compare fine, but numerics and
69
+ # booleans stay typed in JSON while form values are always strings.
70
+ def self.non_string_constant?(operand)
71
+ case operand
72
+ when Numeric, TrueClass, FalseClass
73
+ true
74
+ when Array
75
+ operand.flatten.any? { |element| non_string_constant?(element) }
76
+ else
77
+ false
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -47,6 +47,16 @@ class Glib::JsonUi::ViewBuilder
47
47
  # Use this when field changes require server-side processing.
48
48
  action :onChangeAndLoad
49
49
 
50
+ # Actions executed when the form's dirty state TRANSITIONS (per the same
51
+ # tracking that powers the unsaved-changes prompts): `onDirty` fires
52
+ # when the form first deviates from its initial values, `onPristine`
53
+ # when it returns to them — plus once on mount, since forms always start
54
+ # pristine. Because each hook corresponds to a known state, the actions
55
+ # can use plain static `data` (e.g. a `logics_set` disabling the submit
56
+ # button while pristine) — no conditional rules needed.
57
+ action :onDirty
58
+ action :onPristine
59
+
50
60
  # Custom parameter name for form data when submitting.
51
61
  # By default, uses the model name as the parameter namespace.
52
62
  string :paramNameForFormData
@@ -486,6 +496,10 @@ class Glib::JsonUi::ViewBuilder
486
496
  class BulkEdit2 < View
487
497
  hash :import, required: [:submitUrl, :paramName]
488
498
 
499
+ # Maximum number of data rows an uploaded file may contain; files with
500
+ # more rows are rejected with an error dialog. No limit when unset.
501
+ int :maxRows
502
+
489
503
  # string :statusViewIdPrefix
490
504
  array :viewHeaders
491
505
 
@@ -28,7 +28,7 @@ page.body(
28
28
  value: image&.signed_id
29
29
  }
30
30
 
31
- base_props[:onFinishUpload] = ->(action) do # rubocop:disable Glib/MultilineMethodCallStyle
31
+ base_props[:onFinishUpload] = ->(action) do
32
32
  action.snackbars_alert message: 'Get the signed ids!'
33
33
  end
34
34
 
@@ -0,0 +1,31 @@
1
+ json.title 'Test Page (Form dirty hooks)'
2
+
3
+ page = json_ui_page json
4
+
5
+ render 'json_ui/garage/test_page/header', json: json, page: page
6
+
7
+ page.body childViews: ->(body) do
8
+ body.panels_form \
9
+ padding: glib_json_padding_body,
10
+ # `onDirty`/`onPristine` fire on dirty-state TRANSITIONS (plus `onPristine`
11
+ # once on mount), so plain static `data` is enough -- no conditional rules.
12
+ onDirty: ->(action) do
13
+ action.logics_set targetId: 'dirty_hooks_submit', data: { disabled: false, tooltip: nil }
14
+ end,
15
+ onPristine: ->(action) do
16
+ action.logics_set targetId: 'dirty_hooks_submit', data: { disabled: true, tooltip: 'No changes to save' }
17
+ end,
18
+ childViews: ->(form) do
19
+ form.h2 text: 'Form dirty hooks'
20
+ form.spacer height: 8
21
+
22
+ form.fields_text name: 'user[name]', width: 'matchParent', label: 'Name', value: 'Initial'
23
+ form.spacer height: 14
24
+
25
+ # Excluded from dirty tracking -- edits here must not fire `onDirty`.
26
+ form.fields_text name: 'user[ignored]', width: 'matchParent', label: 'Ignored', disableDirtyCheck: true
27
+ form.spacer height: 14
28
+
29
+ form.fields_submit id: 'dirty_hooks_submit', text: 'Save'
30
+ end
31
+ end
@@ -0,0 +1,41 @@
1
+ json.title 'Test Page (Form Dynamic Dirty)'
2
+
3
+ page = json_ui_page json
4
+
5
+ render 'json_ui/garage/test_page/header', json: json, page: page
6
+
7
+ # A deliberately MINIMAL dynamic group -- no onLoad/conditionalData writes --
8
+ # so the dirty tracker's late-registration handling is observable in
9
+ # isolation: entries added after the initial mount join the baseline with
10
+ # their mount-time values (see form_dynamic for the fully-featured demo).
11
+ page.body childViews: ->(body) do
12
+ body.panels_responsive padding: glib_json_padding_body, childViews: ->(res) do
13
+ res.h2 text: 'Dynamic group dirty tracking'
14
+ res.spacer height: 8
15
+ res.panels_form \
16
+ url: json_ui_garage_url(path: 'forms/generic_post'),
17
+ method: 'post',
18
+ childViews: ->(form) do
19
+ form.fields_text width: 'matchParent', name: 'user[name]', label: 'Name', value: 'Initial'
20
+ form.spacer height: 8
21
+
22
+ form.fields_dynamicGroup id: 'dirty_dynamic_group', width: 'matchParent', name: 'user[items]', groupFieldProperties: [
23
+ [{ name: 'label', value: 'First' }]
24
+ ], content: ->(group) do
25
+ group.template childViews: ->(template) do
26
+ template.fields_text width: 'matchParent', name: 'label', label: 'Label', styleClass: 'item_label'
27
+ end
28
+ end
29
+
30
+ form.spacer height: 8
31
+ form.button \
32
+ text: 'Add item',
33
+ onClick: ->(action) do
34
+ action.components_invoke targetId: 'dirty_dynamic_group', name: 'addGroupEntry'
35
+ end
36
+
37
+ form.spacer height: 20
38
+ form.fields_submit text: 'Submit'
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,55 @@
1
+ json.title 'Test Page (Form Removed Field)'
2
+
3
+ page = json_ui_page json
4
+
5
+ render 'json_ui/garage/test_page/header', json: json, page: page
6
+
7
+ # Probes how dirty tracking treats fields REMOVED from the DOM after they
8
+ # have registered (`displayed: false` drives `loadIf`/v-if, so hidden fields
9
+ # genuinely unmount and leave the form data). This page covers USER-triggered
10
+ # hide/show; the onLoad-hidden flavor lives on form_removed_field_onload so
11
+ # its baseline effect cannot contaminate these scenarios.
12
+ page.body childViews: ->(body) do
13
+ body.panels_responsive padding: glib_json_padding_body, childViews: ->(res) do
14
+ res.h2 text: 'Removed-field dirty tracking'
15
+ res.spacer height: 8
16
+ res.panels_form \
17
+ url: json_ui_garage_url(path: 'forms/generic_post'),
18
+ method: 'post',
19
+ childViews: ->(form) do
20
+ form.fields_text width: 'matchParent', name: 'user[name]', label: 'Name', value: 'Initial'
21
+ form.fields_text width: 'matchParent', id: 'extra_field', name: 'user[extra]', label: 'Extra', value: 'Extra'
22
+
23
+ # Hiding this PANEL unmounts its child field's component subtree --
24
+ # the other removal flavor (a self-hidden field keeps its component
25
+ # mounted and only unmounts its inner v-if).
26
+ form.panels_vertical width: 'matchParent', id: 'extra_panel', childViews: ->(panel) do
27
+ panel.fields_text width: 'matchParent', name: 'user[grouped]', label: 'Grouped', value: 'Grouped'
28
+ end
29
+
30
+ form.spacer height: 8
31
+ form.panels_flow innerPadding: { bottom: 0 }, width: 'matchParent', childViews: ->(flow) do
32
+ flow.button \
33
+ text: 'Hide extra',
34
+ onClick: ->(action) do
35
+ action.logics_set targetId: 'extra_field', data: { displayed: false }
36
+ end
37
+ flow.spacer width: 8
38
+ flow.button \
39
+ text: 'Show extra',
40
+ onClick: ->(action) do
41
+ action.logics_set targetId: 'extra_field', data: { displayed: true }
42
+ end
43
+ flow.spacer width: 8
44
+ flow.button \
45
+ text: 'Hide panel',
46
+ onClick: ->(action) do
47
+ action.logics_set targetId: 'extra_panel', data: { displayed: false }
48
+ end
49
+ end
50
+
51
+ form.spacer height: 20
52
+ form.fields_submit text: 'Submit'
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,34 @@
1
+ json.title 'Test Page (Form Removed Field Onload)'
2
+
3
+ page = json_ui_page json
4
+
5
+ render 'json_ui/garage/test_page/header', json: json, page: page
6
+
7
+ # Companion to form_removed_field: here the field is hidden by PAGE LOGIC at
8
+ # load (the onLoad conditionalData pattern, e.g. a conditional section
9
+ # reflecting current values), after it has already registered with the dirty
10
+ # tracker.
11
+ page.body childViews: ->(body) do
12
+ body.panels_responsive padding: glib_json_padding_body, childViews: ->(res) do
13
+ res.h2 text: 'Removed-field dirty tracking (onLoad)'
14
+ res.spacer height: 8
15
+ res.panels_form \
16
+ url: json_ui_garage_url(path: 'forms/generic_post'),
17
+ method: 'post',
18
+ childViews: ->(form) do
19
+ form.fields_text width: 'matchParent', name: 'user[name]', label: 'Name', value: 'Initial'
20
+ form.fields_text \
21
+ width: 'matchParent',
22
+ id: 'preloaded_field',
23
+ name: 'user[preloaded]',
24
+ label: 'Preloaded',
25
+ value: 'P',
26
+ onLoad: ->(action) do
27
+ action.logics_set targetId: 'preloaded_field', data: { displayed: false }
28
+ end
29
+
30
+ form.spacer height: 20
31
+ form.fields_submit text: 'Submit'
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,35 @@
1
+ json.title 'Test Page (Form Touched Validation)'
2
+
3
+ page = json_ui_page json
4
+
5
+ render 'json_ui/garage/test_page/header', json: json, page: page
6
+
7
+ # Mirrors a sign-in-style form: the first field is empty, required, and
8
+ # autofocused (panels_form defaults autofocus on). Untouched fields must stay
9
+ # silent on blur/navigation; validation surfaces on submit, or on blur once
10
+ # the user has actually typed.
11
+ page.body childViews: ->(body) do
12
+ body.panels_responsive padding: glib_json_padding_body, childViews: ->(res) do
13
+ res.h2 text: 'Touched-field validation'
14
+ res.spacer height: 8
15
+ res.panels_form \
16
+ url: json_ui_garage_url(path: 'forms/generic_post'),
17
+ method: 'post',
18
+ childViews: ->(form) do
19
+ form.fields_email \
20
+ width: 'matchParent',
21
+ name: 'user[email]',
22
+ label: 'Email',
23
+ validation: { presence: { message: "can't be blank" } }
24
+ form.spacer height: 8
25
+ form.fields_text \
26
+ width: 'matchParent',
27
+ name: 'user[nickname]',
28
+ label: 'Nickname',
29
+ validation: { presence: { message: "can't be blank" } }
30
+
31
+ form.spacer height: 20
32
+ form.fields_submit text: 'Submit'
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,79 @@
1
+ json.title 'Test Page (Logics Run)'
2
+
3
+ page = json_ui_page json
4
+
5
+ page.form(
6
+ childViews: ->(body) do
7
+ body.panels_responsive(
8
+ padding: glib_json_padding_body,
9
+ childViews: ->(res) do
10
+ res.h2 text: 'Logics Run - form field condition'
11
+ res.spacer height: 8
12
+ # Form values are strings at the DOM boundary, so the condition
13
+ # compares against a string constant.
14
+ res.fields_text label: 'Status', name: 'user[status]', width: 300, value: 'active'
15
+ res.spacer height: 8
16
+ res.button(
17
+ text: 'Check status',
18
+ onClick: ->(action) do
19
+ action.logics_run(
20
+ condition: { '==': [{ 'var': 'user[status]' }, 'active'] },
21
+ onTrue: ->(subaction) do
22
+ subaction.logics_set targetId: 'run_status', data: { text: 'MATCH' }
23
+ end,
24
+ onFalse: ->(subaction) do
25
+ subaction.logics_set targetId: 'run_status', data: { text: 'NO MATCH' }
26
+ end
27
+ )
28
+ end
29
+ )
30
+ res.spacer height: 8
31
+ res.label id: 'run_status', text: 'PENDING'
32
+
33
+ res.spacer height: 16
34
+ res.hr width: 'matchParent'
35
+ res.spacer height: 16
36
+
37
+ res.h2 text: 'Logics Run - variables'
38
+ res.spacer height: 8
39
+ # Server-provided `variables` keep their JSON types (unlike form
40
+ # values), so a numeric comparison is legitimate here. They are also
41
+ # merged OVER the form data, so a variable that shares a form field's
42
+ # name takes precedence.
43
+ res.button(
44
+ text: 'Check variables',
45
+ onClick: ->(action) do
46
+ action.logics_run(
47
+ condition: { '==': [{ 'var': 'level' }, 5] },
48
+ variables: { level: 5 },
49
+ onTrue: ->(subaction) do
50
+ subaction.logics_set targetId: 'run_variables_status', data: { text: 'MATCH' }
51
+ end,
52
+ onFalse: ->(subaction) do
53
+ subaction.logics_set targetId: 'run_variables_status', data: { text: 'NO MATCH' }
54
+ end
55
+ )
56
+ end
57
+ )
58
+ res.spacer width: 8
59
+ res.button(
60
+ text: 'Check variable precedence',
61
+ onClick: ->(action) do
62
+ action.logics_run(
63
+ condition: { '==': [{ 'var': 'user[status]' }, 'override'] },
64
+ variables: { 'user[status]': 'override' },
65
+ onTrue: ->(subaction) do
66
+ subaction.logics_set targetId: 'run_variables_status', data: { text: 'PRECEDENCE' }
67
+ end,
68
+ onFalse: ->(subaction) do
69
+ subaction.logics_set targetId: 'run_variables_status', data: { text: 'FORM VALUE' }
70
+ end
71
+ )
72
+ end
73
+ )
74
+ res.spacer height: 8
75
+ res.label id: 'run_variables_status', text: 'PENDING'
76
+ end
77
+ )
78
+ end
79
+ )
@@ -0,0 +1,45 @@
1
+ json.title 'Test Page (Logics Run Multi Form)'
2
+
3
+ page = json_ui_page json
4
+
5
+ render 'json_ui/garage/test_page/header', json: json, page: page
6
+
7
+ # Two mounted forms deliberately sharing a field name: actions that read
8
+ # merged form data (logics/run, logics/set conditions, fields/getValues) use
9
+ # the LAST form's value and warn about the collision.
10
+ page.body childViews: ->(body) do
11
+ body.panels_responsive padding: glib_json_padding_body, childViews: ->(res) do
12
+ res.h2 text: 'Cross-form field-name collision'
13
+ res.spacer height: 8
14
+ res.panels_form \
15
+ url: json_ui_garage_url(path: 'forms/generic_post'),
16
+ method: 'post',
17
+ childViews: ->(form) do
18
+ form.fields_text width: 'matchParent', name: 'user[status]', label: 'First form status', value: 'first'
19
+ end
20
+ res.spacer height: 8
21
+ res.panels_form \
22
+ url: json_ui_garage_url(path: 'forms/generic_post'),
23
+ method: 'post',
24
+ childViews: ->(form) do
25
+ form.fields_text width: 'matchParent', name: 'user[status]', label: 'Second form status', value: 'second'
26
+ end
27
+
28
+ res.spacer height: 8
29
+ res.button \
30
+ text: 'Check status',
31
+ onClick: ->(action) do
32
+ action.logics_run(
33
+ condition: { '==': [{ 'var': 'user[status]' }, 'second'] },
34
+ onTrue: ->(subaction) do
35
+ subaction.logics_set targetId: 'collision_status', data: { text: 'LAST FORM WINS' }
36
+ end,
37
+ onFalse: ->(subaction) do
38
+ subaction.logics_set targetId: 'collision_status', data: { text: 'FIRST FORM WINS' }
39
+ end
40
+ )
41
+ end
42
+ res.spacer height: 8
43
+ res.label id: 'collision_status', text: 'PENDING'
44
+ end
45
+ end
@@ -134,6 +134,25 @@ page.form(
134
134
  )
135
135
  res.spacer height: 8
136
136
  res.label id: 'say_label', text: 'Visible label text'
137
+
138
+ res.spacer height: 16
139
+ res.h2 text: 'Logics Set - setEq'
140
+ res.spacer height: 8
141
+ # `setEq` treats both sides as sets of strings (order-insensitive;
142
+ # scalar and single-element array compare equal), which makes it safe
143
+ # for multi-value form fields whose DOM values are always strings.
144
+ set_eq_check = ->(action) do
145
+ action.logics_set(
146
+ targetId: 'set_eq_status',
147
+ conditionalData: {
148
+ text: { 'if': [{ 'setEq': [{ 'var': 'user[tags][]' }, %w[a b]] }, 'MATCH', 'NO MATCH'] }
149
+ }
150
+ )
151
+ end
152
+ res.fields_check name: 'user[tags][]', checkValue: 'a', label: 'Tag A', onChange: set_eq_check
153
+ res.fields_check name: 'user[tags][]', checkValue: 'b', label: 'Tag B', onChange: set_eq_check
154
+ res.spacer height: 8
155
+ res.label id: 'set_eq_status', text: 'PENDING'
137
156
  end
138
157
  )
139
158
  end
@@ -83,6 +83,9 @@ page.body(
83
83
  id: 'bulk_edit_main',
84
84
  width: 'matchParent',
85
85
  paramNameForRowId: 'row_id',
86
+ # Files with more data rows than this are rejected wholesale with an
87
+ # error dialog (the header row doesn't count).
88
+ maxRows: 3,
86
89
  import: import_settings,
87
90
  viewHeaders: default_headers,
88
91
  viewCells: default_cells,
@@ -0,0 +1,31 @@
1
+ json.title 'Test Page (Run Multiple)'
2
+
3
+ page = json_ui_page json
4
+
5
+ render 'json_ui/garage/test_page/header', json: json, page: page
6
+
7
+ page.body childViews: ->(body) do
8
+ body.panels_responsive padding: glib_json_padding_body, childViews: ->(res) do
9
+ res.h2 text: 'runMultiple ordering'
10
+ res.spacer height: 8
11
+ res.button \
12
+ text: 'Run sequence',
13
+ onClick: ->(action) do
14
+ action.runMultiple childActions: ->(multiple) do
15
+ # Two writes to the SAME target: the final text proves in-order,
16
+ # sequential execution (last write wins).
17
+ multiple.logics_set targetId: 'sequence_status', data: { text: 'STEP 1' }
18
+ multiple.logics_set targetId: 'sequence_status', data: { text: 'STEP 2' }
19
+ # Nested runMultiple must execute within the outer sequence.
20
+ multiple.runMultiple childActions: ->(nested) do
21
+ nested.logics_set targetId: 'nested_status', data: { text: 'NESTED DONE' }
22
+ end
23
+ multiple.logics_set targetId: 'sequence_status', data: { text: 'STEP 3' }
24
+ multiple.snackbars_alert message: 'Sequence finished'
25
+ end
26
+ end
27
+ res.spacer height: 8
28
+ res.label id: 'sequence_status', text: 'PENDING'
29
+ res.label id: 'nested_status', text: 'PENDING'
30
+ end
31
+ end
@@ -0,0 +1,57 @@
1
+ json.title 'Test Page (Windows Reload)'
2
+
3
+ page = json_ui_page json
4
+
5
+ render 'json_ui/garage/test_page/header', json: json, page: page
6
+
7
+ # Contract page for `windows_reload` (see the npm package's
8
+ # actions/windows/reload.js): reload re-renders the CURRENT page from the
9
+ # server in place -- user input is discarded (guarded by the dirty prompt),
10
+ # scroll position is preserved, and the history entry is REPLACED, so Back
11
+ # skips the pre-reload state.
12
+ page.body childViews: ->(body) do
13
+ body.panels_responsive padding: glib_json_padding_body, childViews: ->(res) do
14
+ res.h2 text: 'Windows reload contract'
15
+
16
+ # Changes on every render so specs can detect that a reload completed
17
+ # (a bare reload keeps the same URL, so the URL proves nothing).
18
+ res.label text: "Render stamp: #{SecureRandom.hex(8)}"
19
+ res.label text: "Step: #{params[:step] || 'initial'}"
20
+ res.spacer height: 8
21
+
22
+ res.panels_form \
23
+ url: json_ui_garage_url(path: 'forms/generic_post'),
24
+ method: 'post',
25
+ childViews: ->(form) do
26
+ form.fields_text width: 'matchParent', name: 'user[nickname]', label: 'Nickname'
27
+ end
28
+
29
+ res.spacer height: 8
30
+ res.button \
31
+ text: 'windows/reload',
32
+ onClick: ->(action) do
33
+ action.windows_reload
34
+ end
35
+ res.spacer height: 8
36
+ res.button \
37
+ text: 'windows/open (step two)',
38
+ onClick: ->(action) do
39
+ action.windows_open url: json_ui_garage_url(path: 'test_page/windows_reload', step: 'two')
40
+ end
41
+ res.spacer height: 8
42
+ res.button \
43
+ text: 'windows/reload (step three)',
44
+ onClick: ->(action) do
45
+ action.windows_reload url: json_ui_garage_url(path: 'test_page/windows_reload', step: 'three')
46
+ end
47
+
48
+ # Tall filler so the page scrolls; the bottom button lets specs trigger a
49
+ # reload without Cypress scrolling back up to reach a button.
50
+ res.spacer height: 2000
51
+ res.button \
52
+ text: 'windows/reload (from bottom)',
53
+ onClick: ->(action) do
54
+ action.windows_reload
55
+ end
56
+ end
57
+ end
data/lib/glib/rubocop.rb CHANGED
@@ -1,5 +1,4 @@
1
1
  require_relative 'rubocop/cops/localize'
2
- require_relative 'rubocop/cops/multiline_method_call_style'
3
2
  require_relative 'rubocop/cops/json_ui/base_nested_parameter'
4
3
  require_relative 'rubocop/cops/json_ui/nested_block_parameter'
5
4
  require_relative 'rubocop/cops/json_ui/nested_action_parameter'
data/lib/glib/snapshot.rb CHANGED
@@ -155,7 +155,7 @@ module Glib
155
155
  'item' => ::Hashdiff.diff(item.attributes.except(*ignore_keys), attributes.except(*ignore_keys))
156
156
  }
157
157
 
158
- obj['associations'] = associations_for_snapshot.reduce({}) do |prev, curr| # rubocop:disable Glib/MultilineMethodCallStyle
158
+ obj['associations'] = associations_for_snapshot.reduce({}) do |prev, curr|
159
159
  association_records = records_for_snapshot(curr)
160
160
  first_record_off_same_collection = association_records.first
161
161
 
@@ -14,6 +14,7 @@ module Glib
14
14
  # end
15
15
  #
16
16
  # This will:
17
+ # - Build Vite assets (if the app uses Vite) once, before workers fork
17
18
  # - Enable parallel test execution using all available processors
18
19
  # - Configure SimpleCov to track each worker separately
19
20
  # - Automatically merge coverage results from all workers
@@ -21,6 +22,21 @@ module Glib
21
22
  extend ActiveSupport::Concern
22
23
 
23
24
  included do
25
+ # Vite's lazy autoBuild is not fork-safe: its build mutex is a thread
26
+ # Mutex, so when the test manifest is stale, every parallel worker's
27
+ # first asset lookup spawns its own full `vite build` (a node process
28
+ # using several hundred MB). With :number_of_processors workers that
29
+ # is enough concurrent builds to exhaust RAM and swap and OOM the
30
+ # machine. Build once here, in the parent process before forking, so
31
+ # the workers' freshness checks all pass without building. The guard
32
+ # mirrors the lazy path's own `should_build?` so this stays a no-op
33
+ # wherever the workers would never have built either (autoBuild off,
34
+ # or a dev server serving the test mode).
35
+ if defined?(ViteRuby) && ViteRuby.config.auto_build && !ViteRuby.instance.dev_server_running?
36
+ ViteRuby.commands.build ||
37
+ raise('vite build failed (a cached failed build also lands here; see log or run bin/vite build)')
38
+ end
39
+
24
40
  # Set threshold for force parallelization.
25
41
  parallelize(workers: :number_of_processors, threshold: 1)
26
42
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: glib-web
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.1.5
4
+ version: 6.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''
@@ -206,6 +206,7 @@ files:
206
206
  - app/helpers/glib/json_ui/default.rb
207
207
  - app/helpers/glib/json_ui/dynamic_field_builders.rb
208
208
  - app/helpers/glib/json_ui/generic_builders.rb
209
+ - app/helpers/glib/json_ui/json_logic_lint.rb
209
210
  - app/helpers/glib/json_ui/list_builders.rb
210
211
  - app/helpers/glib/json_ui/menu_builder.rb
211
212
  - app/helpers/glib/json_ui/page_helper.rb
@@ -420,7 +421,12 @@ files:
420
421
  - app/views/json_ui/garage/test_page/fields_url_fragment.json.jbuilder
421
422
  - app/views/json_ui/garage/test_page/flow.json.jbuilder
422
423
  - app/views/json_ui/garage/test_page/form.json.jbuilder
424
+ - app/views/json_ui/garage/test_page/form_dirty_hooks.json.jbuilder
423
425
  - app/views/json_ui/garage/test_page/form_dynamic.json.jbuilder
426
+ - app/views/json_ui/garage/test_page/form_dynamic_dirty.json.jbuilder
427
+ - app/views/json_ui/garage/test_page/form_removed_field.json.jbuilder
428
+ - app/views/json_ui/garage/test_page/form_removed_field_onload.json.jbuilder
429
+ - app/views/json_ui/garage/test_page/form_touched_validation.json.jbuilder
424
430
  - app/views/json_ui/garage/test_page/forms.json.jbuilder
425
431
  - app/views/json_ui/garage/test_page/grid.json.jbuilder
426
432
  - app/views/json_ui/garage/test_page/horizontal.json.jbuilder
@@ -429,6 +435,8 @@ files:
429
435
  - app/views/json_ui/garage/test_page/lifecycle.json.jbuilder
430
436
  - app/views/json_ui/garage/test_page/list.json.jbuilder
431
437
  - app/views/json_ui/garage/test_page/list_editable.json.jbuilder
438
+ - app/views/json_ui/garage/test_page/logics_run.json.jbuilder
439
+ - app/views/json_ui/garage/test_page/logics_run_multi_form.json.jbuilder
432
440
  - app/views/json_ui/garage/test_page/logics_set.json.jbuilder
433
441
  - app/views/json_ui/garage/test_page/multimedia_video.json.jbuilder
434
442
  - app/views/json_ui/garage/test_page/pagination.json.jbuilder
@@ -437,6 +445,7 @@ files:
437
445
  - app/views/json_ui/garage/test_page/popovers.json.jbuilder
438
446
  - app/views/json_ui/garage/test_page/progressCircle.json.jbuilder
439
447
  - app/views/json_ui/garage/test_page/responsive.json.jbuilder
448
+ - app/views/json_ui/garage/test_page/run_multiple.json.jbuilder
440
449
  - app/views/json_ui/garage/test_page/scroll.json.jbuilder
441
450
  - app/views/json_ui/garage/test_page/selectable.json.jbuilder
442
451
  - app/views/json_ui/garage/test_page/sheets.json.jbuilder
@@ -453,6 +462,7 @@ files:
453
462
  - app/views/json_ui/garage/test_page/web.json.jbuilder
454
463
  - app/views/json_ui/garage/test_page/window.json.jbuilder
455
464
  - app/views/json_ui/garage/test_page/windows.json.jbuilder
465
+ - app/views/json_ui/garage/test_page/windows_reload.json.jbuilder
456
466
  - app/views/json_ui/garage/views/_chart_data.json.jbuilder
457
467
  - app/views/json_ui/garage/views/_checkbox_expand.json.jbuilder
458
468
  - app/views/json_ui/garage/views/_expand_shrink_ex.json.jbuilder
@@ -522,7 +532,6 @@ files:
522
532
  - lib/glib/rubocop/cops/json_ui/nested_action_parameter.rb
523
533
  - lib/glib/rubocop/cops/json_ui/nested_block_parameter.rb
524
534
  - lib/glib/rubocop/cops/localize.rb
525
- - lib/glib/rubocop/cops/multiline_method_call_style.rb
526
535
  - lib/glib/rubocop/cops/test_name_parentheses.rb
527
536
  - lib/glib/snapshot.rb
528
537
  - lib/glib/test/changed_files_rubocop.rb
@@ -1,250 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RuboCop
4
- module Cop
5
- module Glib
6
- # Enforces parentheses style for multi-line method calls with arguments.
7
- #
8
- # @example
9
- # # bad
10
- # page.footer padding: glib_json_padding_body, backgroundColor: '#b3bac2', childViews: ->(footer) do
11
- # footer.h1 text: 'Footer'
12
- # end
13
- #
14
- # # bad
15
- # page.footer \
16
- # padding: glib_json_padding_body,
17
- # backgroundColor: '#b3bac2',
18
- # childViews: ->(footer) do
19
- # footer.h1 text: 'Footer'
20
- # end
21
- #
22
- # # good
23
- # page.footer(
24
- # padding: glib_json_padding_body,
25
- # backgroundColor: '#b3bac2',
26
- # childViews: ->(footer) do
27
- # footer.h1 text: 'Footer'
28
- # end
29
- # )
30
- class MultilineMethodCallStyle < Base
31
- extend AutoCorrector
32
-
33
- MSG = 'Use parentheses style for multi-line method calls: ' \
34
- 'wrap arguments in parentheses and place each argument on its own line.'
35
-
36
- def on_send(node)
37
- return unless multi_line_call_with_args?(node)
38
- return if proper_parentheses_style?(node)
39
- return if proper_backslash_style?(node) && allow_backslash?
40
- return if inside_correctable_parent?(node)
41
-
42
- add_offense(node) do |corrector|
43
- autocorrect(corrector, node)
44
- end
45
- end
46
-
47
- alias on_csend on_send
48
-
49
- private
50
-
51
- def multi_line_call_with_args?(node)
52
- node.multiline? && node.arguments.any?
53
- end
54
-
55
- def allow_backslash?
56
- # Allow backslash style without warnings when configured
57
- # Set to true to allow both styles (but auto-correct will still convert to parentheses)
58
- # Set to false to enforce parentheses style always
59
- cop_config.fetch('AllowBackslashStyle', true)
60
- end
61
-
62
- def proper_backslash_style?(node)
63
- source = processed_source.buffer.source
64
- method_end_pos = node.loc.selector.end_pos
65
-
66
- # Check if there's a backslash right after the method name
67
- next_char_pos = method_end_pos
68
- while next_char_pos < source.length && source[next_char_pos] == ' '
69
- next_char_pos += 1
70
- end
71
-
72
- return false unless source[next_char_pos] == '\\'
73
-
74
- # Check that each argument is on its own line
75
- args = collect_arguments(node)
76
- return false if args.empty?
77
-
78
- args.each_cons(2) do |arg1, arg2|
79
- return false if arg1.loc.line == arg2.loc.line
80
- end
81
-
82
- true
83
- end
84
-
85
- def proper_parentheses_style?(node)
86
- return false unless node.parenthesized?
87
-
88
- # Check that first argument is on a new line after opening paren
89
- first_arg = node.first_argument
90
- return false if first_arg.loc.line == node.loc.selector.line
91
-
92
- # Check that each argument is on its own line
93
- args = collect_arguments(node)
94
- return false if args.empty?
95
-
96
- args.each_cons(2) do |arg1, arg2|
97
- return false if arg1.loc.line == arg2.loc.line
98
- end
99
-
100
- # Check that closing paren is on its own line
101
- source = processed_source.buffer.source
102
- close_paren_pos = node.loc.end.begin_pos
103
- line_start = source.rindex("\n", close_paren_pos) || 0
104
- line_content = source[line_start...close_paren_pos].strip
105
-
106
- return false unless line_content.empty?
107
-
108
- true
109
- end
110
-
111
- def collect_arguments(node)
112
- args = []
113
-
114
- node.arguments.each do |arg|
115
- if arg.hash_type?
116
- # For hash arguments, treat each pair as a separate argument
117
- arg.pairs.each { |pair| args << pair }
118
- else
119
- args << arg
120
- end
121
- end
122
-
123
- args
124
- end
125
-
126
- def inside_correctable_parent?(node)
127
- # Check if this node is inside a block that belongs to a multi-line call
128
- # that will also be corrected. If so, skip this node to avoid conflicts.
129
- current = node.parent
130
-
131
- while current
132
- if current.send_type? || current.csend_type?
133
- if multi_line_call_with_args?(current) && !proper_parentheses_style?(current)
134
- return true
135
- end
136
- end
137
- current = current.parent
138
- end
139
-
140
- false
141
- end
142
-
143
- def autocorrect(corrector, node)
144
- method_end_pos = node.loc.selector.end_pos
145
-
146
- # Calculate indentation based on the node's actual column position
147
- base_indent = node.loc.column
148
- arg_indent = ' ' * (base_indent + 2)
149
-
150
- # Build the new argument list
151
- arg_parts = []
152
- node.arguments.each do |arg|
153
- next if arg.block_pass_type? # Skip block arguments like &block
154
-
155
- if arg.hash_type?
156
- arg.pairs.each do |pair|
157
- # Re-indent pair if it contains a block
158
- pair_source = reindent_pair_with_block(pair, arg_indent)
159
- arg_parts << pair_source
160
- end
161
- else
162
- arg_parts << arg.source
163
- end
164
- end
165
-
166
- # Build the replacement with parentheses style
167
- if arg_parts.any?
168
- # Opening paren on same line as method, arguments on separate lines
169
- replacement = "(\n#{arg_indent}" + arg_parts.join(",\n#{arg_indent}")
170
-
171
- # If there's a block, add it on the last argument line
172
- if node.block_literal?
173
- block_source = reindent_block(node.block_literal, arg_indent)
174
- replacement += " #{block_source}"
175
- end
176
-
177
- # Closing paren on its own line at base indentation
178
- replacement += "\n#{' ' * base_indent})"
179
- else
180
- # No arguments, just the block (shouldn't happen for multi-line, but handle it)
181
- replacement = if node.block_literal?
182
- " #{node.block_literal.source}"
183
- else
184
- '()'
185
- end
186
- end
187
-
188
- # Remove everything from method name to end of node
189
- range = Parser::Source::Range.new(
190
- processed_source.buffer,
191
- method_end_pos,
192
- node.source_range.end_pos
193
- )
194
-
195
- corrector.replace(range, replacement)
196
- end
197
-
198
- def reindent_pair_with_block(pair, base_indent)
199
- # Check if the pair's value is a block
200
- return pair.source unless pair.value.block_type?
201
-
202
- # Format: "key: ->(param) do ... end"
203
- key_source = pair.key.source
204
- block_source = reindent_block(pair.value, base_indent)
205
-
206
- "#{key_source}: #{block_source}"
207
- end
208
-
209
- def reindent_block(block_node, base_indent)
210
- # Get block source lines
211
- source = block_node.source
212
- lines = source.lines
213
-
214
- return source if lines.length == 1 # Single line block, no reindent needed
215
-
216
- # Find the current indentation of the block's first line
217
- # (this is usually where "do" appears)
218
- first_line = lines[0]
219
-
220
- # Process the block:
221
- # Line 0: "->(param) do" - keep as is
222
- # Lines 1..-2: block body - re-indent to base_indent + 2
223
- # Last line: "end" - indent to base_indent
224
-
225
- result_lines = []
226
- block_body_indent = base_indent + ' ' * 2
227
-
228
- lines.each_with_index do |line, index|
229
- if index == 0
230
- # First line: "->(param) do"
231
- result_lines << line.rstrip
232
- elsif index == lines.length - 1
233
- # Last line: "end"
234
- result_lines << "#{base_indent}#{line.strip}"
235
- else
236
- # Body lines: re-indent
237
- # Remove existing indentation and add new indentation
238
- stripped = line.lstrip
239
- next if stripped.empty? # Skip blank lines
240
-
241
- result_lines << "#{block_body_indent}#{stripped.rstrip}"
242
- end
243
- end
244
-
245
- result_lines.join("\n")
246
- end
247
- end
248
- end
249
- end
250
- end