Browse Source

feat(replays): Create a location qs manager and use it for some state across the replay details page (#36240)

Adds a hook to serialize and update the query params in the url bar. This hook will let us use the query params as a place to store some state, and the read it back later.

It's not great for things that frequently update because the core Router will cause re-renders whenever things change :(

The hook is flexible in that it'll allow access to the whole state object: const {getParamValue} = useUrlParams(); // can get any value
or it'll allow you to narrow the scope and get/set a single value only: const {getParamValue: getName} = useUrlParams('name'); // get name every time
It's all case sensitive.

After writing the hook I implemented it across the FocusArea tabs, the new Aside/Sidebar tabs, and also as a way to toggle between topbar and sidebar layouts in the /v2 demo details page.
ie: /v2/?l_page=sidebar or /v2/?l_page=topbar.

Also, along the way I fixed some bugs related to how we hard-coded the breadcrumbs key, and then compared that to the translated t('Breadcrumbs') string. Also, no more throwing an error if the Sidebar tab t_side is set to an invalid value.
Ryan Albrecht 2 years ago
parent
commit
d60a410e67

+ 39 - 0
static/app/utils/replays/hooks/useActiveReplayTab.tsx

@@ -0,0 +1,39 @@
+import {useCallback} from 'react';
+
+import {t} from 'sentry/locale';
+import useUrlParams from 'sentry/utils/replays/hooks/useUrlParams';
+
+export const ReplayTabs = {
+  console: t('Console'),
+  network: t('Network Waterfall'),
+  network_table: t('Network Table'),
+  trace: t('Trace'),
+  issues: t('Issues'),
+  tags: t('Tags'),
+  memory: t('Memory'),
+};
+
+type TabKey = keyof typeof ReplayTabs;
+
+export function isReplayTab(tab: string): tab is TabKey {
+  return tab in ReplayTabs;
+}
+
+const DEFAULT_TAB = 'console';
+
+function useActiveReplayTab() {
+  const {getParamValue, setParamValue} = useUrlParams('t_main', DEFAULT_TAB);
+
+  const paramValue = getParamValue();
+
+  return {
+    getActiveTab: useCallback(
+      () => (isReplayTab(paramValue || '') ? (paramValue as TabKey) : DEFAULT_TAB),
+      [paramValue]
+    ),
+    setActiveTab: (value: string) =>
+      isReplayTab(value) ? setParamValue(value) : setParamValue(DEFAULT_TAB),
+  };
+}
+
+export default useActiveReplayTab;

+ 0 - 12
static/app/utils/replays/hooks/useActiveTabFromLocation.tsx

@@ -1,12 +0,0 @@
-import {isReplayTab, ReplayTabs} from 'sentry/views/replays/types';
-
-const DEFAULT_TAB = ReplayTabs.CONSOLE;
-
-function useActiveTabFromLocation() {
-  const hash = location.hash.replace(/^#/, '');
-  const tabFromHash = isReplayTab(hash) ? hash.replace('%20', ' ') : DEFAULT_TAB;
-
-  return tabFromHash;
-}
-
-export default useActiveTabFromLocation;

+ 66 - 0
static/app/utils/replays/hooks/useUrlParams.tsx

@@ -0,0 +1,66 @@
+import {useCallback} from 'react';
+import {browserHistory} from 'react-router';
+
+import {useRouteContext} from 'sentry/utils/useRouteContext';
+
+function useUrlParams(
+  defaultKey: string,
+  defaultValue: string
+): {
+  getParamValue: () => string;
+  setParamValue: (value: string) => void;
+};
+function useUrlParams(defaultKey: string): {
+  getParamValue: () => string;
+  setParamValue: (value: string) => void;
+};
+function useUrlParams(): {
+  getParamValue: (key: string) => string;
+  setParamValue: (key: string, value: string) => void;
+};
+function useUrlParams(defaultKey?: string, defaultValue?: string) {
+  const {location} = useRouteContext();
+
+  const getParamValue = useCallback(
+    (key: string) => {
+      return location.query[key] || defaultValue;
+    },
+    [location, defaultValue]
+  );
+
+  const setParamValue = useCallback(
+    (key: string, value: string) => {
+      browserHistory.push({
+        ...location,
+        query: {
+          ...location.query,
+          [key]: value,
+        },
+      });
+    },
+    [location]
+  );
+
+  const getWithDefault = useCallback(
+    () => getParamValue(defaultKey || ''),
+    [getParamValue, defaultKey]
+  );
+  const setWithDefault = useCallback(
+    (value: string) => setParamValue(defaultKey || '', value),
+    [setParamValue, defaultKey]
+  );
+
+  if (defaultKey !== undefined) {
+    return {
+      getParamValue: getWithDefault,
+      setParamValue: setWithDefault,
+    };
+  }
+
+  return {
+    getParamValue,
+    setParamValue,
+  };
+}
+
+export default useUrlParams;

+ 15 - 15
static/app/views/replays/detail/asideTabs.tsx

@@ -1,42 +1,42 @@
-import {useState} from 'react';
 import styled from '@emotion/styled';
 
 import NavTabs from 'sentry/components/navTabs';
 import Placeholder from 'sentry/components/placeholder';
 import {t} from 'sentry/locale';
+import useUrlParams from 'sentry/utils/replays/hooks/useUrlParams';
 import ReplayReader from 'sentry/utils/replays/replayReader';
 
 import Breadcrumbs from './breadcrumbs';
 import TagPanel from './tagPanel';
 
+const TABS = {
+  breadcrumbs: t('Breadcrumbs'),
+  tags: t('Tags'),
+};
+
 type Props = {
   replay: ReplayReader | null;
 };
 
-const TABS = [t('Breadcrumbs'), t('Tags')];
-
 function renderTabContent(key: string, loadedReplay: ReplayReader) {
-  switch (key) {
-    case 'breadcrumbs':
-      return <Breadcrumbs />;
-    case 'tags':
-      return <TagPanel replay={loadedReplay} />;
-    default:
-      throw new Error('Sidebar tab not found');
+  if (key === 'tags') {
+    return <TagPanel replay={loadedReplay} />;
   }
+
+  return <Breadcrumbs />;
 }
 
 function AsideTabs({replay}: Props) {
-  const [active, setActive] = useState<string>(TABS[0].toLowerCase());
+  const {getParamValue, setParamValue} = useUrlParams('t_side', 'breadcrumbs');
+  const active = getParamValue();
 
   return (
     <Container>
       <NavTabs underlined>
-        {TABS.map(tab => {
-          const key = tab.toLowerCase();
+        {Object.entries(TABS).map(([tab, label]) => {
           return (
-            <li key={key} className={active === key ? 'active' : ''}>
-              <a onClick={() => setActive(key)}>{tab}</a>
+            <li key={tab} className={active === tab ? 'active' : ''}>
+              <a onClick={() => setParamValue(tab)}>{label}</a>
             </li>
           );
         })}

+ 4 - 4
static/app/views/replays/detail/focusArea.tsx

@@ -9,7 +9,7 @@ import type {RawCrumb} from 'sentry/types/breadcrumbs';
 import {isBreadcrumbTypeDefault} from 'sentry/types/breadcrumbs';
 import type {EventTransaction} from 'sentry/types/event';
 import {EntryType} from 'sentry/types/event';
-import useActiveTabFromLocation from 'sentry/utils/replays/hooks/useActiveTabFromLocation';
+import useActiveReplayTab from 'sentry/utils/replays/hooks/useActiveReplayTab';
 import useOrganization from 'sentry/utils/useOrganization';
 
 import Console from './console';
@@ -27,7 +27,7 @@ function getBreadcrumbsByCategory(breadcrumbs: RawCrumb[], categories: string[])
 }
 
 function FocusArea({}: Props) {
-  const active = useActiveTabFromLocation();
+  const {getActiveTab} = useActiveReplayTab();
   const {currentTime, currentHoverTime, replay, setCurrentTime, setCurrentHoverTime} =
     useReplayContext();
   const organization = useOrganization();
@@ -48,7 +48,7 @@ function FocusArea({}: Props) {
     return replay.getRawSpans().filter(replay.isNotMemorySpan);
   };
 
-  switch (active) {
+  switch (getActiveTab()) {
     case 'console':
       const consoleMessages = getBreadcrumbsByCategory(replay?.getRawCrumbs(), [
         'console',
@@ -89,7 +89,7 @@ function FocusArea({}: Props) {
 
       return <Spans organization={organization} event={performanceEvents} />;
     }
-    case 'network 2':
+    case 'network_table':
       return <NetworkList event={event} networkSpans={getNetworkSpans()} />;
     case 'trace':
       return <Trace organization={organization} event={event} />;

+ 17 - 17
static/app/views/replays/detail/focusTabs.tsx

@@ -1,29 +1,29 @@
-import React from 'react';
+import {MouseEvent} from 'react';
 import styled from '@emotion/styled';
 
 import NavTabs from 'sentry/components/navTabs';
-import {t} from 'sentry/locale';
-import useActiveTabFromLocation from 'sentry/utils/replays/hooks/useActiveTabFromLocation';
+import useActiveReplayTab, {
+  ReplayTabs,
+} from 'sentry/utils/replays/hooks/useActiveReplayTab';
 
 type Props = {};
 
-const TABS = [
-  t('Console'),
-  t('Network'),
-  t('Network 2'),
-  t('Trace'),
-  t('Issues'),
-  t('Tags'),
-  t('Memory'),
-];
-
 function FocusTabs({}: Props) {
-  const active = useActiveTabFromLocation();
+  const {getActiveTab, setActiveTab} = useActiveReplayTab();
+  const activeTab = getActiveTab();
   return (
     <NavTabs underlined>
-      {TABS.map(tab => (
-        <Tab key={tab} className={active === tab.toLowerCase() ? 'active' : ''}>
-          <a href={`#${tab.toLowerCase()}`}>{tab}</a>
+      {Object.entries(ReplayTabs).map(([tab, label]) => (
+        <Tab key={tab} className={activeTab === tab ? 'active' : ''}>
+          <a
+            href={`#${tab}`}
+            onClick={(e: MouseEvent) => {
+              setActiveTab(tab);
+              e.preventDefault();
+            }}
+          >
+            {label}
+          </a>
         </Tab>
       ))}
     </NavTabs>

+ 4 - 1
static/app/views/replays/details_v2.tsx

@@ -6,6 +6,7 @@ import {Provider as ReplayContextProvider} from 'sentry/components/replays/repla
 import {t} from 'sentry/locale';
 import {PageContent} from 'sentry/styles/organization';
 import useReplayData from 'sentry/utils/replays/hooks/useReplayData';
+import useUrlParam from 'sentry/utils/replays/hooks/useUrlParams';
 import {useRouteContext} from 'sentry/utils/useRouteContext';
 import Layout from 'sentry/views/replays/detail/layout';
 import Page from 'sentry/views/replays/detail/page';
@@ -20,6 +21,8 @@ function ReplayDetails() {
     t: initialTimeOffset, // Time, in seconds, where the video should start
   } = location.query;
 
+  const {getParamValue} = useUrlParam('l_page', 'topbar');
+
   const {fetching, onRetry, replay} = useReplayData({
     eventSlug,
     orgId,
@@ -60,7 +63,7 @@ function ReplayDetails() {
   return (
     <Page eventSlug={eventSlug} orgId={orgId} event={replay?.getEvent()}>
       <ReplayContextProvider replay={replay} initialTimeOffset={initialTimeOffset}>
-        <Layout />
+        <Layout layout={getParamValue() === 'sidebar' ? 'sidebar' : 'topbar'} />
       </ReplayContextProvider>
     </Page>
   );

+ 0 - 14
static/app/views/replays/types.tsx

@@ -16,20 +16,6 @@ export type Replay = {
   'user.username': string;
 };
 
-export enum ReplayTabs {
-  CONSOLE = 'console',
-  NETWORK = 'network',
-  NETWORK_2 = 'network 2',
-  TRACE = 'trace',
-  ISSUES = 'issues',
-  TAGS = 'tags',
-  MEMORY = 'memory',
-}
-
-export function isReplayTab(tab: string): tab is ReplayTabs {
-  return tab.toUpperCase().replace('%20', '_') in ReplayTabs;
-}
-
 /**
  * Highlight Replay Plugin types
  */