glib-web 6.3.2 → 6.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4f55e891cbc4a9325dbf8fe235dcb593982686ec8e36f62d86b6aea853e83f22
4
- data.tar.gz: 6299bcaf264a60d4a604dc6dc7f44f45f2671f2827b8a6fe26f266979d82dbf5
3
+ metadata.gz: d0c733a1c0dbc779a2bd72cbcd304074238f10a2efde2d74ee4c75f50bf861d4
4
+ data.tar.gz: 0ea1e58d18cea237c50fccaafb56a165abaa104caecd2011cbbeb7e5bdf7a97d
5
5
  SHA512:
6
- metadata.gz: 368a1c82a9ee9f539b38d4e3396b0fa14cf449fef03d50862239b549d3d9593a19b8961f41e521d8a627bf58530b63b5ac6f1a89310bebe1d0b5a5e61d811361
7
- data.tar.gz: b8fe1cd9c3256ad9d3d7ea1cafb5adef1ec80e984ec7678edfd5e6aaec585babdc96aaddcef189003269ab9a33b89b00aae5fe5ac1c4988458de0ec3fa33fec4
6
+ metadata.gz: ff566900716099396b7817beca6b934ad2d18f8338c2dfa6743c9b3ed851927becc596d75d5bd71c9fc90ff3e9bbba572f665228755cc4cac60d6389b50103ff
7
+ data.tar.gz: 93e89c3f04019cf9517412416b4c8bb917b3171677f67a3214d8e12d705bddafb9f1744522452b93a1fa77d2278ed14499ee41d9097fc4a482db6b6ec40ac186
@@ -16,6 +16,18 @@
16
16
  module Glib
17
17
  class BlobUrlGeneratorsController < ::ActionController::Base
18
18
  def create
19
+ # Same failure class as the upload fields' `url_for(attachment)` (see
20
+ # AbstractField#existing_attachment_url): `rails_blob_path` resolves
21
+ # through ActiveStorage's default routes, so in apps that disable those
22
+ # this endpoint would 500 in exactly and only the environments with the
23
+ # routes off. Fail with a teaching error instead.
24
+ unless ::ActiveStorage.draw_routes
25
+ raise 'the default blob URL generator returns `rails_blob_path`, which resolves through ' \
26
+ "ActiveStorage's default routes, and this app does not draw them " \
27
+ '(config.active_storage.draw_routes = false). Point `blobUrlGenerator:` at an app ' \
28
+ 'endpoint that returns an authorized URL instead.'
29
+ end
30
+
19
31
  blob = ::ActiveStorage::Blob.find_signed(params[:signed_id])
20
32
  url = blob.present? ? rails_blob_path(blob) : nil
21
33
 
@@ -181,6 +181,34 @@ class Glib::JsonUi::ViewBuilder
181
181
  association = form.nested_associations.last
182
182
  @context ||= association || form
183
183
  end
184
+
185
+ # Builds the URL of a file that is ALREADY attached to the model (e.g.
186
+ # the pre-population list an upload field shows when re-editing a
187
+ # record). An app-defined `glib_attachment_url_for(attachment)` view
188
+ # helper takes precedence; otherwise this falls back to
189
+ # `url_for(attachment)`, which resolves through ActiveStorage's default
190
+ # routes. Apps that disable those routes
191
+ # (`config.active_storage.draw_routes = false`, commonly a
192
+ # production-only hardening) MUST define the helper: without it,
193
+ # url_for raises in exactly and only the environments with the routes
194
+ # off, invisible to tests that run with them on. The draw_routes check
195
+ # below turns that into the same loud, teaching error in every
196
+ # environment.
197
+ def existing_attachment_url(attachment)
198
+ view = page.context
199
+ return view.glib_attachment_url_for(attachment) if view.respond_to?(:glib_attachment_url_for)
200
+
201
+ unless ::ActiveStorage.draw_routes
202
+ raise 'cannot build the URL of an existing attachment: `url_for(attachment)` resolves ' \
203
+ "through ActiveStorage's default routes, and this app does not draw them " \
204
+ '(config.active_storage.draw_routes = false). Define a ' \
205
+ '`glib_attachment_url_for(attachment)` view helper that returns the app\'s own ' \
206
+ 'authorized file URL; for `fields_upload` you can instead supply the list ' \
207
+ 'yourself via `multiProgressView: { files: [...] }`.'
208
+ end
209
+
210
+ url_for(attachment)
211
+ end
184
212
  end
