Browse Source

ref(releases): Add code highlighting to ReleasesPromo (#43812)

(Part of https://github.com/getsentry/sentry/issues/43765)

**Before ——**
<img width="933" alt="Screenshot 2023-01-27 at 3 18 12 PM"
src="https://user-images.githubusercontent.com/44172267/215223733-02d36240-3276-4ed1-b83b-6c84fe70523d.png">

**After ——**
<img width="933" alt="Screenshot 2023-01-27 at 3 18 53 PM"
src="https://user-images.githubusercontent.com/44172267/215223797-af1ecd4d-de75-41a2-9860-38f8195e3d5a.png">
<img width="933" alt="Screenshot 2023-01-27 at 3 17 14 PM"
src="https://user-images.githubusercontent.com/44172267/215223629-84c8906b-cffa-4738-9996-9f4137974ea1.png">
Vu Luong 2 years ago
parent
commit
622198d8da
1 changed files with 148 additions and 156 deletions
  1. 148 156
      static/app/views/releases/list/releasesPromo.tsx

+ 148 - 156
static/app/views/releases/list/releasesPromo.tsx

@@ -1,16 +1,15 @@
-import {useCallback, useEffect, useState} from 'react';
+import {useCallback, useEffect, useMemo, useState} from 'react';
 import styled from '@emotion/styled';
-import copy from 'copy-text-to-clipboard';
 
 import commitImage from 'sentry-images/spot/releases-tour-commits.svg';
 import emailImage from 'sentry-images/spot/releases-tour-email.svg';
 import resolutionImage from 'sentry-images/spot/releases-tour-resolution.svg';
 import statsImage from 'sentry-images/spot/releases-tour-stats.svg';
 
-import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
 import {openCreateReleaseIntegration} from 'sentry/actionCreators/modal';
 import Access from 'sentry/components/acl/access';
 import {Button} from 'sentry/components/button';
+import {CodeSnippet} from 'sentry/components/codeSnippet';
 import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete';
 import {Item} from 'sentry/components/dropdownAutoComplete/types';
 import Link from 'sentry/components/links/link';
@@ -18,7 +17,7 @@ import {TourImage, TourStep, TourText} from 'sentry/components/modals/featureTou
 import {Panel} from 'sentry/components/panels';
 import TextOverflow from 'sentry/components/textOverflow';
 import {Tooltip} from 'sentry/components/tooltip';
-import {IconAdd, IconCopy} from 'sentry/icons';
+import {IconAdd} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
 import {Organization, Project, SentryApp} from 'sentry/types';
@@ -163,29 +162,6 @@ const ReleasesPromo = ({organization, project}: Props) => {
     return newToken.token;
   };
 
-  const handleCopy = () => {
-    if (!token || !selectedItem) {
-      addErrorMessage(t('Select an integration for your auth token!'));
-      return;
-    }
-    const current_text = `
-      # Install the cli
-      curl -sL https://sentry.io/get-cli/ | SENTRY_CLI_VERSION="2.2.0" bash
-      # Setup configuration values
-      SENTRY_AUTH_TOKEN=${token} # From internal integration: ${selectedItem.value.name}
-      SENTRY_ORG=${organization.slug}
-      SENTRY_PROJECT=${project.slug}
-      VERSION=\`sentry-cli releases propose-version\`
-      # Workflow to create releases
-      sentry-cli releases new "$VERSION"
-      sentry-cli releases set-commits "$VERSION" --auto
-      sentry-cli releases finalize "$VERSION"
-      `;
-    copy(current_text);
-    addSuccessMessage(t('Copied to clipboard!'));
-    trackQuickstartCopy();
-  };
-
   const renderIntegrationNode = (integration: SentryApp) => {
     return {
       value: {slug: integration.slug, name: integration.name},
@@ -198,6 +174,30 @@ const ReleasesPromo = ({organization, project}: Props) => {
     };
   };
 
+  const codeChunks = useMemo(
+    () => [
+      `# Install the cli
+curl -sL https://sentry.io/get-cli/ | SENTRY_CLI_VERSION="2.2.0" bash
+
+# Setup configuration values
+SENTRY_AUTH_TOKEN=`,
+
+      token && selectedItem
+        ? `${token} # From internal integration: ${selectedItem.value.name}`
+        : '<click-here-for-your-token>',
+      `
+SENTRY_ORG=${organization.slug}
+SENTRY_PROJECT=${project.slug}
+VERSION=\`sentry-cli releases propose-version\`
+
+# Workflow to create releases
+sentry-cli releases new "$VERSION"
+sentry-cli releases set-commits "$VERSION" --auto
+sentry-cli releases finalize "$VERSION"`,
+    ],
+    [token, selectedItem, organization.slug, project.slug]
+  );
+
   return renderComponent(
     <Panel>
       <Container>
@@ -220,113 +220,101 @@ const ReleasesPromo = ({organization, project}: Props) => {
           )}
         </p>
 
-        <CodeBlock>
-          <CopyButton onClick={handleCopy}>
-            <IconCopy />
-          </CopyButton>
-          <Comment># Install the cli</Comment>
-          <Bash>
-            curl -sL https://sentry.io/get-cli/ | SENTRY_CLI_VERSION="2.2.0" bash
-          </Bash>
-          <Bash>{'\n'}</Bash>
-          <Comment># Setup configuration values</Comment>
-          <Bash>
-            SENTRY_AUTH_TOKEN=
-            <StyledDropdownAutoComplete
-              minWidth={300}
-              maxHeight={400}
-              onOpen={e => {
-                // This can be called multiple times and does not always have `event`
-                e?.stopPropagation();
-              }}
-              items={[
-                {
-                  label: <GroupHeader>{t('Available Integrations')}</GroupHeader>,
-                  id: 'available-integrations',
-                  items: (integrations || []).map(renderIntegrationNode),
-                },
-              ]}
-              alignMenu="left"
-              onSelect={({label, value}) => {
-                selectItem({label, value});
-                fetchToken(value.slug);
-              }}
-              itemSize="small"
-              searchPlaceholder={t('Select Internal Integration')}
-              menuFooter={
-                <Access access={['org:integrations']}>
-                  {({hasAccess}) => (
-                    <Tooltip
-                      title={t(
-                        'You must be an organization owner, manager or admin to create an integration.'
-                      )}
-                      disabled={hasAccess}
-                    >
-                      <CreateIntegrationLink
-                        to=""
-                        data-test-id="create-release-integration"
-                        disabled={!hasAccess}
-                        onClick={() =>
-                          openCreateReleaseIntegration({
-                            organization,
-                            project,
-                            onCreateSuccess: (integration: SentryApp) => {
-                              setIntegrations([integration, ...integrations]);
-                              const {label, value} = renderIntegrationNode(integration);
-                              selectItem({
-                                label,
-                                value,
-                              });
-                              fetchToken(value.slug);
-                              trackQuickstartCreatedIntegration(integration);
-                            },
-                            onCancel: () => {
-                              trackCreateIntegrationModalClose();
-                            },
-                          })
-                        }
+        <CodeSnippetWrapper>
+          <CodeSnippet
+            dark
+            language="bash"
+            hideCopyButton={!token || !selectedItem}
+            onCopy={trackQuickstartCopy}
+          >
+            {codeChunks.join('')}
+          </CodeSnippet>
+          <CodeSnippetOverlay className="prism-dark language-bash">
+            <CodeSnippetOverlaySpan>{codeChunks[0]}</CodeSnippetOverlaySpan>
+            <CodeSnippetDropdownWrapper>
+              <CodeSnippetDropdown
+                minWidth={300}
+                maxHeight={400}
+                onOpen={e => {
+                  // This can be called multiple times and does not always have `event`
+                  e?.stopPropagation();
+                }}
+                items={[
+                  {
+                    label: <GroupHeader>{t('Available Integrations')}</GroupHeader>,
+                    id: 'available-integrations',
+                    items: (integrations || []).map(renderIntegrationNode),
+                  },
+                ]}
+                alignMenu="left"
+                onSelect={({label, value}) => {
+                  selectItem({label, value});
+                  fetchToken(value.slug);
+                }}
+                itemSize="small"
+                searchPlaceholder={t('Select Internal Integration')}
+                menuFooter={
+                  <Access access={['org:integrations']}>
+                    {({hasAccess}) => (
+                      <Tooltip
+                        title={t(
+                          'You must be an organization owner, manager or admin to create an integration.'
+                        )}
+                        disabled={hasAccess}
                       >
-                        <MenuItemFooterWrapper>
-                          <IconContainer>
-                            <IconAdd color="activeText" isCircled legacySize="14px" />
-                          </IconContainer>
-                          <Label>{t('Create New Integration')}</Label>
-                        </MenuItemFooterWrapper>
-                      </CreateIntegrationLink>
-                    </Tooltip>
-                  )}
-                </Access>
-              }
-              disableLabelPadding
-              emptyHidesInput
-            >
-              {() => {
-                return token && selectedItem ? (
-                  <span style={{display: 'flex'}}>
-                    <Bash>{token}</Bash>
-                    <Comment>{` # From internal integration: ${selectedItem.value.name} `}</Comment>
-                  </span>
-                ) : (
-                  <Bash style={{color: '#7cc5c4'}}>{'<click-here-for-your-token>'}</Bash>
-                );
-              }}
-            </StyledDropdownAutoComplete>
-          </Bash>
-
-          <Bash>{`SENTRY_ORG=${organization.slug}`}</Bash>
-          <Bash>{`SENTRY_PROJECT=${project.slug}`}</Bash>
-          <Bash>VERSION=`sentry-cli releases propose-version`</Bash>
-          <Bash>{'\n'}</Bash>
-          <Comment># Workflow to create releases</Comment>
-          <Bash>sentry-cli releases new "$VERSION"</Bash>
-          <Bash>sentry-cli releases set-commits "$VERSION" --auto</Bash>
-          <Bash>sentry-cli releases finalize "$VERSION"</Bash>
-        </CodeBlock>
+                        <CreateIntegrationLink
+                          to=""
+                          data-test-id="create-release-integration"
+                          disabled={!hasAccess}
+                          onClick={() =>
+                            openCreateReleaseIntegration({
+                              organization,
+                              project,
+                              onCreateSuccess: (integration: SentryApp) => {
+                                setIntegrations([integration, ...integrations]);
+                                const {label, value} = renderIntegrationNode(integration);
+                                selectItem({
+                                  label,
+                                  value,
+                                });
+                                fetchToken(value.slug);
+                                trackQuickstartCreatedIntegration(integration);
+                              },
+                              onCancel: () => {
+                                trackCreateIntegrationModalClose();
+                              },
+                            })
+                          }
+                        >
+                          <MenuItemFooterWrapper>
+                            <IconContainer>
+                              <IconAdd color="activeText" isCircled legacySize="14px" />
+                            </IconContainer>
+                            <Label>{t('Create New Integration')}</Label>
+                          </MenuItemFooterWrapper>
+                        </CreateIntegrationLink>
+                      </Tooltip>
+                    )}
+                  </Access>
+                }
+                disableLabelPadding
+                emptyHidesInput
+              >
+                {() => <CodeSnippetOverlaySpan>{codeChunks[1]}</CodeSnippetOverlaySpan>}
+              </CodeSnippetDropdown>
+            </CodeSnippetDropdownWrapper>
+            <CodeSnippetOverlaySpan>{codeChunks[2]}</CodeSnippetOverlaySpan>
+          </CodeSnippetOverlay>
+        </CodeSnippetWrapper>
       </Container>
     </Panel>
   );
 };
 
+const Container = styled('div')`
+  padding: ${space(3)};
+`;
+
 const ContainerHeader = styled('div')`
   display: flex;
   justify-content: space-between;
@@ -348,48 +336,52 @@ const ContainerHeader = styled('div')`
   }
 `;
 
-const CodeBlock = styled('pre')`
-  background: #251f3d;
-  display: flex;
-  flex-direction: column;
-  padding: ${space(2)};
-  overflow: initial;
+const CodeSnippetWrapper = styled('div')`
   position: relative;
 `;
 
