glib-web 6.10.2 → 6.10.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a644c2f81a6edaeaf5bb946f57402098bab2f8e841392a57568a76244856be89
4
- data.tar.gz: 03ab9e4058180c9ecd70f14d6a26e488ca56dcdc1388175393f24c8ed5d4176b
3
+ metadata.gz: af62a06f4071bb5b2779e61b2dfa771d9984d4f2f15bd6ed121fd71a676d7918
4
+ data.tar.gz: f5100fe92e6d2959a4479b82887e0009c0aaea1a2ef419f1ae6205c8fbda427b
5
5
  SHA512:
6
- metadata.gz: 13fb675a42739c4d67d50b26a359a6cc4136fc695c0f2730d2c21e721b193f955335a39bcab550704bc29cbfb3073569154b7c08b45924de5c576036842fb965
7
- data.tar.gz: 1610d45e4b2e60d70475115ca741929585939c847108f46e7b5c209cbc5bff7292720ad85e3eced53f43a43efd2c9bb5756ac3067bab586fe0db17a07177efb0
6
+ metadata.gz: b0e19fa8b04dc2a7d44826f0c28eefe5411610d26226180dbd20079af4287177d44967331c56dd1c0dc7ebf0b3f01df08f1a38939958fe58c37a08183e8d10c0
7
+ data.tar.gz: 3c717ab4989d1e67f712d7d118d77cc6e99d136e0b1ed2e4e403a0974d0c364f705d064bad706b5f1bbfeca295333c523708e76f259a78cba969e958f6a494de
@@ -96,6 +96,18 @@ module Glib::Json::Libs
96
96
 
97
97
  json_ui_redirect_to url, status: status
98
98
  end
99
+ # Any other request format (e.g. a browser tab opening a protected file link
100
+ # `/temp_blobs/:id/report.pdf`, where the filename sets the format) used to raise
101
+ # ActionController::UnknownFormat -> a bare 406 with an empty body. Mirror the html
102
+ # branch instead: whatever opened the URL (browser tab, iframe, img) is redirected
103
+ # to the target, which the browser follows natively.
104
+ #
105
+ # MUST stay the LAST block. `negotiate_mime` answers `Accept: */*` requests with
106
+ # the FIRST declared format, so an `any` block placed before html would capture
107
+ # ordinary browser page loads and leave request.format as `*/*` for the action.
108
+ format.any do
109
+ redirect_to url
110
+ end
99
111
  end
100
112
  end
101
113
 
@@ -142,6 +154,17 @@ module Glib::Json::Libs
142
154
  redirect_to sign_in_url, **redirect_options
143
155
  end
144
156
  end
157
+ # Other non-HTML/JSON formats (a protected file link whose filename sets the
158
+ # request format, an XML export, ...) used to raise UnknownFormat -> a bare 406.
159
+ # Mirrors the csv branch: a clean gate status in test, a redirect in production.
160
+ # MUST stay the LAST block -- see glib_redirect_to.
161
+ format.any do
162
+ if Rails.env.test?
163
+ head :unauthorized
164
+ else
165
+ redirect_to sign_in_url, **redirect_options
166
+ end
167
+ end
145
168
  end
146
169
  end
147
170
 
@@ -185,6 +208,16 @@ module Glib::Json::Libs
185
208
  redirect_to user_default_url
186
209
  end
187
210
  end
211
+ # Other non-HTML/JSON formats -- mirrors the csv branch (clean gate status in
212
+ # test, redirect in production); previously raised UnknownFormat -> a bare 406.
213
+ # MUST stay the LAST block -- see glib_redirect_to.
214
+ format.any do
215
+ if Rails.env.test?
216
+ head :forbidden
217
+ else
218
+ redirect_to user_default_url
219
+ end
220
+ end
188
221
  end
189
222
  end
190
223
 
@@ -211,6 +211,51 @@ class Glib::JsonUi::ViewBuilder
211
211
  end
