Skip to content

Commit d89cf20

Browse files
committed
feat: dsn search bar + refactor
1 parent 2f2fed8 commit d89cf20

File tree

9 files changed

+666
-598
lines changed

9 files changed

+666
-598
lines changed

src/components/codeKeywords.tsx

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

0 commit comments

Comments
 (0)