Skip to content

Commit 00d3fd5

Browse files
authored
Merge pull request #141 from gtt-project/fix/js-robustness
JS robustness: fetch races, JSON-mode round trip, notification helper
2 parents eb20cb7 + 4157cc9 commit 00d3fd5

10 files changed

Lines changed: 358 additions & 29 deletions

File tree

app/views/subscription_templates/copy.js.erb

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,8 @@ function copyToClipboard(command) {
4444
}
4545
}
4646

47-
// Function to display notification
48-
function showNotification(message) {
49-
var notification = document.getElementById('temporaryNotification');
50-
notification.textContent = message;
51-
notification.classList.add('visible');
52-
setTimeout(function() {
53-
notification.classList.remove('visible');
54-
}, 3000);
55-
}
47+
// showNotification comes from the shared gtt_fiware.js asset, loaded on
48+
// every page by the layout hook.
5649

5750
// Copy the cURL command to the clipboard
5851
copyToClipboard(command);

assets/javascripts/gtt_fiware.js

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
1-
function showNotification(message) {
2-
// Get the notification box
3-
var notification = document.getElementById('temporaryNotification');
1+
/* Shared page helpers, loaded on every page via the layout hook.
2+
*
3+
* showNotification is a deliberate global: the publish/unpublish/copy/sync
4+
* js.erb responses and the subscription list call it by name. It writes to
5+
* the #temporaryNotification box the list view renders; on pages without
6+
* that box the call is a no-op rather than a TypeError.
7+
*/
8+
(function() {
9+
'use strict';
410

5-
// Change the text of the notification box
6-
notification.textContent = message;
11+
window.showNotification = function(message) {
12+
var notification = document.getElementById('temporaryNotification');
13+
if (!notification) { return; }
714

8-
// Show the notification box
9-
notification.classList.add('visible');
10-
11-
// Hide the notification box after 3 seconds
12-
setTimeout(function() {
13-
notification.classList.remove('visible');
14-
}, 3000);
15-
}
15+
notification.textContent = message;
16+
notification.classList.add('visible');
17+
setTimeout(function() {
18+
notification.classList.remove('visible');
19+
}, 3000);
20+
};
21+
})();

assets/javascripts/gtt_fiware_form.js

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,96 @@
3232
var json = document.getElementById('gtt-fiware-' + target + '-json');
3333
if (!rows || !json) { return; }
3434
var showJson = json.classList.contains('hidden');
35-
if (showJson) { serialize(); }
35+
if (showJson) {
36+
serialize();
37+
} else if (!restoreRows(target)) {
38+
// The JSON cannot be shown by the picker; leaving JSON mode would
39+
// overwrite the hand-edited JSON with stale rows on submit, so the
40+
// form stays in JSON mode.
41+
return;
42+
}
3643
json.classList.toggle('hidden', !showJson);
3744
rows.style.display = showJson ? 'none' : '';
3845
}
3946