212
212
  end
213
213
 
214
+ # Timing options for the `onChange` / `onChangeAndLoad` actions of fields
215
+ # whose value changes while the user is still typing.
216
+ #
217
+ # Honoured by the single-line text family (text, number, email, url,
218
+ # password -- all one Vue component) and by textarea. Other fields ignore
219
+ # them: the discrete ones (check, radio, select, chip group, date pickers)
220
+ # commit a value the moment it is picked, and richText/phone/file keep
221
+ # their own reporting. The options are declared on `Text`, so its other
222
+ # subclasses accept them without effect -- same as `leftIcon` and
223
+ # `onTypeStart` there.
224
+ #
225
+ # In both modes the change is also reported when focus leaves the field.
226
+ # Under `input` that only flushes a change already pending in the debounce
227
+ # window -- it cannot produce a second run, because a value that was
228
+ # already reported is not reported again.
229
+ module ChangeTrigger
230
+ def self.included(base)
231
+ # When the `onChange` / `onChangeAndLoad` actions fire.
232
+ #
233
+ # - `input` (default): after the user pauses typing for `changeDelay`.
234
+ # - `blur`: only once focus leaves the field. Use for actions too
235
+ # expensive to run mid-word -- a server round trip, a recalculation
236
+ # that rewrites other fields under the user's cursor.
237
+ #
238
+ # @example Round-trip only once the user is done with the field
239
+ # form.fields_text \
240
+ # name: 'user[promo_code]',
241
+ # label: 'Promo code',
242
+ # changeOn: 'blur',
243
+ # onChange: ->(action) { action.http_get url: verify_promo_path }
244
+ base.enum :changeOn, options: %w[input blur]
245
+
246
+ # Milliseconds of no typing before `changeOn: 'input'` fires.
247
+ # Defaults to 300. Ignored under `changeOn: 'blur'`.
248
+ #
249
+ # @example Slow down a search-as-you-type round trip
250
+ # form.fields_text \
251
+ # name: 'q',
252
+ # label: 'Search',
253
+ # changeDelay: 800,
254
+ # onChange: ->(action) { action.http_get url: search_path }
255
+ base.int :changeDelay
256
+ end
257
+ end
258
+
214
259
  # Basic text input field for single-line text entry.
215
260
  #
216
261
  # The most common field type, supporting various customizations like
@@ -228,6 +273,8 @@ class Glib::JsonUi::ViewBuilder
228
273
  #
229
274
  # @see app/views/json_ui/garage/forms/basic.json.jbuilder Garage example
230
275
  class Text < AbstractField
276
+ include ChangeTrigger
277
+
231
278
  # Maximum number of characters allowed.
232
279
  int :maxLength
233
280
 
@@ -6,17 +6,21 @@ module Glib
6
6
  # Opt-in: if the host app has not run the gem's `create_snapshot_blob_references` migration,
7
7
  # `SnapshotBlobReference.table_exists?` is false and the exclusion subquery short-circuits —
8
8
  # the job then behaves identically to Rails' built-in unattached-blob cleanup, just delayed
9
- # by 8 hours to give upload flows time to attach.
9
+ # by `Glib::PurgeUnattached::Config.min_age` (8 hours by default) to give upload flows time
10
+ # to attach.
10
11
  #
11
12
  # Schedule from the host app (e.g. via sidekiq-cron):
12
13
  # Glib::PurgeUnattachedJob.perform_later
14
+ #
15
+ # Adjust the grace window from the host app (e.g. for a slower upload flow):
16
+ # Glib::PurgeUnattached::Config.min_age = 10.hours
13
17
  class PurgeUnattachedJob < ApplicationJob
14
18
  queue_as :cleanup
15
19
 
16
20
  def perform(*_args)
17
21
  scope = ActiveStorage::Blob
18
22
  .unattached
19
- .where('active_storage_blobs.created_at < ?', 8.hours.ago)
23
+ .where('active_storage_blobs.created_at < ?', Glib::PurgeUnattached::Config.min_age.ago)
20
24
 
21
25
  if SnapshotBlobReference.table_exists?
22
26
  scope = scope.where.not(id: SnapshotBlobReference.select(:blob_id))
@@ -10,6 +10,27 @@ require 'hashdiff'
10
10
  # 2. Nil has_one safe-nav in check_snapshot_changed.
11
11
  # 3. has_one wrapping in diff_associations (V2 only tested with has_many in mapping-web).
12
12
  # 4. ActiveStorage in diff_associations (convert Attached::One/Many to attachment records).
13
+ # 5. same_as_before? derives from the computed diff instead of the instance-local
14
+ # `snapshot_changed` flag (mapping-web's flag-gating silently dropped a target's
15
+ # snapshot when taken after the owner's -- see "Snapshot ordering" below, issue #481),
16
+ # with the flag re-entering as a NEGATIVE signal for first snapshots only (see
17
+ # `same_as_before?` for why an empty association baseline cannot decide those).
18
+ #
19
+ # ## Snapshot ordering: owner vs association targets
20
+ #
21
+ # Owner and association-target snapshots may be taken in ANY order within a request.
22
+ # `same_as_before?` decides from persisted state, so a target snapshotted after its owner
23
+ # (through the replaced association target) still records its version.
24
+ #
25
+ # The one residual ordering caveat: `diff` compares against the instance's IN-MEMORY
26
+ # attributes, so unsaved changes live only on the instance that holds them. If a target is
27
+ # modified in memory without saving, snapshot it via a local captured BEFORE the owner's
28
+ # `glib_create_snapshot!` (whose `with_lock` reload replaces the association targets) --
29
+ # reading the target back through the association snapshots the persisted state instead.
30
+ #
31
+ # `snapshot_changed` / `attributes_before_save` remain writable instance accessors for
32
+ # call-site use; `snapshot_changed` is consulted by the skip decision only as a NEGATIVE
33
+ # signal on first snapshots (see `same_as_before?`).
13
34
  #
14
35
  # To get all records of a model, you can use the following query:
15
36
  # MyModel.joins(:snapshots).distinct
@@ -42,7 +63,16 @@ module Glib
42
63
  # Only set snapshot_changed if not already set (handles case where before_save
43
64
  # is called multiple times - e.g., when associated records are saved before parent)
44
65
  if snapshot_changed.nil?
45
- self.attributes_before_save = attributes_in_database
66
+ # For a save with no pending change `attributes_in_database` is empty -- an
67
+ # unchanged persisted record has no database-side delta -- which would make a
68
+ # first snapshot diff every attribute as an addition. Capture the full
69
+ # persisted state instead, so an unchanged save yields an empty item diff and
70
+ # the nil-guard keeps that baseline stable for later changes on the same
71
+ # instance. New records keep `attributes_in_database` (their all-nil
72
+ # originals): that non-empty baseline is exactly why a create's version 1
73
+ # records the full initial state. `unless` keeps the accessor nil, never
74
+ # `false`, for call-site nil-safety.
75
+ self.attributes_before_save = attributes_in_database.presence || (attributes unless new_record?)
46
76
  self.snapshot_changed = check_snapshot_changed
47
77
  end
48
78
  end
@@ -106,8 +136,11 @@ module Glib
106
136
  metadata: {} # Keep for potential future use, but main data in columns
107
137
  }
108
138
 
109
- # Don't create version if same as before
110
- unless same_as_before?
139
+ # Don't create version if same as before. Reuses `calculated_diff` instead of
140
+ # recomputing it inside `same_as_before?`. `version == 1` (no previous
141
+ # version exists) tells `same_as_before?` this is a FIRST snapshot, where the
142
+ # association baseline is empty and the flag carries decision weight.
143
+ unless same_as_before?(calculated_diff, first_snapshot: version == 1)
111
144
  # No nil guard: active_snapshot's `create_snapshot!` raises on failure and
