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 +4 -4
- data/app/helpers/glib/json_ui/view_builder/fields.rb +47 -0
- data/app/jobs/glib/purge_unattached_job.rb +6 -2
- data/app/models/concerns/glib/snapshot_v2.rb +29 -6
- data/app/views/json_ui/garage/test_page/_header.json.jbuilder +1 -0
- data/app/views/json_ui/garage/test_page/fields_change_trigger.json.jbuilder +128 -0
- data/lib/glib/purge_unattached/config.rb +38 -0
- data/lib/glib-web.rb +4 -2
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4466ade33c7e7109a75befa19d22951379a4b57f0c20608b26ba80a9e539901b
|
|
4
|
+
data.tar.gz: 9d05610c2feda72d878a64a44dec1f3cf8aa48c7f9237d2b3ce769d89beb9c8a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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 < ?',
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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?)
|
|
@@ -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`
|
|
10
|
-
# autoloading during initialization -- and so the
|
|
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.
|
|
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
|
+
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
|