diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index 9ead9fd..74ae7fb 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -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: > # Unique keys of your project and organization. You can find them in SonarCloud > Information (bottom-left menu) # mandatory -Dsonar.projectKey=arnabnandy7_openissue.dev diff --git a/src/features/issues/components/issue-finder.tsx b/src/features/issues/components/issue-finder.tsx index be25489..b6466ee 100644 --- a/src/features/issues/components/issue-finder.tsx +++ b/src/features/issues/components/issue-finder.tsx @@ -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, @@ -48,13 +54,22 @@ export function IssueFinder() { const [error, setError] = useState(null); const [cooldown, setCooldown] = useState(false); + const [savedSearches, setSavedSearches] = useState([]); + 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( @@ -73,10 +88,80 @@ export function IssueFinder() { return issues.length < data.candidateCount && data.issues.length === 24; }, [data, issues]); - async function searchIssues(event?: FormEvent) { - 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, + 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; } @@ -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 { @@ -178,8 +263,9 @@ export function IssueFinder() { Find active open-source issues by tech.

- 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.

@@ -200,7 +286,11 @@ export function IssueFinder() { setSavedSearchName(event.target.value)} + placeholder="Search name" + aria-label="Saved search name" + /> + + + + + {savedSearches.length === 0 ? ( +

+ No saved searches yet. +

+ ) : ( +
+ {savedSearches.map((savedSearch) => ( +
+
+

+ {savedSearch.name} +

+ +

+ {savedSearch.tech} ยท {savedSearch.label} +

+
+ +
+ + + +
+
+ ))} +
+ )} + +
{error ? ( - Search failed + + Search failed + {error} @@ -374,13 +569,15 @@ export function IssueFinder() { No matching issues - Try a broader technology, another label, or recently updated sorting. + Try a broader technology, another label, or recently updated + sorting. ) : null} - {!isLoading && issues.map((issue) => )} + {!isLoading && + issues.map((issue) => )} {!isLoading && hasMore && (
diff --git a/src/features/issues/lib/saved-searches.ts b/src/features/issues/lib/saved-searches.ts new file mode 100644 index 0000000..3b6dd9c --- /dev/null +++ b/src/features/issues/lib/saved-searches.ts @@ -0,0 +1,115 @@ +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; + + 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[]): boolean { + if (typeof window === "undefined") { + return false; + } + + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(searches)); + return true; + } catch { + return false; + // Ignore storage failures so the search UI remains usable. + } +} + +let fallbackIdCounter = 0; + +function createSavedSearchId(): string { + if (globalThis.crypto?.randomUUID) { + return globalThis.crypto.randomUUID(); + } + + fallbackIdCounter += 1; + return `${Date.now()}-${fallbackIdCounter}`; +} + +export function addSavedSearch( + search: Omit, +): SavedSearch { + const savedSearch: SavedSearch = { + ...search, + id: createSavedSearchId(), + createdAt: new Date().toISOString(), + }; + + const searches = getSavedSearches(); + const saved = saveSavedSearches([...searches, savedSearch]); + + if (!saved) { + throw new Error("Unable to save search."); + } + + return savedSearch; +} + +export function deleteSavedSearch(id: string): void { + const searches = getSavedSearches(); + saveSavedSearches(searches.filter((search) => search.id !== id)); +}