112
145
  # always returns the snapshot.
113
146
  snapshot = create_snapshot!(**snapshot_obj)
@@ -166,10 +199,29 @@ module Glib
166
199
  snapshots.order(version: :desc).first
167
200
  end
168
201
 
169
- def same_as_before?
170
- return !snapshot_changed if snapshot_prev.blank?
202
+ # Decides whether `glib_create_snapshot!` should skip the version write. Derived from
203
+ # the computed diff (persisted state), not from the instance-local `snapshot_changed`
204
+ # flag: the flag dies with its instance, and an owner's snapshot replaces association
205
+ # targets with fresh instances (`with_lock`'s reload clears the association cache, then
206
+ # the diff walk re-reads the targets), so flag-gating used to silently drop a target's
207
+ # follow-up snapshot taken through the association (issue #481).
208
+ #
209
+ # The one place the flag still decides: a FIRST snapshot whose association diff is
210
+ # meaningless. With no previous version, `fetch_snapshot_items(nil)` returns `{}`
211
+ # for associations, so every pre-existing child row reads as `+` and the diff alone
212
+ # can never prove a no-op — any never-snapshotted record with children would mint a
213
+ # version on every snapshot call (observed as spurious "Edited" audit entries on
214
+ # routine re-saves). There the flag is consulted as a NEGATIVE signal:
215
+ # - `false` — this instance crossed the before_save boundary and
216
+ # `check_snapshot_changed` found no item or child change, and the item diff is
217
+ # net-zero against `attributes_before_save` => skip;
218
+ # - `nil` — no save boundary on this instance (an owner-triggered snapshot of a
219
+ # replaced association target, issue #481) => mint;
220
+ # - `true` — a change was detected at the save boundary; falls through to the
221
+ # diff-based decision (a reverted change still no-ops).
222
+ def same_as_before?(computed_diff = diff, first_snapshot: false)
223
+ return true if first_snapshot && snapshot_changed == false && computed_diff['item'].blank?
171
224
 
172
- computed_diff = diff
173
225
  item_unchanged = computed_diff['item'].blank?
174
226
  assoc_diff = computed_diff['associations']
175
227
  associations_unchanged = assoc_diff.nil? || assoc_diff.values.all?(&:blank?)
