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.
- checksums.yaml +4 -4
- data/app/controllers/concerns/glib/json/ui.rb +10 -0
- data/app/controllers/glib/errors_controller.rb +57 -1
- data/app/helpers/glib/json_ui/action_builder/logics.rb +14 -0
- data/app/helpers/glib/json_ui/action_builder/windows.rb +20 -5
- data/app/helpers/glib/json_ui/json_logic_lint.rb +82 -0
- data/app/helpers/glib/json_ui/view_builder/panels.rb +14 -0
- data/app/views/json_ui/garage/test_page/fields_upload.json.jbuilder +1 -1
- data/app/views/json_ui/garage/test_page/form_dirty_hooks.json.jbuilder +31 -0
- data/app/views/json_ui/garage/test_page/form_dynamic_dirty.json.jbuilder +41 -0
- data/app/views/json_ui/garage/test_page/form_removed_field.json.jbuilder +55 -0
- data/app/views/json_ui/garage/test_page/form_removed_field_onload.json.jbuilder +34 -0
- data/app/views/json_ui/garage/test_page/form_touched_validation.json.jbuilder +35 -0
- data/app/views/json_ui/garage/test_page/logics_run.json.jbuilder +79 -0
- data/app/views/json_ui/garage/test_page/logics_run_multi_form.json.jbuilder +45 -0
- data/app/views/json_ui/garage/test_page/logics_set.json.jbuilder +19 -0
- data/app/views/json_ui/garage/test_page/panels_bulkEdit2.json.jbuilder +3 -0
- data/app/views/json_ui/garage/test_page/run_multiple.json.jbuilder +31 -0
- data/app/views/json_ui/garage/test_page/windows_reload.json.jbuilder +57 -0
- data/lib/glib/rubocop.rb +0 -1
- data/lib/glib/snapshot.rb +1 -1
- data/lib/glib/test/parallel_coverage.rb +16 -0
- metadata +11 -2
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 52b6b62180ee0cd668652772110774bc915d8789f1fd65e9ef22fadd781977a4
|
|
4
|
+
data.tar.gz: 3d47de6a06859cce937834478bbef460e2cac999bbfbdf72cef10a781399e68f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
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
|
|
|
@@ -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|
|
|
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:
|
|
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
|