]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-10709 Web API page looses filtering after clicking on endpoint
authorPascal Mugnier <pascal.mugnier@sonarsource.com>
Mon, 4 Jun 2018 09:14:17 +0000 (11:14 +0200)
committerSonarTech <sonartech@sonarsource.com>
Wed, 6 Jun 2018 18:20:51 +0000 (20:20 +0200)
server/sonar-web/src/main/js/apps/web-api/components/Domain.tsx
server/sonar-web/src/main/js/apps/web-api/components/Menu.tsx
server/sonar-web/src/main/js/apps/web-api/components/Search.tsx
server/sonar-web/src/main/js/apps/web-api/components/WebApiApp.tsx
server/sonar-web/src/main/js/apps/web-api/components/__tests__/Domain-test.tsx
server/sonar-web/src/main/js/apps/web-api/components/__tests__/Menu-test.tsx
server/sonar-web/src/main/js/apps/web-api/components/__tests__/Search-test.tsx
server/sonar-web/src/main/js/apps/web-api/components/__tests__/__snapshots__/Menu-test.tsx.snap
server/sonar-web/src/main/js/apps/web-api/components/__tests__/__snapshots__/Search-test.tsx.snap
server/sonar-web/src/main/js/apps/web-api/utils.ts

index 4c9bbcc042631e6e0829b9931f1cab138a98ee90..bf7798134fdb44bfbb06d6de46d6b8bd13f1e08e 100644 (file)
@@ -21,20 +21,16 @@ import * as React from 'react';
 import Action from './Action';
 import DeprecatedBadge from './DeprecatedBadge';
 import InternalBadge from './InternalBadge';
-import { getActionKey, actionsFilter } from '../utils';
+import { getActionKey, actionsFilter, Query } from '../utils';
 import { Domain as DomainType } from '../../../api/web-api';
 
 interface Props {
   domain: DomainType;
-  showDeprecated: boolean;
-  showInternal: boolean;
-  searchQuery: string;
+  query: Query;
 }
 