47+
// Rebuilds the picker rows from the JSON field when leaving JSON mode.
48+
// Returns false when the JSON is not representable by the picker
49+
// (unparsable, non-array, extra keys, non-string values); the caller
50+
// then keeps JSON mode, so hand-edited JSON is never lost.
51+
function restoreRows(target) {
52+
var config = target === 'entities' ? {
53+
fieldId: 'subscription_template_entities_string',
54+
containerId: 'gtt-fiware-entity-rows',
55+
prototypeId: 'gtt-fiware-entity-prototype',
56+
rowClass: 'gtt-fiware-entity-row',
57+
addId: 'gtt-fiware-entity-add',
58+
rowValues: entityRowValues
59+
} : {
60+
fieldId: 'subscription_template_attachments_string',
61+
containerId: 'gtt-fiware-attachment-rows',
62+
prototypeId: 'gtt-fiware-attachment-prototype',
63+
rowClass: 'gtt-fiware-attachment-row',
64+
addId: 'gtt-fiware-attachment-add',
65+
rowValues: attachmentRowValues
66+
};
67+
var field = document.getElementById(config.fieldId);
68+
var container = document.getElementById(config.containerId);
69+
var prototype = document.getElementById(config.prototypeId);
70+
if (!field || !container || !prototype) { return false; }
71+
72+
var text = field.value.trim();
73+
var parsed = [];
74+
if (text !== '' && text !== 'null') {
75+
try { parsed = JSON.parse(text); } catch (err) { return false; }
76+
if (!Array.isArray(parsed)) { return false; }
77+
}
78+
var rows = parsed.map(config.rowValues);
79+
if (rows.some(function(values) { return values === null; })) { return false; }
80+
81+
container.querySelectorAll('.' + config.rowClass).forEach(function(row) { row.remove(); });
82+
rows.forEach(function(values) {
83+
var clone = prototype.content.firstElementChild.cloneNode(true);
84+
Object.keys(values).forEach(function(selector) {
85+
var el = clone.querySelector(selector);
86+
if (el) { el.value = values[selector]; }
87+
});
88+
container.insertBefore(clone, document.getElementById(config.addId));
89+
});
90+
return true;
91+
}
92+
93+
// One picker row per entity selector: a type plus at most one
94+
// id/idPattern member.
95+
function entityRowValues(obj) {
96+
if (!obj || typeof obj !== 'object' || typeof obj.type !== 'string') { return null; }
97+
var rest = Object.keys(obj).filter(function(key) { return key !== 'type'; });
98+
// Always set all three inputs: the cloned prototype row carries a
99+
// default match value that must not leak into a restored row.
100+
if (rest.length === 0) {
101+
return { '.js-entity-type': obj.type, '.js-entity-match-kind': 'idPattern', '.js-entity-match-value': '' };
102+
}
103+
if (rest.length > 1 || (rest[0] !== 'id' && rest[0] !== 'idPattern')) { return null; }
104+
if (typeof obj[rest[0]] !== 'string') { return null; }
105+
return {
106+
'.js-entity-type': obj.type,
107+
'.js-entity-match-kind': rest[0],
108+
'.js-entity-match-value': obj[rest[0]]
109+
};
110+
}
111+
112+
function attachmentRowValues(obj) {
113+
if (!obj || typeof obj !== 'object' || typeof obj.url !== 'string') { return null; }
114+
var allowed = ['url', 'filename', 'description'];
115+
var keys = Object.keys(obj);
116+
if (keys.some(function(key) { return allowed.indexOf(key) === -1; })) { return null; }
117+
if (keys.some(function(key) { return typeof obj[key] !== 'string'; })) { return null; }
118+
return {
119+
'.js-attachment-url': obj.url,
120+
'.js-attachment-filename': obj.filename || '',
121+
'.js-attachment-description': obj.description || ''
122+
};
123+
}
124+
40125
function rowValues(row, selectors) {
41126
return selectors.map(function(sel) {
42127
var el = row.querySelector(sel);
@@ -98,10 +183,24 @@
98183
var georel = document.getElementById('subscription_template_expression_georel');
99184
var geometry = document.getElementById('subscription_template_expression_geometry');
100185
var coords = document.getElementById('subscription_template_expression_coords');
186+
if (!georel || !geometry || !coords) { return; }
101187
if (mode.value === 'anywhere') {
102188
georel.value = ''; geometry.value = ''; coords.value = '';
103189
} else if (mode.value === 'boundary') {
104-
var geom = JSON.parse(mode.dataset.geom).geometry.coordinates[0]
190+
var ring;
191+
try {
192+
ring = JSON.parse(mode.dataset.geom).geometry.coordinates[0];
193+
} catch (err) {
194+
ring = null;
195+
}
196+
// data-geom is server-rendered; if it is missing, malformed or not
197+
// a ring of number pairs, leave the stored triple untouched rather
198+
// than throwing from inside the submit listener.
199+
var isPair = function(c) {
200+
return Array.isArray(c) && typeof c[0] === 'number' && typeof c[1] === 'number';
201+
};
202+
if (!Array.isArray(ring) || ring.length === 0 || !ring.every(isPair)) { return; }
203+
var geom = ring
105204
.map(function(c) { return [Number(c[1].toFixed(5)), Number(c[0].toFixed(5))]; })
106205
.join(';');
107206
georel.value = 'coveredBy'; geometry.value = 'polygon'; coords.value = geom;
@@ -250,14 +349,22 @@
250349
});
251350
}
252351

352+
// Guards against out-of-order responses: rapid tracker/member changes
353+
// fire concurrent fetches, and without the token the last response to
354+
// ARRIVE would rebuild the select, which may belong to the first
355+
// request SENT (a stale tracker's statuses shown for the current one).
356+
var statusRequestToken = 0;
357+
253358
function refreshIssueStatuses() {
254359
if (!issueStatusSelect || !issueStatusSelect.dataset.statusesUrl) { return; }
255360
var url = issueStatusSelect.dataset.statusesUrl +
256361
'?tracker_id=' + encodeURIComponent(trackerSelect ? trackerSelect.value : '') +
257362
'&member_id=' + encodeURIComponent(memberSelect ? memberSelect.value : '');
363+
var token = ++statusRequestToken;
258364
fetch(url, { headers: { 'Accept': 'application/json' } })
259365
.then(function(response) { return response.json(); })
260366
.then(function(statuses) {
367+
if (token !== statusRequestToken) { return; }
261368
var current = issueStatusSelect.value;
262369
var currentText = issueStatusSelect.selectedOptions[0] ?
263370
issueStatusSelect.selectedOptions[0].textContent : '';
@@ -312,10 +419,15 @@
312419
}
313420

314421
var previewButton = document.getElementById('gtt-fiware-preview-button');
422+
// Same out-of-order guard as the status refetch: a double-click fires
423+
// two requests, and only the latest one may write the result box.
424+
var previewRequestToken = 0;
315425
if (previewButton) {
316426
previewButton.addEventListener('click', function(e) {
317427
e.preventDefault();
318428
var out = document.getElementById('gtt-fiware-preview-result');
429+
if (!out) { return; }
430+
var token = ++previewRequestToken;
319431
out.style.display = '';
320432
out.textContent = previewButton.dataset.loading;
321433

@@ -342,6 +454,7 @@
342454
}).then(function(response) {
343455
return response.json().then(function(json) { return { ok: response.ok, json: json }; });
344456
}).then(function(result) {
457+
if (token !== previewRequestToken) { return; }
345458
if (!result.ok) {
346459
out.textContent = result.json.error || previewButton.dataset.error;
347460
return;
@@ -359,6 +472,7 @@
359472
(result.json.has_geometry ? ', ' + previewButton.dataset.geometryLabel : '');
360473
out.appendChild(em);
361474
}).catch(function() {
475+
if (token !== previewRequestToken) { return; }
362476
out.textContent = previewButton.dataset.error;
363477
});
364478
});

assets/javascripts/gtt_fiware_wizard.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
li.classList.toggle('active', Number(li.dataset.step) === step);
5959
});
6060
var activeItem = nav.querySelector('li[data-step="' + step + '"]');
61-
help.textContent = activeItem ? activeItem.dataset.help : '';
61+
if (help) { help.textContent = activeItem ? activeItem.dataset.help : ''; }
6262
back.disabled = step === 1;
6363
next.style.display = step === TOTAL_STEPS ? 'none' : '';
6464
// The preview result manages its own visibility (the preview button

test/javascripts/issue_details.test.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,4 +125,34 @@ describe('status refetch', () => {
125125
await tick();
126126
expect(Array.from(statusSelect().options)).toHaveLength(2);
127127
});
128+
129+
// Rapid tracker changes fire concurrent fetches. When the first request's
130+
// response arrives last, it must not overwrite the newer one: the select
131+
// would show a stale tracker's statuses for the current tracker.
132+
it('ignores an out-of-order response', async () => {
133+
buildForm();
134+
initForm();
135+
const pending = [];
136+
vi.stubGlobal('fetch', (url) => new Promise((resolve) => {
137+
pending.push({
138+
url,
139+
respond(statuses) {
140+
resolve({ json: () => Promise.resolve(statuses) });
141+
}
142+
});
143+
}));
144+
145+
changeTracker('2');
146+
changeTracker('1');
147+
expect(pending).toHaveLength(2);
148+
149+
pending[1].respond([{ id: 5, name: 'Current Tracker Status' }]);
150+
await tick();
151+
pending[0].respond([{ id: 9, name: 'Stale Tracker Status' }]);
152+
await tick();
153+
154+
const names = Array.from(statusSelect().options).map((o) => o.textContent);
155+
expect(names).toContain('Current Tracker Status');
156+
expect(names).not.toContain('Stale Tracker Status');
157+
});
128158
});

