glib-web 6.7.0 → 6.8.0

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: c367b3b3e866dd9b73204e7d0cfe4b84e6148283b683c83068a52140c91ae987
4
- data.tar.gz: a375dce696ed47b95d76e1f45091b004bb2c3aeafba70ebe0a31f0db37401d5a
3
+ metadata.gz: 0f8366db6a89ce988b77fb0392bc24078686aabf55873f2d8fcd38a5f39950bf
4
+ data.tar.gz: b27ab6afcea23ef3ea93f9b1c8289cb0a905ff563fb768d537cb940afae50a2a
5
5
  SHA512:
6
- metadata.gz: 1dda92d5d21302e9b4064305b9732bcf39fed88a6e904873ba2d4ccb1cfb7dcfec00e88b3792a91574c26b37212d02fd0ce3a1d4e0a7efdd5556effa366d0ddf
7
- data.tar.gz: b5abf3dccb58e18a81da0885fd6c7781da4906f9e329d767359c53a14352b20314506f42e6c2eb5742e483ef48a7cbaeebed43d4793d6de828cde8037080b474
6
+ metadata.gz: 13c54527c80a6b95961dddec9e419c50c36c6d628a2c47577216e144583b44879caa7308c1e5b924a63d29aad3f935e83de86dc72095c333cf5660b8e8f53958
7
+ data.tar.gz: 4b4037b3e40cd01c00086219414a846e10a978510ebd3084db7b614b5480b83577c326bea105a60f0871ecf7962ad3392cccb2d826fed58034eba32758fd6668
@@ -7,15 +7,15 @@ module Glib
7
7
 
8
8
  def status(data)