-export default function Domain({ domain, showInternal, showDeprecated, searchQuery }: Props) {
-  const filteredActions = domain.actions.filter(action =>
-    actionsFilter(showDeprecated, showInternal, searchQuery, domain, action)
-  );
+export default function Domain({ domain, query }: Props) {
+  const filteredActions = domain.actions.filter(action => actionsFilter(query, domain, action));
 
   return (
     <div className="web-api-domain">
@@ -64,11 +60,11 @@ export default function Domain({ domain, showInternal, showDeprecated, searchQue
       <div className="web-api-domain-actions">
         {filteredActions.map(action => (
           <Action
-            key={getActionKey(domain.path, action.key)}
             action={action}
             domain={domain}
-            showDeprecated={showDeprecated}
-            showInternal={showInternal}
+            key={getActionKey(domain.path, action.key)}
+            showDeprecated={query.deprecated}
+            showInternal={query.internal}
           />
         ))}
       </div>
index 334979d242c70707664ded842d0032b21e0d7ff4..b45675c25e9d5657be109de6dc924a1689c3a531 100644 (file)
@@ -22,24 +22,20 @@ import { Link } from 'react-router';
 import * as classNames from 'classnames';
 import DeprecatedBadge from './DeprecatedBadge';
 import InternalBadge from './InternalBadge';
-import { isDomainPathActive, actionsFilter } from '../utils';
+import { isDomainPathActive, actionsFilter, Query, serializeQuery } from '../utils';
 import { Domain } from '../../../api/web-api';
 
 interface Props {
   domains: Domain[];
-  showDeprecated: boolean;
-  showInternal: boolean;
-  searchQuery: string;
+  query: Query;
   splat: string;
 }
 
 export default function Menu(props: Props) {
-  const { domains, showInternal, showDeprecated, searchQuery, splat } = props;
+  const { domains, query, splat } = props;
   const filteredDomains = (domains || [])
     .map(domain => {
-      const filteredActions = domain.actions.filter(action =>
-        actionsFilter(showDeprecated, showInternal, searchQuery, domain, action)
-      );
+      const filteredActions = domain.actions.filter(action => actionsFilter(query, domain, action));
       return { ...domain, filteredActions };
     })
     .filter(domain => domain.filteredActions.length);
@@ -53,7 +49,7 @@ export default function Menu(props: Props) {
               active: isDomainPathActive(domain.path, splat)
             })}
             key={domain.path}
-            to={'/web_api/' + domain.path}>
+            to={{ pathname: '/web_api/' + domain.path, query: serializeQuery(query) }}>
             <h3 className="list-group-item-heading">
               {domain.path}
               {domain.deprecated && <DeprecatedBadge />}
index 49bc4e20220e04cef6c7b0a48a0b27aeffe1c600..09a1f31986334cb3ec6191f2e9fdf103fe46e9ab 100644 (file)
@@ -22,26 +22,30 @@ import Checkbox from '../../../components/controls/Checkbox';
 import HelpTooltip from '../../../components/controls/HelpTooltip';
 import { translate } from '../../../helpers/l10n';
 import SearchBox from '../../../components/controls/SearchBox';
+import { Query } from '../utils';
 
 interface Props {
-  showDeprecated: boolean;
-  showInternal: boolean;
+  query: Query;
   onSearch: (search: string) => void;
   onToggleInternal: () => void;
   onToggleDeprecated: () => void;
 }
 
 export default function Search(props: Props) {
-  const { showInternal, showDeprecated, onToggleInternal, onToggleDeprecated } = props;
+  const { query, onToggleInternal, onToggleDeprecated } = props;
 
   return (
     <div className="web-api-search">
       <div>
-        <SearchBox onChange={props.onSearch} placeholder={translate('api_documentation.search')} />
+        <SearchBox
+          onChange={props.onSearch}
+          placeholder={translate('api_documentation.search')}
+          value={query.search}
+        />
       </div>
 
       <div className="big-spacer-top">
-        <Checkbox checked={showInternal} className="text-middle" onCheck={onToggleInternal}>
+        <Checkbox checked={query.internal} className="text-middle" onCheck={onToggleInternal}>
           <span className="little-spacer-left">{translate('api_documentation.show_internal')}</span>
         </Checkbox>
         <HelpTooltip
@@ -51,7 +55,7 @@ export default function Search(props: Props) {
       </div>
 
       <div className="spacer-top">
-        <Checkbox checked={showDeprecated} className="text-middle" onCheck={onToggleDeprecated}>
+        <Checkbox checked={query.deprecated} className="text-middle" onCheck={onToggleDeprecated}>
           <span className="little-spacer-left">
             {translate('api_documentation.show_deprecated')}
           </span>
index 0b79a15c48360272e29ed3eb3757bb553a2022b3..77630d3f8a44a30e705f6c596836766c73f0b975 100644 (file)
@@ -26,21 +26,20 @@ import Search from './Search';
 import Domain from './Domain';
 import { Domain as DomainType, fetchWebApi } from '../../../api/web-api';
 import ScreenPositionHelper from '../../../components/common/ScreenPositionHelper';
-import { getActionKey, isDomainPathActive } from '../utils';
+import { getActionKey, isDomainPathActive, Query, serializeQuery, parseQuery } from '../utils';
 import { scrollToElement } from '../../../helpers/scrolling';
 import { translate } from '../../../helpers/l10n';
 import Suggestions from '../../../app/components/embed-docs-modal/Suggestions';
+import { RawQuery } from '../../../helpers/query';
 import '../styles/web-api.css';
 
 interface Props {
+  location: { pathname: string; query: RawQuery };
   params: { splat?: string };
 }
 
 interface State {
   domains: DomainType[];
-  searchQuery: string;
-  showDeprecated: boolean;
-  showInternal: boolean;
 }
 
 export default class WebApiApp extends React.PureComponent<Props, State> {
@@ -52,12 +51,7 @@ export default class WebApiApp extends React.PureComponent<Props, State> {
 
   constructor(props: Props) {
     super(props);
-    this.state = {
-      domains: [],
-      searchQuery: '',
-      showDeprecated: false,
-      showInternal: false
-    };
+    this.state = { domains: [] };
   }
 
   componentDidMount() {
@@ -103,47 +97,62 @@ export default class WebApiApp extends React.PureComponent<Props, State> {
     }
   };
 
+  updateQuery = (newQuery: Partial<Query>) => {
+    const query = serializeQuery({ ...parseQuery(this.props.location.query), ...newQuery });
+    this.context.router.push({ pathname: this.props.location.pathname, query });
+  };
+
   toggleInternalInitially() {
     const splat = this.props.params.splat || '';
-    const { domains, showInternal } = this.state;
-
-    if (!showInternal) {
-      domains.forEach(domain => {
-        if (domain.path === splat && domain.internal) {
-          this.setState({ showInternal: true });
+    const { domains } = this.state;
+    const query = parseQuery(this.props.location.query);
+
+    if (!query.internal && splat) {
+      const domain = domains.find(domain => domain.path.startsWith(splat));
+      if (domain) {
+        let action;
+        if (domain.path !== splat) {
+          action = domain.actions.find(action => getActionKey(domain.path, action.key) === splat);
         }
-        domain.actions.forEach(action => {
-          const actionKey = getActionKey(domain.path, action.key);
-          if (actionKey === splat && action.internal) {
-            this.setState({ showInternal: true });
-          }
-        });
-      });
+        if (domain.internal || (action && action.internal)) {
+          this.updateQuery({ internal: true });
+        }
+      }
     }
   }
 
-  handleSearch = (searchQuery: string) => this.setState({ searchQuery });
+  handleSearch = (search: string) => {
+    this.updateQuery({ search });
+  };
 
   handleToggleInternal = () => {
     const splat = this.props.params.splat || '';
     const { router } = this.context;
     const { domains } = this.state;
     const domain = domains.find(domain => isDomainPathActive(domain.path, splat));
-    const showInternal = !this.state.showInternal;
+    const query = parseQuery(this.props.location.query);
+    const internal = !query.internal;
 
-    if (domain && domain.internal && !showInternal) {
-      router.push('/web_api');
+    if (domain && domain.internal && !internal) {
+      router.push({
+        pathname: '/web_api',
+        query: { ...serializeQuery(query), internal: false }
+      });
+      return;
     }
 
-    this.setState({ showInternal });
+    this.updateQuery({ internal });
   };
 
-  handleToggleDeprecated = () =>
-    this.setState(state => ({ showDeprecated: !state.showDeprecated }));
+  handleToggleDeprecated = () => {
+    const query = parseQuery(this.props.location.query);
+    this.updateQuery({ deprecated: !query.deprecated });
+  };
 
   render() {
     const splat = this.props.params.splat || '';
-    const { domains, showInternal, showDeprecated, searchQuery } = this.state;
+    const query = parseQuery(this.props.location.query);
+    const { domains } = this.state;
 
     const domain = domains.find(domain => isDomainPathActive(domain.path, splat));
 
@@ -163,20 +172,13 @@ export default class WebApiApp extends React.PureComponent<Props, State> {
                   </div>
 
                   <Search
-                    showDeprecated={showDeprecated}
-                    showInternal={showInternal}
                     onSearch={this.handleSearch}
-                    onToggleInternal={this.handleToggleInternal}
                     onToggleDeprecated={this.handleToggleDeprecated}
+                    onToggleInternal={this.handleToggleInternal}
+                    query={query}
                   />
 
-                  <Menu
-                    domains={this.state.domains}
-                    showDeprecated={showDeprecated}
-                    showInternal={showInternal}
-                    searchQuery={searchQuery}
-                    splat={splat}
-                  />
+                  <Menu domains={this.state.domains} query={query} splat={splat} />
                 </div>
               </div>
             </div>
@@ -185,15 +187,7 @@ export default class WebApiApp extends React.PureComponent<Props, State> {
 
         <div className="layout-page-main">
           <div className="layout-page-main-inner">
-            {domain && (
-              <Domain
-                key={domain.path}
-                domain={domain}
-                showDeprecated={showDeprecated}
-                showInternal={showInternal}
-                searchQuery={searchQuery}
-              />
-            )}
+            {domain && <Domain domain={domain} key={domain.path} query={query} />}
           </div>
         </div>
       </div>
index ddc3c2921d0a1d4c602c8eee8ace23242ec0451c..cdf43ee79b6df30585bf3874b9df0c3772ec778f 100644 (file)
@@ -38,16 +38,17 @@ const DOMAIN = {
 };
 const DEFAULT_PROPS = {
   domain: DOMAIN,
-  showDeprecated: false,
-  showInternal: false,
-  searchQuery: ''
+  query: { search: '', deprecated: false, internal: false }
 };
+const SHOW_DEPRECATED = { search: '', deprecated: true, internal: false };
+const SHOW_INTERNAL = { search: '', deprecated: false, internal: true };
+const SEARCH_FOO = { search: 'Foo', deprecated: false, internal: false };
 
 it('should render deprecated actions', () => {
   const action = { ...ACTION, deprecatedSince: '5.0' };
   const domain = { ...DOMAIN, actions: [action] };
   expect(
-    shallow(<Domain {...DEFAULT_PROPS} domain={domain} showDeprecated={true} />)
+    shallow(<Domain {...DEFAULT_PROPS} domain={domain} query={SHOW_DEPRECATED} />)
   ).toMatchSnapshot();
 });
 
@@ -55,7 +56,7 @@ it('should not render deprecated actions', () => {
   const action = { ...ACTION, deprecatedSince: '5.0' };
   const domain = { ...DOMAIN, actions: [action] };
   expect(
-    shallow(<Domain {...DEFAULT_PROPS} domain={domain} showDeprecated={false} />)
+    shallow(<Domain {...DEFAULT_PROPS} domain={domain} query={SHOW_INTERNAL} />)
   ).toMatchSnapshot();
 });
 
@@ -63,23 +64,21 @@ it('should render internal actions', () => {
   const action = { ...ACTION, internal: true };
   const domain = { ...DOMAIN, actions: [action] };
   expect(
-    shallow(<Domain {...DEFAULT_PROPS} domain={domain} showInternal={true} />)
+    shallow(<Domain {...DEFAULT_PROPS} domain={domain} query={SHOW_INTERNAL} />)
   ).toMatchSnapshot();
 });
 
 it('should not render internal actions', () => {
   const action = { ...ACTION, internal: true };
   const domain = { ...DOMAIN, actions: [action] };
-  expect(
-    shallow(<Domain {...DEFAULT_PROPS} domain={domain} showInternal={false} />)
-  ).toMatchSnapshot();
+  expect(shallow(<Domain {...DEFAULT_PROPS} domain={domain} />)).toMatchSnapshot();
 });
 
 it('should render only actions matching the query', () => {
   const actions = [ACTION, { ...ACTION, key: 'bar', description: 'Bar desc' }];
   const domain = { ...DOMAIN, actions };
   expect(
-    shallow(<Domain {...DEFAULT_PROPS} domain={domain} searchQuery="Foo" />)
+    shallow(<Domain {...DEFAULT_PROPS} domain={domain} query={SEARCH_FOO} />)
   ).toMatchSnapshot();
 });
 
@@ -91,6 +90,6 @@ it('should also render actions with a description matching the query', () => {
   ];
   const domain = { ...DOMAIN, actions };
   expect(
-    shallow(<Domain {...DEFAULT_PROPS} domain={domain} searchQuery="Foo" />)
+    shallow(<Domain {...DEFAULT_PROPS} domain={domain} query={SEARCH_FOO} />)
   ).toMatchSnapshot();
 });
index 89b885c05ccf11bdf5e0b3e45037824d145583c0..5c2a39fb7bfd27170293dd4a084d1a7d9fce27b3 100644 (file)
@@ -45,12 +45,15 @@ const DOMAIN2 = {
 };
 const PROPS = {
   domains: [DOMAIN1, DOMAIN2],
-  showDeprecated: false,
-  showInternal: false,
-  searchQuery: '',
+  query: { search: '', deprecated: false, internal: false },
   splat: ''
 };
 
+const SHOW_DEPRECATED = { search: '', deprecated: true, internal: false };
+const SHOW_INTERNAL = { search: '', deprecated: false, internal: true };
+const SEARCH_FOO = { search: 'Foo', deprecated: false, internal: false };
+const SEARCH_BAR = { search: 'Bar', deprecated: false, internal: false };
+
 it('should render deprecated domains', () => {
   const domain = {
     ...DOMAIN2,
@@ -58,7 +61,7 @@ it('should render deprecated domains', () => {
     actions: [{ ...ACTION, deprecatedSince: '5.0' }]
   };
   const domains = [DOMAIN1, domain];
-  expect(shallow(<Menu {...PROPS} domains={domains} showDeprecated={true} />)).toMatchSnapshot();
+  expect(shallow(<Menu {...PROPS} domains={domains} query={SHOW_DEPRECATED} />)).toMatchSnapshot();
 });
 
 it('should not render deprecated domains', () => {
@@ -68,19 +71,19 @@ it('should not render deprecated domains', () => {
     actions: [{ ...ACTION, deprecatedSince: '5.0' }]
   };
   const domains = [DOMAIN1, domain];
-  expect(shallow(<Menu {...PROPS} domains={domains} showDeprecated={false} />)).toMatchSnapshot();
+  expect(shallow(<Menu {...PROPS} domains={domains} />)).toMatchSnapshot();
 });
 
 it('should render internal domains', () => {
   const domain = { ...DOMAIN2, internal: true, actions: [{ ...ACTION, internal: true }] };
   const domains = [DOMAIN1, domain];
-  expect(shallow(<Menu {...PROPS} domains={domains} showInternal={true} />)).toMatchSnapshot();
+  expect(shallow(<Menu {...PROPS} domains={domains} query={SHOW_INTERNAL} />)).toMatchSnapshot();
 });
 
 it('should not render internal domains', () => {
   const domain = { ...DOMAIN2, internal: true, actions: [{ ...ACTION, internal: true }] };
   const domains = [DOMAIN1, domain];
-  expect(shallow(<Menu {...PROPS} domains={domains} showInternal={false} />)).toMatchSnapshot();
+  expect(shallow(<Menu {...PROPS} domains={domains} />)).toMatchSnapshot();
 });
 
 it('should render only domains with an action matching the query', () => {
@@ -89,7 +92,7 @@ it('should render only domains with an action matching the query', () => {
     actions: [{ ...ACTION, key: 'bar', path: 'bar', description: 'Bar Desc' }]
   };
   const domains = [DOMAIN1, domain];
-  expect(shallow(<Menu {...PROPS} domains={domains} searchQuery="Foo" />)).toMatchSnapshot();
+  expect(shallow(<Menu {...PROPS} domains={domains} query={SEARCH_FOO} />)).toMatchSnapshot();
 });
 
 it('should also render domains with an actions description matching the query', () => {
@@ -100,5 +103,5 @@ it('should also render domains with an actions description matching the query',
     actions: [{ ...ACTION, key: 'baz', path: 'baz', description: 'barbaz' }]
   };
   const domains = [DOMAIN1, DOMAIN2, domain];
-  expect(shallow(<Menu {...PROPS} domains={domains} searchQuery="Bar" />)).toMatchSnapshot();
+  expect(shallow(<Menu {...PROPS} domains={domains} query={SEARCH_BAR} />)).toMatchSnapshot();
 });
index c4a67af96ddbb7216434e28ddb5a25bfede35231..f476ceccc49280cc3112df02853ea760840e231b 100644 (file)
@@ -22,8 +22,7 @@ import { shallow } from 'enzyme';
 import Search from '../Search';
 
 const PROPS = {
-  showDeprecated: false,
-  showInternal: false,
+  query: { search: '', deprecated: false, internal: false },
   onSearch: () => {},
   onToggleInternal: () => {},
   onToggleDeprecated: () => {}
index 57099cb038352b3d8db395977959ef4b7c7b564d..b2479be343b1cf1237bf9ed0986c5f3d2546dae9 100644 (file)
@@ -12,7 +12,14 @@ exports[`should also render domains with an actions description matching the que
       key="bar"
       onlyActiveOnIndex={false}
       style={Object {}}
-      to="/web_api/bar"
+      to={
+        Object {
+          "pathname": "/web_api/bar",
+          "query": Object {
+            "query": "Bar",
+          },
+        }
+      }
     >
       <h3
         className="list-group-item-heading"
@@ -25,7 +32,14 @@ exports[`should also render domains with an actions description matching the que
       key="baz"
       onlyActiveOnIndex={false}
       style={Object {}}
-      to="/web_api/baz"
+      to={
+        Object {
+          "pathname": "/web_api/baz",
+          "query": Object {
+            "query": "Bar",
+          },
+        }
+      }
     >
       <h3
         className="list-group-item-heading"
@@ -49,7 +63,12 @@ exports[`should not render deprecated domains 1`] = `
       key="foo"
       onlyActiveOnIndex={false}
       style={Object {}}
-      to="/web_api/foo"
+      to={
+        Object {
+          "pathname": "/web_api/foo",
+          "query": Object {},
+        }
+      }
     >
       <h3
         className="list-group-item-heading"
@@ -73,7 +92,12 @@ exports[`should not render internal domains 1`] = `
       key="foo"
       onlyActiveOnIndex={false}
       style={Object {}}
-      to="/web_api/foo"
+      to={
+        Object {
+          "pathname": "/web_api/foo",
+          "query": Object {},
+        }
+      }
     >
       <h3
         className="list-group-item-heading"
@@ -97,7 +121,14 @@ exports[`should render deprecated domains 1`] = `
       key="foo"
       onlyActiveOnIndex={false}
       style={Object {}}
-      to="/web_api/foo"
+      to={
+        Object {
+          "pathname": "/web_api/foo",
+          "query": Object {
+            "deprecated": true,
+          },
+        }
+      }
     >
       <h3
         className="list-group-item-heading"
@@ -110,7 +141,14 @@ exports[`should render deprecated domains 1`] = `
       key="bar"
       onlyActiveOnIndex={false}
       style={Object {}}
-      to="/web_api/bar"
+      to={
+        Object {
+          "pathname": "/web_api/bar",
+          "query": Object {
+            "deprecated": true,
+          },
+        }
+      }
     >
       <h3
         className="list-group-item-heading"
@@ -134,7 +172,14 @@ exports[`should render internal domains 1`] = `
       key="foo"
       onlyActiveOnIndex={false}
       style={Object {}}
-      to="/web_api/foo"
+      to={
+        Object {
+          "pathname": "/web_api/foo",
+          "query": Object {
+            "internal": true,
+          },
+        }
+      }
     >
       <h3
         className="list-group-item-heading"
@@ -147,7 +192,14 @@ exports[`should render internal domains 1`] = `
       key="bar"
       onlyActiveOnIndex={false}
       style={Object {}}
-      to="/web_api/bar"
+      to={
+        Object {
+          "pathname": "/web_api/bar",
+          "query": Object {
+            "internal": true,
+          },
+        }
+      }
     >
       <h3
         className="list-group-item-heading"
@@ -172,7 +224,14 @@ exports[`should render only domains with an action matching the query 1`] = `
       key="foo"
       onlyActiveOnIndex={false}
       style={Object {}}
-      to="/web_api/foo"
+      to={
+        Object {
+          "pathname": "/web_api/foo",
+          "query": Object {
+            "query": "Foo",
+          },
+        }
+      }
     >
       <h3
         className="list-group-item-heading"
index 1802e14098850eda6d01e28e7e4b5a4716038b62..5555d7fa83b75ba7fc671182de22ca0e41edffa9 100644 (file)
@@ -8,6 +8,7 @@ exports[`should render correctly 1`] = `
     <SearchBox
       onChange={[Function]}
       placeholder="api_documentation.search"
+      value=""
     />
   </div>
   <div
index 4c149fad5895764af867fce729a7345a9d5000ff..dceaf980e70392394c72fd5a907715446d32810b 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+import { memoize } from 'lodash';
 import { Domain, Action } from '../../api/web-api';
+import {
+  cleanQuery,
+  RawQuery,
+  serializeString,
+  parseAsOptionalBoolean,
+  parseAsString
+} from '../../helpers/query';
 
-export function actionsFilter(
-  showDeprecated: boolean,
-  showInternal: boolean,
-  searchQuery: string,
-  domain: Domain,
-  action: Action
-) {
-  const lowSearchQuery = searchQuery.toLowerCase();
+export interface Query {
+  search: string;
+  deprecated: boolean;
+  internal: boolean;
+}
+
+export function actionsFilter(query: Query, domain: Domain, action: Action) {
+  const lowSearchQuery = query.search.toLowerCase();
   return (
-    (showInternal || !action.internal) &&
-    (showDeprecated || !action.deprecatedSince) &&
+    (query.internal || !action.internal) &&
+    (query.deprecated || !action.deprecatedSince) &&
     (getActionKey(domain.path, action.key).includes(lowSearchQuery) ||
       (action.description || '').toLowerCase().includes(lowSearchQuery))
   );
@@ -55,3 +63,17 @@ export const isDomainPathActive = (path: string, splat: string) => {
 
   return true;
 };
+
+export const parseQuery = memoize((urlQuery: RawQuery): Query => ({
+  search: parseAsString(urlQuery['query']),
+  deprecated: parseAsOptionalBoolean(urlQuery['deprecated']) || false,
+  internal: parseAsOptionalBoolean(urlQuery['internal']) || false
+}));
+
+export const serializeQuery = memoize((query: Query): RawQuery =>
+  cleanQuery({
+    query: query.search ? serializeString(query.search) : undefined,
+    deprecated: query.deprecated || undefined,
+    internal: query.internal || undefined
+  })
+);