@@ -37,6 +37,7 @@ nav_groups = {
37
37
  'fields_stripeExternalAccount',
38
38
  'fields_creditCard',
39
39
  'fields_timer',
40
+ 'fields_change_trigger',
40
41
  'fields_upload',
41
42
  'fields_url_fragment',
42
43
  'fields_captcha',
@@ -0,0 +1,128 @@
1
+ json.title 'Test Page (Fields Change Trigger)'
2
+
3
+ page = json_ui_page json
4
+
5
+ render 'json_ui/garage/test_page/header', json: json, page: page
6
+
7
+ page.body(
8
+ childViews: ->(body) do
9
+
10
+ body.panels_responsive(
11
+ padding: glib_json_padding_body,
12
+ childViews: ->(res) do
13
+ res.h2 text: 'Fields Change Trigger'
14
+ res.spacer height: 8
15
+ res.label text: 'changeOn / changeDelay control WHEN a typed-input field reports its change to onChange.'
16
+ res.spacer height: 12
17
+
18
+ res.panels_form(
19
+ url: json_ui_garage_url(path: 'forms/generic_post'),
20
+ method: 'post',
21
+ childViews: ->(form) do
22
+
23
+ # ── Default: changeOn 'input', 300ms ──────────────────────────────
24
+ form.h4 text: 'Default (changeOn: input)'
25
+ form.spacer height: 8
26
+ form.label text: 'No options set: reports once the user pauses for the default 300ms.'
27
+ form.spacer height: 8
28
+
29
+ form.fields_text(
30
+ name: 'user[default_value]',
31
+ label: 'Default',
32
+ placeholder: 'default',
33
+ width: 'matchParent',
34
+ onChange: ->(action) do
35
+ action.logics_set(
36
+ targetId: 'status_default',
37
+ conditionalData: { text: { 'printf': ['Default: {0}', { 'var': 'user[default_value]' }] } }
38
+ )
39
+ end
40
+ )
41
+ form.spacer height: 8
42
+ form.label id: 'status_default', text: 'Default: idle'
43
+
44
+ form.spacer height: 12
45
+ form.hr width: 'matchParent'
46
+ form.spacer height: 12
47
+
48
+ # ── changeDelay ───────────────────────────────────────────────────
49
+ form.h4 text: 'changeDelay: 1500'
50
+ form.spacer height: 8
51
+ form.label text: 'Same input mode, longer pause. Leaving the field still reports right away, rather than waiting out the remaining delay.'
52
+ form.spacer height: 8
53
+
54
+ form.fields_text(
55
+ name: 'user[slow]',
56
+ label: 'Slow',
57
+ placeholder: 'slow',
58
+ width: 'matchParent',
59
+ changeDelay: 1500,
60
+ onChange: ->(action) do
61
+ action.logics_set(
62
+ targetId: 'status_slow',
63
+ conditionalData: { text: { 'printf': ['Slow: {0}', { 'var': 'user[slow]' }] } }
64
+ )
65
+ end
66
+ )
67
+ form.spacer height: 8
68
+ form.label id: 'status_slow', text: 'Slow: idle'
69
+
70
+ form.spacer height: 12
71
+ form.hr width: 'matchParent'
72
+ form.spacer height: 12
73
+
74
+ # ── changeOn: 'blur' ──────────────────────────────────────────────
75
+ form.h4 text: "changeOn: 'blur'"
76
+ form.spacer height: 8
77
+ form.label text: 'Stays idle no matter how long the user pauses; reports only once focus leaves.'
78
+ form.spacer height: 8
79
+
80
+ form.fields_text(
81
+ name: 'user[on_blur]',
82
+ label: 'Reports on blur',
83
+ placeholder: 'on blur',
84
+ width: 'matchParent',
85
+ changeOn: 'blur',
86
+ onChange: ->(action) do
87
+ action.logics_set(
88
+ targetId: 'status_on_blur',
89
+ conditionalData: { text: { 'printf': ['Blur: {0}', { 'var': 'user[on_blur]' }] } }
90
+ )
91
+ end
92
+ )
93
+ form.spacer height: 8
94
+ form.label id: 'status_on_blur', text: 'Blur: idle'
95
+
96
+ form.spacer height: 12
97
+ form.hr width: 'matchParent'
98
+ form.spacer height: 12
99
+
100
+ # ── Textarea on blur ──────────────────────────────────────────────
101
+ form.h4 text: "fields_textarea with changeOn: 'blur'"
102
+ form.spacer height: 8
103
+
104
+ form.fields_textarea(
105
+ name: 'user[notes]',
106
+ label: 'Notes',
107
+ placeholder: 'notes',
108
+ width: 'matchParent',
109
+ rows: 3,
110
+ changeOn: 'blur',
111
+ onChange: ->(action) do
112
+ action.logics_set(
113
+ targetId: 'status_notes',
114
+ conditionalData: { text: { 'printf': ['Notes: {0}', { 'var': 'user[notes]' }] } }
115
+ )
116
+ end
117
+ )
118
+ form.spacer height: 8
119
+ form.label id: 'status_notes', text: 'Notes: idle'
120
+
121
+ form.spacer height: 16
122
+ form.fields_submit text: 'Submit'
123
+ end
124
+ )
125
+ end
126
+ )
127
+ end
128
+ )
@@ -0,0 +1,38 @@
1
+ require 'active_support/core_ext/numeric/time'
2
+
3
+ module Glib
4
+ module PurgeUnattached
5
+ # Global settings for Glib::PurgeUnattachedJob.
6
+ #
7
+ # `min_age` is how long an unattached ActiveStorage blob must exist before
8
+ # the purge may remove it. The 8-hour default leaves room for multi-step
9
+ # upload flows (direct upload -> form submit -> attach) to finish before
10
+ # their blob becomes eligible; apps with slower flows raise it:
11
+ #
12
+ # # config/initializers/glib.rb
13
+ # Glib::PurgeUnattached::Config.min_age = 10.hours
14
+ #
15
+ # Deliberately NOT a setting on the job class: the job is autoloaded from
16
+ # app/, so an initializer-assigned value would be wiped the first time the
17
+ # class is reloaded. This file lives in lib/ and is required eagerly by
18
+ # glib-web.rb, which is what makes the setting survive reloads.
19
+ class Config
20
+ # The shipped default. Apps opt into a different window via the setter.
21
+ DEFAULT_MIN_AGE = 8.hours
22
+
23
+ @@min_age = DEFAULT_MIN_AGE
24
+
25
+ def self.min_age
26
+ @@min_age
27
+ end
28
+
29
+ def self.min_age=(value)
30
+ unless value.is_a?(ActiveSupport::Duration) && value.positive?
31
+ raise ArgumentError, "Invalid min_age: #{value.inspect}. Expected a positive ActiveSupport::Duration (e.g. 10.hours)."
32
+ end
33
+
34
+ @@min_age = value
35
+ end
36
+ end
37
+ end
38
+ end
data/lib/glib-web.rb CHANGED
@@ -6,10 +6,12 @@ if defined?(::Rails)
6
6
  end
7
7
  require 'glib/value'
8
8
  # Loaded eagerly (not autoloaded from app/) so an app initializer can set
9
- # `Glib::JsonUi::Config.name_separator` without tripping Rails' ban on
10
- # autoloading during initialization -- and so the setting survives a reload.
9
+ # `Glib::JsonUi::Config.name_separator` or `Glib::PurgeUnattached::Config.min_age`
10
+ # without tripping Rails' ban on autoloading during initialization -- and so the
11
+ # setting survives a reload.
11
12
  require 'glib/json_ui/config'
12
13
  require 'glib/json_ui/name'
14
+ require 'glib/purge_unattached/config'
13
15
  require 'glib/json_crawler'
14
16
 
15
17
  require 'glib/dynamic_text'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: glib-web
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.10.2
4
+ version: 6.10.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-11 00:00:00.000000000 Z
11
+ date: 2026-09-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activestorage
@@ -456,6 +456,7 @@ files:
456
456
  - app/views/json_ui/garage/test_page/duplicate_names.json.jbuilder
457
457
  - app/views/json_ui/garage/test_page/fields.json.jbuilder
458
458
  - app/views/json_ui/garage/test_page/fields_captcha.json.jbuilder
459
+ - app/views/json_ui/garage/test_page/fields_change_trigger.json.jbuilder
459
460
  - app/views/json_ui/garage/test_page/fields_creditCard.json.jbuilder
460
461
  - app/views/json_ui/garage/test_page/fields_date_time.json.jbuilder
461
462
  - app/views/json_ui/garage/test_page/fields_dynamicSelect.json.jbuilder
@@ -590,6 +591,7 @@ files:
590
591
  - lib/glib/last_resort_unit_test.rb
591
592
  - lib/glib/mailer_tester.rb
592
593
  - lib/glib/non_http_integration_test.rb
594
+ - lib/glib/purge_unattached/config.rb
593
595
  - lib/glib/rubocop.rb
594
596
  - lib/glib/rubocop/cops/json_ui/base_nested_parameter.rb
595
597
  - lib/glib/rubocop/cops/json_ui/nested_action_parameter.rb