Skip to content

Commit ed088bb

Browse files
Luca ForstnerArthurKnaus
andauthored
Add org selection dropdown to org auth token button (#7716)
Co-authored-by: ArthurKnaus <[email protected]>
1 parent b76ce93 commit ed088bb

File tree

3 files changed

+212
-68
lines changed

3 files changed

+212
-68
lines changed

src/components/__tests__/__snapshots__/codeBlock.test.js.snap

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,31 @@ exports[`CodeWrapper renders org auth token placeholder when signed in 1`] = `
122122
<span
123123
className="css-1rhw0e5"
124124
onClick={[Function]}
125+
onKeyDown={[Function]}
126+
role="button"
127+
tabIndex={0}
128+
title="Click to generate token"
125129
>
126-
Click to generate token
130+
<span
131+
style={
132+
{
133+
"display": undefined,
134+
}
135+
}
136+
>
137+
<span
138+
className="css-1f5vc5"
139+
style={
140+
{
141+
"opacity": 1,
142+
"position": "relative",
143+
"transform": "none",
144+
}
145+
}
146+
>
147+
Click to generate token
148+
</span>
149+
</span>
127150
</span>
128151
</code>
129152
`;

src/components/codeBlock.tsx

Lines changed: 187 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -105,53 +105,163 @@ type KeywordSelectorProps = {
105105
keyword: string;
106106
};
107107

108+
type TokenState =
109+
| {status: 'none'}
110+
| {status: 'loading'}
111+
| {status: 'success'; token: string}
112+
| {status: 'error'};
113+
114+
const dropdownPopperOptions = {
115+
placement: 'bottom' as const,
116+
modifiers: [
117+
{
118+
name: 'offset',
119+
options: {offset: [0, 10]},
120+
},
121+
{name: 'arrow'},
122+
],
123+
};
124+
108125
function OrgAuthTokenCreator() {
109-
const codeContext = useContext(CodeContext);
126+
const {codeKeywords} = useContext(CodeContext);
110127

111-
const [tokenState, setTokenState] = useState<'none' | 'loading' | 'success' | 'error'>(
112-
'none'
128+
const [tokenState, setTokenState] = useState<TokenState>({status: 'none'});
129+
const [isOpen, setIsOpen] = useState(false);
130+
const [referenceEl, setReferenceEl] = useState<HTMLSpanElement>(null);
131+
const [dropdownEl, setDropdownEl] = useState<HTMLElement>(null);
132+
const {styles, state, attributes} = usePopper(
133+
referenceEl,
134+
dropdownEl,
135+
dropdownPopperOptions
113136
);
114-
const [token, setToken] = useState(null);
115-
const [sharedSelection] = codeContext.sharedKeywordSelection;
116-
const {codeKeywords} = codeContext;
117137

118-
const choices = codeKeywords?.PROJECT;
138+
useOnClickOutside({
139+
ref: {current: referenceEl},
140+
enabled: isOpen,
141+
handler: () => setIsOpen(false),
142+
});
143+
144+
const createToken = async (orgSlug: string) => {
145+
setTokenState({status: 'loading'});
146+
const token = await createOrgAuthToken({
147+
orgSlug,
148+
name: `Generated by Docs on ${new Date().toISOString().slice(0, 10)}`,
149+
});
150+
151+
if (token) {
152+
setTokenState({
153+
status: 'success',
154+
token,
155+
});
156+
} else {
157+
setTokenState({
158+
status: 'error',
159+
});
160+
}
161+
};
162+
163+
const orgSet = new Set<string>();
164+
codeKeywords?.PROJECT?.forEach(projectKeyword => {
165+
orgSet.add(projectKeyword.ORG_SLUG);
166+
});
167+
const orgSlugs = [...orgSet];
168+
169+
const [isAnimating, setIsAnimating] = useState(false);
119170

120-
// When not signed in, we just show a placeholder, as the user can't generate a token in this case
121171
if (!codeKeywords.USER) {
172+
// User is not logged in - show dummy token
122173
return <Fragment>sntrys_YOUR_TOKEN_HERE</Fragment>;
123174
}
124175

125-
const currentSelectionIdx = sharedSelection.PROJECT ?? 0;
126-
const currentSelection = choices[currentSelectionIdx];
176+
if (tokenState.status === 'success') {
177+
return <Fragment>{tokenState.token}</Fragment>;
178+
}
127179

128-
const name = `Generated by Docs for ${currentSelection.PROJECT_SLUG} on ${new Date()
129-
.toISOString()
130-
.slice(0, 10)}`;
180+
if (tokenState.status === 'error') {
181+
return <Fragment>There was an error while generating your token.</Fragment>;
182+
}
183+
184+
if (tokenState.status === 'loading') {
185+
return <Fragment>Generating token...</Fragment>;
186+
}
131187

132-
const updateToken = async () => {
133-
if (tokenState !== 'none') {
134-
return;
188+
const selector = isOpen && (
189+
<PositionWrapper style={styles.popper} ref={setDropdownEl} {...attributes.popper}>
190+
<AnimatedContainer>
191+
<Dropdown>
192+
<Arrow
193+
style={styles.arrow}
194+
data-placement={state?.placement}
195+
data-popper-arrow
196+
/>
197+
<DropdownHeader>Select an organization:</DropdownHeader>
198+
<Selections>
199+
{orgSlugs.map(org => {
200+
return (
201+
<ItemButton
202+
key={org}
203+
isActive={false}
204+
onClick={() => {
205+
createToken(org);
206+
setIsOpen(false);
207+
}}
208+
>
209+
{org}
210+
</ItemButton>
211+
);
212+
})}
213+
</Selections>
214+
</Dropdown>
215+
</AnimatedContainer>
216+
</PositionWrapper>
217+
);
218+
219+
const portal = getPortal();
220+
221+
const handlePress = () => {
222+
if (orgSlugs.length === 1) {
223+
createToken(orgSlugs[0]);
224+
} else {
225+
setIsOpen(!isOpen);
135226
}
136-
setTokenState('loading');
137-
const tokenStr = await createOrgAuthToken({
138-
orgSlug: currentSelection.ORG_SLUG,
139-
name,
140-
});
141-
setTokenState(token ? 'success' : 'error');
142-
setToken(tokenStr);
143227
};
144228

145229
return (
146-
<KeywordDropdown onClick={updateToken}>
147-
{tokenState === 'none'
148-
? 'Click to generate token'
149-
: tokenState === 'loading'
150-
? 'Generating...'
151-
: token
152-
? token
153-
: 'Error generating token'}
154-
</KeywordDropdown>
230+
<Fragment>
231+
<KeywordDropdown
232+
ref={setReferenceEl}
233+
role="button"
234+
title="Click to generate token"
235+
tabIndex={0}
236+
onClick={() => {
237+
handlePress();
238+
}}
239+
onKeyDown={e => {
240+
if (['Enter', 'Space'].includes(e.key)) {
241+
handlePress();
242+
}
243+
}}
244+
>
245+
<span
246+
style={{
247+
// We set inline-grid only when animating the keyword so they
248+
// correctly overlap during animations, but this must be removed
249+
// after so copy-paste correctly works.
250+
display: isAnimating ? 'inline-grid' : undefined,
251+
}}
252+
>
253+
<AnimatePresence initial={false}>
254+
<Keyword
255+
onAnimationStart={() => setIsAnimating(true)}
256+
onAnimationComplete={() => setIsAnimating(false)}
257+
>
258+
Click to generate token
259+
</Keyword>
260+
</AnimatePresence>
261+
</span>
262+
</KeywordDropdown>
263+
{portal && createPortal(<AnimatePresence>{selector}</AnimatePresence>, portal)}
264+
</Fragment>
155265
);
156266
}
157267

@@ -162,16 +272,11 @@ function KeywordSelector({keyword, group, index}: KeywordSelectorProps) {
162272
const [referenceEl, setReferenceEl] = useState<HTMLSpanElement>(null);
163273
const [dropdownEl, setDropdownEl] = useState<HTMLElement>(null);
164274

165-
const {styles, state, attributes} = usePopper(referenceEl, dropdownEl, {
166-
placement: 'bottom',
167-
modifiers: [
168-
{
169-
name: 'offset',
170-
options: {offset: [0, 10]},
171-
},
172-
{name: 'arrow'},
173-
],
174-
});
275+
const {styles, state, attributes} = usePopper(
276+
referenceEl,
277+
dropdownEl,
278+
dropdownPopperOptions
279+
);
175280

176281
useOnClickOutside({
177282
ref: {current: referenceEl},
@@ -195,26 +300,32 @@ function KeywordSelector({keyword, group, index}: KeywordSelectorProps) {
195300
const selector = isOpen && (
196301
<PositionWrapper style={styles.popper} ref={setDropdownEl} {...attributes.popper}>
197302
<AnimatedContainer>
198-
<Arrow style={styles.arrow} data-placement={state?.placement} data-popper-arrow />
199-
<Selections>
200-
{choices.map((item, idx) => {
201-
const isActive = idx === currentSelectionIdx;
202-
return (
203-
<ItemButton
204-
key={idx}
205-
isActive={isActive}
206-
onClick={() => {
207-
const newSharedSelection = {...sharedSelection};
208-
newSharedSelection[group] = idx;
209-
setSharedSelection(newSharedSelection);
210-
setIsOpen(false);
211-
}}
212-
>
213-
{item.title}
214-
</ItemButton>
215-
);
216-
})}
217-
</Selections>
303+
<Dropdown>
304+
<Arrow
305+
style={styles.arrow}
306+
data-placement={state?.placement}
307+
data-popper-arrow
308+
/>
309+
<Selections>
310+
{choices.map((item, idx) => {
311+
const isActive = idx === currentSelectionIdx;
312+
return (
313+
<ItemButton
314+
key={idx}
315+
isActive={isActive}
316+
onClick={() => {
317+
const newSharedSelection = {...sharedSelection};
318+
newSharedSelection[group] = idx;
319+
setSharedSelection(newSharedSelection);
320+
setIsOpen(false);
321+
}}
322+
>
323+
{item.title}
324+
</ItemButton>
325+
);
326+
})}
327+
</Selections>
328+
</Dropdown>
218329
</AnimatedContainer>
219330
</PositionWrapper>
220331
);
@@ -342,16 +453,19 @@ const Arrow = styled('div')`
342453
}
343454
`;
344455

456+
const Dropdown = styled('div')`
457+
overflow: hidden;
458+
border-radius: 3px;
459+
background: #fff;
460+
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
461+
`;
462+
345463
const Selections = styled('div')`
346464
padding: 4px 0;
347-
margin-top: -2px;
348-
background: #fff;
349-
border-radius: 3px;
350465
overflow: scroll;
351466
overscroll-behavior: contain;
352467
max-height: 210px;
353468
min-width: 300px;
354-
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
355469
`;
356470

357471
const AnimatedContainer = styled(motion.div)``;
@@ -367,6 +481,13 @@ AnimatedContainer.defaultProps = {
367481
},
368482
};
369483

484+
const DropdownHeader = styled('div')`
485+
padding: 4px 8px;
486+
color: #80708f;
487+
background-color: #fff;
488+
border-bottom: 1px solid #dbd6e1;
489+
`;
490+
370491
const ItemButton = styled('button')<{isActive: boolean}>`
371492
font-family: 'Rubik', -apple-system, BlinkMacSystemFont, 'Segoe UI';
372493
font-size: 0.85rem;

src/components/codeContext.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ export async function createOrgAuthToken({
206206
}: {
207207
name: string;
208208
orgSlug: string;
209-
}) {
209+
}): Promise<string | null> {
210210
const baseUrl =
211211
process.env.NODE_ENV === 'development'
212212
? 'http://dev.getsentry.net:8000/'

0 commit comments

Comments
 (0)