From 3cbe50f37d3baf497861492cab5eb37c57846f64 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 19 Sep 2024 14:27:05 +0200 Subject: [PATCH] feat: Make dsn in code blocks searchable (#11393) --- src/components/codeKeywords.tsx | 598 ------------------ .../codeKeywords/animatedContainer.tsx | 25 + src/components/codeKeywords/codeKeywords.tsx | 84 +++ src/components/codeKeywords/index.ts | 5 + src/components/codeKeywords/keyword.tsx | 31 + .../codeKeywords/keywordSelector.tsx | 163 +++++ .../codeKeywords/orgAuthTokenCreator.tsx | 204 ++++++ src/components/codeKeywords/styles.css.ts | 160 +++++ src/components/codeKeywords/utils.ts | 10 + src/hooks/isMounted.tsx | 11 + 10 files changed, 693 insertions(+), 598 deletions(-) delete mode 100644 src/components/codeKeywords.tsx create mode 100644 src/components/codeKeywords/animatedContainer.tsx create mode 100644 src/components/codeKeywords/codeKeywords.tsx create mode 100644 src/components/codeKeywords/index.ts create mode 100644 src/components/codeKeywords/keyword.tsx create mode 100644 src/components/codeKeywords/keywordSelector.tsx create mode 100644 src/components/codeKeywords/orgAuthTokenCreator.tsx create mode 100644 src/components/codeKeywords/styles.css.ts create mode 100644 src/components/codeKeywords/utils.ts create mode 100644 src/hooks/isMounted.tsx diff --git a/src/components/codeKeywords.tsx b/src/components/codeKeywords.tsx deleted file mode 100644 index 2b3ccdd11b35b..0000000000000 --- a/src/components/codeKeywords.tsx +++ /dev/null @@ -1,598 +0,0 @@ -import { - Children, - cloneElement, - ComponentProps, - Fragment, - ReactElement, - useContext, - useState, -} from 'react'; -import {createPortal} from 'react-dom'; -import {ArrowDown} from 'react-feather'; -import {usePopper} from 'react-popper'; -import styled from '@emotion/styled'; -import {AnimatePresence, motion, MotionProps} from 'framer-motion'; - -import {useOnClickOutside} from 'sentry-docs/clientUtils'; - -import {CodeContext, createOrgAuthToken} from './codeContext'; - -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[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 => ( - - )); -} - -function makeProjectKeywordsClickable(arr: ChildrenItem[], str: string) { - runRegex(arr, str, KEYWORDS_REGEX, (lastIndex, match) => ( - - )); -} - -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); - } -} - -const getPortal = (): HTMLElement | null => { - if (typeof document === 'undefined') { - return null; - } - - let portal = document.getElementById('selector-portal'); - if (!portal) { - portal = document.createElement('div'); - portal.setAttribute('id', 'selector-portal'); - document.body.appendChild(portal); - } - return portal; -}; - -type KeywordSelectorProps = { - group: string; - index: number; - keyword: string; -}; - -type TokenState = - | {status: 'none'} - | {status: 'loading'} - | {status: 'success'; token: string} - | {status: 'error'}; - -const dropdownPopperOptions = { - placement: 'bottom' as const, - modifiers: [ - { - name: 'offset', - options: {offset: [0, 10]}, - }, - {name: 'arrow'}, - ], -}; - -function OrgAuthTokenCreator() { - const [tokenState, setTokenState] = useState({status: 'none'}); - const [isOpen, setIsOpen] = useState(false); - const [referenceEl, setReferenceEl] = useState(null); - const [dropdownEl, setDropdownEl] = useState(null); - const {styles, state, attributes} = usePopper( - referenceEl, - dropdownEl, - dropdownPopperOptions - ); - const [isAnimating, setIsAnimating] = useState(false); - - useOnClickOutside({ - ref: {current: referenceEl}, - enabled: isOpen, - handler: () => setIsOpen(false), - }); - - const updateSelectedOrg = (orgSlug: string) => { - const choices = codeKeywords.PROJECT ?? []; - const currentSelectionIdx = sharedSelection.PROJECT ?? 0; - const currentSelection = choices[currentSelectionIdx]; - - // Already selected correct org, nothing to do - if (currentSelection && currentSelection.ORG_SLUG === orgSlug) { - return; - } - - // Else, select first project of the selected org - const newSelectionIdx = choices.findIndex(choice => choice.ORG_SLUG === orgSlug); - if (newSelectionIdx > -1) { - const newSharedSelection = {...sharedSelection}; - newSharedSelection.PROJECT = newSelectionIdx; - setSharedSelection(newSharedSelection); - } - }; - - const createToken = async (orgSlug: string) => { - setTokenState({status: 'loading'}); - const token = await createOrgAuthToken({ - orgSlug, - name: `Generated by Docs on ${new Date().toISOString().slice(0, 10)}`, - }); - - if (token) { - setTokenState({ - status: 'success', - token, - }); - - updateSelectedOrg(orgSlug); - } else { - setTokenState({ - status: 'error', - }); - } - }; - - const codeContext = useContext(CodeContext); - if (!codeContext) { - return null; - } - const {codeKeywords, sharedKeywordSelection} = codeContext; - const [sharedSelection, setSharedSelection] = sharedKeywordSelection; - - const orgSet = new Set(); - codeKeywords?.PROJECT?.forEach(projectKeyword => { - orgSet.add(projectKeyword.ORG_SLUG); - }); - const orgSlugs = [...orgSet]; - - if (!codeKeywords.USER) { - // User is not logged in - show dummy token - return sntrys_YOUR_TOKEN_HERE; - } - - if (tokenState.status === 'success') { - return {tokenState.token}; - } - - if (tokenState.status === 'error') { - return There was an error while generating your token.; - } - - if (tokenState.status === 'loading') { - return Generating token...; - } - - const selector = isOpen && ( - - - - - Select an organization: - - {orgSlugs.map(org => { - return ( - { - createToken(org); - setIsOpen(false); - }} - > - {org} - - ); - })} - - - - - ); - - const portal = getPortal(); - - const handlePress = () => { - if (orgSlugs.length === 1) { - createToken(orgSlugs[0]); - } else { - setIsOpen(!isOpen); - } - }; - - return ( - - { - handlePress(); - }} - onKeyDown={e => { - if (['Enter', 'Space'].includes(e.key)) { - handlePress(); - } - }} - > - - - setIsAnimating(true)} - onAnimationComplete={() => setIsAnimating(false)} - > - Click to generate token - - - - - {portal && createPortal({selector}, portal)} - - ); -} - -function KeywordSelector({keyword, group, index}: KeywordSelectorProps) { - const [isOpen, setIsOpen] = useState(false); - const [referenceEl, setReferenceEl] = useState(null); - const [dropdownEl, setDropdownEl] = useState(null); - const [isAnimating, setIsAnimating] = useState(false); - - 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 keyword; - } - - const selector = isOpen && ( - - - - - - {choices.map((item, idx) => { - const isActive = idx === currentSelectionIdx; - return ( - { - const newSharedSelection = {...sharedSelection}; - newSharedSelection[group] = idx; - setSharedSelection(newSharedSelection); - setIsOpen(false); - }} - > - {item.title} - - ); - })} - - - - - ); - - const portal = getPortal(); - - return ( - - setIsOpen(!isOpen)} - onKeyDown={e => e.key === 'Enter' && setIsOpen(!isOpen)} - > - - - - setIsAnimating(true)} - onAnimationComplete={() => setIsAnimating(false)} - key={currentSelectionIdx} - > - {currentSelection[keyword]} - - - - - {portal && createPortal({selector}, portal)} - - ); -} - -const KeywordSpan = styled(motion.span)` - grid-row: 1; - grid-column: 1; -`; - -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 ( - - ); -} - -const KeywordDropdown = styled('span')` - border-radius: 3px; - margin: 0 2px; - padding: 0 4px; - z-index: -1; - cursor: pointer; - background: #382f5c; - transition: background 200ms ease-in-out; - - &:focus { - outline: none; - } - - &:focus, - &:hover { - background: #1d1127; - } -`; - -const KeywordIndicator = styled(ArrowDown, {shouldForwardProp: p => p !== 'isOpen'})<{ - isOpen: boolean; -}>` - user-select: none; - margin-right: 2px; - transition: transform 200ms ease-in-out; - transform: rotate(${p => (p.isOpen ? '180deg' : '0')}); - stroke-width: 3px; - position: relative; - top: -1px; -`; - -function KeywordIndicatorComponent({ - isOpen, - size = '12px', - ...props -}: ComponentProps) { - return ; -} - -const PositionWrapper = styled('div')` - z-index: 100; -`; - -const Arrow = styled('div')` - position: absolute; - width: 10px; - height: 5px; - margin-top: -10px; - - &::before { - content: ''; - display: block; - border: 5px solid transparent; - } - - &[data-placement*='bottom'] { - &::before { - border-bottom-color: #fff; - } - } - - &[data-placement*='top'] { - bottom: -5px; - &::before { - border-top-color: #fff; - } - } -`; - -const Dropdown = styled('div')` - font-family: - 'Rubik', - -apple-system, - BlinkMacSystemFont, - 'Segoe UI'; - overflow: hidden; - border-radius: 3px; - background: #fff; - box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); -`; - -const Selections = styled('div')` - overflow: scroll; - overscroll-behavior: contain; - max-height: 210px; - min-width: 300px; -`; - -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 ( - - ); -} - -const DropdownHeader = styled('div')` - padding: 6px 8px; - font-size: 0.875rem; - color: #80708f; - background-color: #fff; - border-bottom: 1px solid #dbd6e1; -`; - -const ItemButton = styled('button')<{isActive: boolean}>` - font-family: - 'Rubik', - -apple-system, - BlinkMacSystemFont, - 'Segoe UI'; - font-size: 0.85rem; - text-align: left; - padding: 6px 8px; - cursor: pointer; - display: block; - width: 100%; - background: none; - border: none; - outline: none; - - &:not(:last-child) { - border-bottom: 1px solid #eee; - } - - &:focus { - outline: none; - background: #eee; - } - - ${p => - p.isActive - ? ` - background-color: #6C5FC7; - color: #EBE6EF; - ` - : ` - color: #3E3446; - - &:hover, - &.active { - background-color: #FAF9FB; - } - `} -`; diff --git a/src/components/codeKeywords/animatedContainer.tsx b/src/components/codeKeywords/animatedContainer.tsx new file mode 100644 index 0000000000000..a42c0ce442a89 --- /dev/null +++ b/src/components/codeKeywords/animatedContainer.tsx @@ -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 ( + + ); +} diff --git a/src/components/codeKeywords/codeKeywords.tsx b/src/components/codeKeywords/codeKeywords.tsx new file mode 100644 index 0000000000000..6ae34e6b1c60a --- /dev/null +++ b/src/components/codeKeywords/codeKeywords.tsx @@ -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[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 => ( + + )); +} + +function makeProjectKeywordsClickable(arr: ChildrenItem[], str: string) { + runRegex(arr, str, KEYWORDS_REGEX, (lastIndex, match) => ( + + )); +} + +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); + } +} diff --git a/src/components/codeKeywords/index.ts b/src/components/codeKeywords/index.ts new file mode 100644 index 0000000000000..d457972db178b --- /dev/null +++ b/src/components/codeKeywords/index.ts @@ -0,0 +1,5 @@ +export { + makeKeywordsClickable, + ORG_AUTH_TOKEN_REGEX, + KEYWORDS_REGEX, +} from './codeKeywords'; diff --git a/src/components/codeKeywords/keyword.tsx b/src/components/codeKeywords/keyword.tsx new file mode 100644 index 0000000000000..03fb5595b3745 --- /dev/null +++ b/src/components/codeKeywords/keyword.tsx @@ -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 ( + + ); +} diff --git a/src/components/codeKeywords/keywordSelector.tsx b/src/components/codeKeywords/keywordSelector.tsx new file mode 100644 index 0000000000000..c57480d09920b --- /dev/null +++ b/src/components/codeKeywords/keywordSelector.tsx @@ -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(null); + const [dropdownEl, setDropdownEl] = useState(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 keyword; + } + + const selector = isOpen && ( + + + + + {choices.length > 5 && ( + e.stopPropagation()} + value={orgFilter} + onChange={e => setOrgFilter(e.target.value)} + dark={isDarkMode} + /> + )} + + {choices + .filter(({title}) => { + return title.includes(orgFilter); + }) + .map((item, idx) => { + const isActive = idx === currentSelectionIdx; + return ( + { + const newSharedSelection = {...sharedSelection}; + newSharedSelection[group] = idx; + setSharedSelection(newSharedSelection); + setIsOpen(false); + }} + dark={isDarkMode} + > + {item.title} + + ); + })} + + + + + ); + + return ( + + setIsOpen(!isOpen)} + onKeyDown={e => e.key === 'Enter' && setIsOpen(!isOpen)} + > + + + + setIsAnimating(true)} + onAnimationComplete={() => setIsAnimating(false)} + key={currentSelectionIdx} + > + {currentSelection[keyword]} + + + + + {isMounted && + createPortal({selector}, document.body)} + + ); +} + +function KeywordIndicatorComponent({ + isOpen, + size = '12px', + ...props +}: ComponentProps) { + return ; +} diff --git a/src/components/codeKeywords/orgAuthTokenCreator.tsx b/src/components/codeKeywords/orgAuthTokenCreator.tsx new file mode 100644 index 0000000000000..f5cfdbbadc5bd --- /dev/null +++ b/src/components/codeKeywords/orgAuthTokenCreator.tsx @@ -0,0 +1,204 @@ +'use client'; + +import {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, createOrgAuthToken} from '../codeContext'; + +import {AnimatedContainer} from './animatedContainer'; +import {Keyword} from './keyword'; +import { + Arrow, + Dropdown, + DropdownHeader, + ItemButton, + KeywordDropdown, + PositionWrapper, + Selections, +} from './styles.css'; +import {dropdownPopperOptions} from './utils'; + +type TokenState = + | {status: 'none'} + | {status: 'loading'} + | {status: 'success'; token: string} + | {status: 'error'}; + +export function OrgAuthTokenCreator() { + const [tokenState, setTokenState] = useState({status: 'none'}); + const [isOpen, setIsOpen] = useState(false); + const [referenceEl, setReferenceEl] = useState(null); + const [dropdownEl, setDropdownEl] = useState(null); + const {styles, state, attributes} = usePopper( + referenceEl, + dropdownEl, + dropdownPopperOptions + ); + const [isAnimating, setIsAnimating] = useState(false); + const {theme} = useTheme(); + const isDarkMode = theme === 'dark'; + + const {isMounted} = useIsMounted(); + + useOnClickOutside({ + ref: {current: referenceEl}, + enabled: isOpen, + handler: () => setIsOpen(false), + }); + + const updateSelectedOrg = (orgSlug: string) => { + const choices = codeKeywords.PROJECT ?? []; + const currentSelectionIdx = sharedSelection.PROJECT ?? 0; + const currentSelection = choices[currentSelectionIdx]; + + // Already selected correct org, nothing to do + if (currentSelection && currentSelection.ORG_SLUG === orgSlug) { + return; + } + + // Else, select first project of the selected org + const newSelectionIdx = choices.findIndex(choice => choice.ORG_SLUG === orgSlug); + if (newSelectionIdx > -1) { + const newSharedSelection = {...sharedSelection}; + newSharedSelection.PROJECT = newSelectionIdx; + setSharedSelection(newSharedSelection); + } + }; + + const createToken = async (orgSlug: string) => { + setTokenState({status: 'loading'}); + const token = await createOrgAuthToken({ + orgSlug, + name: `Generated by Docs on ${new Date().toISOString().slice(0, 10)}`, + }); + + if (token) { + setTokenState({ + status: 'success', + token, + }); + + updateSelectedOrg(orgSlug); + } else { + setTokenState({ + status: 'error', + }); + } + }; + + const codeContext = useContext(CodeContext); + if (!codeContext) { + return null; + } + const {codeKeywords, sharedKeywordSelection} = codeContext; + const [sharedSelection, setSharedSelection] = sharedKeywordSelection; + + const orgSet = new Set(); + codeKeywords?.PROJECT?.forEach(projectKeyword => { + orgSet.add(projectKeyword.ORG_SLUG); + }); + const orgSlugs = [...orgSet]; + + if (!codeKeywords.USER) { + // User is not logged in - show dummy token + return sntrys_YOUR_TOKEN_HERE; + } + + if (tokenState.status === 'success') { + return {tokenState.token}; + } + + if (tokenState.status === 'error') { + return There was an error while generating your token.; + } + + if (tokenState.status === 'loading') { + return Generating token...; + } + + const selector = isOpen && ( + + Select an organization: + + + + + {orgSlugs.map(org => { + return ( + { + createToken(org); + setIsOpen(false); + }} + dark={isDarkMode} + > + {org} + + ); + })} + + + + + ); + + const handlePress = () => { + if (orgSlugs.length === 1) { + createToken(orgSlugs[0]); + } else { + setIsOpen(!isOpen); + } + }; + + return ( + + { + handlePress(); + }} + onKeyDown={e => { + if (['Enter', 'Space'].includes(e.key)) { + handlePress(); + } + }} + > + + + setIsAnimating(true)} + onAnimationComplete={() => setIsAnimating(false)} + > + Click to generate token + + + + + {isMounted && + createPortal({selector}, document.body)} + + ); +} diff --git a/src/components/codeKeywords/styles.css.ts b/src/components/codeKeywords/styles.css.ts new file mode 100644 index 0000000000000..832d6838a1015 --- /dev/null +++ b/src/components/codeKeywords/styles.css.ts @@ -0,0 +1,160 @@ +'use client'; + +import {ArrowDown} from 'react-feather'; +import styled from '@emotion/styled'; +import {motion} from 'framer-motion'; + +export const PositionWrapper = styled('div')` + z-index: 100; +`; + +export const Arrow = styled('div')` + position: absolute; + width: 10px; + height: 5px; + margin-top: -10px; + + &::before { + content: ''; + display: block; + border: 5px solid transparent; + } + + &[data-placement*='bottom'] { + &::before { + border-bottom-color: #fff; + } + } + + &[data-placement*='top'] { + bottom: -5px; + &::before { + border-top-color: #fff; + } + } +`; + +export const Dropdown = styled('div')<{dark: boolean}>` + font-family: + 'Rubik', + -apple-system, + BlinkMacSystemFont, + 'Segoe UI'; + overflow: hidden; + border-radius: 3px; + background: ${p => (p.dark ? 'var(--gray-4)' : '#fff')}; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); +`; + +export const Selections = styled('div')` + overflow: scroll; + overscroll-behavior: contain; + max-height: 210px; + min-width: 300px; +`; + +export const DropdownHeader = styled('div')` + padding: 6px 8px; + font-size: 0.875rem; + color: #80708f; + border-bottom: 1px solid #dbd6e1; +`; + +export const ItemButton = styled('button')<{dark: boolean; isActive: boolean}>` + font-family: + 'Rubik', + -apple-system, + BlinkMacSystemFont, + 'Segoe UI'; + font-size: 0.85rem; + text-align: left; + padding: 6px 8px; + cursor: pointer; + display: block; + width: 100%; + background: none; + border: none; + outline: none; + + &:not(:last-child) { + border-bottom: 1px solid var(--border-color); + } + + ${p => + p.isActive + ? ` + background-color: #6C5FC7; + color: #EBE6EF; + ` + : ` + + + &:focus { + outline: none; + background-color: ${p.dark ? 'var(--gray-a4)' : 'var(--accent-purple-light)'}; + } + &:hover, + &.active { + background-color: ${p.dark ? 'var(--gray-a4)' : 'var(--accent-purple-light)'}; + } + `} +`; + +export const KeywordDropdown = styled('span')` + border-radius: 3px; + margin: 0 2px; + padding: 0 4px; + z-index: -1; + cursor: pointer; + background: #382f5c; + transition: background 200ms ease-in-out; + + &:focus { + outline: none; + } + + &:focus, + &:hover { + background: #1d1127; + } +`; + +export const KeywordIndicator = styled(ArrowDown, { + shouldForwardProp: p => p !== 'isOpen', +})<{ + isOpen: boolean; +}>` + user-select: none; + margin-right: 2px; + transition: transform 200ms ease-in-out; + transform: rotate(${p => (p.isOpen ? '180deg' : '0')}); + stroke-width: 3px; + position: relative; + top: -1px; +`; + +export const KeywordSpan = styled(motion.span)` + grid-row: 1; + grid-column: 1; +`; + +export const KeywordSearchInput = styled('input')<{dark: boolean}>` + border-width: 1.5px; + border-style: solid; + border-color: ${p => (p.dark ? '' : 'var(--desatPurple12)')}; + border-radius: 0.25rem; + width: 280px; + -webkit-appearance: none; + appearance: none; + padding: 0.25rem 0.75rem; + line-height: 1.8; + border-radius: 0.25rem; + outline: none; + margin: 10px; + + &:focus { + border-color: var(--accent-purple); + box-shadow: 0 0 0 0.2rem + ${p => (p.dark ? 'var(--gray-a4)' : 'var(--accent-purple-light)')}; + } +`; diff --git a/src/components/codeKeywords/utils.ts b/src/components/codeKeywords/utils.ts new file mode 100644 index 0000000000000..e02d8e13cbb47 --- /dev/null +++ b/src/components/codeKeywords/utils.ts @@ -0,0 +1,10 @@ +export const dropdownPopperOptions = { + placement: 'bottom' as const, + modifiers: [ + { + name: 'offset', + options: {offset: [0, 10]}, + }, + {name: 'arrow'}, + ], +}; diff --git a/src/hooks/isMounted.tsx b/src/hooks/isMounted.tsx new file mode 100644 index 0000000000000..0f9c22a1805ae --- /dev/null +++ b/src/hooks/isMounted.tsx @@ -0,0 +1,11 @@ +import {useEffect, useState} from 'react'; + +export const useIsMounted = () => { + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + setIsMounted(true); + }, []); + + return {isMounted}; +};