All files / src routing.js

100% Statements 68/68
98.46% Branches 64/65
100% Functions 16/16
100% Lines 68/68

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384                                                                                                                                                                                                      180x 180x                                                 1x           26x       26x               38x                             14x                     51x   4x   47x   24x   23x                                 31x 31x 31x   14x           17x           16x   12x     4x   4x 4x   1x   3x   1x   4x                   58x 58x   1x   57x 57x 57x   3x   57x   53x   1x 1x   1x     52x   4x               18x   8x       18x   14x   1x   13x   6x       7x         4x   1x         3x                   3x                 8x                 3x                 12x 12x 12x                     26x       26x   8x   7x   6x       26x                 9x 9x 9x       38x 38x            
/*
 * Part of Pleiar.no - a collection of tools for nurses
 *
 * Copyright (C) Fagforbundet 2019
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 *
 */
// @flow
 
/*
 * RATIONALE
 * ---------
 * In pleiar.no the redux store is the single source of truth for state data.
 * However, for linkability, sometimes some of that state needs to be put into
 * the URL, at which point we have a problem. We need to synchronize the state
 * from the redux store to the URL, and from the URL to the redux store.
 *
 * If we don't want to do two-way sync, then we need to put the data into
 * the URL when entered, and the result of that is that 1) redux isn't the
 * single source of truth, and 2) we get large performance issues, because
 * updating the URL is very expensive, and input fields start lagging, fast.
 *
 * So, either we need to not do search updates while the user is typing,
 * ending up in a degraded user experience, not update th URL, which also
 * results in a degraded exprience since the user can't link, or we need
 * to perform some sort of two/way sync between the redux store and the
 * URL.
 *
 * This is a result of choosing the latter option.
 *
 * REQUIREMENTS
 * ------------
 * There are some requirements to the implementation. The most important bit
 * is performance. It should not negatively affect the performance of the
 * application. Secondly, the sync must be two-way. If a user navigates to
 * a new URL, the routing helper must update the redux state accordingly.
 *
 * IMPLEMENTATION
 * --------------
 * The implementation consists of three objects. Two are react-classes,
 * one is a global helper.
 *
 * RoutingAssistantInit is a react class intended to be used high up in the
 * react hierarchy. It is cheap, and all it does is provide
 * RoutingAssistantAssistant with the redux store and react-router history
 * object.
 *
 * RoutingAssistantSyncRoutes is a react class that is used within any route
 * that requires state to be synced from the URL to the redux store.
 *
 * RoutingAssistant is the global object that contains all of the actual logic.
 * The react objects are thin wrappers around this that just help with
 * updating its state.
 *
 * IMPLEMENTATION ASSUMPTIONS
 * --------------------------
 * The RoutingAssistantSyncRoutes handler assumes that the path includes a
 * match element, which is optional, named "query", which contains the search
 * string.  It will call the onSync callback with a sanitized version of this
 * as its parameters, onSync(query);
 *
 * CAVEATS
 * -------
 * To assure preformance, there is a delay between when a user requests the URL
 * to be updated, and when it is actually updated. Updating the URL is expensive,
 * and ends up blocking the thread, so RoutingAssistant will wait 500ms before it
 * updates the URL. If another request arrives within those 500ms, it will abort the
 * previous request to update the URL, reset the timer, and wait another 500ms.
 */
 
import * as React from 'react';
import debounce from 'lodash/debounce';
import { withRouter } from 'react-router';
 
import type { RouterHistory, Match as RouterMatch } from 'react-router-dom';
 
export type onSyncSignature = (string) => mixed;
 
/**
 * Initializes the routing assistant. Only required once during the lifetime of
 * the app, and should only be placed once.
 */
class RoutingAssistantInit extends React.Component<{|history: RouterHistory, match: RouterMatch|}>
{
    // eslint-disable-next-line require-jsdoc
    render (): null
    {
        RoutingAssistant.history = this.props.history;
        return null;
    }
}
 
 
type RoutingAssistantSyncRoutesOwnProps = {|
    onSync: onSyncSignature,
|};
type RoutingAssistantSyncRoutesRouterProps = {|
    match: RouterMatch,
|};
type RoutingAssistantSyncRoutesProps = {|
    ...RoutingAssistantSyncRoutesOwnProps,
    ...RoutingAssistantSyncRoutesRouterProps,
|};
/**
 * Helper component for the RoutingAssistant that handles updating the state
 * for a single route. You should add this inside a `<Route>` for any route
 * that you want to apply the routing assistant to
 */