9
9
  action = {
10
- action: 'labels/set',
10
+ action: Glib::JsonUi::Name.for('labels/set'),
11
11
  targetId: 'label-1',
12
12
  text: data['text'],
13
13
  onSet: {
14
- action: 'timeouts/set',
14
+ action: Glib::JsonUi::Name.for('timeouts/set'),
15
15
  interval: 3000,
16
16
  timerId: 'label-1-set',
17
17
  onTimeout: {
18
- action: 'labels/set',
18
+ action: Glib::JsonUi::Name.for('labels/set'),
19
19
  targetId: 'label-1',
20
20
  text: "Room ##{data['room'].upcase}"
21
21
  }
@@ -23,7 +23,7 @@ module Glib
23
23
  ActionCable.server.broadcast \
24
24
  "#{self.class.channel_name}_#{params['conversation_id']}",
25
25
  action: {
26
- action: 'component/set',
26
+ action: Glib::JsonUi::Name.for('component/set'),
27
27
  text: text,
28
28
  status: data['status'],
29
29
  user_id: data['user_id']
@@ -23,7 +23,7 @@ module Glib
23
23
  ActionCable.server.broadcast \
24
24
  "#{self.class.channel_name}_#{params['conversation_id']}",
25
25
  action: {
26
- action: 'component/set',
26
+ action: Glib::JsonUi::Name.for('component/set'),
27
27
  text: text,
28
28
  status: data['status'],
29
29
  value: data['reset_value'],
@@ -21,9 +21,9 @@ module Glib::Auth
21
21
  view: 'button',
22
22
  text: 'Re-login',
23
23
  onClick: {
24
- action: 'dialogs/close',
24
+ action: Glib::JsonUi::Name.for('dialogs/close'),
25
25
  onClose: {
26
- action: allow_other_host ? 'windows/openWeb' : 'windows/open',
26
+ action: Glib::JsonUi::Name.for(allow_other_host ? 'windows/openWeb' : 'windows/open'),
27
27
  url: sign_in_url,
28
28
  targetWindow: allow_other_host ? '_self' : nil
29
29
  }
@@ -172,7 +172,7 @@ module Glib::Json::Libs
172
172
  else
173
173
  {
174
174
  onResponse: {
175
- action: 'windows/open', url: user_default_url
175
+ action: Glib::JsonUi::Name.for('windows/open'), url: user_default_url
176
176
  }
177
177
  }
178
178
  end
@@ -215,7 +215,7 @@ module Glib::Json::Libs
215
215
 
216
216
  def __glib_error_dialog(title, message, style_classes, bottom_views = [])
217
217
  {
218
- action: 'dialogs/show',
218
+ action: Glib::JsonUi::Name.for('dialogs/show'),
219
219
  # Don't show another dialog, just use the current one since it's a new dialog anyway, which means
220
220
  # that it wouldn't contain existing user data.
221
221
  updateExisting: glib_json_dialog_mode?,
@@ -223,7 +223,7 @@ module Glib::Json::Libs
223
223
  body: {
224
224
  childViews: [
225
225
  {
226
- view: 'panels/vertical',
226
+ view: Glib::JsonUi::Name.for('panels/vertical'),
227
227
  styleClasses: ['glib-error-dialog'].concat(style_classes),
228
228
  childViews: [
229
229
  {
@@ -286,12 +286,12 @@ module Glib::Json::Libs
286
286
  def json_ui_redirect_to(url, status: :ok)
287
287
  # Use windows_reload instead of windows_open so clicking back will skip the bridging page.
288
288
  # on_response = { action: 'windows/open', url: url }
289
- on_response = { action: 'windows/reload', url: url }
289
+ on_response = { action: Glib::JsonUi::Name.for('windows/reload'), url: url }
290
290
 
291
291
  if glib_json_dialog_mode?
292
292
  # Only do this in dialog mode because this seems to add to the rendering time
293
293
  # (i.e. longer flicker time), presumably due to the use of things like `nextTick()`.
294
- on_response = { action: 'dialogs/close', onClose: on_response }
294
+ on_response = { action: Glib::JsonUi::Name.for('dialogs/close'), onClose: on_response }
295
295
  end
296
296
 
297
297
  # Use onResponse because it is harder to guarantee that `onLoad` executes in Vuejs because it relies
@@ -389,14 +389,14 @@ module Glib::Json::Libs
389
389
  view: 'button',
390
390
  text: 'Refresh',
391
391
  onClick: {
392
- action: 'auth/saveCsrfToken',
392
+ action: Glib::JsonUi::Name.for('auth/saveCsrfToken'),
393
393
  token: form_authenticity_token,
394
394
  onSave: {
395
- action: 'dialogs/close',
395
+ action: Glib::JsonUi::Name.for('dialogs/close'),
396
396
  onClose: {
397
- action: 'windows/reload',
397
+ action: Glib::JsonUi::Name.for('windows/reload'),
398
398
  onReload: {
399
- action: 'snackbars/alert',
399
+ action: Glib::JsonUi::Name.for('snackbars/alert'),
400
400
  message: 'Refreshed. You can try again now.'
401
401
  }
402
402
  }
@@ -82,7 +82,7 @@ module Glib::Json::Traversal
82
82
  def traverse_multiple(views, block)
83
83
  if views.is_a? Array
84
84
  views.each do |view|
85
- case view['view']
85
+ case Glib::JsonUi::Name.normalize(view['view'])
86
86
  when 'panels/form'
87
87
  @forms << view
88
88
  traverse_single view, block
@@ -54,7 +54,7 @@ module Glib
54
54
  chat_params = params[:chat]
55
55
 
56
56
  payload = {
57
- action: 'lists/append',
57
+ action: Glib::JsonUi::Name.for('lists/append'),
58
58
  targetId: 'list-1',
59
59
  row: {
60
60
  template: 'thumbnail', title: chat_params[:user], subtitle: chat_params[:body]
@@ -15,7 +15,7 @@ module Glib
15
15
 
16
16
  def add_singleton_element(type, name, *args)
17
17
  name_components = name.to_s.split('_')
18
- json.set! type, "#{name_components.join('/')}" if type
18
+ json.set! type, Name.join(name_components) if type
19
19
  args = [yield] if block_given?
20
20
 
21
21
  # TODO: consider caching these instances
@@ -264,7 +264,7 @@ module Glib
264
264
  panel_options = options.slice!(:url, :method, :local, :onSubmit, :paramNameForFormData, :model, :onChange, :onChangeAndLoad, :autoValidate, :disableDirtyCheck)
265
265
 
266
266
  json.fullPageForm do
267
- json.view 'panels/fullPageForm'
267
+ json.view Name.for('panels/fullPageForm')
268
268
  form.props(options)
269
269
  end
270
270
 
@@ -294,7 +294,7 @@ class Glib::JsonUi::ViewBuilder
294
294
  json.childViews do
295
295
  if @method != :get
296
296
  json.child! do
297
- json.view 'fields/hidden'
297
+ json.view Glib::JsonUi::Name.for('fields/hidden')
298
298
  json.name 'authenticity_token'
299
299
  json.value page.context.form_authenticity_token
300
300
  end
@@ -606,6 +606,116 @@ class Glib::JsonUi::ViewBuilder
606
606
 
607
607
  end
608
608
 
609
+ # The generic bulk-edit table: bulkEdit2's skeleton plus everything
610
+ # bulkEditSg hard-coded for the Surfguard imports, expressed as config so
611
+ # any consumer can compose the same behavior (bulkEditSg's replacement --
612
+ # see the component's header comment for the full mapping).
613
+ class BulkEdit3 < View
614
+ hash :import, required: [:submitUrl, :paramName]
615
+
616
+ # Override
617
+ def created
618
+ # Same form-peer rule as bulkEdit2 (see its comment).
619
+ if page.current_form
620
+ raise 'panels_bulkEdit3 is not allowed inside a form; place it as a sibling and target it with forms_submit targetId'
621
+ end
622
+ end
623
+
624
+ # Maximum number of data rows an uploaded file may contain; files with
625
+ # more rows are rejected whole. No limit when unset.
626
+ int :maxRows
627
+
628
+ array :viewHeaders
629
+
630
+ string :paramNameForRowId
631
+
632
+ action :onCellChange
633
+ action :onRowsLoad
634
+ action :onRowsSelect
635
+ action :onRowsSucceed
636
+ # Fires when parked rows drain out of the table on save (they never
637
+ # POST), so the page can keep its Loaded/Uploaded/Skipped arithmetic
638
+ # honest.
639
+ action :onRowsSkip
640
+ action :onSubmitStart
641
+ action :onSubmitEnd
642
+
643
+ # The file shape. Without it the table takes plain CSVs (header row
644
+ # first, header names = field ids -- the bulkEdit2 shape):
645
+ # detectHeader: { allOf: [...], anyOf: [...], scanRows: N } -- find
646
+ # the header row BY CONTENT (report preambles survive);
647
+ # headerMap: { csvHeader => field } -- header-keyed, order-independent
648
+ # mapping (unknown columns ignored);
649
+ # requiredHeaders: [...] -- named rejection when missing;
650
+ # rejectIf: [{ when: <JsonLogic over {headers, rowCount}>, error: }]
651
+ # -- named wrong-file rejections, first match wins;
652
+ # transforms: { field => ['unguard', 'date', 'lowercase', 'trim',
653
+ # 'coalesce:<other_field>' (fills an empty self), 'prefer:<other_field>'
654
+ # (the other field wins when present)] };
655
+ # derived: { field => <JsonLogic over the mapped values> };
656
+ # uniqueBy: { field:, error: } -- duplicate-value rejection;
657
+ # errors: { headerNotFound:, missingHeaders:, tooManyRows:,
658
+ # noDataRows:, duplicates:, unreadable:, readFailure: } -- copy
659
+ # templates ({rowCount} {maxRows} {headers} {values});
660
+ # dropzone: { title:, subtitle: }; accept: '.csv'.
661
+ hash :parse
662
+
663
+ # Ordered row-eligibility rules: the first JsonLogic `when` that matches
664
+ # parks the row (unticked, disabled checkbox, note under the note
665
+ # column, warning-icon tooltip). note/noteDetail/checkboxTitle are
666
+ # strings or JsonLogic. Editing a cell re-evaluates the gates and can
667
+ # revive a parked row.
668
+ array :rowGates
669
+
670
+ # Newest-row-wins: marks superseded rows (older rows per groupBy) with a
671
+ # `superseded` var that rowGates can test and order against.
672
+ # dedup: { groupBy: <JsonLogic>, newestBy: [date fields in priority] }
673
+ hash :dedup
674
+
675
+ # Referenced rows submit last (one level): rows whose `reference` value
676
+ # (e.g. a guardian email) matches another selected row's `target` value
677
+ # (e.g. a member email) go after theirs.
678
+ # submitOrder: { reference: <JsonLogic>, target: <JsonLogic> }
679
+ hash :submitOrder
680
+
681
+ # File-level notes rendered after load: [{ when: <JsonLogic over
682
+ # {headers, rowCount, rows}>, text: string-or-JsonLogic, style:
683
+ # 'info'|'warning' }].
684
+ array :fileNotes
685
+
686
+ # Labels for the visibility chips (rendered when the file left rows
687
+ # out). Defaults: 'To import' / 'Left out'.
688
+ hash :viewChips
689
+
690
+ # Rendered when the included view is empty and rows were left out.
691
+ string :emptyNote
692
+
693
+ # The column whose cell carries a parked row's note (default: the first
694
+ # viewHeader's id).
695
+ string :noteColumn
696
+
697
+ # Constants merged into every rule context (row gates, derived fields,
698
+ # file notes) at the lowest precedence -- e.g. an award-spelling map.
699
+ hash :variables
700
+
701
+ def viewCells(values)
702
+ json.set! 'viewCells' do
703
+ values.each do |value|
704
+ value.call(page.view_builder)
705
+ end
706
+ end
707
+ end
708
+
709
+ def dataRows(value)
710
+ json.dataRows value
711
+ end
712
+
713
+ def viewRows(value)
714
+ json.viewRows value
715
+ end
716
+
717
+ end
718
+
609
719
  # deprecated
610
720
  class BulkEdit < View
611
721
  hash :nextPage
@@ -0,0 +1,279 @@
1
+ json.title 'Test Page (Panels Bulk Edit 3)'
2
+
3
+ page = json_ui_page json
4
+
5
+ render 'json_ui/garage/test_page/header', json: json, page: page
6
+
7
+ # A report-shaped file: preamble rows before the header, apostrophe-guarded
8
+ # IDs, mixed date formats, a Status column and rows with no email -- enough to
9
+ # exercise header detection, headerMap, transforms, gates and the chips.
10
+ report_header_map = {
11
+ 'Member ID' => :sg_id,
12
+ 'First Name' => :given_name,
13
+ 'Last Name' => :family_name,
14
+ 'Email Address 1' => :email,
15
+ 'Status' => :status,
16
+ 'Member Since' => :member_since
17
+ }
18
+
19
+ # Ordered eligibility rules: first match parks the row. Interpolated copy via
20
+ # printf, static copy as strings.
21
+ report_row_gates = [
22
+ {
23
+ when: { '!=' => [{ 'var' => 'status' }, 'Active'] },
24
+ note: { 'printf' => ['Status: {0}', { 'var' => 'status' }] },
25
+ checkboxTitle: { 'printf' => ['Status is {0}: only Active members are imported', { 'var' => 'status' }] }
26
+ },
27
+ {
28
+ when: { '!' => { 'or' => [{ 'isPresent' => [{ 'var' => 'email' }] }] } },
29
+ note: 'No email in this file',
30
+ noteDetail: 'This row has no email address, so there is no way to reach the member. Record one in the source system and re-export.'
31
+ }
32
+ ]
33
+
34
+ plain_headers = [
35
+ { text: 'Month', id: 'month', importable: true, tooltip: { text: 'Month', placement: 'top' } },
36
+ { text: 'Electricity usage', id: 'electricity_usage', importable: true },
37
+ { text: 'Gas usage', id: 'gas_usage', importable: true }
38
+ ]
39
+
40
+ plain_cells = [
41
+ ->(view) do
42
+ view.fields_text styleClass: 'compact', width: 'matchParent'
43
+ end,
44
+ ->(view) do
45
+ view.fields_number styleClass: 'compact', width: 'matchParent'
46
+ end,
47
+ ->(view) do
48
+ view.fields_number styleClass: 'compact', width: 'matchParent'
49
+ end
50
+ ]
51
+
52
+ report_headers = [
53
+ { text: 'Name', id: 'display_name', readOnly: true },
54
+ { text: 'Source ID', id: 'sg_id', readOnly: true },
55
+ { text: 'Email', id: 'email', importable: true },
56
+ { text: 'Status', id: 'status', readOnly: true },
57
+ { text: 'Member Since', id: 'member_since', readOnly: true }
58
+ ]
59
+
60
+ report_cells = [
61
+ ->(view) do
62
+ view.label
63
+ end,
64
+ ->(view) do
65
+ view.label
66
+ end,
67
+ ->(view) do
68
+ view.fields_email styleClass: 'compact', width: 'matchParent'
69
+ end,
70
+ ->(view) do
71
+ view.label
72
+ end,
73
+ ->(view) do
74
+ view.label
75
+ end
76
+ ]
77
+
78
+ import_settings = { submitUrl: json_ui_garage_url(path: 'forms/bulk_edit_post2'), paramName: 'user' }
79
+
80
+ page.body(
81
+ childViews: ->(body) do
82
+ body.panels_responsive(
83
+ padding: glib_json_padding_body,
84
+ childViews: ->(res) do
85
+ res.h2 text: 'Overview'
86
+ res.p text: 'panels_bulkEdit3 is the generic bulk-edit table: bulkEdit2’s skeleton plus config-driven file parsing, row gates, read-only columns, view chips and submission extras.'
87
+ res.spacer height: 12
88
+ res.hr width: 'matchParent'
89
+
90
+ res.h2 text: 'Basic example (plain CSV)'
91
+ res.spacer height: 8
92
+ res.p text: 'Without parse config the table takes plain CSVs: header row first, header names as field ids (the bulkEdit2 shape). maxRows 3 rejects oversized files whole.'
93
+ res.spacer height: 8
94
+ res.panels_horizontal(
95
+ align: 'middle',
96
+ childViews: ->(horizontal) do
97
+ horizontal.label text: 'Loaded: '
98
+ horizontal.label id: 'bulk_edit3_loaded_count', text: '0'
99
+ horizontal.spacer width: 12
100
+ horizontal.label text: 'Selected: '
101
+ horizontal.label id: 'bulk_edit3_selected_count', text: '0'
102
+ end
103
+ )
104
+ res.spacer height: 8
105
+ res.panels_bulkEdit3(
106
+ id: 'bulk_edit3_plain',
107
+ width: 'matchParent',
108
+ paramNameForRowId: 'row_id',
109
+ maxRows: 3,
110
+ import: import_settings,
111
+ viewHeaders: plain_headers,
112
+ viewCells: plain_cells,
113
+ onRowsLoad: ->(action) do
114
+ action.runMultiple(
115
+ fieldName: 'data',
116
+ childActions: ->(multiple) do
117
+ multiple.logics_set(
118
+ targetId: 'bulk_edit3_loaded_count',
119
+ paramName: 'variables',
120
+ fieldName: 'data',
121
+ conditionalData: {
122
+ text: { 'count': [{ 'var': 'data.loadedRows' }] }
123
+ }
124
+ )
125
+ end
126
+ )
127
+ end,
128
+ onRowsSelect: ->(action) do
129
+ action.logics_set(
130
+ targetId: 'bulk_edit3_selected_count',
131
+ paramName: 'variables',
132
+ fieldName: 'data',
133
+ conditionalData: {
134
+ text: { 'count': [{ 'var': 'data.selectedRows' }] }
135
+ }
136
+ )
137
+ end
138
+ )
139
+ res.spacer height: 12
140
+ res.hr width: 'matchParent'
141
+
142
+ res.h2 text: 'Report-shaped file (parse config + row gates)'
143
+ res.spacer height: 8
144
+ res.markdown text: 'The same component with `parse` config: the header row is found by content (preamble rows survive), CSV headers map to fields, IDs are apostrophe-unwrapped, dates normalize, and the first matching row gate parks the row (unticked, disabled checkbox, note under the first column). Parked rows drain into Skipped on save.'
145
+ res.spacer height: 8
146
+ res.panels_horizontal(
147
+ align: 'middle',
148
+ childViews: ->(horizontal) do
149
+ horizontal.label text: 'Loaded: '
150
+ horizontal.label id: 'bulk_edit3_report_loaded_count', text: '0'
151
+ horizontal.spacer width: 12
152
+ horizontal.label text: 'Selected: '
153
+ horizontal.label id: 'bulk_edit3_report_selected_count', text: '0'
154
+ horizontal.panels_horizontal id: 'bulk_edit3_skipped_counter', displayed: false, childViews: ->(counter) do
155
+ counter.label text: ' - '
156
+ counter.chip id: 'bulk_edit3_skipped_count', text: 'Skipped: 0'
157
+ end
158
+ end
159
+ )
160
+ res.spacer height: 8
161
+ res.panels_bulkEdit3(
162
+ id: 'bulk_edit3_report',
163
+ width: 'matchParent',
164
+ paramNameForRowId: 'row_id',
165
+ maxRows: 100,
166
+ parse: {
167
+ headerMap: report_header_map,
168
+ requiredHeaders: ['Member ID', 'First Name', 'Status'],
169
+ detectHeader: {
170
+ allOf: ['Member ID'],
171
+ anyOf: ['Status', 'Email Address 1'],
172
+ scanRows: 12
173
+ },
174
+ transforms: {
175
+ sg_id: ['unguard'],
176
+ member_since: ['date'],
177
+ email: ['lowercase']
178
+ },
179
+ derived: {
180
+ display_name: { 'joinNonEmpty': [' ', { 'var': 'given_name' }, { 'var': 'family_name' }] }
181
+ },
182
+ uniqueBy: {
183
+ field: 'sg_id',
184
+ error: 'This file contains duplicate Member IDs ({values}). Each member row must have a unique Member ID.'
185
+ },
186
+ errors: {
187
+ headerNotFound: "This doesn't look like the expected export. Couldn't find the column headers."
188
+ }
189
+ },
190
+ rowGates: report_row_gates,
191
+ fileNotes: [
192
+ {
193
+ when: { '<': [0, { 'count': [{ 'filter': [{ 'var': 'rows' }, { '!=': [{ 'var': 'status' }, 'Active'] }] }] }] },
194
+ text: { 'printf': ['Heads up: {0} member(s) in this file are not Active.', { 'count': [{ 'filter': [{ 'var': 'rows' }, { '!=': [{ 'var': 'status' }, 'Active'] }] }] }] },
195
+ style: 'info'
196
+ }
197
+ ],
198
+ import: {
199
+ submitUrl: json_ui_garage_url(path: 'forms/bulk_edit_post2'),
200
+ paramName: 'member_import',
201
+ rowFields: ['sg_id', 'status']
202
+ },
203
+ viewHeaders: report_headers,
204
+ viewCells: report_cells,
205
+ onRowsLoad: ->(action) do
206
+ action.runMultiple(
207
+ fieldName: 'data',
208
+ childActions: ->(multiple) do
209
+ multiple.logics_set(
210
+ targetId: 'bulk_edit3_report_loaded_count',
211
+ paramName: 'variables',
212
+ fieldName: 'data',
213
+ conditionalData: {
214
+ text: { 'count': [{ 'var': 'data.loadedRows' }] }
215
+ }
216
+ )
217
+ end
218
+ )
219
+ end,
220
+ onRowsSelect: ->(action) do
221
+ action.runMultiple(
222
+ fieldName: 'data',
223
+ childActions: ->(multiple) do
224
+ multiple.logics_set(
225
+ targetId: 'bulk_edit3_report_selected_count',
226
+ paramName: 'variables',
227
+ fieldName: 'data',
228
+ conditionalData: {
229
+ text: { 'count': [{ 'var': 'data.selectedRows' }] }
230
+ }
231
+ )
232
+ end
233
+ )
234
+ end,
235
+ onRowsSkip: ->(action) do
236
+ action.runMultiple(
237
+ fieldName: 'data',
238
+ childActions: ->(multiple) do
239
+ multiple.components_set targetId: 'bulk_edit3_skipped_counter', data: { displayed: true }
240
+ multiple.logics_set(
241
+ targetId: 'bulk_edit3_skipped_count',
242
+ paramName: 'variables',
243
+ fieldName: 'data',
244
+ conditionalData: {
245
+ text: {
246
+ 'printf': ['Skipped: {0}', { 'count': [{ 'var': 'data.skippedRows' }] }]
247
+ }
248
+ }
249
+ )
250
+ end
251
+ )
252
+ end
253
+ )
254
+ res.spacer height: 12
255
+ res.hr width: 'matchParent'
256
+ res.h2 text: 'Actions/events'
257
+ res.spacer height: 8
258
+ res.panels_flow(
259
+ innerPadding: { bottom: 0 },
260
+ childViews: ->(flow) do
261
+ flow.button(
262
+ text: 'Submit (forms_submit)',
263
+ onClick: ->(action) do
264
+ action.forms_submit targetId: 'bulk_edit3_plain'
265
+ end
266
+ )
267
+ flow.spacer width: 8
268
+ flow.button(
269
+ text: 'Start over (invoke)',
270
+ onClick: ->(action) do
271
+ action.components_invoke targetId: 'bulk_edit3_report', name: 'startOver'
272
+ end
273
+ )
274
+ end
275
+ )
276
+ end
277
+ )
278
+ end
279
+ )
@@ -4,7 +4,7 @@ module Glib
4
4
  def initialize(method, http, args, controller)
5
5
  @http = http
6
6
  json = @http.send(method, args['url'], controller, args.fetch('formData', {}))
7
- @http.router.http_actions.add([args['action'], args['url'], args.fetch('formData', {})])
7
+ @http.router.http_actions.add([Glib::JsonUi::Name.normalize(args['action']), args['url'], args.fetch('formData', {})])
8
8
  perform(json['onResponse'])
9
9
  end
10
10
  end
@@ -15,8 +15,8 @@ module Glib
15
15
 
16
16
  raise "Submit action needs to be inside a form: #{http.router.last_log}" unless form
17
17
 
18
- case form['view']
19
- when 'panels/bulkEdit2'
18
+ case Glib::JsonUi::Name.normalize(form['view'])
19
+ when 'panels/bulkEdit2', 'panels/bulkEdit3'
20
20
  method = 'post'
21
21
  action = "forms/#{method}"
22
22
  url = form['import']['submitUrl']
@@ -43,7 +43,7 @@ module Glib
43
43
  child_views = view['childViews'] || []
44
44
 
45
45
  # If this form is page level, we need to check the child views of the entire page.
46
- if view['view'] == 'panels/fullPageForm'
46
+ if Glib::JsonUi::Name.eq?(view['view'], 'panels/fullPageForm')
47
47
  child_views += @http.router.page_spec['header']&.[]('childViews') || []
48
48
  child_views += @http.router.page_spec['body']&.[]('childViews') || []
49
49
  child_views += @http.router.page_spec['footer']&.[]('childViews') || []
@@ -52,7 +52,7 @@ module Glib
52
52
  @http.router.crawl_multiple child_views, ->(child) do
53
53
  next if child['template'].present?
54
54
 
55
- name = child['view'] || ''
55
+ name = Glib::JsonUi::Name.normalize(child['view']) || ''
56
56
  if name.start_with?('fields/')
57
57
  fields << child
58
58
 
@@ -66,7 +66,7 @@ module Glib
66
66
 
67
67
  def step(http, args)
68
68
  # TODO: Refactor
69
- case args['view']
69
+ case Glib::JsonUi::Name.normalize(args['view'])
70
70
  when 'fields/submit'
71
71
  @depth += 1
72
72
  # forms = @visitor.forms
@@ -98,7 +98,10 @@ module Glib
98
98
  end
99
99
 
100
100
  def process_action(http, spec)
101
- action = spec&.fetch('action')
101
+ # Normalize to the canonical slash form so the crawler works against apps
102
+ # configured with either separator, and so recorded/logged action names
103
+ # stay stable (permission snapshots compare them).
104
+ action = Glib::JsonUi::Name.normalize(spec&.fetch('action'))
102
105
  params = spec
103
106
 
104
107
  assert_target_ids_exist(params)
@@ -304,7 +307,7 @@ module Glib
304
307
  http_actions.each do |http_action|
305
308
  next if http_action[1].blank?
306
309
  distance = ::DamerauLevenshtein.distance(http_action[1], params['url'])
307
- result = (distance <= 1) && (http_action[0] == params['action'])
310
+ result = (distance <= 1) && (http_action[0] == Glib::JsonUi::Name.normalize(params['action']))
308
311
 
309
312
  break if result
310
313
  end
@@ -0,0 +1,40 @@
1
+ module Glib
2
+ module JsonUi
3
+ # Global JSON UI settings.
4
+ #
5
+ # `name_separator` controls the character that joins the namespace and the
6
+ # component/action name in the generated JSON, e.g. `panels/responsive`
7
+ # (default) vs `panels_responsive`.
8
+ #
9
+ # The default stays `/` for backwards compatibility; `_` becomes the default
10
+ # in the next major version. Apps opt in early via an initializer:
11
+ #
12
+ # # config/initializers/glib.rb
13
+ # Glib::JsonUi::Config.name_separator = '_'
14
+ #
15
+ # Frontends built on glib-web-npm accept either form, so flipping this is a
16
+ # server-side-only change.
17
+ class Config
18
+ SEPARATORS = ['/', '_'].freeze
19
+
20
+ # The shipped default. Stays '/' until the next major version, when it
21
+ # becomes '_'. Apps opt in early via `Config.name_separator = '_'`.
22
+ DEFAULT_NAME_SEPARATOR = '/'
23
+
24
+ @@name_separator = DEFAULT_NAME_SEPARATOR
25
+
26
+ def self.name_separator
27
+ @@name_separator
28
+ end
29
+
30
+ def self.name_separator=(value)
31
+ value = value.to_s
32
+ unless SEPARATORS.include?(value)
33
+ raise ArgumentError, "Invalid name separator: #{value.inspect}. Allowed: #{SEPARATORS.map(&:inspect).join(', ')}"
34
+ end
35
+
36
+ @@name_separator = value
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,53 @@
1
+ module Glib
2
+ module JsonUi
3
+ # Helpers for the namespaced names that appear in the generated JSON as
4
+ # `view`, `action` and `template` values.
5
+ #
6
+ # Names are always written in canonical form in this codebase (`panels/form`)
7
+ # regardless of what `Config.name_separator` is set to:
8
+ #
9
+ # - Producing: run the canonical literal through `Name.for` (or build it
10
+ # from parts with `Name.join`) so it is emitted using the configured
11
+ # separator.
12
+ # - Consuming: compare with `Name.eq?` / `Name.start_with?`, which accept
13
+ # either separator. Reads are deliberately separator-agnostic so tools
14
+ # like the crawler work against any app, and against fixtures recorded
15
+ # before the switch.
16
+ module Name
17
+ class << self
18
+ # 'windows/reload' -> 'windows/reload' or 'windows_reload'
19
+ def for(canonical)
20
+ return canonical unless canonical.is_a?(String)
21
+
22
+ canonical.tr('/', Config.name_separator)
23
+ end
24
+
25
+ # ['windows', 'reload'] -> 'windows/reload' or 'windows_reload'
26
+ def join(components)
27
+ components.join(Config.name_separator)
28
+ end
29
+
30
+ # Rewrites a name coming from JSON into canonical (slash) form. Only the
31
+ # first separator is rewritten: the second part may itself be camelCase
32
+ # but never contains a separator (e.g. `storageItems_set`).
33
+ def normalize(value)
34
+ return value unless value.is_a?(String)
35
+
36
+ value.sub('_', '/')
37
+ end
38
+
39
+ # Either side may use either separator.
40
+ def eq?(value, canonical)
41
+ normalize(value) == normalize(canonical)
42
+ end
43
+
44
+ def start_with?(value, *canonicals)
45
+ normalized = normalize(value)
46
+ return false unless normalized.is_a?(String)
47
+
48
+ canonicals.any? { |canonical| normalized.start_with?(normalize(canonical)) }
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -229,12 +229,14 @@ module Glib
229
229
  end
230
230
 
231
231
  def __view_is_form?(view)
232
- view.is_a?(Hash) && (
233
- view['view']&.start_with?('panels/form') ||
234
- view['view']&.start_with?('panels/fullPageForm') ||
235
- view['view'] == 'panels/bulkEdit2' ||
236
- view['view'] == 'panels/splitForm'
237
- )
232
+ return false unless view.is_a?(Hash)
233
+
234
+ name = Glib::JsonUi::Name.normalize(view['view'])
235
+ name&.start_with?('panels/form') ||
236
+ name&.start_with?('panels/fullPageForm') ||
237
+ name == 'panels/bulkEdit2' ||
238
+ name == 'panels/bulkEdit3' ||
239
+ name == 'panels/splitForm'
238
240
  end
239
241
 
240
242
  def __build_form_params(form, data)
@@ -247,7 +249,7 @@ module Glib
247
249
  data_prefixes = data.keys.map { |k| "#{k}[" }
248
250
 
249
251
  __extract_form_fields(form).each do |field|
250
- next unless field[:view] == 'fields/hidden'
252
+ next unless Glib::JsonUi::Name.eq?(field[:view], 'fields/hidden')
251
253
  next if data_prefixes.any? { |prefix| field[:name]&.start_with?(prefix) }
252
254
 
253
255
  params[field[:name]] = field[:value]
@@ -268,7 +270,7 @@ module Glib
268
270
  def __extract_form_fields(form)
269
271
  fields = []
270
272
  __traverse_form_views(form['childViews'] || []) do |view|
271
- if view.is_a?(Hash) && view['view']&.start_with?('fields/')
273
+ if view.is_a?(Hash) && Glib::JsonUi::Name.start_with?(view['view'], 'fields/')
272
274
  fields << { name: view['name'], value: view['value'], view: view['view'] }
273
275
  end
274
276
  end
data/lib/glib-web.rb CHANGED
@@ -5,6 +5,11 @@ if defined?(::Rails)
5
5
  require 'glib/snapshot'
6
6
  end
7
7
  require 'glib/value'
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.
11
+ require 'glib/json_ui/config'
12
+ require 'glib/json_ui/name'
8
13
  require 'glib/json_crawler'
9
14
 
10
15
  require 'glib/dynamic_text'
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.7.0
4
+ version: 6.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-08-22 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: []
@@ -476,6 +478,7 @@ files:
476
478
  - app/views/json_ui/garage/test_page/pagination.json.jbuilder
477
479
  - app/views/json_ui/garage/test_page/panels.json.jbuilder
478
480
  - app/views/json_ui/garage/test_page/panels_bulkEdit2.json.jbuilder
481
+ - app/views/json_ui/garage/test_page/panels_bulkEdit3.json.jbuilder
479
482
  - app/views/json_ui/garage/test_page/popovers.json.jbuilder
480
483
  - app/views/json_ui/garage/test_page/progressCircle.json.jbuilder
481
484
  - app/views/json_ui/garage/test_page/responsive.json.jbuilder
@@ -558,6 +561,8 @@ files:
558
561
  - lib/glib/json_crawler/coverage.rb
559
562
  - lib/glib/json_crawler/http.rb
560
563
  - lib/glib/json_crawler/router.rb
564
+ - lib/glib/json_ui/config.rb
565
+ - lib/glib/json_ui/name.rb
561
566
  - lib/glib/last_resort_unit_test.rb
562
567
  - lib/glib/mailer_tester.rb
563
568
  - lib/glib/non_http_integration_test.rb
@@ -575,8 +580,10 @@ files:
575
580
  - lib/glib/time_returning_mailer.rb
576
581
  - lib/glib/value.rb
577
582
  - lib/tasks/db.rake
583
+ homepage:
578
584
  licenses: []
579
585
  metadata: {}
586
+ post_install_message:
580
587
  rdoc_options: []
581
588
  require_paths:
582
589
  - lib
@@ -591,7 +598,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
591
598
  - !ruby/object:Gem::Version
592
599
  version: '0'
593
600
  requirements: []
594
- rubygems_version: 4.0.6
601
+ rubygems_version: 3.4.6
602
+ signing_key:
595
603
  specification_version: 4
596
604
  summary: ''
597
605
  test_files: []