Skip to content

Commit 3cbe50f

Browse files
authored
feat: Make dsn in code blocks searchable (#11393)
1 parent 038ae7f commit 3cbe50f

File tree

10 files changed

+693
-598
lines changed

10 files changed

+693
-598
lines changed

src/components/codeKeywords.tsx

Lines changed: 0 additions & 598 deletions
This file was deleted.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
'use client';
2+
3+
import {motion, MotionProps} from 'framer-motion';
4+
5+
export function AnimatedContainer({
6+
initial = {opacity: 0, y: 5},
7+
animate = {opacity: 1, y: 0},
8+
exit = {opacity: 0, scale: 0.95},
9+
transition = {
10+
opacity: {duration: 0.15},
11+
y: {duration: 0.3},
12+
scale: {duration: 0.3},
13+
},
14+
...props
15+
}: MotionProps) {
16+
return (
17+
<motion.div
18+
initial={initial}
19+
animate={animate}
20+
exit={exit}
21+
transition={transition}
22+
{...props}
23+
/>
24+
);
25+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
'use client';
2+
3+
import {Children, cloneElement, ReactElement} from 'react';
4+
5+
import {KeywordSelector} from './keywordSelector';
6+
import {OrgAuthTokenCreator} from './orgAuthTokenCreator';
7+
8+
export const KEYWORDS_REGEX = /\b___(?:([A-Z_][A-Z0-9_]*)\.)?([A-Z_][A-Z0-9_]*)___\b/g;
9+
10+
export const ORG_AUTH_TOKEN_REGEX = /___ORG_AUTH_TOKEN___/g;
11+
12+
type ChildrenItem = ReturnType<typeof Children.toArray>[number] | React.ReactNode;
13+
14+
export function makeKeywordsClickable(children: React.ReactNode) {
15+
const items = Children.toArray(children);
16+
17+
return items.reduce((arr: ChildrenItem[], child) => {
18+
if (typeof child !== 'string') {
19+
const updatedChild = cloneElement(
20+
child as ReactElement,
21+
{},
22+
makeKeywordsClickable((child as ReactElement).props.children)
23+
);
24+
arr.push(updatedChild);
25+
return arr;
26+
}
27+
if (ORG_AUTH_TOKEN_REGEX.test(child)) {
28+
makeOrgAuthTokenClickable(arr, child);
29+
} else if (KEYWORDS_REGEX.test(child)) {
30+
makeProjectKeywordsClickable(arr, child);
31+
} else {
32+
arr.push(child);
33+
}
34+
35+
return arr;
36+
}, [] as ChildrenItem[]);
37+
}
38+
39+
function makeOrgAuthTokenClickable(arr: ChildrenItem[], str: string) {
40+
runRegex(arr, str, ORG_AUTH_TOKEN_REGEX, lastIndex => (
41+
<OrgAuthTokenCreator key={`org-token-${lastIndex}`} />
42+
));
43+
}
44+
45+
function makeProjectKeywordsClickable(arr: ChildrenItem[], str: string) {
46+
runRegex(arr, str, KEYWORDS_REGEX, (lastIndex, match) => (
47+
<KeywordSelector
48+
key={`project-keyword-${lastIndex}`}
49+
index={lastIndex}
50+
group={match[1] || 'PROJECT'}
51+
keyword={match[2]}
52+
/>
53+
));
54+
}
55+
56+
function runRegex(
57+
arr: ChildrenItem[],
58+
str: string,
59+
regex: RegExp,
60+
cb: (lastIndex: number, match: RegExpExecArray) => React.ReactNode
61+
): void {
62+
regex.lastIndex = 0;
63+
64+
let match: RegExpExecArray | null;
65+
let lastIndex = 0;
66+
// eslint-disable-next-line no-cond-assign
67+
while ((match = regex.exec(str)) !== null) {
68+
const afterMatch = regex.lastIndex - match[0].length;
69+
const before = str.substring(lastIndex, afterMatch);
70+
71+
if (before.length > 0) {
72+
arr.push(before);
73+
}
74+
75+
arr.push(cb(lastIndex, match));
76+
77+
lastIndex = regex.lastIndex;
78+
}
79+
80+
const after = str.substring(lastIndex);
81+
if (after.length > 0) {
82+
arr.push(after);
83+
}
84+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export {
2+
makeKeywordsClickable,
3+
ORG_AUTH_TOKEN_REGEX,
4+
KEYWORDS_REGEX,
5+
} from './codeKeywords';
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
'use client';
2+
3+
import {MotionProps} from 'framer-motion';
4+
5+
import {KeywordSpan} from './styles.css';
6+
7+
export function Keyword({
8+
initial = {opacity: 0, y: -10, position: 'absolute'},
9+
animate = {
10+
position: 'relative',
11+
opacity: 1,
12+
y: 0,
13+
transition: {delay: 0.1},
14+
},
15+
exit = {opacity: 0, y: 20},
16+
transition = {
17+
opacity: {duration: 0.15},
18+
y: {duration: 0.25},
19+
},
20+
...props
21+
}: MotionProps) {
22+
return (
23+
<KeywordSpan
24+
initial={initial}
25+
animate={animate}
26+
exit={exit}
27+
transition={transition}
28+
{...props}
29+
/>
30+
);
31+
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
'use client';
2+
3+
import {ComponentProps, Fragment, useContext, useState} from 'react';
4+
import {createPortal} from 'react-dom';
5+
import {usePopper} from 'react-popper';
6+
import {AnimatePresence} from 'framer-motion';
7+
import {useTheme} from 'next-themes';
8+
9+
import {useOnClickOutside} from 'sentry-docs/clientUtils';
10+
import {useIsMounted} from 'sentry-docs/hooks/isMounted';
11+
12+
import {CodeContext} from '../codeContext';
13+
14+
import {AnimatedContainer} from './animatedContainer';
15+
import {Keyword} from './keyword';
16+
import {
17+
Arrow,
18+
Dropdown,
19+
ItemButton,
20+
KeywordDropdown,
21+
KeywordIndicator,
22+
KeywordSearchInput,
23+
PositionWrapper,
24+
Selections,
25+
} from './styles.css';
26+
import {dropdownPopperOptions} from './utils';
27+
28+
type KeywordSelectorProps = {
29+
group: string;
30+
index: number;
31+
keyword: string;
32+
};
33+
34+
export function KeywordSelector({keyword, group, index}: KeywordSelectorProps) {
35+
const [isOpen, setIsOpen] = useState(false);
36+
const [referenceEl, setReferenceEl] = useState<HTMLSpanElement | null>(null);
37+
const [dropdownEl, setDropdownEl] = useState<HTMLElement | null>(null);
38+
const [isAnimating, setIsAnimating] = useState(false);
39+
const [orgFilter, setOrgFilter] = useState('');
40+
const {theme} = useTheme();
41+
const isDarkMode = theme === 'dark';
42+
const {isMounted} = useIsMounted();
43+
44+
const {styles, state, attributes} = usePopper(
45+
referenceEl,
46+
dropdownEl,
47+
dropdownPopperOptions
48+
);
49+
50+
useOnClickOutside({
51+
ref: {current: referenceEl},
52+
enabled: isOpen,
53+
handler: () => setIsOpen(false),
54+
});
55+
56+
const codeContext = useContext(CodeContext);
57+
if (!codeContext) {
58+
return null;
59+
}
60+
61+
const [sharedSelection, setSharedSelection] = codeContext.sharedKeywordSelection;
62+
63+
const {codeKeywords} = codeContext;
64+
const choices = codeKeywords?.[group] ?? [];
65+
const currentSelectionIdx = sharedSelection[group] ?? 0;
66+
const currentSelection = choices[currentSelectionIdx];
67+
68+
if (!currentSelection) {
69+
return <Fragment>keyword</Fragment>;
70+
}
71+
72+
const selector = isOpen && (
73+
<PositionWrapper style={styles.popper} ref={setDropdownEl} {...attributes.popper}>
74+
<AnimatedContainer>
75+
<Dropdown dark={isDarkMode}>
76+
<Arrow
77+
style={styles.arrow}
78+
data-placement={state?.placement}
79+
data-popper-arrow
80+
/>
81+
{choices.length > 5 && (
82+
<KeywordSearchInput
83+
placeholder="Search Project"
84+
onClick={e => e.stopPropagation()}
85+
value={orgFilter}
86+
onChange={e => setOrgFilter(e.target.value)}
87+
dark={isDarkMode}
88+
/>
89+
)}
90+
<Selections>
91+
{choices
92+
.filter(({title}) => {
93+
return title.includes(orgFilter);
94+
})
95+
.map((item, idx) => {
96+
const isActive = idx === currentSelectionIdx;
97+
return (
98+
<ItemButton
99+
data-sentry-mask
100+
key={idx}
101+
isActive={isActive}
102+
onClick={() => {
103+
const newSharedSelection = {...sharedSelection};
104+
newSharedSelection[group] = idx;
105+
setSharedSelection(newSharedSelection);
106+
setIsOpen(false);
107+
}}
108+
dark={isDarkMode}
109+
>
110+
{item.title}
111+
</ItemButton>
112+
);
113+
})}
114+
</Selections>
115+
</Dropdown>
116+
</AnimatedContainer>
117+
</PositionWrapper>
118+
);
119+
120+
return (
121+
<Fragment>
122+
<KeywordDropdown
123+
key={index}
124+
ref={setReferenceEl}
125+
role="button"
126+
tabIndex={0}
127+
title={currentSelection?.title}
128+
onClick={() => setIsOpen(!isOpen)}
129+
onKeyDown={e => e.key === 'Enter' && setIsOpen(!isOpen)}
130+
>
131+
<KeywordIndicatorComponent isOpen={isOpen} />
132+
<span
133+
style={{
134+
// We set inline-grid only when animating the keyword so they
135+
// correctly overlap during animations, but this must be removed
136+
// after so copy-paste correctly works.
137+
display: isAnimating ? 'inline-grid' : undefined,
138+
}}
139+
>
140+
<AnimatePresence initial={false}>
141+
<Keyword
142+
onAnimationStart={() => setIsAnimating(true)}
143+
onAnimationComplete={() => setIsAnimating(false)}
144+
key={currentSelectionIdx}
145+
>
146+
{currentSelection[keyword]}
147+
</Keyword>
148+
</AnimatePresence>
149+
</span>
150+
</KeywordDropdown>
151+
{isMounted &&
152+
createPortal(<AnimatePresence>{selector}</AnimatePresence>, document.body)}
153+
</Fragment>
154+
);
155+
}
156+
157+
function KeywordIndicatorComponent({
158+
isOpen,
159+
size = '12px',
160+
...props
161+
}: ComponentProps<typeof KeywordIndicator>) {
162+
return <KeywordIndicator isOpen={isOpen} size={size} {...props} />;
163+
}

0 commit comments

Comments
 (0)