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.
- 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/engine.rb +132 -0
- data/lib/glib/rubocop.rb +0 -1
- data/lib/glib/snapshot.rb +57 -12
- data/lib/glib/test/parallel_coverage.rb +16 -0
- data/lib/tasks/db.rake +6 -0
- metadata +12 -3
- 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: 8378cb302a462be1ed8ee965c9ace54b77c4eb29f7d1cc9d83a469c89d0f6d93
|
|
4
|
+
data.tar.gz: 91f29270361f13067e3bc513cd01091e82b09d7a89a40eadb4dab893b59f7a66
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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,
|