diff --git a/.dockerignore b/.dockerignore index ec502fa3c..84703a3f8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,6 +17,7 @@ frontend/static/frontend/*.js frontend/static/frontend/*.css frontend/static/frontend/*.map frontend/static/frontend/report.html +frontend/safe_mode/safe-mode.min.js staticfiles/ frontend/lib/queries/__generated__/ querybuilder.js diff --git a/.gitignore b/.gitignore index ee5fd5386..0c3252a50 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ frontend/static/frontend/*.js frontend/static/frontend/*.css frontend/static/frontend/*.map frontend/static/frontend/report.html +frontend/safe_mode/safe-mode.min.js staticfiles/ frontend/lib/queries/__generated__/ querybuilder.js diff --git a/frontend/context_processors.py b/frontend/context_processors.py new file mode 100644 index 000000000..79d64fa53 --- /dev/null +++ b/frontend/context_processors.py @@ -0,0 +1,19 @@ +from typing import Dict, Any +from project.util.js_snippet import JsSnippetContextProcessor +import frontend.safe_mode + + +class SafeModeJsSnippet(JsSnippetContextProcessor): + @property + def template(self) -> str: + return frontend.safe_mode.SAFE_MODE_JS.read_text() + + var_name = 'SAFE_MODE_SNIPPET' + + +def safe_mode(request): + is_enabled = frontend.safe_mode.is_enabled(request) + ctx: Dict[str, Any] = {'is_safe_mode_enabled': is_enabled} + if not is_enabled: + ctx.update(SafeModeJsSnippet()(request)) + return ctx diff --git a/frontend/lib/app.tsx b/frontend/lib/app.tsx index ee60e950e..0fbeca707 100644 --- a/frontend/lib/app.tsx +++ b/frontend/lib/app.tsx @@ -194,10 +194,6 @@ export class AppWithoutRouter extends React.Component -
- {/* This is a no-JS fallback. */} - -
diff --git a/frontend/lib/main.ts b/frontend/lib/main.ts index 7997992ca..8b3bf584a 100644 --- a/frontend/lib/main.ts +++ b/frontend/lib/main.ts @@ -21,7 +21,5 @@ window.addEventListener('load', () => { // Since JS is now loaded, let's remove that restriction. div.removeAttribute('hidden'); - document.documentElement.classList.remove('jf-no-js'); - startApp(div, initialProps); }); diff --git a/frontend/lib/navbar.tsx b/frontend/lib/navbar.tsx index ef946e944..b2f4b1673 100644 --- a/frontend/lib/navbar.tsx +++ b/frontend/lib/navbar.tsx @@ -4,25 +4,28 @@ import { Link } from 'react-router-dom'; import autobind from 'autobind-decorator'; import { AriaExpandableButton } from './aria'; import { bulmaClasses } from './bulma'; -import { AppContext, AppContextType } from './app-context'; +import { AppContextType, withAppContext } from './app-context'; import Routes from './routes'; -type Dropdown = 'developer'; +type Dropdown = 'developer'|'all'; -export interface NavbarProps { -} +export type NavbarProps = AppContextType; interface NavbarState { currentDropdown: Dropdown|null; isHamburgerOpen: boolean; } -export default class Navbar extends React.Component { +class NavbarWithoutAppContext extends React.Component { navbarRef: React.RefObject; constructor(props: NavbarProps) { super(props); - this.state = { currentDropdown: null, isHamburgerOpen: false }; + if (props.session.isSafeModeEnabled) { + this.state = { currentDropdown: 'all', isHamburgerOpen: true }; + } else { + this.state = { currentDropdown: null, isHamburgerOpen: false }; + } this.navbarRef = React.createRef(); } @@ -56,13 +59,33 @@ export default class Navbar extends React.Component { componentDidMount() { window.addEventListener('focus', this.handleFocus, true); + window.addEventListener('resize', this.handleResize, false); } componentWillUnmount() { window.removeEventListener('focus', this.handleFocus, true); + window.removeEventListener('resize', this.handleResize, false); + } + + isDropdownActive(dropdown: Dropdown) { + return this.state.currentDropdown === dropdown || this.state.currentDropdown === 'all'; + } + + @autobind + handleResize() { + this.setState({ + currentDropdown: null, + isHamburgerOpen: false + }); } - renderDevMenu({ server }: AppContextType): JSX.Element|null { + @autobind + handleShowSafeModeUI() { + window.SafeMode.showUI(); + } + + renderDevMenu(): JSX.Element|null { + const { server, session } = this.props; const { state } = this; if (!server.debug) return null; @@ -71,18 +94,21 @@ export default class Navbar extends React.Component { this.toggleDropdown('developer')} > Webpack analysis GraphiQL Example PDF GitHub + {!session.isSafeModeEnabled && + Show safe mode UI} ); } - renderNavbarBrand({ server }: AppContextType): JSX.Element { + renderNavbarBrand(): JSX.Element { + const { server } = this.props; const { state } = this; return ( @@ -106,30 +132,31 @@ export default class Navbar extends React.Component { render() { const { state } = this; + const { session, server } = this.props; return ( - - {appContext => ( - ); } } +const Navbar = withAppContext(NavbarWithoutAppContext); + +export default Navbar; + interface NavbarDropdownProps { name: string; children: any; diff --git a/frontend/lib/progressive-enhancement.tsx b/frontend/lib/progressive-enhancement.tsx index c36b11cf2..a5c4d1a61 100644 --- a/frontend/lib/progressive-enhancement.tsx +++ b/frontend/lib/progressive-enhancement.tsx @@ -77,6 +77,9 @@ export class ProgressiveEnhancement extends React.Component = { customIssues: [], accessDates: [], landlordDetails: null, - letterRequest: null + letterRequest: null, + isSafeModeEnabled: false }; export const FakeAppContext: AppContextType = { diff --git a/frontend/safe_mode/__init__.py b/frontend/safe_mode/__init__.py new file mode 100644 index 000000000..766ed4fa1 --- /dev/null +++ b/frontend/safe_mode/__init__.py @@ -0,0 +1 @@ +from .safe_mode import * # noqa diff --git a/frontend/safe_mode/safe-mode-globals.d.ts b/frontend/safe_mode/safe-mode-globals.d.ts new file mode 100644 index 000000000..fe7c3d4e8 --- /dev/null +++ b/frontend/safe_mode/safe-mode-globals.d.ts @@ -0,0 +1,23 @@ +interface Window { + SafeMode: { + /** + * Let safe mode know that an error has been handled, so it + * shouldn't show its UI if it sees the error. + */ + ignoreError(e: Error): void; + + /** + * Report an error to safe mode. Normally safe mode + * automatically detects these via an error event + * listener, so this is only really intended to be + * used by tests. + */ + reportError(e: Error): void; + + /** + * Show the safe-mode opt-in UI. This is intended primarily + * for manual testing, but client code can use it too. + */ + showUI(): void; + } +} diff --git a/frontend/safe_mode/safe-mode.js b/frontend/safe_mode/safe-mode.js new file mode 100644 index 000000000..ea3423b99 --- /dev/null +++ b/frontend/safe_mode/safe-mode.js @@ -0,0 +1,145 @@ +// @ts-check + +/** + * This file contains ES3-compliant JavaScript that will be minified and inserted + * into the top of the page as an inline snippet. Its purpose is to listen for any + * uncaught errors that are raised on the page and present a UI to opt the user + * into "safe mode" (also known as "compatibility mode"), whereby we deliver + * nearly zero JavaScript to the client browser. + */ +(function() { + /** + * The amount of time from when we receive an error to when we show the + * opt-in UI for activating safe mode. + * + * The reason there is any delay is because in some cases, an error + * event occurs and our own client-side code later deals with it + * gracefully on its own, obviating the need for the user to enter + * safe mode. We need to provide some leeway for that code to + * let us know that the error has been handled, hence this delay. + */ + var SHOW_UI_DELAY_MS = 250; + + /** + * The data attribute we're using to determine whether the + * safe mode opt-in UI is hidden or not. We're not using the + * standard 'hidden' attribute because under some situations, + * e.g. if JS in the client browser is completely disabled or + * this script fails to run, we actually want the UI to be + * visible even if it has this attribute, and we don't want + * e.g. assistive technologies to hide the element just because + * it has the 'hidden' attribute. + */ + var HIDDEN_ATTR = 'data-safe-mode-hidden'; + + /** + * A list of error messages that other client-side code has told + * us to ignore. + * + * @type {string[]} + */ + var errorsToIgnore = []; + + /** + * A list of error messages that we've received so far. + * + * @type {string[]} + */ + var errors = []; + + /** + * Book-keeping used to control the display of the UI. + * + * @type {number|null} + */ + var showUiTimeout = null; + + /** + * Check to see if any valid errors have been logged and return + * true if so. + * + * @returns {boolean} + */ + function validErrorsExist() { + for (var i = 0; i < errors.length; i++) { + if (errorsToIgnore.indexOf(errors[i]) === -1) { + return true; + } + } + return false; + } + + /** Shedule a check to see if we should display the opt-in UI. */ + function scheduleShowUICheck() { + if (showUiTimeout !== null) { + window.clearTimeout(showUiTimeout); + showUiTimeout = null; + } + showUiTimeout = window.setTimeout(function() { + var el = document.getElementById('safe-mode-enable'); + + showUiTimeout = null; + + if (el && el.hasAttribute(HIDDEN_ATTR) && validErrorsExist()) { + el.removeAttribute(HIDDEN_ATTR); + el.focus(); + + /** @type {HTMLButtonElement|null} */ + var deleteBtn = el.querySelector('button.delete'); + if (deleteBtn) { + deleteBtn.onclick = function() { + if (el) { + el.setAttribute(HIDDEN_ATTR, ''); + } + }; + } + } + + errors = []; + errorsToIgnore = []; + }, SHOW_UI_DELAY_MS); + } + + /** + * Record the given error and show the safe mode opt-in API + * if needed. + * + * @param err {Error} + */ + function reportError(err) { + try { + errors.push(err.toString()); + } catch (e) { + errors.push('unknown error'); + } + scheduleShowUICheck(); + } + + /** Our public API. See safe-mode.d.ts for more documentation. */ + window.SafeMode = { + showUI: function() { + errors.push('showUI() called'); + scheduleShowUICheck(); + }, + reportError: reportError, + ignoreError: function(e) { + errorsToIgnore.push(e.toString()); + } + }; + + /** Listen for any error events and report them. */ + window.addEventListener('error', function(e) { + reportError(e.error); + }); + + /** + * It's possible that some errors occurred while our page + * was loading, but the opt-in UI wasn't available yet. + * If that was the case, schedule another check to display + * the UI just in case. + */ + window.addEventListener('load', scheduleShowUICheck); + + var htmlEl = document.getElementsByTagName('html')[0]; + htmlEl.removeAttribute('data-safe-mode-no-js'); +})(); diff --git a/frontend/safe_mode/safe_mode.py b/frontend/safe_mode/safe_mode.py new file mode 100644 index 000000000..c44bef479 --- /dev/null +++ b/frontend/safe_mode/safe_mode.py @@ -0,0 +1,35 @@ +from pathlib import Path +from django.http import HttpResponseRedirect, HttpRequest +from django.urls import path +from django.views.decorators.http import require_POST + + +MY_DIR = Path(__file__).parent.resolve() + +SESSION_KEY = 'enable_safe_mode' + +SAFE_MODE_JS = MY_DIR / 'safe-mode.min.js' + + +def is_enabled(request: HttpRequest) -> bool: + return request.session.get(SESSION_KEY, False) + + +@require_POST +def enable(request: HttpRequest): + request.session[SESSION_KEY] = True + return HttpResponseRedirect(request.META.get('HTTP_REFERER', '/')) + + +@require_POST +def disable(request: HttpRequest): + request.session[SESSION_KEY] = False + return HttpResponseRedirect(request.META.get('HTTP_REFERER', '/')) + + +app_name = 'safe_mode' + +urlpatterns = [ + path('enable', enable, name='enable'), + path('disable', disable, name='disable'), +] diff --git a/frontend/safe_mode/tests/safe-mode.test.ts b/frontend/safe_mode/tests/safe-mode.test.ts new file mode 100644 index 000000000..05c23a96a --- /dev/null +++ b/frontend/safe_mode/tests/safe-mode.test.ts @@ -0,0 +1,92 @@ +import '../safe-mode'; + + +describe("safe mode", () => { + const HIDDEN_ATTR = 'data-safe-mode-hidden'; + + let div = document.createElement('div'); + let deleteBtn = document.createElement('button'); + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllTimers(); + + div = document.createElement('div'); + div.id = 'safe-mode-enable'; + div.setAttribute(HIDDEN_ATTR, ''); + document.body.appendChild(div); + + deleteBtn = document.createElement('button'); + deleteBtn.className = 'delete'; + div.appendChild(deleteBtn); + }); + + afterEach(() => { + document.body.removeChild(div); + }); + + it('shows UI when instructed to', () => { + window.SafeMode.showUI(); + expect(div.hasAttribute(HIDDEN_ATTR)).toBe(true); + jest.runAllTimers(); + expect(div.hasAttribute(HIDDEN_ATTR)).toBe(false); + }); + + it('works if showUI() is called multiple times', () => { + const clearTimeoutMock = jest.spyOn(window, 'clearTimeout'); + window.SafeMode.showUI(); + expect(clearTimeoutMock.mock.calls).toHaveLength(0); + window.SafeMode.showUI(); + expect(clearTimeoutMock.mock.calls).toHaveLength(1); + expect(div.hasAttribute(HIDDEN_ATTR)).toBe(true); + jest.runAllTimers(); + expect(div.hasAttribute(HIDDEN_ATTR)).toBe(false); + }); + + it('hides UI when close button is clicked', () => { + window.SafeMode.showUI(); + jest.runAllTimers(); + if (!deleteBtn.onclick) { + throw new Error('delete button should have onclick defined'); + } + expect(div.hasAttribute(HIDDEN_ATTR)).toBe(false); + deleteBtn.onclick(null as any); + expect(div.hasAttribute(HIDDEN_ATTR)).toBe(true); + }); + + it('shows UI if error is undefined', () => { + window.SafeMode.reportError(undefined as any); + jest.runAllTimers(); + expect(div.hasAttribute(HIDDEN_ATTR)).toBe(false); + }); + + it('shows UI if error.toString() throws', () => { + window.SafeMode.reportError({ toString() { throw new Error() } } as any); + jest.runAllTimers(); + expect(div.hasAttribute(HIDDEN_ATTR)).toBe(false); + }); + + it('shows UI if a non-ignored error is reported', () => { + window.SafeMode.ignoreError(new Error('blap')); + window.SafeMode.reportError(new Error('boop')); + jest.runAllTimers(); + expect(div.hasAttribute(HIDDEN_ATTR)).toBe(false); + }); + + it('does not show UI on pre-ignored errors', () => { + const err = new Error('boop'); + window.SafeMode.ignoreError(err); + window.SafeMode.reportError(err); + jest.runAllTimers(); + expect(div.hasAttribute(HIDDEN_ATTR)).toBe(true); + }); + + it('does not show UI on post-ignored errors', () => { + const err = new Error('bap'); + window.SafeMode.reportError(err); + jest.runTimersToTime(10); + window.SafeMode.ignoreError(err); + jest.runTimersToTime(1000); + expect(div.hasAttribute(HIDDEN_ATTR)).toBe(true); + }); +}); diff --git a/frontend/safe_mode/watcher.js b/frontend/safe_mode/watcher.js new file mode 100644 index 000000000..32f83e519 --- /dev/null +++ b/frontend/safe_mode/watcher.js @@ -0,0 +1,23 @@ +// @ts-check + +const { execSync } = require('child_process'); +const chokidar = require('chokidar'); +const chalk = require('chalk').default; + +const FILENAME = 'safe-mode.js'; + +function uglify() { + console.log(`Uglifying ${FILENAME}.`); + try { + execSync('npm run safe_mode_snippet', { stdio: 'inherit' }); + } catch (e) { + console.log(chalk.redBright(`Uglification failed!`)); + } +} + +if (!module.parent) { + console.log(`Waiting for changes to ${FILENAME}...`); + chokidar.watch(`${__dirname}/${FILENAME}`, { awaitWriteFinish: true }) + .on('ready', uglify) + .on('change', uglify); +} diff --git a/frontend/sass/_no-js.scss b/frontend/sass/_no-js.scss deleted file mode 100644 index 6d9b0efe6..000000000 --- a/frontend/sass/_no-js.scss +++ /dev/null @@ -1,132 +0,0 @@ -// Because no-JS isn't a very common use case, but also because we don't want -// content that starts out collapsed to be inaccessible by clients without JS, -// we'll adopt a strategy whereby, through the use of CSS animations, content -// starts out the way it would on JS-enabled clients, but then transitions -// to its non-JS representation once a certain period of time has elapsed. -// -// We're going to call this period of time "long load time". - -$jf-long-load-time: 1.5s; -$jf-long-load-animation-time: $jf-long-load-time * 2; - -// This is an embarassingly large amount of code just to add support for non-JS -// clients in a non-janky way, but hopefully it will be reduced a lot by -// minification and compression. - -@keyframes jf-delayed-slidedown { - 0% { - max-height: 0; - overflow: hidden; - padding: 0 0; - } - - 50% { - max-height: 0; - overflow: hidden; - padding: 0 0; - } - - 100% { - max-height: 1000px; - padding: 0.5rem 0; - } -} - -@keyframes jf-delayed-fadein { - 0% { - opacity: 0; - } - - 50% { - opacity: 0; - } - - 100% { - opacity: 1; - } -} - -// A mixin to automatically expand collapsed parts of -// a navbar for clients that have JS disabled. -@mixin autoexpand-fallback { - display: block; - .navbar-item, .navbar-link { - color: $justfix-blue; - } - .navbar-item:focus, .navbar-link:focus { - outline: 2px dashed $justfix-blue; - } - animation-duration: $jf-long-load-animation-time; - animation-name: jf-delayed-slidedown; -} - -html:not(.jf-no-js) { - // As soon as we have JS, hide the bottom navbar, since - // it's just there as a no-JS fallback. - .hero-foot nav.navbar { - // We just want to hide it rather than remove it - // entirely, to avoid jank. - opacity: 0; - } - - // We want to gently (but quickly) fade-in elements that - // were hidden while we were loading JS, to avoid - // jank. - - @media screen and (max-width: $desktop - 1) { - .navbar-burger { - animation-duration: 0.5s; - animation-name: jf-fadein; - } - } - - @media screen and (min-width: $desktop) { - .hero-head .navbar-link { - animation-duration: 0.5s; - animation-name: jf-fadein; - } - } -} - -// Avoid displaying the branding on the footer navbar, it's -// too repetitive. -.hero-foot .navbar-brand { - display: none; -} - -.hero-foot nav.navbar { - transition: opacity 1s; -} - -// Styles to display on systems that either have -// no JS, or which haven't yet loaded it. -html.jf-no-js { - .hero-foot nav.navbar { - animation-duration: $jf-long-load-animation-time; - animation-name: jf-delayed-fadein; - } - - @media screen and (max-width: $desktop - 1) { - // Don't show the hamburger at all, since people - // will just be confused by its lack of interactivity. - .navbar-burger { - visibility: hidden; - } - - .hero-foot .navbar-menu { - @include autoexpand-fallback(); - } - } - - @media screen and (min-width: $desktop) { - // Don't show any links with dropdowns in the top - // navbar, since they won't be interactive. - .hero-head .navbar-link { - visibility: hidden; - } - - .hero-foot .navbar-item .navbar-dropdown { - @include autoexpand-fallback(); - } - } -} diff --git a/frontend/sass/_safe-mode.scss b/frontend/sass/_safe-mode.scss new file mode 100644 index 000000000..f67849d2c --- /dev/null +++ b/frontend/sass/_safe-mode.scss @@ -0,0 +1,24 @@ +aside.safe-mode { + position: fixed; + bottom: 0; + left: 0; + right: 0; + padding: 1em; + z-index: 1000; +} + +.safe-mode-disable, .safe-mode-disable .notification { + background-color: $justfix-blue; +} + +.safe-mode[data-safe-mode-hidden] { + display: none; +} + +html[data-safe-mode-no-js] .safe-mode[data-safe-mode-hidden] { + display: block; + + button.delete { + display: none; + } +} diff --git a/frontend/sass/styles.scss b/frontend/sass/styles.scss index 24d736984..346177416 100644 --- a/frontend/sass/styles.scss +++ b/frontend/sass/styles.scss @@ -1,7 +1,21 @@ @charset "utf-8"; @import "../../node_modules/bulma/bulma.sass"; @import "./_colors.scss"; -@import "./_no-js.scss"; +@import "./_safe-mode.scss"; + +html[data-safe-mode-no-js] { + // Don't show the hamburger at all, since people + // will just be confused by its lack of interactivity. + .navbar-burger { + display: none; + } +} + +html:not([data-safe-mode-no-js]) .hero nav.navbar .is-active { + &.navbar-menu, .navbar-dropdown { + animation: jf-slidedown 0.5s; + } +} .hero nav.navbar { background: $justfix-blue; @@ -19,10 +33,6 @@ } .is-active { - &.navbar-menu, .navbar-dropdown { - animation: jf-slidedown 0.5s; - } - .navbar-item:focus, .navbar-link:focus:not([role="button"]) { outline: 2px dashed $justfix-blue; } @@ -31,6 +41,22 @@ color: $justfix-blue; } + // This handles the extremely weird case where the hamburger + // is active, but the browser window is also wide enough to + // not be showing the menu bar. + // + // Most significantly, this is the case in safe mode, when + // we show both the hamburger and all dropdowns simultaneously, + // and have no ability to change the DOM via JS. + // + // This is probably a symptom of how horrible all this CSS is. + @media screen and (min-width: $desktop) { + .navbar-end > .navbar-item:not(:hover) { + color: white; + outline-color: white; + } + } + .navbar-link::after { border-color: $justfix-blue; } diff --git a/frontend/templates/frontend/safe_mode_ui.html b/frontend/templates/frontend/safe_mode_ui.html new file mode 100644 index 000000000..4e0f4ecac --- /dev/null +++ b/frontend/templates/frontend/safe_mode_ui.html @@ -0,0 +1,42 @@ +{% if is_safe_mode_enabled %} +
+
+
+
+ {% csrf_token %} +
+
+ This site is currently in compatibility mode. For an enhanced + experience, you can disable it, but this may cause compatibility issues + with your current browser. +
+
+ +
+
+
+
+
+
+{% else %} + +{% endif %} diff --git a/frontend/tests/test_safe_mode.py b/frontend/tests/test_safe_mode.py new file mode 100644 index 000000000..a8f2f3d58 --- /dev/null +++ b/frontend/tests/test_safe_mode.py @@ -0,0 +1,92 @@ +import pytest +from django.urls import path, include +from django.template import Template, RequestContext +from django.http import HttpResponse + +from frontend.context_processors import safe_mode as ctx_processor + + +def show_safe_mode_snippet(request): + template = Template( + ''' + {{ SAFE_MODE_SNIPPET }} + {% include 'frontend/safe_mode_ui.html' %} + '''.strip() + ) + return HttpResponse(template.render(RequestContext(request))) + + +urlpatterns = [ + path('snippet', show_safe_mode_snippet), + path('safe-mode/', include('frontend.safe_mode')), +] + + +class FakeRequest: + def __init__(self, **kwargs): + self.session = kwargs + + +def test_ctx_processor_works_when_not_in_safe_mode(): + d = ctx_processor(FakeRequest()) + assert d['is_safe_mode_enabled'] is False + assert 'SAFE_MODE_SNIPPET' in d + + +def test_ctx_processor_works_when_in_safe_mode(): + d = ctx_processor(FakeRequest(enable_safe_mode=True)) + assert d['is_safe_mode_enabled'] is True + assert 'SAFE_MODE_SNIPPET' not in d + + +# A string we know will be in the minified JS snippet. +JS_SENTINEL = "var SHOW_UI_DELAY_MS=" + + +def get_snippet_html(client): + res = client.get('/snippet') + assert res.status_code == 200 + return res.content.decode('utf-8') + + +def assert_html_is_not_in_safe_mode(html): + assert JS_SENTINEL in html + assert 'Activate compatibility mode' in html + + +def assert_html_is_in_safe_mode(html): + assert JS_SENTINEL not in html + assert 'Deactivate compatibility mode' in html + + +def enable_safe_mode(client): + session = client.session + session['enable_safe_mode'] = True + session.save() + + +@pytest.mark.urls(__name__) +def test_snippet_and_ui_work_when_not_in_safe_mode(client): + assert_html_is_not_in_safe_mode(get_snippet_html(client)) + + +@pytest.mark.django_db +@pytest.mark.urls(__name__) +def test_snippet_and_ui_work_when_in_safe_mode(client): + enable_safe_mode(client) + assert_html_is_in_safe_mode(get_snippet_html(client)) + + +@pytest.mark.django_db +@pytest.mark.urls(__name__) +def test_activating_and_deactivating_safe_mode_works(django_app): + response = django_app.get('/snippet') + assert_html_is_not_in_safe_mode(response) + response = response.form.submit().follow() + + assert response.status == '200 OK' + assert_html_is_in_safe_mode(response) + + response = response.form.submit().follow() + assert response.status == '200 OK' + assert_html_is_not_in_safe_mode(response) diff --git a/jest.config.js b/jest.config.js index 612ef27e5..84dffa35a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,6 +7,7 @@ module.exports = { "frontend" ], "collectCoverage": true, + "coveragePathIgnorePatterns": ["/node_modules/", "safe-mode-globals.d.ts"], "coverageReporters": [ "lcov", "html" diff --git a/package.json b/package.json index cb4e043b4..8b275e986 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,10 @@ "webpack": "webpack", "webpack:watch": "webpack --watch", "webpack:querybuilder": "webpack --config frontend/webpack/querybuilder.config.js --silent", - "build": "npm run sass && npm run webpack", - "start": "npm run sass && npm run webpack:querybuilder && concurrently --kill-others \"npm run webpack:watch\" \"npm run sass:watch\" \"npm run querybuilder:watch\"" + "safe_mode_snippet": "uglifyjs frontend/safe_mode/safe-mode.js -o frontend/safe_mode/safe-mode.min.js", + "safe_mode_snippet:watch": "node frontend/safe_mode/watcher.js", + "build": "npm run sass && npm run webpack && npm run safe_mode_snippet", + "start": "npm run sass && npm run webpack:querybuilder && concurrently --kill-others \"npm run webpack:watch\" \"npm run sass:watch\" \"npm run querybuilder:watch\" \"npm run safe_mode_snippet:watch\"" }, "repository": { "type": "git", diff --git a/project/schema.py b/project/schema.py index 1be6f71dd..158f56f38 100644 --- a/project/schema.py +++ b/project/schema.py @@ -8,6 +8,7 @@ from onboarding.schema import OnboardingMutations, OnboardingSessionInfo from issues.schema import IssueMutations, IssueSessionInfo from loc.schema import LocMutations, LocSessionInfo +from frontend import safe_mode from . import forms @@ -29,6 +30,14 @@ class SessionInfo(LocSessionInfo, OnboardingSessionInfo, IssueSessionInfo, graph required=True ) + is_safe_mode_enabled = graphene.Boolean( + description=( + "Whether or not the current session has safe/compatibility mode " + "compatibility mode) enabled." + ), + required=True + ) + def resolve_phone_number(self, info: ResolveInfo) -> Optional[str]: request = info.context if not request.user.is_authenticated: @@ -42,6 +51,9 @@ def resolve_csrf_token(self, info: ResolveInfo) -> str: def resolve_is_staff(self, info: ResolveInfo) -> bool: return info.context.user.is_staff + def resolve_is_safe_mode_enabled(self, info: ResolveInfo) -> bool: + return safe_mode.is_enabled(info.context) + class Example(DjangoFormMutation): class Meta: diff --git a/project/settings.py b/project/settings.py index 172ad15c9..55b37a9bd 100644 --- a/project/settings.py +++ b/project/settings.py @@ -82,6 +82,7 @@ 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', + 'frontend.context_processors.safe_mode', 'project.context_processors.ga_snippet', 'project.context_processors.rollbar_snippet', ], diff --git a/project/templates/index.html b/project/templates/index.html index 4186d5df5..0d91b1cf8 100644 --- a/project/templates/index.html +++ b/project/templates/index.html @@ -1,9 +1,10 @@ {% load static %} - + + {{ SAFE_MODE_SNIPPET }} {{ ROLLBAR_SNIPPET }} {{ GA_SNIPPET }} {{ title_tag }} @@ -11,9 +12,12 @@
{{ modal_html }}
{{ initial_render }}
- {{ initial_props|json_script:'initial-props' }} - {% for bundle_url in bundle_urls %} - - {% endfor %} + {% if not is_safe_mode_enabled %} + {{ initial_props|json_script:'initial-props' }} + {% for bundle_url in bundle_urls %} + + {% endfor %} + {% endif %} + {% include 'frontend/safe_mode_ui.html' %} \ No newline at end of file diff --git a/project/tests/test_views.py b/project/tests/test_views.py index 5367a8dd9..d1a888559 100644 --- a/project/tests/test_views.py +++ b/project/tests/test_views.py @@ -10,6 +10,7 @@ ) from users.tests.factories import UserFactory from .util import qdict +from frontend.tests import test_safe_mode def test_get_legacy_form_submission_raises_errors(graphql_client): @@ -47,12 +48,34 @@ def test_invalid_post_returns_400(client): assert response.content == b'No GraphQL query found' -def test_index_works(client): +# HTML we know will appear in pages only when safe mode is enabled/disabled. +SAFE_MODE_ENABLED_SENTINEL = "navbar-menu is-active" +SAFE_MODE_DISABLED_SENTINEL = "main.bundle.js" + + +def test_index_works_when_not_in_safe_mode(client): response = client.get('/') assert response.status_code == 200 assert 'JustFix.nyc' in response.context['title_tag'] assert '