Skip to content

Commit e8769f2

Browse files
authored
Merge branch 'main' into add-validator-page
2 parents 70eb610 + 47a99b5 commit e8769f2

38 files changed

+2475
-1094
lines changed

app/__init__.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from flask_bootstrap import Bootstrap
77
from flask_htmx import HTMX
88
from flask_migrate import Migrate
9+
from flask_talisman import Talisman
910

1011
from app.filters import else_na, usa_icon, utc_isoformat
1112
from database.models import db
@@ -70,6 +71,62 @@ def remove_auth(spec):
7071
# from the load manager but log them
7172
logger.warning("Load manager startup failed with exception: %s", repr(e))
7273

74+
# Content-Security-Policy headers
75+
# single quotes need to appear in some of the strings
76+
csp = {
77+
"default-src": "'self'",
78+
"script-src": " ".join(
79+
[
80+
"'self'",
81+
"'unsafe-hashes'",
82+
"https://code.jquery.com", # Jquery (needed for bootstrap)
83+
"https://stackpath.bootstrapcdn.com", # bootstrap
84+
"https://www.googletagmanager.com",
85+
]
86+
),
87+
"font-src": " ".join(
88+
[
89+
"'self'", # USWDS fonts
90+
"https://cdnjs.cloudflare.com", # font awesome
91+
]
92+
),
93+
"img-src": " ".join(
94+
[
95+
"'self'",
96+
"data:",
97+
"https://s3-us-gov-west-1.amazonaws.com", # GSA Starmark
98+
"https://raw.githubusercontent.com", # github logos repo
99+
]
100+
),
101+
"connect-src": " ".join(
102+
[
103+
"'self'",
104+
]
105+
),
106+
"frame-src": "https://www.googletagmanager.com",
107+
"style-src-attr": " ".join(
108+
[
109+
"'self'",
110+
]
111+
),
112+
"style-src-elem": " ".join(
113+
[
114+
"'self'",
115+
"'unsafe-hashes'", # local styles.css
116+
"https://stackpath.bootstrapcdn.com", # bootstrap
117+
"https://cdnjs.cloudflare.com", # font-awesome
118+
"'sha256-faU7yAF8NxuMTNEwVmBz+VcYeIoBQ2EMHW3WaVxCvnk='", # htmx.min.js
119+
]
120+
),
121+
}
122+
Talisman(
123+
app,
124+
content_security_policy=csp,
125+
content_security_policy_nonce_in=["script-src", "style-src-elem"],
126+
# our https connections are terminated outside this app
127+
force_https=False,
128+
)
129+
73130
return app
74131

75132

app/forms.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,13 @@ def strip_filter(data):
4444

4545
def comma_separated_filter(data):
4646
"""Turn the list or the repr of a list into just comma-separated values."""
47-
# "[" isn't likely to be an alias so if we see it, it's probably the repr
48-
# of a list
4947
if isinstance(data, list):
5048
return ", ".join(data)
49+
# we need to string-process data, if we get anything else, pass it along
50+
if not isinstance(data, str):
51+
return data
52+
# "[" isn't likely to be an alias so if we see it, it's probably the repr
53+
# of a list
5154
if data.startswith("["):
5255
try:
5356
data_list = ast.literal_eval(data)

app/routes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -924,7 +924,7 @@ def download_harvest_errors_by_job(job_id, error_type):
924924
csv_writer.writerow(header)
925925

926926
# Write job errors
927-
errors = db.get_harvest_job_errors_by_job(job_id)
927+
errors = db._to_dict(db.get_harvest_job_errors_by_job(job_id))
928928
for error_dict in errors:
929929
row = [
930930
str(error_dict.get("harvest_job_id", "")),

app/static/_scss/_uswds-theme-custom-styles.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ ul.menu {
3939
}
4040
}
4141

