Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading