glib-web 5.1.5 → 6.3.1

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 (26) 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/engine.rb +132 -0
  21. data/lib/glib/rubocop.rb +0 -1
  22. data/lib/glib/snapshot.rb +57 -12
  23. data/lib/glib/test/parallel_coverage.rb +16 -0
  24. data/lib/tasks/db.rake +6 -0
  25. metadata +12 -3
  26. 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: 8378cb302a462be1ed8ee965c9ace54b77c4eb29f7d1cc9d83a469c89d0f6d93
4
+ data.tar.gz: 91f29270361f13067e3bc513cd01091e82b09d7a89a40eadb4dab893b59f7a66
5
5
  SHA512:
6
- metadata.gz: 37260aff5b03ad55ed5ffd904bc349d8a256d11ceb824380b4c541db0765237b82b05d376cb0835d8d83948460be97bacbfe176b6a442603496626d7ddf2fd83
7
- data.tar.gz: ff4072d9f930e5a8da3ee7084c6a4be66da62d06b9fe49e3160876564c5ea3b251671832d68d44b9551b046b1182f3e7e3e3edabba9c07879b58a59026f23c96
6
+ metadata.gz: 62c1600d052a52e8fb020662ca4db7455518945e298c6e5ea5663e4960955d9d29129252b0780107b138bd4d585a4463f4bef253dc26460203acee362b083af8
7
+ data.tar.gz: 4d816f8451f71efbbb96fbcbe64fe1586211b3e328c42ec89580d711abe9f5d62033ff645f1622b855fa31e86eee5a383ed2ff6732d0a817ba6462bea6e23821
@@ -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,