class RoutingAssistantSyncRoutes extends React.Component<RoutingAssistantSyncRoutesProps>
{
    // eslint-disable-next-line require-jsdoc
    componentWillUmount ()
    {
        RoutingAssistant.stateReset();
    }
 
    // eslint-disable-next-line require-jsdoc
    render (): null
    {
        RoutingAssistant.syncToStore({
            match: this.props.match,
            onSync: this.props.onSync
        });
        return null;
    }
}
 
/**
 * This is a helper object that assists with synchronizing state between the
 * URL and the application
 */
const RoutingAssistant = {
    history: ({}: RouterHistory),
    match: ({}: RouterMatch),
    debouncedReplace: (null: null | () => mixed ),
    ourChange: "",
    changingToURL: "",
    _debounceTimeout: 500,
 
    /**
     * Reset the internal state, done by RoutingAssistantSyncRoutes when it
     * unmounts, so that we know that we're now outside of a URL that we're
     * handling
     */
    stateReset ()
    {
        RoutingAssistant.ourChange = RoutingAssistant.changingToURL = "";
    },
 
    /**
     * This checks if the current URL is a URL that we have changed ourselves
     * (returns true) or if it is something outside of our control that has
     * made a change (returns false). Used to determine if we need to run an
     * onSync or not.
     */
    isManagedChange (): boolean
    {
        if(RoutingAssistant.history.action === "REPLACE")
        {
            return true;
        }
        if(RoutingAssistant.ourChange === window.location.pathname || RoutingAssistant.changingToURL === window.location.pathname)
        {
            return true;
        }
        return false;
    },
 
    /**
     * Synchronizes the URL to the redux store, using onSync(), *if* the URL
     * that we have right now wasn't changed by us. Called by
     * RoutingAssistantSyncRoutes, so this should only ever be called when we're
     * in a path that we manage.
     *
     * match is the react-router match object
     * onSync is the callback that will be called with (searchQuery,renderElements,filters)
     */
    syncToStore(settings: {|
        match: RouterMatch,
        onSync: onSyncSignature
    |})
    {
        const {match,onSync} = settings;
        RoutingAssistant.match = match;
        if(RoutingAssistant.isManagedChange())
        {
            return;
        }
        /*
         * Because react doesn't like state being changed within render(), we
         * queue our update to happen after the current render cycle.
         */
        setTimeout(() =>
        {
            /*
             * Protection against ourselves, in case something happened to the URL
             * resulting in it becoming managed since we queued ourselves
             */
            if(RoutingAssistant.isManagedChange())
            {
                return;
            }
            /* Set the URL state so that we know we've handled it */
            RoutingAssistant.ourChange = window.location.pathname;
            /* Parse the query, if it's undefined just set it to an empty string */
            let query: ?string = match.params.query;
            if(query === undefined || query === null)
            {
                query = "";
            }
            else if(match.params.renderElements === undefined && /^el:\d+/.test(query))
            {
                query = "";
            }
            onSync(query);
        },1);
    },
 
    /**
     * This retrieves the number of elements to render. It returns either the
     * elements specified in the URL, or 10.
     */
    getAutoRenderElements (): number
    {
        const match = RoutingAssistant.match;
        if(match === undefined || match === null || match.params === null || match.params === undefined)
        {
            return 10;
        }
        let renderElements: ?string | ?number = match.params.renderElements;
        const query = match.params.query;
        if(renderElements !== undefined && renderElements !== null && typeof(renderElements) !== 'number')
        {
            renderElements = Number.parseInt(renderElements,10);
        }
        if(renderElements === null || renderElements === undefined || renderElements < 10 || Number.isNaN(renderElements))
        {
            if(query !== null && query !== undefined && query !== "" && /^el:/.test(query))
            {
                const queryNumbers = Number.parseInt(query.replace(/\D/g,''),10);
                Eif(queryNumbers !== null && !Number.isNaN(queryNumbers))
                {
                    return queryNumbers;
                }
            }
            return 10;
        }
        return renderElements;
    },
 
    /**
     * The underlaying logic that builds URL's for generate*
     */
    _constructURL(root: string, search: string, renderElements: number | "auto", filter: ?string): string
    {
        if (renderElements === "auto")
        {
            renderElements = RoutingAssistant.getAutoRenderElements();
        }
        // If we have a search query, generate /root/search/renderElements if renderElements >10,
        // /root/search if renderElements=10
        if(search != "" && search !== null && search !== undefined)
        {
            if(filter !== null && filter !== undefined && filter !== "")
            {
                return root+'/'+search+'/'+renderElements+'/'+filter;
            }
            else if(renderElements > 10)
            {
                return root+'/'+search+'/'+renderElements;
            }
            else
            {
                return root+'/'+search;
            }
        }
        // If we have no search query, but are on a renderElements greater than 10, use
        // the el:N syntax
        else if(renderElements > 10)
        {
            return root+'/el:'+renderElements;
        }
        // Otherwise, just return the root
        else
        {
            return root;
        }
    },
 
    /**
     * Variant of push() that constructs the URL by combining the provided
     * components.
     */
    generatePush(root: string, search: string, renderElements: number | "auto", filter?: string): void
    {
        return RoutingAssistant.push( RoutingAssistant._constructURL(root,search,renderElements,filter) );
    },
 
    /**
     * Variant of replace() that constructs the URL by combining the provided
     * components.
     */
    generateReplace(root: string, search: string, renderElements: number | "auto", filter?: string): void
    {
        return RoutingAssistant.replace( RoutingAssistant._constructURL(root,search,renderElements, filter) );
    },
 
    /**
     * Variant of replaceNOW() that constructs the URL by combining the provided
     * components.
     */
    generateReplaceNOW(root: string, search: string, renderElements: number | "auto", filter?: string | null): void
    {
        return RoutingAssistant.replaceNOW( RoutingAssistant._constructURL(root,search,renderElements, filter) );
    },
 
    /**
     * Synonym for history.push() that sets our internal state so that
     * we know we manage this change, and avoid updating redux state.
     */
    push(URL: string)
    {
        RoutingAssistant.stateReset();
        RoutingAssistant.ourChange = URL;
        RoutingAssistant.history.push(URL);
    },
 
    /**
     * Variant of history.push that is debounced (so that it is only actually
     * executed if it has been at least 500ms since the last replace() call). This
     * ensures performance when a user is typing into a field, since updating the URL
     * is an expensive operation.
     */
    replace(URL: string)
    {
        RoutingAssistant.changingToURL = URL;
        // We're a static object, we have only one instance, so we don't have a
        // constructor() to build our debouncedReplace method in, so we opt to
        // do it the first time replace() is called.
        if (!RoutingAssistant.debouncedReplace)
        {
            RoutingAssistant.debouncedReplace = debounce( () =>
            {
                if(RoutingAssistant.changingToURL !== "")
                {
                    RoutingAssistant.replaceNOW(RoutingAssistant.changingToURL);
                }
            },RoutingAssistant._debounceTimeout);
        }
        RoutingAssistant.debouncedReplace();
    },
 
    /**
     * Variant of replace() that is not debounced. This is also used by the
     * debounced variant inside the debounce callback.
     */
    replaceNOW (URL: string)
    {
        RoutingAssistant.ourChange = URL;
        RoutingAssistant.history.replace(URL);
        RoutingAssistant.changingToURL = "";
    }
};
 
const RoutingAssistantInitWithRouter = (withRouter(RoutingAssistantInit): React.AbstractComponent<{| |}>);
const RoutingAssistantSyncRoutesWithRouter = (withRouter(RoutingAssistantSyncRoutes): React.AbstractComponent<RoutingAssistantSyncRoutesOwnProps>);
 
export default RoutingAssistant;
export { RoutingAssistantSyncRoutesWithRouter as RoutingAssistantSyncRoutes, RoutingAssistantInitWithRouter as RoutingAssistantInit };
// Testing exports
export { RoutingAssistantSyncRoutes as RoutingAssistantSyncRoutesRaw, RoutingAssistantInit as RoutingAssistantInitRaw };