glib-web 6.10.2 → 6.10.3

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: 4466ade33c7e7109a75befa19d22951379a4b57f0c20608b26ba80a9e539901b
4
+ data.tar.gz: 9d05610c2feda72d878a64a44dec1f3cf8aa48c7f9237d2b3ce769d89beb9c8a
5
5
  SHA512:
6
- metadata.gz: 13fb675a42739c4d67d50b26a359a6cc4136fc695c0f2730d2c21e721b193f955335a39bcab550704bc29cbfb3073569154b7c08b45924de5c576036842fb965
7
- data.tar.gz: 1610d45e4b2e60d70475115ca741929585939c847108f46e7b5c209cbc5bff7292720ad85e3eced53f43a43efd2c9bb5756ac3067bab586fe0db17a07177efb0
6
+ metadata.gz: b2bbafdaaa9ddc994cafe76cc6520a3b8b7dd07e0ae456d666fe62ec061efd8f854f3e719d297d39146e6c0f259c80074f5b28655e2f898b5dbdc2ae243eb4aa
7
+ data.tar.gz: aed913d8a8aeb19d81e0b01bde95480efe12ddbec54acd2095aed02fcb05e2753d57068c586faba341a2e6f282be41ec9b0e353951e881b853130e292d5a4ddd
@@ -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,24 @@ 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
+ #
17
+ # ## Snapshot ordering: owner vs association targets
18
+ #
19
+ # Owner and association-target snapshots may be taken in ANY order within a request.
20
+ # `same_as_before?` decides from persisted state, so a target snapshotted after its owner
21
+ # (through the replaced association target) still records its version.
22
+ #
23
+ # The one residual ordering caveat: `diff` compares against the instance's IN-MEMORY
24
+ # attributes, so unsaved changes live only on the instance that holds them. If a target is
25
+ # modified in memory without saving, snapshot it via a local captured BEFORE the owner's
26
+ # `glib_create_snapshot!` (whose `with_lock` reload replaces the association targets) --
27
+ # reading the target back through the association snapshots the persisted state instead.
28
+ #
29
+ # `snapshot_changed` / `attributes_before_save` remain writable instance accessors for
30
+ # call-site use, but they are no longer consulted for the skip decision.
13
31
  #
14
32
  # To get all records of a model, you can use the following query:
15
33
  # MyModel.joins(:snapshots).distinct
@@ -106,8 +124,9 @@ module Glib
106
124
  metadata: {} # Keep for potential future use, but main data in columns
107
125
  }
108
126
 
109
- # Don't create version if same as before
110
- unless same_as_before?
127
+ # Don't create version if same as before. Reuses `calculated_diff` instead of
128
+ # recomputing it inside `same_as_before?`.
129
+ unless same_as_before?(calculated_diff)
111
130
  # No nil guard: active_snapshot's `create_snapshot!` raises on failure and
112
131
  # always returns the snapshot.
113
132
  snapshot = create_snapshot!(**snapshot_obj)
@@ -166,10 +185,14 @@ module Glib
166
185
  snapshots.order(version: :desc).first
167
186
  end
168
187
 
169
- def same_as_before?
170
- return !snapshot_changed if snapshot_prev.blank?
171
-
172
- computed_diff = diff
188
+ # Decides whether `glib_create_snapshot!` should skip the version write. Derived from
189
+ # the computed diff (persisted state), never from the instance-local `snapshot_changed`
190
+ # flag: the flag dies with its instance, and an owner's snapshot replaces association
191
+ # targets with fresh instances (`with_lock`'s reload clears the association cache, then
192
+ # the diff walk re-reads the targets), so flag-gating used to silently drop a target's
193
+ # follow-up snapshot taken through the association (issue #481). For a first snapshot
194
+ # (no previous version) the diff runs current state against `attributes_before_save || {}`.
195
+ def same_as_before?(computed_diff = diff)
173
196
  item_unchanged = computed_diff['item'].blank?
174
197
  assoc_diff = computed_diff['associations']
175
198
  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.3
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-16 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