test/javascripts/json_mode.test.js

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// field, hides the rows, and switching back restores the picker.
33

44
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
5-
import { buildForm, initForm, loadScript } from './support/form_fixture.js';
5+
import { buildForm, initForm, loadScript, submitForm } from './support/form_fixture.js';
66

77
beforeAll(loadScript);
88

@@ -33,12 +33,39 @@ describe('entities JSON mode', () => {
3333
expect(rows.style.display).toBe('');
3434
});
3535

36-
it('does not overwrite hand-edited JSON when toggling back and forth', () => {
36+
it('rebuilds the rows from hand-edited JSON when toggling back', () => {
37+
toggle('entities');
38+
const field = document.getElementById('subscription_template_entities_string');
39+
field.value = '[{"type":"HandEdited","id":"urn:x:1"}]';
40+
toggle('entities');
41+
const row = document.querySelector('.gtt-fiware-entity-row');
42+
expect(row.querySelector('.js-entity-type').value).toBe('HandEdited');
43+
expect(row.querySelector('.js-entity-match-kind').value).toBe('id');
44+
expect(row.querySelector('.js-entity-match-value').value).toBe('urn:x:1');
45+
});
46+
47+
// The regression this pins: the field used to survive the toggle-back
48+
// only until submit, when the stale rows overwrote it.
49+
it('keeps hand-edited JSON through toggle-back and submit', () => {
3750
toggle('entities');
3851
const field = document.getElementById('subscription_template_entities_string');
3952
field.value = '[{"type":"HandEdited"}]';
40-
toggle('entities'); // back to rows: serialize skips, JSON mode was on
41-
expect(field.value).toBe('[{"type":"HandEdited"}]');
53+
toggle('entities');
54+
submitForm();
55+
expect(JSON.parse(field.value)).toEqual([{ type: 'HandEdited' }]);
56+
});
57+
58+
// JSON the picker cannot show (extra members) must never be replaced by
59+
// stale rows: the form refuses to leave JSON mode.
60+
it('stays in JSON mode when the JSON is not representable by the picker', () => {
61+
toggle('entities');
62+
const field = document.getElementById('subscription_template_entities_string');
63+
field.value = '[{"type":"Sensor","id":"urn:x:1","extra":"member"}]';
64+
toggle('entities');
65+
const json = document.getElementById('gtt-fiware-entities-json');
66+
expect(json.classList.contains('hidden')).toBe(false);
67+
submitForm();
68+
expect(field.value).toBe('[{"type":"Sensor","id":"urn:x:1","extra":"member"}]');
4269
});
4370
});
4471

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// The shared page helper (gtt_fiware.js): showNotification is a global the
2+
// publish/unpublish/copy/sync responses call by name. It is loaded on every
3+
// page by the layout hook, so it must be a no-op (not a TypeError) on pages
4+
// without the #temporaryNotification box.
5+
6+
import { readFileSync } from 'node:fs';
7+
import { fileURLToPath } from 'node:url';
8+
import { dirname, join } from 'node:path';
9+
import { beforeAll, describe, expect, it, vi } from 'vitest';
10+
11+
const here = dirname(fileURLToPath(import.meta.url));
12+
13+
beforeAll(() => {
14+
window.eval(readFileSync(join(here, '../../assets/javascripts/gtt_fiware.js'), 'utf8'));
15+
});
16+
17+
describe('showNotification', () => {
18+
it('is a no-op on pages without the notification box', () => {
19+
document.body.innerHTML = '';
20+
expect(() => window.showNotification('hello')).not.toThrow();
21+
});
22+
23+
it('shows the message and hides it again after the timeout', () => {
24+
vi.useFakeTimers();
25+
document.body.innerHTML = '<div id="temporaryNotification"></div>';
26+
window.showNotification('Command copied');
27+
28+
const box = document.getElementById('temporaryNotification');
29+
expect(box.textContent).toBe('Command copied');
30+
expect(box.classList.contains('visible')).toBe(true);
31+
32+
vi.advanceTimersByTime(3000);
33+
expect(box.classList.contains('visible')).toBe(false);
34+
vi.useRealTimers();
35+
});
36+
});

0 commit comments

Comments
 (0)