fe 2.4.0 → 2.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: e5901527c0d559c346f9b5260706bae3c5dca50aa53acad6e3389a9953ad3258
4
- data.tar.gz: 6ce8e73924606203040a84222cf1ff2127855dff223331b9f83524a91ee8effb
3
+ metadata.gz: 07eacfa22a5212645158341f32b217501965ae360aa06b6e68b37ad4aee69431
4
+ data.tar.gz: 53552e5f2241cefc323121123da3778bd372cfe1ad65eb167173af4fa37c5bd8
5
5
  SHA512:
6
- metadata.gz: 2d944254d553ae54b9ac39016114c797a2176fcdedaee5543e50da0fa9dc6162a81087df058f893900194c963c321141c63de5c21d3e25c3c74a103a263a7bc5
7
- data.tar.gz: 120a3a78d3a8b443ca1c2cf9f43c36166e0883dfd8d075cc61655637053d02b4600100c9bcdce96a6102fe5fa40ca0e4c7ca150c13ba342234027aa10fe77c70
6
+ metadata.gz: 43ec7038e4d46ee2fcd3937685a877b8ff8bb3145a3a80957942313c3522c4d16000c60efda5b7f912db8fe6b73af39e280499904756dc5bfae08ee9b56f2fb5
7
+ data.tar.gz: '0854508da501735e100982d86842624bb71f634ec91b1736f0f687c34bfbd98b18b8b56ceacb300522ed53769c46066811fe1c4a5e4e4b94e13436747bdd8877'
data/README.md CHANGED
@@ -202,6 +202,7 @@ sends 403. Exceptions raised in your hooks are not rescued.
202
202
 
203
203
  ### 2.4
204
204
 