185
213
 
186
214
  # Basic text input field for single-line text entry.
@@ -740,7 +768,7 @@ class Glib::JsonUi::ViewBuilder
740
768
 
741
769
  if (value = context.field_value(@prop)).attached?
742
770
  json.fileTitle value.blob.filename
743
- json.fileUrl url_for(value)
771
+ json.fileUrl existing_attachment_url(value.attachment)
744
772
  end
745
773
  end
746
774
 
@@ -755,6 +783,15 @@ class Glib::JsonUi::ViewBuilder
755
783
  end
756
784
  end
757
785
 
786
+ # File upload field with direct-upload support and a progress list of
787
+ # already-attached files.
788
+ #
789
+ # When re-editing a record, the field pre-populates
790
+ # `multiProgressView.files` from the model's attachments. Apps that
791
+ # disable ActiveStorage's default routes
792
+ # (`config.active_storage.draw_routes = false`) must define a
793
+ # `glib_attachment_url_for(attachment)` view helper returning their own
794
+ # authorized file URL -- see AbstractField#existing_attachment_url.
758
795
  class Upload < AbstractField
759
796
  include Glib::JsonUi::Upload
760
797
  include Glib::JsonUi::FileUploadErrorHandler
@@ -766,7 +803,12 @@ class Glib::JsonUi::ViewBuilder
766
803
  hash :clipboardView, optional: [:icon]
767
804
 
768
805
  def multiProgressView(values)
769
- @multi_progress = values
806
+ # Consumers naturally write jbuilder hashes with symbol keys, but the
807
+ # reads in `created` use string keys. Normalize so a symbol-keyed
808
+ # `files:`/`responseMessages:` is honored instead of silently ignored
809
+ # (an ignored `files:` used to send the field down the url_for
810
+ # pre-population path even when the app supplied its own list).
811
+ @multi_progress = values&.stringify_keys
770
812
  end
771
813
 
772
814
  def created
@@ -779,18 +821,25 @@ class Glib::JsonUi::ViewBuilder
779
821
  if @prop && context && @multi_progress
780
822
  value = context.field_value(@prop, collect_ids: false)
781
823
  attachments = value.is_a?(::ActiveStorage::Attached::One) ? Array.wrap(value.attachment) : value.to_a
782
- @multi_progress['files'] ||= attachments.map { |file| { name: file.blob&.filename, signed_id: file.signed_id, url: url_for(file) } }
824
+ @multi_progress['files'] ||= attachments.map do |file|
825
+ { name: file.blob&.filename, signed_id: file.signed_id, url: existing_attachment_url(file) }
826
+ end
783
827
  end
784
828
 
785
829
  if @multi_progress
786
- @multi_progress['responseMessages'] ||= {}
830
+ # Copy before writing: `stringify_keys` is shallow, so the nested
831
+ # hash may still be the caller's own object, and the I18n merge
832
+ # below must not mutate (or accumulate state in) a hash the
833
+ # consumer may freeze or reuse across renders.
834
+ messages = (@multi_progress['responseMessages'] || {}).dup
787
835
  ['200', '403', '401', 'else'].each do |status|
788
836
  key = "glib.multi_upload.responseMessages.#{status}"
789
- @multi_progress['responseMessages'][status] = I18n.t(key) if I18n.exists?(key)
837
+ messages[status] = I18n.t(key) if I18n.exists?(key)
790
838
  end
839
+ @multi_progress['responseMessages'] = messages
791
840
 
