Skip to content

Commit

Permalink
feat: Make dsn in code blocks searchable (#11393)
Browse files Browse the repository at this point in the history
  • Loading branch information
chargome committed Sep 19, 2024
1 parent 038ae7f commit 3cbe50f
Show file tree
Hide file tree
Showing 10 changed files with 693 additions and 598 deletions.
598 changes: 0 additions & 598 deletions src/components/codeKeywords.tsx

This file was deleted.

25 changes: 25 additions & 0 deletions src/components/codeKeywords/animatedContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
'use client';

import {motion, MotionProps} from 'framer-motion';

export function AnimatedContainer({
initial = {opacity: 0, y: 5},
animate = {opacity: 1, y: 0},
exit = {opacity: 0, scale: 0.95},
transition = {
opacity: {duration: 0.15},
y: {duration: 0.3},
scale: {duration: 0.3},
},
...props
}: MotionProps) {
return (
<motion.div
initial={initial}
animate={animate}
exit={exit}
transition={transition}
{...props}
/>
);
}
84 changes: 84 additions & 0 deletions src/components/codeKeywords/codeKeywords.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
'use client';

import {Children, cloneElement, ReactElement} from 'react';

import {KeywordSelector} from './keywordSelector';
import {OrgAuthTokenCreator} from './orgAuthTokenCreator';

export const KEYWORDS_REGEX = /\b___(?:([A-Z_][A-Z0-9_]*)\.)?([A-Z_][A-Z0-9_]*)___\b/g;

export const ORG_AUTH_TOKEN_REGEX = /___ORG_AUTH_TOKEN___/g;

type ChildrenItem = ReturnType<typeof Children.toArray>[number] | React.ReactNode;

export function makeKeywordsClickable(children: React.ReactNode) {
const items = Children.toArray(children);

return items.reduce((arr: ChildrenItem[], child) => {
if (typeof child !== 'string') {
const updatedChild = cloneElement(
child as ReactElement,
{},
makeKeywordsClickable((child as ReactElement).props.children)
);
arr.push(updatedChild);
return arr;
}
if (ORG_AUTH_TOKEN_REGEX.test(child)) {
makeOrgAuthTokenClickable(arr, child);
} else if (KEYWORDS_REGEX.test(child)) {
makeProjectKeywordsClickable(arr, child);
} else {
arr.push(child);
}

return arr;
}, [] as ChildrenItem[]);
}

function makeOrgAuthTokenClickable(arr: ChildrenItem[], str: string) {
runRegex(arr, str, ORG_AUTH_TOKEN_REGEX, lastIndex => (
<OrgAuthTokenCreator key={`org-token-${lastIndex}`} />
));
}

function makeProjectKeywordsClickable(arr: ChildrenItem[], str: string) {
runRegex(arr, str, KEYWORDS_REGEX, (lastIndex, match) => (
<KeywordSelector
key={`project-keyword-${lastIndex}`}
index={lastIndex}
group={match[1] || 'PROJECT'}
keyword={match[2]}
/>
));
}

function runRegex(
arr: ChildrenItem[],
str: string,
regex: RegExp,
cb: (lastIndex: number, match: RegExpExecArray) => React.ReactNode
): void {
regex.lastIndex = 0;

let match: RegExpExecArray | null;
let lastIndex = 0;
// eslint-disable-next-line no-cond-assign
while ((match = regex.exec(str)) !== null) {
const afterMatch = regex.lastIndex - match[0].length;
const before = str.substring(lastIndex, afterMatch);

if (before.length > 0) {
arr.push(before);
}

arr.push(cb(lastIndex, match));

lastIndex = regex.lastIndex;
}

const after = str.substring(lastIndex);
if (after.length > 0) {
arr.push(after);
}
}
5 changes: 5 additions & 0 deletions src/components/codeKeywords/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export {
makeKeywordsClickable,
ORG_AUTH_TOKEN_REGEX,
KEYWORDS_REGEX,
} from './codeKeywords';
31 changes: 31 additions & 0 deletions src/components/codeKeywords/keyword.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use client';

import {MotionProps} from 'framer-motion';

import {KeywordSpan} from './styles.css';

export function Keyword({
initial = {opacity: 0, y: -10, position: 'absolute'},
animate = {
position: 'relative',
opacity: 1,
y: 0,
transition: {delay: 0.1},
},
exit = {opacity: 0, y: 20},
transition = {
opacity: {duration: 0.15},
y: {duration: 0.25},
},
...props
}: MotionProps) {
return (
<KeywordSpan
initial={initial}
animate={animate}
exit={exit}
transition={transition}
{...props}
/>
);
}
163 changes: 163 additions & 0 deletions src/components/codeKeywords/keywordSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
'use client';

import {ComponentProps, Fragment, useContext, useState} from 'react';
import {createPortal} from 'react-dom';
import {usePopper} from 'react-popper';
import {AnimatePresence} from 'framer-motion';
import {useTheme} from 'next-themes';

import {useOnClickOutside} from 'sentry-docs/clientUtils';
import {useIsMounted} from 'sentry-docs/hooks/isMounted';

import {CodeContext} from '../codeContext';

import {AnimatedContainer} from './animatedContainer';
import {Keyword} from './keyword';
import {
Arrow,
Dropdown,
ItemButton,
KeywordDropdown,
KeywordIndicator,
KeywordSearchInput,
PositionWrapper,
Selections,
} from './styles.css';
import {dropdownPopperOptions} from './utils';

type KeywordSelectorProps = {
group: string;
index: number;
keyword: string;
};

export function KeywordSelector({keyword, group, index}: KeywordSelectorProps) {
const [isOpen, setIsOpen] = useState(false);
const [referenceEl, setReferenceEl] = useState<HTMLSpanElement | null>(null);
const [dropdownEl, setDropdownEl] = useState<HTMLElement | null>(null);
const [isAnimating, setIsAnimating] = useState(false);
const [orgFilter, setOrgFilter] = useState('');
const {theme} = useTheme();
const isDarkMode = theme === 'dark';
const {isMounted} = useIsMounted();

const {styles, state, attributes} = usePopper(
referenceEl,
dropdownEl,
dropdownPopperOptions
);

useOnClickOutside({
ref: {current: referenceEl},
enabled: isOpen,
handler: () => setIsOpen(false),
});

const codeContext = useContext(CodeContext);
if (!codeContext) {
return null;
}

const [sharedSelection, setSharedSelection] = codeContext.sharedKeywordSelection;

const {codeKeywords} = codeContext;
const choices = codeKeywords?.[group] ?? [];
const currentSelectionIdx = sharedSelection[group] ?? 0;
const currentSelection = choices[currentSelectionIdx];

if (!currentSelection) {
return <Fragment>keyword</Fragment>;
}

const selector = isOpen && (
<PositionWrapper style={styles.popper} ref={setDropdownEl} {...attributes.popper}>
<AnimatedContainer>
<Dropdown dark={isDarkMode}>
<Arrow
style={styles.arrow}
data-placement={state?.placement}
data-popper-arrow
/>
{choices.length > 5 && (
<KeywordSearchInput
placeholder="Search Project"
onClick={e => e.stopPropagation()}
value={orgFilter}
onChange={e => setOrgFilter(e.target.value)}
dark={isDarkMode}
/>
)}
<Selections>
{choices
.filter(({title}) => {
return title.includes(orgFilter);
})
.map((item, idx) => {
const isActive = idx === currentSelectionIdx;
return (
<ItemButton
data-sentry-mask
key={idx}
isActive={isActive}
onClick={() => {
const newSharedSelection = {...sharedSelection};
newSharedSelection[group] = idx;
setSharedSelection(newSharedSelection);
setIsOpen(false);
}}
dark={isDarkMode}
>
{item.title}
</ItemButton>
);
})}
</Selections>
</Dropdown>
</AnimatedContainer>
</PositionWrapper>
);

return (
<Fragment>
<KeywordDropdown
key={index}
ref={setReferenceEl}
role="button"
tabIndex={0}
title={currentSelection?.title}
onClick={() => setIsOpen(!isOpen)}
onKeyDown={e => e.key === 'Enter' && setIsOpen(!isOpen)}
>
<KeywordIndicatorComponent isOpen={isOpen} />
<span
style={{
// We set inline-grid only when animating the keyword so they
// correctly overlap during animations, but this must be removed
// after so copy-paste correctly works.
display: isAnimating ? 'inline-grid' : undefined,
}}
>
<AnimatePresence initial={false}>
<Keyword
onAnimationStart={() => setIsAnimating(true)}
onAnimationComplete={() => setIsAnimating(false)}
key={currentSelectionIdx}
>
{currentSelection[keyword]}
</Keyword>
</AnimatePresence>
</span>
</KeywordDropdown>
{isMounted &&
createPortal(<AnimatePresence>{selector}</AnimatePresence>, document.body)}
</Fragment>
);
}

function KeywordIndicatorComponent({
isOpen,
size = '12px',
...props
}: ComponentProps<typeof KeywordIndicator>) {
return <KeywordIndicator isOpen={isOpen} size={size} {...props} />;
}
Loading

0 comments on commit 3cbe50f

Please sign in to comment.