Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
246 changes: 218 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,73 @@ 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;
}

const savedSearch = addSavedSearch({
name,
tech: tech.trim(),
label,
sort,
linkedPr,
hacktoberfest,
});

setSavedSearches((current) => [...current, savedSearch]);
setSavedSearchName("");
setError(null);
}

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 +166,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 +256,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 +279,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 +296,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 +313,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 +330,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 +361,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 +433,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 +562,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