-const CopyButton = styled(Button)`
+/**
+ * CodeSnippet stringifies all inner children (due to Prism code highlighting), so we
+ * can't put CodeSnippetDropdown inside of it. Instead, we can render a pre wrap
+ * containing the same code (without Prism highlighting) with CodeSnippetDropdown in the
+ * middle and overlay it on top of CodeSnippet.
+ */
+const CodeSnippetOverlay = styled('pre')`
   position: absolute;
-  right: 20px;
-`;
-const Language = styled('code')`
-  font-size: 15px;
-  text-shadow: none;
-  direction: ltr;
-  text-align: left;
-  white-space: pre;
-  word-spacing: normal;
-  word-break: normal;
-  line-height: 1.5;
-  display: flex;
-  align-items: center;
-`;
-const Bash = styled(Language)`
-  color: #f2edf6;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  z-index: 2;
+  margin-bottom: 0;
+  pointer-events: none;
+
+  && {
+    background: transparent;
+  }
 `;
 
-const Comment = styled(Language)`
-  color: #77658b;
+/**
+ * Invisible code span overlaid on top of the highlighted code. Exists only to
+ * properly position <CodeSnippetDropdown /> inside <CodeSnippetOverlay />.
+ */
+const CodeSnippetOverlaySpan = styled('span')`
+  visibility: hidden;
 `;
-const Container = styled('div')`
-  padding: ${space(3)};
+
+const CodeSnippetDropdownWrapper = styled('span')`
+  /* Re-enable pointer events (disabled by CodeSnippetOverlay) */
+  pointer-events: initial;
 `;
 
-const StyledDropdownAutoComplete = styled(DropdownAutoComplete)`
+const CodeSnippetDropdown = styled(DropdownAutoComplete)`
+  position: absolute;
   font-family: ${p => p.theme.text.family};
   border: none;
   border-radius: 4px;
   width: 300px;
 `;
+
 const GroupHeader = styled('div')`
   font-size: ${p => p.theme.fontSizeSmall};
   font-family: ${p => p.theme.text.family};