205
+ - **2.4.1**: Fixes to overwrite protection. The first save after a page loads is now checked in apps that load jQuery and fe's JavaScript as modules (importmap); before, the inline script that records the page's starting answers threw `$ is not defined`. Questionnaire edit pages now tell Turbo not to cache them, so Back loads the current answers instead of a copy whose old digest made the next save fail with a false conflict. A form Turbo shows without a full page load no longer keeps the last form's autosave timer or conflict lock. Answering a question that reveals another page no longer turns off autosave for the current page or makes Next skip saving it, which lost what was typed on that page afterward. Apps that keep their own copy of `fe.public.nojquery.js` should copy it again to get the last two fixes.
205
206
  - **Deny by default**: fe refuses answer sheets and its admin screens unless the host app allows them, and the app won't boot until it defines fe's access hooks. See [Upgrading to 2.4](#upgrading-to-24).
206
207
 
207
208
  ### 2.2
@@ -37,7 +37,9 @@ fe.pageHandler = {
37
37
 
38
38
  initialize : function(page) {
39
39
  this.auto_save_frequency = 30; // seconds
40
- this.timer_id = null;
40
+ // A form Turbo shows without a full page load reuses this object, so the last
41
+ // form's conflict lock must not carry over to it.
42
+ this._locked = false;
41
43
 
42
44
  this.current_page = page;
43
45
  $('#' + page).data('form_data', this.captureForm($('#' + page)));
@@ -153,19 +155,24 @@ fe.pageHandler = {
153
155
  return;
154
156
  }
155
157
 
156
- this.unregisterAutoSave(); // don't auto-save while loading/saving
157
- // will register auto-save on new page once loaded/shown
158
-
159
- // Only save the current page if we haven't already saved it during navigation.
160
- // Rapid Next/Prev clicks can call loadPage multiple times while current_page
161
- // is still the old page — the second save would have a stale digest.
162
- let current = $('#' + this.current_page);
163
- if (!current.data('saved_on_leave')) {
164
- current.data('saved_on_leave', true);
165
- fe.debugLog('[fe concurrency] loadPage calling savePage for ' + this.current_page);
166
- this.savePage();
167
- } else {
168
- fe.debugLog('[fe concurrency] loadPage: already saved ' + this.current_page + ' on leave, skipping');
158
+ // A background load (a conditional revealing another page) stays on the current
159
+ // page, so it must not stop autosave or mark the page as saved on leave; nothing
160
+ // would undo either, and Next would then skip saving what was typed after it.
161
+ if (!background_load) {
162
+ this.unregisterAutoSave(); // don't auto-save while loading/saving
163
+ // will register auto-save on new page once loaded/shown
164
+
165
+ // Only save the current page if we haven't already saved it during navigation.
166
+ // Rapid Next/Prev clicks can call loadPage multiple times while current_page
167
+ // is still the old page, and the second save would have a stale digest.
168
+ let current = $('#' + this.current_page);
169
+ if (!current.data('saved_on_leave')) {
170
+ current.data('saved_on_leave', true);
171
+ fe.debugLog('[fe concurrency] loadPage calling savePage for ' + this.current_page);
172
+ this.savePage();
173
+ } else {
174
+ fe.debugLog('[fe concurrency] loadPage: already saved ' + this.current_page + ' on leave, skipping');
175
+ }
169
176
  }
170
177
 
171
178
  if (!background_load) {
@@ -296,6 +303,7 @@ fe.pageHandler = {
296
303
 
297
304
  // setup a timer to auto-save (only one timer, for the page being viewed)
298
305
  registerAutoSave: function(page) {
306
+ this.unregisterAutoSave(); // one timer at a time; a second load of the form must not leave an extra one running
299
307
  this.timer_id = setInterval(this.savePages, this.auto_save_frequency * 1000);
300
308
  },
301
309
 
@@ -113,16 +113,33 @@ module Fe::AnswerSheetsControllerConcern
113
113
  return true
114
114
  end
115
115
 
116
- # Inject the initial answers digest into the page as a <script> tag.
117
- # This runs regardless of which views the host app overrides.
116
+ # Inject the initial answers digest into the page as a <script> tag, and keep the
117
+ # page out of Turbo's cache. This runs regardless of which views the host app overrides.
118
118
  def inject_answers_digest_script
119
119
  return unless @answers_digest.present? && @presenter&.active_page_link
120
120
  return unless response.content_type&.include?('text/html')
121
121
 
122
- page_dom = @presenter.active_page_link.dom_id
122
+ page_selector = "##{@presenter.active_page_link.dom_id}".to_json
123
+ digest = @answers_digest.to_json
123
124
  debug_js = Fe.verbose_js_logging? ? "fe._verboseLogging = true; " : ""
124
- script = "<script>$(function(){ #{debug_js}$('##{page_dom}').data('answers_digest', '#{@answers_digest}'); " \
125
- "fe.debugLog('[fe concurrency] initial digest set via after_action: #{@answers_digest}'); });</script>"
126
- response.body = response.body.sub('</body>', "#{script}</body>")
125
+ # Hosts that load jQuery and fe's JS as modules (importmap) run them after this
126
+ # script, so while the page is still loading it waits for DOMContentLoaded, which
127
+ # fires after those modules.
128
+ script = <<~HTML
129
+ <script>(function(){
130
+ var tries = 0;
131
+ var run = function(){
132
+ if (!window.jQuery) { if (++tries < 100) { setTimeout(run, 50); } return; }
133
+ jQuery(#{page_selector}).data('answers_digest', #{digest});
134
+ if (window.fe) { #{debug_js}fe.debugLog('[fe concurrency] initial digest set via after_action: ' + #{digest}); }
135
+ };
136
+ if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', run); } else { run(); }
137
+ })();</script>
138
+ HTML
139
+ # A copy Turbo restored on Back would carry the answers and digest from when the
140
+ # page was left, so a later save could get a false conflict. Back loads it fresh.
141
+ response.body = response.body
142
+ .sub('</head>', %(<meta name="turbo-cache-control" content="no-cache"></head>))
143
+ .sub('</body>', "#{script}</body>")
127
144
  end
128
145
  end
data/lib/fe/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Fe
2
- VERSION = "2.4.0"
2
+ VERSION = "2.4.1"
3
3
  end
@@ -4,6 +4,10 @@ describe Fe::AnswerSheetsController, type: :controller do
4
4
  let(:question_sheet) { create(:question_sheet) }
5
5
  let(:answer_sheet) { create(:answer_sheet, applicant_id: create(:fe_person).id) }
6
6
 
7
+ def digest_script(body)
8
+ body.scan(%r{<script>(.*?)</script>}m).flatten.find { |script| script.include?('answers_digest') }
9
+ end
10
+
7
11
  context 'access control' do
8
12
  before do
9
13
  create(:answer_sheet_question_sheet, question_sheet: question_sheet, answer_sheet: answer_sheet)
@@ -143,6 +147,70 @@ describe Fe::AnswerSheetsController, type: :controller do
143
147
  expect(assigns(:elements)).to eq([])
144
148
  expect(assigns(:page)).to eq(page)
145
149
  end
150
+ it 'adds the initial answers digest script without calling jQuery first' do
151
+ create(:answer_sheet_question_sheet, question_sheet: question_sheet, answer_sheet: answer_sheet)
152
+ create(:page, question_sheet: question_sheet)
153
+
154
+ get :edit, params: {id: answer_sheet.id}
155
+
156
+ script = digest_script(response.body)
157
+ expect(script).to include(assigns(:answers_digest))
158
+ expect(script).not_to match(/\A\s*\$/)
159
+ end
160
+ it 'keeps the page out of the Turbo cache, so Back loads its current answers and digest' do
161
+ create(:answer_sheet_question_sheet, question_sheet: question_sheet, answer_sheet: answer_sheet)
162
+ create(:page, question_sheet: question_sheet)
163
+
164
+ get :edit, params: {id: answer_sheet.id}
165
+
166
+ head = response.body[%r{<head>.*</head>}m]
167
+ expect(head).to include('<meta name="turbo-cache-control" content="no-cache">')
168
+ end
169
+ it 'stores the initial answers digest whenever jQuery and fe load' do
170
+ skip 'node is not installed' unless system('node', '-v', out: File::NULL, err: File::NULL)
171
+ allow(Fe).to receive(:verbose_js_logging?).and_return(true)
172
+ create(:answer_sheet_question_sheet, question_sheet: question_sheet, answer_sheet: answer_sheet)
173
+ create(:page, question_sheet: question_sheet)
174
+
175
+ get :edit, params: {id: answer_sheet.id}
176
+
177
+ harness = <<~JS
178
+ const vm = require('vm');
179
+ // Runs the script in a fake page and returns the digest it stored in jQuery data.
180
+ // jquery: loaded 'before' the script, 'after' it but before DOMContentLoaded, or 'later' still.
181
+ const run = ({ readyState, jquery, fe = true }) => {
182
+ const data = {}, listeners = [], timers = [];
183
+ const ctx = {
184
+ document: { readyState, addEventListener: (event, fn) => { if (event === 'DOMContentLoaded') listeners.push(fn); } },
185
+ setTimeout: (fn) => { timers.push(fn); },
186
+ };
187
+ ctx.window = ctx;
188
+ const page = #{"##{assigns(:presenter).active_page_link.dom_id}".to_json};
189
+ const jQuery = (selector) => {
190
+ if (selector !== page) throw new Error('digest script looked up ' + selector + ' instead of ' + page);
191
+ return { data: (key, value) => { data[key] = value; } };
192
+ };
193
+ const load = () => { ctx.jQuery = jQuery; if (fe) ctx.fe = { debugLog() {} }; };
194
+ if (jquery === 'before') load();
195
+ vm.runInNewContext(#{digest_script(response.body).to_json}, ctx);
196
+ if (jquery === 'after') load();
197
+ listeners.forEach((fn) => fn());
198
+ if (jquery === 'later') load();
199
+ while (timers.length) timers.shift()();
200
+ return data.answers_digest || null;
201
+ };
202
+ console.log(JSON.stringify([
203
+ run({ readyState: 'loading', jquery: 'after' }),
204
+ run({ readyState: 'loading', jquery: 'before' }),
205
+ run({ readyState: 'complete', jquery: 'before' }),
206
+ run({ readyState: 'complete', jquery: 'before', fe: false }),
207
+ run({ readyState: 'complete', jquery: 'later' }),
208
+ ]));
209
+ JS
210
+
211
+ output = IO.popen(['node', '-e', harness], &:read)
212
+ expect(JSON.parse(output)).to eq([assigns(:answers_digest)] * 5)
213
+ end
146
214
  context 'when the question_sheet is blank' do
147
215
  context 'when referrer is available' do
148
216
  it 'should redirect to referrer' do
@@ -14,6 +14,21 @@ describe Fe::AnswerPagesController, 'optimistic concurrency', type: :controller
14
14
  page.rebuild_all_element_ids
15
15
  end
16
16
 
17
+ describe 'save response' do
18
+ it 'sends the page its new digest in the header and in the JS it returns' do
19
+ put :update, params: {
20
+ answers: { element1.id.to_s => 'An answer' },
21
+ answers_digest: answer_sheet.answers_digest(page),
22
+ id: page.id,
23
+ answer_sheet_id: answer_sheet.id
24
+ }, xhr: true, format: :js
25
+
26
+ new_digest = answer_sheet.answers_digest(page)
27
+ expect(response.headers['X-Answers-Digest']).to eq(new_digest)
28
+ expect(response.body).to include(".data('answers_digest', '#{new_digest}')")
29
+ end
30
+ end
31
+
17
32
  describe 'full round-trip: load → save → concurrent edit → stale save' do
18
33
  it 'allows first save, rejects second save with stale digest' do
19
34
  # === Tab A loads the page and gets the initial digest ===
@@ -0,0 +1,49 @@
1
+ require 'rails_helper'
2
+
3
+ # fe has no JS test runner, so this loads the real fe.public.nojquery script in Node
4
+ # with a stand-in jQuery and checks what loadPage does to the page being left.
5
+ describe 'fe.pageHandler.loadPage' do
6
+ let(:script) do
7
+ ERB.new(File.read(Fe::Engine.root.join('app/assets/javascripts/fe/fe.public.nojquery.js.erb'))).result(binding)
8
+ end
9
+
10
+ def run_load_page(background_load)
11
+ harness = <<~JS
12
+ const vm = require('vm');
13
+ const elements = {};
14
+ const element = (selector) => {
15
+ if (elements[selector]) return elements[selector];
16
+ const data = {};
17
+ const target = { length: 0, data: (key, value) => (value === undefined ? data[key] : (data[key] = value, proxy)) };
18
+ const proxy = new Proxy(target, { get: (t, key) => (key in t ? t[key] : () => proxy) });
19
+ return (elements[selector] = proxy);
20
+ };
21
+ const chain = new Proxy(function() {}, { get: () => chain, apply: () => chain, set: () => true });
22
+ const $ = (arg) => (typeof arg === 'string' ? element(arg) : chain);
23
+ Object.assign($, { validator: chain, ajax: () => {}, _data: () => ({}) });
24
+ const cleared = [];
25
+ const ctx = { $, jQuery: $, document: { addEventListener() {} }, console, clearInterval: (id) => cleared.push(id), setInterval: () => 99 };
26
+ ctx.window = ctx;
27
+ vm.runInNewContext(#{script.to_json}, ctx);
28
+ const handler = ctx.fe.pageHandler;
29
+ let saves = 0;
30
+ Object.assign(handler, {
31
+ current_page: 'page_a', timer_id: 42,
32
+ savePage: () => { saves += 1; }, validatePage: () => true, isPageLoaded: () => true, showPage: () => {},
33
+ });
34
+ handler.loadPage('page_b', '/page_b/edit', #{background_load});
35
+ console.log(JSON.stringify({ timerCleared: cleared.includes(42), saves, savedOnLeave: element('#page_a').data('saved_on_leave') || false }));
36
+ JS
37
+ JSON.parse(IO.popen(['node', '-e', harness], &:read))
38
+ end
39
+
40
+ before { skip 'node is not installed' unless system('node', '-v', out: File::NULL, err: File::NULL) }
41
+
42
+ it 'leaves autosave running and the page unmarked when a conditional loads another page in the background' do
43
+ expect(run_load_page(true)).to eq('timerCleared' => false, 'saves' => 0, 'savedOnLeave' => false)
44
+ end
45
+
46
+ it 'stops autosave and saves the page once when the user moves to another page' do
47
+ expect(run_load_page(false)).to eq('timerCleared' => true, 'saves' => 1, 'savedOnLeave' => true)
48
+ end
49
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fe
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.4.0
4
+ version: 2.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - CruGlobal
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-11-10 00:00:00.000000000 Z
11
+ date: 2026-11-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -639,6 +639,7 @@ files:
639
639
  - spec/factories/question_sheet.rb
640
640
  - spec/factories/reference_questions.rb
641
641
  - spec/factories/reference_sheets.rb
642
+ - spec/javascripts/fe_page_handler_spec.rb
642
643
  - spec/jobs/fe/update_reference_sheet_visibility_job_spec.rb
643
644
  - spec/lib/fe/access_control_spec.rb
644
645
  - spec/mailers/fe/notifier_spec.rb
@@ -797,6 +798,7 @@ test_files:
797
798
  - spec/factories/question_sheet.rb
798
799
  - spec/factories/reference_questions.rb
799
800
  - spec/factories/reference_sheets.rb
801
+ - spec/javascripts/fe_page_handler_spec.rb
800
802
  - spec/jobs/fe/update_reference_sheet_visibility_job_spec.rb
801
803
  - spec/lib/fe/access_control_spec.rb
802
804
  - spec/mailers/fe/notifier_spec.rb