792
841
  json.responseMessages(
793
- (@multi_progress['responseMessages'] || {}).reverse_merge(
842
+ messages.reverse_merge(
794
843
  '200' => 'Completed',
795
844
  '403' => 'Forbidden',
796
845
  '401' => 'Session expired',
@@ -5,11 +5,15 @@ module Glib
5
5
 
6
6
  self.abstract_class = true
7
7
 
8
- scope :created_asc, -> { order(created_at: :asc) }
9
- scope :created_desc, -> { order(created_at: :desc) }
8
+ # The id tiebreakers keep the order deterministic when timestamps tie
9
+ # (rows created in one transaction, dumped fixtures): a bare timestamp
10
+ # ORDER BY returns tied rows in unspecified database order, which flips
11
+ # listings -- and their test snapshots -- between runs.
12
+ scope :created_asc, -> { order(created_at: :asc, id: :asc) }
13
+ scope :created_desc, -> { order(created_at: :desc, id: :desc) }
10
14
 
11
- scope :updated_asc, -> { order(updated_at: :asc) }
12
- scope :updated_desc, -> { order(updated_at: :desc) }
15
+ scope :updated_asc, -> { order(updated_at: :asc, id: :asc) }
16
+ scope :updated_desc, -> { order(updated_at: :desc, id: :desc) }
13
17
 
14
18
  def self.glib_has_one_attached(field_name)
15
19
  has_one_attached field_name
@@ -0,0 +1,152 @@
1
+ json.title 'Test Page (Form dirty fields)'
2
+
3
+ page = json_ui_page json
4
+
5
+ render 'json_ui/garage/test_page/header', json: json, page: page
6
+
7
+ # A battery of field types wired to the same dirty-hooks contract as
8
+ # `form_dirty_hooks`: the submit enables only while the form is dirty. Each
9
+ # field here has at least one value-changing gesture that must flip the
10
+ # dirty state (pinned by formDirtyFields.cy.js).
11
+ page.body childViews: ->(body) do
12
+ body.panels_form \
13
+ padding: glib_json_padding_body,
14
+ onDirty: ->(action) do
15
+ action.logics_set targetId: 'dirty_fields_submit', data: { disabled: false }
16
+ end,
17
+ onPristine: ->(action) do
18
+ action.logics_set targetId: 'dirty_fields_submit', data: { disabled: true }
19
+ end,
20
+ # Pins that programmatic value changes reach the form-level onChange too
21
+ # (they fire the same synthetic `change` that native edits do).
22
+ onChange: ->(action) do
23
+ action.logics_set targetId: 'change_marker', data: { text: 'Changed' }
24
+ end,
25
+ childViews: ->(form) do
26
+ form.h2 text: 'Form dirty fields'
27
+ form.spacer height: 8
28
+
29
+ form.label id: 'change_marker', text: 'Unchanged'
30
+ form.spacer height: 8
31
+
32
+ form.fields_text name: 'user[name]', width: 'matchParent', label: 'Name', value: 'Initial', clearable: true
33
+ form.spacer height: 14
34
+
35
+ form.fields_date id: 'dirty_date', name: 'user[date]', width: 'matchParent', label: 'Date', value: '2020-01-10', clearable: true
36
+ form.spacer height: 14
37
+
38
+ form.fields_rating id: 'dirty_rating', name: 'user[rating]', value: 2
39
+ form.spacer height: 14
40
+
41
+ form.fields_otp id: 'dirty_otp', name: 'user[otp]', width: 'matchParent', length: 4
42
+ form.spacer height: 14
43
+
44
+ form.fields_dynamicSelect id: 'dirty_dynamic_select', name: 'user[language]', width: 'matchParent', label: 'Language',
45
+ selectedOptions: [{ value: 'id3', text: 'Item 3' }],
46
+ url: json_ui_garage_url(path: 'forms/dynamic_select_data'),
47
+ placeholder: 'Start typing'
48
+ form.spacer height: 14
49
+
50
+ form.button text: 'Check all', onClick: ->(action) do
51
+ action.components_invoke targetId: 'dirty_check_group', name: 'checkAll'
52
+ end
53
+ form.spacer height: 4
54
+ form.fields_checkGroup id: 'dirty_check_group', name: 'user[hobbies][]', childViews: ->(group) do
55
+ group.fields_check checkValue: 'grape', label: 'Grape'
56
+ group.fields_check checkValue: 'banana', label: 'Banana'
57
+ end
58
+ form.spacer height: 14
59
+
60
+ properties = [
61
+ [{ name: 'question', value: 'Punctuality' }]
62
+ ]
63
+ form.fields_dynamicGroup id: 'dirty_dynamic_group', width: 'matchParent', name: 'user[evaluation]', groupFieldProperties: properties, titlePrefix: 'Entry', content: ->(group) do
64
+ group.template childViews: ->(template) do
65
+ template.fields_text width: 'matchParent', name: 'question', label: 'Question', styleClass: 'question'
66
+ end
67
+ end
68
+ form.spacer height: 14
69
+
70
+ form.fields_upload \
71
+ id: 'dirty_upload',
72
+ name: 'user[attachments][]',
73
+ label: 'Attachments',
74
+ width: 320,
75
+ directUploadUrl: glib_direct_uploads_url,
76
+ multiProgressView: { files: [] }
77
+ form.spacer height: 14
78
+
79
+ # Avatar-style upload prepopulated with a saved value; resetting it
80
+ # changes the submitted value, so it must count as dirty.
81
+ form.fields_upload \
82
+ id: 'dirty_avatar',
83
+ name: 'user[avatar]',
84
+ width: 320,
85
+ directUploadUrl: glib_direct_uploads_url,
86
+ value: 'saved-avatar-signed-id',
87
+ placeholderView: { type: 'avatar', url: 'https://picsum.photos/id/11/100/60', width: 64, height: 64 }
88
+ form.button text: 'Reset avatar', onClick: ->(action) do
89
+ action.components_invoke targetId: 'dirty_avatar', name: 'reset'
90
+ end
91
+ form.spacer height: 14
92
+
93
+ form.fields_sign id: 'dirty_sign', name: 'user[signature]', directUploadUrl: glib_direct_uploads_url, width: 320, height: 160
94
+ form.spacer height: 14
95
+
96
+ # Prepopulated phone: must stay pristine at load (the lib normalises the
97
+ # value at mount) and a type/backspace round trip must return pristine.
98
+ form.fields_phone id: 'dirty_phone', name: 'user[phone]', width: 'matchParent', label: 'Phone',
99
+ value: '+61212345678', defaultCountry: 'AU', disableAutoDetect: true
100
+ form.spacer height: 14
101
+
102
+ # Input-view upload; reset must count as a change.
103
+ form.fields_upload \
104
+ id: 'dirty_document',
105
+ name: 'user[document]',
106
+ label: 'Document',
107
+ width: 320,
108
+ directUploadUrl: glib_direct_uploads_url,
109
+ inputView: {}
110
+ form.button text: 'Reset document', onClick: ->(action) do
111
+ action.components_invoke targetId: 'dirty_document', name: 'reset'
112
+ end
113
+ form.spacer height: 14
114
+
115
+ # Clipboard uploads: direct-upload mode and plain (no-upload) mode.
116
+ form.fields_upload \
117
+ id: 'dirty_clipboard',
118
+ name: 'user[snippet]',
119
+ label: 'Snippet',
120
+ width: 320,
121
+ directUploadUrl: glib_direct_uploads_url,
122
+ clipboardView: {}
123
+ form.spacer height: 14
124
+ form.fields_upload \
125
+ id: 'dirty_clipboard_plain',
126
+ name: 'user[snippet_plain]',
127
+ label: 'Snippet (plain)',
128
+ width: 320,
129
+ clipboardView: {}
130
+ form.spacer height: 14
131
+
132
+ # Single-file upload with a saved value; replacing it must count.
133
+ form.fields_upload \
134
+ id: 'dirty_cover',
135
+ name: 'user[cover]',
136
+ label: 'Cover',
137
+ width: 320,
138
+ directUploadUrl: glib_direct_uploads_url,
139
+ multiple: false,
140
+ multiProgressView: { files: [{ name: 'cover.png', signed_id: 'saved-cover', url: 'https://picsum.photos/id/12/100/60' }] }
141
+ form.spacer height: 14
142
+
143
+ form.fields_richText id: 'dirty_rich_text', name: 'user[bio]', width: 'matchParent', label: 'Bio', value: '<p>hello</p>'
144
+ form.spacer height: 14
145
+
146
+ # Populated from the URL hash at mount; must be absorbed into the
147
+ # pristine baseline, not read as an edit.
148
+ form.fields_urlFragment name: 'user[fragment]'
149
+
150
+ form.fields_submit id: 'dirty_fields_submit', text: 'Save'
151
+ end
152
+ end
@@ -26,6 +26,17 @@ page.body childViews: ->(body) do
26
26
  form.fields_text name: 'user[ignored]', width: 'matchParent', label: 'Ignored', disableDirtyCheck: true
27
27
  form.spacer height: 14
28
28
 
29
+ # Direct-upload field -- a completed upload (and a removal) must count
30
+ # towards dirtiness just like a typed edit.
31
+ form.fields_upload \
32
+ name: 'user[attachments][]',
33
+ id: 'dirty_hooks_upload',
34
+ label: 'Attachments',
35
+ width: 320,
36
+ directUploadUrl: glib_direct_uploads_url,
37
+ multiProgressView: { files: [] }
38
+ form.spacer height: 14
39
+
29
40
  form.fields_submit id: 'dirty_hooks_submit', text: 'Save'
30
41
  end
31
42
  end
data/lib/glib/engine.rb CHANGED
@@ -16,9 +16,10 @@ module Glib
16
16
  end
17
17
 
18
18
  # Mirror `Glib::ApplicationRecord.created_desc` so call sites can use the codebase-wide
19
- # convention instead of `order(created_at: :desc)`.
19
+ # convention instead of `order(created_at: :desc)`. Includes the same id
20
+ # tiebreaker: tied timestamps otherwise return in unspecified order.
20
21
  unless respond_to?(:created_desc)
21
- scope :created_desc, -> { order(created_at: :desc) }
22
+ scope :created_desc, -> { order(created_at: :desc, id: :desc) }
22
23
  end
23
24
 
24
25
  # Parses the stored diff into a flat hash of Change structs:
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: glib-web
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.3.2
4
+ version: 6.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 2026-07-24 00:00:00.000000000 Z
11
+ date: 2026-08-12 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: activestorage
@@ -177,6 +178,7 @@ dependencies:
177
178
  - - ">="
178
179
  - !ruby/object:Gem::Version
179
180
  version: '0'
181
+ description:
180
182
  email: ''
181
183
  executables: []
182
184
  extensions: []
@@ -451,6 +453,7 @@ files:
451
453
  - app/views/json_ui/garage/test_page/fields_url_fragment.json.jbuilder
452
454
  - app/views/json_ui/garage/test_page/flow.json.jbuilder
453
455
  - app/views/json_ui/garage/test_page/form.json.jbuilder
456
+ - app/views/json_ui/garage/test_page/form_dirty_fields.json.jbuilder
454
457
  - app/views/json_ui/garage/test_page/form_dirty_hooks.json.jbuilder
455
458
  - app/views/json_ui/garage/test_page/form_dynamic.json.jbuilder
456
459
  - app/views/json_ui/garage/test_page/form_dynamic_dirty.json.jbuilder
@@ -571,8 +574,10 @@ files:
571
574
  - lib/glib/time_returning_mailer.rb
572
575
  - lib/glib/value.rb
573
576
  - lib/tasks/db.rake
577
+ homepage:
574
578
  licenses: []
575
579
  metadata: {}
580
+ post_install_message:
576
581
  rdoc_options: []
577
582
  require_paths:
578
583
  - lib
@@ -587,7 +592,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
587
592
  - !ruby/object:Gem::Version
588
593
  version: '0'
589
594
  requirements: []
590
- rubygems_version: 4.0.6
595
+ rubygems_version: 3.4.6
596
+ signing_key:
591
597
  specification_version: 4
592
598
  summary: ''
593
599
  test_files: []