42+
.word-break-all {
43+
word-break: break-all;
44+
}
45+
4246
.view-buttons {
4347
button {
4448
margin-right: .5rem;

app/static/js/filter.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
2+
function filter(e) {
3+
search = e.value.toLowerCase();
4+
document.querySelectorAll('.site-component-card').forEach(function (row) {
5+
text = row.getAttribute("data-meta").toLowerCase();
6+
if (text.match(search)) {
7+
row.classList.remove("display-none");
8+
} else {
9+
row.classList.add("display-none");
10+
}
11+
});
12+
componentCount = document.querySelectorAll('.site-component-card:not(.display-none)').length;
13+
var word = (componentCount === 1) ? "source" : "sources";
14+
document.getElementById("component-count").innerHTML = `<strong>${componentCount}</strong> ${word} found`
15+
}
16+
17+
document.addEventListener("DOMContentLoaded", () => {
18+
// add onkeyup event handlers
19+
const filterInput = document.getElementById("icon-filter");
20+
if (filterInput) filterInput.addEventListener("keyup", function (e) {filter(this)});
21+
});

app/static/js/glossary-panel.js

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
'use strict';
2+
3+
var List = require('list.js');
4+
var objectAssign = require('object-assign');
5+
var Accordion = require('aria-accordion').Accordion;
6+
7+
var KEYCODE_ENTER = 13;
8+
var KEYCODE_ESC = 27;
9+
10+
// https://davidwalsh.name/element-matches-selector
11+
function selectorMatches(el, selector) {
12+
var p = Element.prototype;
13+
var f = p.matches || p.webkitMatchesSelector || p.mozMatchesSelector || p.msMatchesSelector || function(s) {
14+
return [].indexOf.call(document.querySelectorAll(s), this) !== -1;
15+
};
16+
return f.call(el, selector);
17+
}
18+
19+
20+
// get nearest parent element matching selector
21+
function closest(el, selector) {
22+
while (el) {
23+
if (selectorMatches(el, selector)) {
24+
break;
25+
}
26+
el = el.parentElement;
27+
}
28+
return el;
29+
}
30+
31+
function forEach(values, callback) {
32+
return [].forEach.call(values, callback);
33+
}
34+
35+
var itemTemplate = function(values) {
36+
var id = 'glossary-term-' + values.termId;
37+
return '<li class="' + values.glossaryItemClass + '">' +
38+
'<button class="data-glossary-term ' + values.termClass + '" aria-controls="' + id + '">' +
39+
values.term +
40+
'</button>' +
41+
'<div id="' + id + '" class="' + values.definitionClass + '">' + values.definition + '</div>' +
42+
'</li>'
43+
}
44+
45+
var defaultSelectors = {
46+
glossaryID: '#glossary',
47+
toggle: '.js-glossary-toggle',
48+
close: '.js-glossary-close',
49+
listClass: '.js-glossary-list',
50+
searchClass: '.js-glossary-search'
51+
};
52+
53+
var defaultClasses = {
54+
definitionClass: 'glossary__definition',
55+
glossaryItemClass: 'glossary__item',
56+
highlightedTerm: 'term--highlight',
57+
termClass: 'glossary__term'
58+
};
59+
60+
function removeTabindex(elm) {
61+
var elms = getTabIndex(elm);
62+
forEach(elms, function(elm) {
63+
elm.setAttribute('tabIndex', '-1');
64+
});
65+
}
66+
67+
function restoreTabindex(elm) {
68+
var elms = getTabIndex(elm);
69+
forEach(elms, function(elm) {
70+
elm.setAttribute('tabIndex', '0');
71+
});
72+
}
73+
74+
function getTabIndex(elm) {
75+
return elm.querySelectorAll('a, button, input, [tabindex]');
76+
}
77+
78+
/**
79+
* Glossary widget
80+
* @constructor
81+
* @param {Array} terms - Term objects with "glossary-term" and "glossary-definition" keys
82+
* @param {Object} selectors - CSS selectors for glossary components
83+
* @param {Object} classes - CSS classes to be applied for styling
84+
*/
85+
function Glossary(terms, selectors, classes) {
86+
this.terms = terms;
87+
this.selectors = objectAssign({}, defaultSelectors, selectors);
88+
this.classes = objectAssign({}, defaultClasses, classes);
89+
90+
this.body = document.querySelector(this.selectors.glossaryID);
91+
this.toggleBtn = document.querySelector(this.selectors.toggle);
92+
this.closeBtn = document.querySelector(this.selectors.close);
93+
this.search = this.body.querySelector(this.selectors.searchClass);
94+
this.listElm = this.body.querySelector(this.selectors.listClass);
95+
this.selectedTerm = this.toggleBtn;
96+
97+
// Initialize state
98+
this.isOpen = false;
99+
100+
// Update DOM
101+
this.populate();
102+
this.initList();
103+
this.linkTerms();
104+
105+
// Remove tabindices
106+
removeTabindex(this.body);
107+
108+
// Initialize accordions
109+
this.accordion = new Accordion(this.listElm, null, {contentPrefix: 'glossary'});
110+
111+
// Bind listeners
112+
this.listeners = [];
113+
this.addEventListener(this.toggleBtn, 'click', this.toggle.bind(this));
114+
this.addEventListener(this.closeBtn, 'click', this.hide.bind(this));
115+
this.addEventListener(this.search, 'input', this.handleInput.bind(this));
116+
this.addEventListener(document.body, 'keyup', this.handleKeyup.bind(this));
117+
this.addEventListener(document,'click', this.closeOpenGlossary.bind(this));
118+
}
119+
120+
Glossary.prototype.populate = function() {
121+
this.terms.forEach(function(term, i) {
122+
var opts = {
123+
term: term.term,
124+
definition: term.definition,
125+
definitionClass: this.classes.definitionClass,
126+
glossaryItemClass: this.classes.glossaryItemClass,
127+
termClass: this.classes.termClass,
128+
termId: i
129+
};
130+
this.listElm.insertAdjacentHTML('beforeend', itemTemplate(opts));
131+
}, this);
132+
};
133+
134+
/** Initialize list.js list of terms */
135+
Glossary.prototype.initList = function() {
136+
var glossaryId = this.selectors.glossaryID.slice(1);
137+
var listClass = this.selectors.listClass.slice(1);
138+
var searchClass = this.selectors.searchClass.slice(1);
139+
var options = {
140+
valueNames: ['data-glossary-term'],
141+
listClass: listClass,
142+
searchClass: searchClass,
143+
};
144+
this.list = new List(glossaryId, options);
145+
this.list.sort('data-glossary-term', {order: 'asc'});
146+
};
147+
148+
/** Add links to terms in body */
149+
Glossary.prototype.linkTerms = function() {
150+
var terms = document.querySelectorAll('[data-term]');
151+
forEach(terms, function(term) {
152+
term.setAttribute('title', 'Click to define');
153+
term.setAttribute('tabIndex', 0);
154+
term.setAttribute('data-term', (term.getAttribute('data-term') || '').toLowerCase());
155+
});
156+
document.body.addEventListener('click', this.handleTermTouch.bind(this));
157+
document.body.addEventListener('keyup', this.handleTermTouch.bind(this));
158+
};
159+
160+
Glossary.prototype.handleTermTouch = function(e) {
161+
if (e.which === KEYCODE_ENTER || e.type === 'click') {
162+
if (selectorMatches(e.target, '[data-term]')) {
163+
e.stopPropagation();
164+
this.show(e);
165+
this.selectedTerm = e.target;
166+
this.findTerm(e.target.getAttribute('data-term'));
167+
}
168+
else {
169+
this.selectedTerm = this.toggleBtn;
170+
}
171+
}
172+
};
173+
174+
/** Highlight a term */
175+
Glossary.prototype.findTerm = function(term) {
176+
this.search.value = term;
177+
var highlightClass = this.classes.highlightedTerm;
178+
179+
// Highlight the term and remove other highlights
180+
forEach(this.body.querySelectorAll('.' + highlightClass), function(term) {
181+
term.classList.remove(highlightClass);
182+
});
183+
forEach(this.body.querySelectorAll('span[data-term="' + term + '"]'), function(term) {
184+
term.classList.add(highlightClass);
185+
});
186+
this.list.filter(function(item) {
187+
return item._values['data-glossary-term'].toLowerCase() === term;
188+
});
189+
190+
this.list.search();
191+
var button = this.list.visibleItems[0].elm.querySelector('button');
192+
this.accordion.expand(button);
193+
};
194+
195+
Glossary.prototype.toggle = function() {
196+
var method = this.isOpen ? this.hide : this.show;
197+
method.apply(this);
198+
};
199+
200+
Glossary.prototype.show = function() {
201+
this.body.setAttribute('aria-hidden', 'false');
202+
this.toggleBtn.setAttribute('aria-expanded', 'true');
203+
this.search.focus();
204+
this.isOpen = true;
205+
restoreTabindex(this.body);
206+
};
207+
208+
Glossary.prototype.hide = function() {
209+
this.body.setAttribute('aria-hidden', 'true');
210+
this.toggleBtn.setAttribute('aria-expanded', 'false');
211+
this.selectedTerm.focus();
212+
this.isOpen = false;
213+
removeTabindex(this.body);
214+
};
215+
216+
/** Remove existing filters on input */
217+
Glossary.prototype.handleInput = function() {
218+
if (this.list.filtered) {
219+
this.list.filter();
220+
}
221+
};
222+
223+
/** Close glossary on escape keypress */
224+
Glossary.prototype.handleKeyup = function(e) {
225+
if (e.keyCode == KEYCODE_ESC) {
226+
if (this.isOpen) {
227+
this.hide();
228+
}
229+
}
230+
};
231+
232+
// Close glossary when clicking outside of glossary
233+
Glossary.prototype.closeOpenGlossary = function(e) {
234+
if ( e.target !== this.toggleBtn && this.isOpen) {
235+
if (!(closest(e.target, this.selectors.glossaryID))) {
236+
this.hide();
237+
}
238+
}
239+
};
240+
241+
Glossary.prototype.addEventListener = function(elm, event, callback) {
242+
if (elm) {
243+
elm.addEventListener(event, callback);
244+
this.listeners.push({
245+
elm: elm,
246+
event: event,
247+
callback: callback
248+
});
249+
}
250+
};
251+
252+
Glossary.prototype.destroy = function() {
253+
this.accordion.destroy();
254+
this.listeners.forEach(function(listener) {
255+
listener.elm.removeEventListener(listener.event, listener.callback);
256+
});
257+
};
258+
259+
module.exports = Glossary;

app/static/js/glossary.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
var Glossary = require("glossary-panel");
1+
var Glossary = require("./glossary-panel.js");
22
var terms = require("../data/glossary.json");
33

44
var body = document.querySelectorAll(

0 commit comments

Comments
 (0)