Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
178 changes: 165 additions & 13 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,6 +54,13 @@ 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],
Expand All @@ -73,13 +86,77 @@ 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 (!tech.trim()) {
setError("Enter a technology to search.");
return;
}
if (!name) {
setError("Enter a name for the saved search.");
return;
Comment thread
ShashankShekhar31 marked this conversation as resolved.
Outdated
}

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;
}

setIsLoading(true);
setCooldown(true);
Expand All @@ -88,11 +165,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 @@ -329,6 +406,81 @@ export function IssueFinder() {
})}
</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
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">
Expand Down
98 changes: 98 additions & 0 deletions src/features/issues/lib/saved-searches.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import {
HACKTOBERFEST_FILTERS,
LABEL_OPTIONS,
LINKED_PR_FILTERS,
SORT_OPTIONS,
} from "@/features/issues/data/search-options";

export type SavedSearch = {
id: string;
name: string;
tech: string;
label: string;
sort: string;
linkedPr: string;
hacktoberfest: string;
createdAt: string;
};

const STORAGE_KEY = "openissue:saved-searches";

function isValidSavedSearch(value: unknown): value is SavedSearch {
if (!value || typeof value !== "object") {
return false;
}

const search = value as Partial<SavedSearch>;

return (
typeof search.id === "string" &&
typeof search.name === "string" &&
typeof search.tech === "string" &&
typeof search.label === "string" &&
typeof search.sort === "string" &&
typeof search.linkedPr === "string" &&
typeof search.hacktoberfest === "string" &&
typeof search.createdAt === "string" &&
LABEL_OPTIONS.some((option) => option.value === search.label) &&
SORT_OPTIONS.some((option) => option.value === search.sort) &&
LINKED_PR_FILTERS.has(search.linkedPr) &&
HACKTOBERFEST_FILTERS.has(search.hacktoberfest)
);
}

export function getSavedSearches(): SavedSearch[] {
if (typeof window === "undefined") {
return [];
}

try {
const stored = window.localStorage.getItem(STORAGE_KEY);

if (!stored) {
return [];
}

const parsed: unknown = JSON.parse(stored);

if (!Array.isArray(parsed)) {
return [];
}

return parsed.filter(isValidSavedSearch);
} catch {
return [];
}
}

function saveSavedSearches(searches: SavedSearch[]): void {
Comment thread
arnabnandy7 marked this conversation as resolved.
Outdated
if (typeof window === "undefined") {
return;
}

try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(searches));
} catch {
// Ignore storage failures so the search UI remains usable.
}
}

export function addSavedSearch(
search: Omit<SavedSearch, "id" | "createdAt">,
): SavedSearch {
const savedSearch: SavedSearch = {
...search,
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
};
Comment thread
ShashankShekhar31 marked this conversation as resolved.

const searches = getSavedSearches();
saveSavedSearches([...searches, savedSearch]);

return savedSearch;
}
Comment thread
arnabnandy7 marked this conversation as resolved.

export function deleteSavedSearch(id: string): void {
const searches = getSavedSearches();
saveSavedSearches(searches.filter((search) => search.id !== id));
}