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 +4 -4
- data/README.md +1 -0
- data/app/assets/javascripts/fe/fe.public.nojquery.js.erb +22 -14
- data/app/controllers/concerns/fe/answer_sheets_controller_concern.rb +23 -6
- data/lib/fe/version.rb +1 -1
- data/spec/controllers/fe/answer_sheets_controller_spec.rb +68 -0
- data/spec/controllers/fe/optimistic_concurrency_spec.rb +15 -0
- data/spec/javascripts/fe_page_handler_spec.rb +49 -0
- 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: 07eacfa22a5212645158341f32b217501965ae360aa06b6e68b37ad4aee69431
|
|
4
|
+
data.tar.gz: 53552e5f2241cefc323121123da3778bd372cfe1ad65eb167173af4fa37c5bd8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
this.
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
@@ -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.
|
|
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-
|
|
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
|