Skip to content

Commit b330e43

Browse files
Copilotdwjohnston
andcommitted
Add Stack Overflow link components and tests
Co-authored-by: dwjohnston <[email protected]>
1 parent 27a4b5b commit b330e43

14 files changed

+419
-8
lines changed

src/library/GithubPermalink/github-permalink.css

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,3 +400,94 @@ svg.github-logo{
400400
margin: 0 2px;
401401
}
402402
}
403+
404+
/* Stack Overflow link styles */
405+
.react-stackoverflow-link {
406+
border: 1px solid var(--rgp-color-border);
407+
border-radius: 8px;
408+
background-color: var(--rgp-color-bg-stark);
409+
color: var(--rgp-color-text-stark);
410+
font-size: 14px;
411+
font-family: "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
412+
}
413+
414+
.react-stackoverflow-link a {
415+
text-decoration: none;
416+
color: inherit;
417+
}
418+
419+
.react-stackoverflow-link .header {
420+
background-color: var(--rgp-color-bg-frame);
421+
padding: 8px;
422+
}
423+
424+
.react-stackoverflow-link-header {
425+
display: flex;
426+
align-items: center;
427+
gap: 8px;
428+
}
429+
430+
.react-stackoverflow-link-header p {
431+
margin: 0;
432+
font-weight: 600;
433+
color: var(--rgp-color-text-frame);
434+
}
435+
436+
.react-stackoverflow-link-body {
437+
padding: 8px;
438+
}
439+
440+
.react-stackoverflow-link-title {
441+
font-weight: 600;
442+
color: var(--rgp-color-text-stark);
443+
display: block;
444+
margin-bottom: 8px;
445+
}
446+
447+
.react-stackoverflow-link-stats {
448+
display: flex;
449+
gap: 16px;
450+
margin-bottom: 8px;
451+
font-size: 12px;
452+
}
453+
454+
.react-stackoverflow-link-stats .stat-label {
455+
color: var(--rgp-color-text-frame);
456+
}
457+
458+
.react-stackoverflow-link-stats .stat-value {
459+
font-weight: 600;
460+
color: var(--rgp-color-text-stark);
461+
}
462+
463+
.react-stackoverflow-link-stats .answered-indicator {
464+
color: #2f7d32;
465+
font-weight: bold;
466+
}
467+
468+
.react-stackoverflow-link-tags {
469+
display: flex;
470+
flex-wrap: wrap;
471+
gap: 4px;
472+
}
473+
474+
.react-stackoverflow-link-tags .tag {
475+
background-color: #e8f4fd;
476+
color: #39739d;
477+
font-size: 11px;
478+
padding: 2px 6px;
479+
border-radius: 3px;
480+
border: 1px solid #e8f4fd;
481+
}
482+
483+
@media (prefers-color-scheme: dark) {
484+
.react-stackoverflow-link-tags .tag {
485+
background-color: #2d4a5a;
486+
color: #9cc3d5;
487+
border-color: #2d4a5a;
488+
}
489+
}
490+
491+
.react-stackoverflow-link-inline {
492+
color: var(--rgp-color-reaction-foreground);
493+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"use client"
2+
3+
import { useContext, useEffect, useState } from "react";
4+
5+
import { GithubPermalinkContext, StackOverflowLinkDataResponse } from "../config/GithubPermalinkContext";
6+
import { StackOverflowLinkBase, StackOverflowLinkBaseProps } from "./StackOverflowLinkBase";
7+
8+
type StackOverflowLinkProps = Omit<StackOverflowLinkBaseProps, "data">;
9+
10+
export function StackOverflowLink(props: StackOverflowLinkProps) {
11+
12+
const { questionLink } = props;
13+
const [data, setData] = useState(null as null | StackOverflowLinkDataResponse)
14+
const { getStackOverflowFn, onError} = useContext(GithubPermalinkContext);
15+
const [isLoading, setIsLoading] = useState(true);
16+
17+
useEffect(() => {
18+
getStackOverflowFn(questionLink, onError).then((v) => {
19+
setIsLoading(false);
20+
setData(v);
21+
})
22+
}, [getStackOverflowFn, questionLink, onError])
23+
24+
if (isLoading) {
25+
return null;
26+
}
27+
if (!data) {
28+
throw new Error("Loading is complete, but no data was returned.")
29+
}
30+
31+
return <StackOverflowLinkBase {...props} data={data}/>
32+
33+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { Meta, StoryObj } from '@storybook/react';
2+
import { StackOverflowLinkBase } from './StackOverflowLinkBase';
3+
4+
const meta: Meta<typeof StackOverflowLinkBase> = {
5+
title: 'Example/StackOverflowLinkBase',
6+
component: StackOverflowLinkBase,
7+
parameters: {
8+
layout: 'padded',
9+
},
10+
tags: ['autodocs'],
11+
argTypes: {
12+
variant: {
13+
control: 'select',
14+
options: ['block', 'inline'],
15+
},
16+
},
17+
};
18+
19+
export default meta;
20+
type Story = StoryObj<typeof meta>;
21+
22+
const mockSuccessData = {
23+
questionTitle: 'How to create a React component?',
24+
questionId: '123456',
25+
isAnswered: true,
26+
answerCount: 12,
27+
score: 45,
28+
viewCount: 1250,
29+
tags: ['reactjs', 'javascript', 'components'],
30+
creationDate: 1640995200,
31+
status: 'ok' as const,
32+
};
33+
34+
const mockErrorData = {
35+
status: '404' as const,
36+
};
37+
38+
export const Default: Story = {
39+
args: {
40+
questionLink: 'https://stackoverflow.com/questions/123456/how-to-create-react-component',
41+
data: mockSuccessData,
42+
variant: 'block',
43+
},
44+
};
45+
46+
export const Inline: Story = {
47+
args: {
48+
questionLink: 'https://stackoverflow.com/questions/123456/how-to-create-react-component',
49+
data: mockSuccessData,
50+
variant: 'inline',
51+
},
52+
};
53+
54+
export const NotFound: Story = {
55+
args: {
56+
questionLink: 'https://stackoverflow.com/questions/999999/non-existent-question',
57+
data: mockErrorData,
58+
variant: 'block',
59+
},
60+
};
61+
62+
export const Unanswered: Story = {
63+
args: {
64+
questionLink: 'https://stackoverflow.com/questions/789012/unanswered-question',
65+
data: {
66+
...mockSuccessData,
67+
questionTitle: 'How to solve this complex problem?',
68+
questionId: '789012',
69+
isAnswered: false,
70+
answerCount: 0,
71+
score: 2,
72+
viewCount: 34,
73+
tags: ['algorithm', 'optimization'],
74+
},
75+
variant: 'block',
76+
},
77+
};
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { PropsWithChildren } from "react";
2+
import { StackOverflowSvg } from "../StackOverflowSvg/StackOverflowSvg";
3+
import { StackOverflowLinkDataResponse } from "../config/GithubPermalinkContext";
4+
import { ErrorMessages } from "../ErrorMessages/ErrorMessages";
5+
import { Inline } from "../common/Inline/Inline";
6+
7+
8+
export type StackOverflowLinkBaseProps = {
9+
className?: string;
10+
questionLink: string;
11+
data: StackOverflowLinkDataResponse;
12+
variant?: "inline" | "block"
13+
}
14+
15+
16+
17+
export function StackOverflowLinkBase(props: StackOverflowLinkBaseProps) {
18+
const { data, variant ="block", questionLink} = props;
19+
20+
if (variant === "inline"){
21+
if(data.status === "ok"){
22+
return <Inline href={questionLink} text={`stackoverflow.com/q/${data.questionId}: ${data.questionTitle}`}/>
23+
}
24+
else {
25+
return <Inline href={questionLink} text={questionLink}/>
26+
}
27+
}
28+
29+
if (data.status === "ok") {
30+
return <StackOverflowLinkInner {...props} header={<>
31+
<div className="react-stackoverflow-link-header">
32+
<StackOverflowSvg />
33+
<p>
34+
Stack Overflow
35+
</p>
36+
</div>
37+
38+
<div className="react-stackoverflow-link-body">
39+
<p><span className="react-stackoverflow-link-title">{data.questionTitle}</span></p>
40+
41+
<div className="react-stackoverflow-link-stats">
42+
<div className="react-stackoverflow-link-score">
43+
<span className="stat-label">Score:</span> <span className="stat-value">{data.score}</span>
44+
</div>
45+
<div className="react-stackoverflow-link-answers">
46+
<span className="stat-label">Answers:</span> <span className="stat-value">{data.answerCount}</span>
47+
{data.isAnswered && <span className="answered-indicator"></span>}
48+
</div>
49+
<div className="react-stackoverflow-link-views">
50+
<span className="stat-label">Views:</span> <span className="stat-value">{data.viewCount.toLocaleString()}</span>
51+
</div>
52+
</div>
53+
54+
{data.tags && data.tags.length > 0 && (
55+
<div className="react-stackoverflow-link-tags">
56+
{data.tags.map(tag => (
57+
<span key={tag} className="tag">{tag}</span>
58+
))}
59+
</div>
60+
)}
61+
</div>
62+
</>}>
63+
</StackOverflowLinkInner>
64+
65+
}
66+
67+
return <StackOverflowLinkInner {...props}>
68+
<ErrorMessages data={data} />
69+
</StackOverflowLinkInner>
70+
71+
}
72+
73+
74+
function StackOverflowLinkInner(props: PropsWithChildren<{
75+
header?: React.ReactNode
76+
} & {
77+
questionLink: string;
78+
className?: string;
79+
}>) {
80+
81+
const {questionLink, className =''} = props;
82+
83+
return <div className={`rgp-base react-stackoverflow-link ${className}`}>
84+
<a href={questionLink}>
85+
<div className="header">
86+
{props.header ?? <a href={questionLink} className="file-link">{questionLink}</a>}
87+
</div>
88+
{props.children}
89+
</a>
90+
</div>
91+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { githubPermalinkRscConfig } from "../config/GithubPermalinkRscConfig"
2+
import { StackOverflowLinkBase, StackOverflowLinkBaseProps } from "./StackOverflowLinkBase";
3+
4+
export type StackOverflowLinkRscProps = Omit<StackOverflowLinkBaseProps, "data">;
5+
6+
export async function StackOverflowLinkRsc(props: StackOverflowLinkRscProps) {
7+
const dataFn = githubPermalinkRscConfig.getStackOverflowFn();
8+
const onError = githubPermalinkRscConfig.getOnError();
9+
10+
const data = await dataFn(props.questionLink, onError);
11+
return <StackOverflowLinkBase {...props} data={data}/>
12+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export function StackOverflowSvg(props: {
2+
size?: number
3+
}) {
4+
5+
const size = props.size ?? 16;
6+
7+
return <svg className="stackoverflow-logo" height={`${size}`} width={`${size}`} aria-hidden="true" viewBox="0 0 24 24" version="1.1">
8+
<path fill="#f48024" d="M18.986 21.865v-6.404h2.134V24H1.844v-8.539h2.13v6.404h15.012zM6.111 19.731H16.85v-2.137H6.111v2.137zm.259-4.852l10.48 2.189.451-2.07-10.478-2.187-.453 2.068zm1.359-5.056l9.705 4.53.903-1.95-9.706-4.53-.902 1.95zm2.715-4.785l8.217 6.855 1.359-1.62-8.216-6.853-1.36 1.618zM15.539 0l-1.7 1.265 6.381 8.588 1.7-1.265L15.539 0zM6.369 17.594h10.739v-2.137H6.369v2.137z"/>
9+
</svg>
10+
}

src/library/config/BaseConfiguration.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { defaultGetPermalinkFn } from "./defaultFunctions";
2-
import { defaultGetIssueFn } from "./defaultFunctions";
2+
import { defaultGetIssueFn, defaultGetStackOverflowFn } from "./defaultFunctions";
33

44

55
export type BaseConfiguration = {
@@ -11,6 +11,9 @@ export type BaseConfiguration = {
1111
/** Function to provide issue data payload */
1212
getIssueFn: typeof defaultGetIssueFn;
1313

14+
/** Function to provide Stack Overflow question data payload */
15+
getStackOverflowFn: typeof defaultGetStackOverflowFn;
16+
1417
/**
1518
* A github personal access token - will be passed to the data fetching functions
1619
*/

src/library/config/GithubPermalinkContext.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client"
22
import { PropsWithChildren, createContext } from "react";
33
import { BaseConfiguration } from "./BaseConfiguration";
4-
import { defaultGetIssueFn } from "./defaultFunctions";
4+
import { defaultGetIssueFn, defaultGetStackOverflowFn } from "./defaultFunctions";
55
import { defaultGetPermalinkFn } from "./defaultFunctions";
66

77
// Thanks ChatGPT
@@ -56,19 +56,33 @@ export type GithubIssueLinkDataResponse = {
5656
}
5757
} | ErrorResponses;
5858

59+
export type StackOverflowLinkDataResponse = {
60+
questionTitle: string;
61+
questionId: string;
62+
isAnswered: boolean;
63+
answerCount: number;
64+
score: number;
65+
viewCount: number;
66+
tags: string[];
67+
creationDate: number;
68+
status: "ok"
69+
} | ErrorResponses;
70+
5971

6072

6173

6274

6375
export const GithubPermalinkContext = createContext<BaseConfiguration>({
6476
getDataFn: defaultGetPermalinkFn,
6577
getIssueFn: defaultGetIssueFn,
78+
getStackOverflowFn: defaultGetStackOverflowFn,
6679
});
6780

6881
export function GithubPermalinkProvider(props: PropsWithChildren<Partial<BaseConfiguration>>) {
6982
return <GithubPermalinkContext.Provider value={{
7083
getDataFn: props.getDataFn ?? defaultGetPermalinkFn,
7184
getIssueFn: props.getIssueFn ?? defaultGetIssueFn,
85+
getStackOverflowFn: props.getStackOverflowFn ?? defaultGetStackOverflowFn,
7286
githubToken: props.githubToken,
7387
onError: props.onError,
7488
}}>

0 commit comments

Comments
 (0)