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
2 changes: 1 addition & 1 deletion .github/workflows/sonarcloud.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ jobs:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} # Generate a token on Sonarcloud.io, add it to the secrets of this repo with the name SONAR_TOKEN
with:
# Additional arguments for the SonarScanner CLI
args:
args: >
Comment thread
arnabnandy7 marked this conversation as resolved.
# Unique keys of your project and organization. You can find them in SonarCloud > Information (bottom-left menu)
# mandatory
-Dsonar.projectKey=arnabnandy7_openissue.dev
Expand Down
253 changes: 225 additions & 28 deletions src/features/issues/components/issue-finder.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
"use client";

import { FormEvent, useMemo, useState } from "react";
import { GitPullRequest, Search } from "lucide-react";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { Bookmark, GitPullRequest, Search, Trash2 } from "lucide-react";
import { ThemeToggle } from "@/components/theme-toggle";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
addSavedSearch,
deleteSavedSearch,
getSavedSearches,
type SavedSearch,
} from "@/features/issues/lib/saved-searches";
import {
Card,
CardContent,
Expand Down Expand Up @@ -48,13 +54,22 @@ export function IssueFinder() {
const [error, setError] = useState<string | null>(null);
const [cooldown, setCooldown] = useState(false);

const [savedSearches, setSavedSearches] = useState<SavedSearch[]>([]);
const [savedSearchName, setSavedSearchName] = useState("");

useEffect(() => {
setSavedSearches(getSavedSearches());
}, []);

const selectedLabel = useMemo(
() => LABEL_OPTIONS.find((item) => item.value === label) ?? LABEL_OPTIONS[0],
() =>
LABEL_OPTIONS.find((item) => item.value === label) ?? LABEL_OPTIONS[0],
[label],
);
const selectedLinkedPr = useMemo(
() =>
LINKED_PR_OPTIONS.find((item) => item.value === linkedPr) ?? LINKED_PR_OPTIONS[0],
LINKED_PR_OPTIONS.find((item) => item.value === linkedPr) ??
LINKED_PR_OPTIONS[0],
[linkedPr],
);
const selectedSort = useMemo(
Expand All @@ -73,10 +88,80 @@ export function IssueFinder() {
return issues.length < data.candidateCount && data.issues.length === 24;
}, [data, issues]);

async function searchIssues(event?: FormEvent<HTMLFormElement>) {
event?.preventDefault();
function handleSaveSearch() {
const name = savedSearchName.trim();

if (!name) {
setError("Enter a name for the saved search.");
return;
}

if (!tech.trim()) {
setError("Enter a technology before saving the search.");
return;
}
try {
const savedSearch = addSavedSearch({
name,
tech: tech.trim(),
label,
sort,
linkedPr,
hacktoberfest,
});

setSavedSearches((current) => [...current, savedSearch]);
setSavedSearchName("");
setError(null);
}catch (saveError) {
setError(
saveError instanceof Error
? saveError.message
: "Unable to save search.",
);
}
}

function handleDeleteSavedSearch(id: string) {
deleteSavedSearch(id);
setSavedSearches(getSavedSearches());
}

function handleRunSavedSearch(savedSearch: SavedSearch) {
setTech(savedSearch.tech);
setLabel(savedSearch.label);
setSort(savedSearch.sort);
setLinkedPr(savedSearch.linkedPr);
setHacktoberfest(savedSearch.hacktoberfest);

void searchIssues(undefined, {
tech: savedSearch.tech,
label: savedSearch.label,
sort: savedSearch.sort,
linkedPr: savedSearch.linkedPr,
hacktoberfest: savedSearch.hacktoberfest,
});
}

async function searchIssues(
event?: FormEvent<HTMLFormElement>,
searchOverride?: {
tech: string;
label: string;
sort: string;
linkedPr: string;
hacktoberfest: string;
},
) {
event?.preventDefault();

const searchTech = searchOverride?.tech ?? tech;
const searchLabel = searchOverride?.label ?? label;
const searchSort = searchOverride?.sort ?? sort;
const searchLinkedPr = searchOverride?.linkedPr ?? linkedPr;
const searchHacktoberfest = searchOverride?.hacktoberfest ?? hacktoberfest;

if (!searchTech.trim()) {
setError("Enter a technology to search.");
return;
}
Expand All @@ -88,11 +173,11 @@ export function IssueFinder() {
setPage(1);

const params = new URLSearchParams({
tech: tech.trim(),
label,
sort,
linkedPr,
hacktoberfest,
tech: searchTech.trim(),
label: searchLabel,
sort: searchSort,
linkedPr: searchLinkedPr,
hacktoberfest: searchHacktoberfest,
});

try {
Expand Down Expand Up @@ -178,8 +263,9 @@ export function IssueFinder() {
Find active open-source issues by tech.
</h1>
<p className="max-w-2xl text-base leading-7 text-muted-foreground sm:text-lg">
Search contributor-friendly GitHub issues with labels like help wanted,
good first issue, up-for-grabs, and documentation.
Search contributor-friendly GitHub issues with labels like
help wanted, good first issue, up-for-grabs, and
documentation.
</p>
</div>
</div>
Expand All @@ -200,7 +286,11 @@ export function IssueFinder() {
</div>

<Select value={label} onValueChange={setLabel}>
<SelectTrigger className="h-11 w-full" size="lg" aria-label="Issue label">
<SelectTrigger
className="h-11 w-full"
size="lg"
aria-label="Issue label"
>
<SelectValue>{selectedLabel.label}</SelectValue>
</SelectTrigger>
<SelectContent>
Expand All @@ -213,7 +303,11 @@ export function IssueFinder() {
</Select>

<Select value={sort} onValueChange={setSort}>
<SelectTrigger className="h-11 w-full" size="lg" aria-label="Sort results">
<SelectTrigger
className="h-11 w-full"
size="lg"
aria-label="Sort results"
>
<SelectValue>{selectedSort.label}</SelectValue>
</SelectTrigger>
<SelectContent>
Expand All @@ -226,7 +320,11 @@ export function IssueFinder() {
</Select>

<Select value={linkedPr} onValueChange={setLinkedPr}>
<SelectTrigger className="h-11 w-full" size="lg" aria-label="Linked PR filter">
<SelectTrigger
className="h-11 w-full"
size="lg"
aria-label="Linked PR filter"
>
<SelectValue>{selectedLinkedPr.label}</SelectValue>
</SelectTrigger>
<SelectContent>
Expand All @@ -239,7 +337,11 @@ export function IssueFinder() {
</Select>

<Select value={hacktoberfest} onValueChange={setHacktoberfest}>
<SelectTrigger className="h-11 w-full" size="lg" aria-label="Hacktoberfest filter">
<SelectTrigger
className="h-11 w-full"
size="lg"
aria-label="Hacktoberfest filter"
>
<SelectValue>{selectedHacktoberfest.label}</SelectValue>
</SelectTrigger>
<SelectContent>
Expand All @@ -266,17 +368,32 @@ export function IssueFinder() {
<CardHeader>
<CardTitle className="text-base">Quality score</CardTitle>
<CardDescription>
Results are boosted for recency, stars, clear labels, low comment count,
and unassigned issues.
Results are boosted for recency, stars, clear labels, low
comment count, and unassigned issues.
</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3 text-sm">
<Metric label="Label" value={selectedLabel.label} />
<Metric label="Sort" value={sort === "created" ? "newest" : sort} />
<Metric label="Linked PR" value={selectedLinkedPr.label.replace("Linked PR: ", "")} />
<Metric label="Hacktoberfest" value={selectedHacktoberfest.label} />
<Metric label="Ranked" value={data ? compactNumber(data.candidateCount) : "-"} />
<Metric label="Raw GitHub matches" value={data ? compactNumber(data.totalCount) : "-"} />
<Metric
label="Sort"
value={sort === "created" ? "newest" : sort}
/>
<Metric
label="Linked PR"
value={selectedLinkedPr.label.replace("Linked PR: ", "")}
/>
<Metric
label="Hacktoberfest"
value={selectedHacktoberfest.label}
/>
<Metric
label="Ranked"
value={data ? compactNumber(data.candidateCount) : "-"}
/>
<Metric
label="Raw GitHub matches"
value={data ? compactNumber(data.totalCount) : "-"}
/>
<Metric
label="GitHub token"
value={data?.tokenConfigured ? "configured" : "not set"}
Expand Down Expand Up @@ -323,19 +440,97 @@ export function IssueFinder() {
className="flex w-full items-center gap-2 rounded-md border px-3 py-2 text-left text-sm transition-colors hover:bg-accent"
>
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{option.label}</span>
<span className="min-w-0 flex-1 truncate">
{option.label}
</span>
</button>
);
})}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Saved searches</CardTitle>
<CardDescription>
Save your current filters and reuse them later.
</CardDescription>
</CardHeader>

<CardContent className="space-y-3">
<div className="space-y-2">
<Input
value={savedSearchName}
onChange={(event) => setSavedSearchName(event.target.value)}
placeholder="Search name"
aria-label="Saved search name"
/>

<Button
type="button"
className="w-full gap-2"
onClick={handleSaveSearch}
>
<Bookmark className="h-4 w-4" />
Save current search
</Button>
</div>

{savedSearches.length === 0 ? (
<p className="text-sm text-muted-foreground">
No saved searches yet.
</p>
) : (
<div className="space-y-2">
{savedSearches.map((savedSearch) => (
<div key={savedSearch.id} className="rounded-md border p-3">
<div className="mb-2 min-w-0">
<p className="truncate text-sm font-medium">
{savedSearch.name}
</p>

<p className="truncate text-xs text-muted-foreground">
{savedSearch.tech} · {savedSearch.label}
</p>
</div>

<div className="flex gap-2">
<Button
Comment thread
arnabnandy7 marked this conversation as resolved.
type="button"
variant="outline"
size="sm"
className="flex-1"
onClick={() => handleRunSavedSearch(savedSearch)}
>
Run
</Button>

<Button
type="button"
variant="outline"
size="sm"
aria-label={`Delete ${savedSearch.name}`}
onClick={() =>
handleDeleteSavedSearch(savedSearch.id)
}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</aside>

<div className="space-y-4">
{error ? (
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="text-base text-destructive">Search failed</CardTitle>
<CardTitle className="text-base text-destructive">
Search failed
</CardTitle>
<CardDescription>{error}</CardDescription>
</CardHeader>
</Card>
Expand Down Expand Up @@ -374,13 +569,15 @@ export function IssueFinder() {
<CardHeader>
<CardTitle className="text-base">No matching issues</CardTitle>
<CardDescription>
Try a broader technology, another label, or recently updated sorting.
Try a broader technology, another label, or recently updated
sorting.
</CardDescription>
</CardHeader>
</Card>
) : null}

{!isLoading && issues.map((issue) => <IssueCard key={issue.id} issue={issue} />)}
{!isLoading &&
issues.map((issue) => <IssueCard key={issue.id} issue={issue} />)}

{!isLoading && hasMore && (
<div className="flex justify-center pt-4">
Expand Down
Loading