diff --git a/apps/dashboard/src/components/issues/new/new-issue-page.tsx b/apps/dashboard/src/components/issues/new/new-issue-page.tsx new file mode 100644 index 0000000..bf835a1 --- /dev/null +++ b/apps/dashboard/src/components/issues/new/new-issue-page.tsx @@ -0,0 +1,663 @@ +import { + CheckIcon, + CloseIcon, + IssuesIcon, + PlusSignIcon, + SearchIcon, +} from "@diffkit/icons"; +import { Button } from "@diffkit/ui/components/button"; +import { + MarkdownEditor, + type MentionCandidate, +} from "@diffkit/ui/components/markdown-editor"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@diffkit/ui/components/popover"; +import { Spinner } from "@diffkit/ui/components/spinner"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { getRouteApi, Link, useRouter } from "@tanstack/react-router"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { DetailSidebar } from "#/components/details/detail-sidebar"; +import { createIssue } from "#/lib/github.functions"; +import { + type GitHubQueryScope, + githubRepoCollaboratorsQueryOptions, + githubRepoLabelsQueryOptions, +} from "#/lib/github.query"; +import type { GitHubLabel, RepoCollaborator } from "#/lib/github.types"; + +const routeApi = getRouteApi("/_protected/$owner/$repo/issues/new"); + +export function NewIssuePage() { + const { user } = routeApi.useRouteContext(); + const { owner, repo } = routeApi.useParams(); + const scope = useMemo(() => ({ userId: user.id }), [user.id]); + + return ; +} + +function NewIssueForm({ + owner, + repo, + scope, +}: { + owner: string; + repo: string; + scope: GitHubQueryScope; +}) { + const router = useRouter(); + const queryClient = useQueryClient(); + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [selectedLabels, setSelectedLabels] = useState([]); + const [selectedAssignees, setSelectedAssignees] = useState< + RepoCollaborator[] + >([]); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + // Prefetch labels + const labelsQuery = useQuery( + githubRepoLabelsQueryOptions(scope, { owner, repo }), + ); + const repoLabels = labelsQuery.data ?? []; + + // Collaborators for mentions + const collaboratorsQuery = useQuery( + githubRepoCollaboratorsQueryOptions(scope, { owner, repo }), + ); + + const mentionCandidates: MentionCandidate[] = useMemo( + () => + (collaboratorsQuery.data ?? []).map((c) => ({ + id: c.login, + label: c.login, + avatarUrl: c.avatarUrl, + })), + [collaboratorsQuery.data], + ); + + const mentionConfig = useMemo( + () => ({ + candidates: mentionCandidates, + isLoading: collaboratorsQuery.isLoading, + }), + [mentionCandidates, collaboratorsQuery.isLoading], + ); + + const toggleLabel = useCallback((label: GitHubLabel) => { + setSelectedLabels((prev) => { + const exists = prev.some((l) => l.name === label.name); + if (exists) return prev.filter((l) => l.name !== label.name); + return [...prev, label]; + }); + }, []); + + const toggleAssignee = useCallback((collaborator: RepoCollaborator) => { + setSelectedAssignees((prev) => { + const exists = prev.some((a) => a.login === collaborator.login); + if (exists) return prev.filter((a) => a.login !== collaborator.login); + return [...prev, collaborator]; + }); + }, []); + + const handleSubmit = async () => { + if (!title.trim()) return; + setSubmitting(true); + setError(null); + + try { + const result = await createIssue({ + data: { + owner, + repo, + title: title.trim(), + body: body.trim() || undefined, + labels: + selectedLabels.length > 0 + ? selectedLabels.map((l) => l.name) + : undefined, + assignees: + selectedAssignees.length > 0 + ? selectedAssignees.map((a) => a.login) + : undefined, + }, + }); + + if (result.ok) { + await queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.some( + (k) => + typeof k === "string" && + (k.includes("issues") || k.includes("repoMeta")), + ) + ); + }, + }); + router.navigate({ + to: "/$owner/$repo/issues/$issueId", + params: { owner, repo, issueId: String(result.issueNumber) }, + }); + } else { + setError(result.error); + } + } catch { + setError("Failed to create issue. Please try again."); + } finally { + setSubmitting(false); + } + }; + + const canSubmit = title.trim().length > 0 && !submitting; + + return ( +
+
+ {/* Main */} +
+ {/* Breadcrumb */} +
+ + {owner}/{repo} + + / + New issue +
+ + {/* Header */} +
+
+ +
+

+ Create new issue +

+
+ + {/* Title */} +
+ + setTitle(e.target.value)} + placeholder="Issue title" + className="flex h-9 w-full rounded-md border bg-surface-1 px-3 py-1 text-sm outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]" + /> +
+ + {/* Body */} +
+ + Description + + +
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Actions */} +
+ + +
+
+ + {/* Sidebar */} + +
+
+ ); +} + +function NewIssueSidebar({ + repoLabels, + selectedLabels, + onToggleLabel, + isLoading, + selectedAssignees, + onToggleAssignee, + scope, + owner, + repo, +}: { + repoLabels: GitHubLabel[]; + selectedLabels: GitHubLabel[]; + onToggleLabel: (label: GitHubLabel) => void; + isLoading: boolean; + selectedAssignees: RepoCollaborator[]; + onToggleAssignee: (collaborator: RepoCollaborator) => void; + scope: GitHubQueryScope; + owner: string; + repo: string; +}) { + return ( + + + + + + ); +} + +function NewIssueLabelsPicker({ + repoLabels, + selectedLabels, + onToggleLabel, + isLoading, +}: { + repoLabels: GitHubLabel[]; + selectedLabels: GitHubLabel[]; + onToggleLabel: (label: GitHubLabel) => void; + isLoading: boolean; +}) { + const [pickerOpen, setPickerOpen] = useState(false); + const [search, setSearch] = useState(""); + const [focusedIndex, setFocusedIndex] = useState(-1); + const listRef = useRef(null); + + const selectedNames = useMemo( + () => new Set(selectedLabels.map((l) => l.name)), + [selectedLabels], + ); + + const filtered = useMemo(() => { + if (!search) return repoLabels; + const q = search.toLowerCase(); + return repoLabels.filter((l) => l.name.toLowerCase().includes(q)); + }, [repoLabels, search]); + + const scrollToFocused = useCallback((index: number) => { + const el = listRef.current?.querySelector(`[data-index="${index}"]`); + if (el) { + el.scrollIntoView({ block: "nearest" }); + } + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (filtered.length === 0) return; + + if (e.key === "ArrowDown") { + e.preventDefault(); + const next = focusedIndex < filtered.length - 1 ? focusedIndex + 1 : 0; + setFocusedIndex(next); + scrollToFocused(next); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + const next = focusedIndex > 0 ? focusedIndex - 1 : filtered.length - 1; + setFocusedIndex(next); + scrollToFocused(next); + } else if (e.key === "Enter") { + e.preventDefault(); + if (focusedIndex >= 0 && focusedIndex < filtered.length) { + onToggleLabel(filtered[focusedIndex]); + } + } + }; + + return ( +
+
+

+ Labels +

+ { + setPickerOpen(open); + if (!open) { + setSearch(""); + setFocusedIndex(-1); + } + }} + > + + + + +
+ + { + setSearch(e.target.value); + setFocusedIndex(-1); + }} + onKeyDown={handleKeyDown} + placeholder="Search labels..." + className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> +
+
+ {isLoading ? ( +

+ Loading… +

+ ) : filtered.length === 0 ? ( +

+ {search ? "No labels found" : "No labels available"} +

+ ) : ( + filtered.map((label, i) => { + const isSelected = selectedNames.has(label.name); + return ( + + ); + }) + )} +
+
+
+
+ {selectedLabels.length > 0 ? ( +
+ {selectedLabels.map((label) => ( + + {label.name} + + + ))} +
+ ) : ( +

No labels

+ )} +
+ ); +} + +function AssigneesSection({ + selectedAssignees, + onToggleAssignee, + scope, + owner, + repo, +}: { + selectedAssignees: RepoCollaborator[]; + onToggleAssignee: (collaborator: RepoCollaborator) => void; + scope: GitHubQueryScope; + owner: string; + repo: string; +}) { + const queryClient = useQueryClient(); + const [pickerOpen, setPickerOpen] = useState(false); + const [search, setSearch] = useState(""); + const [focusedIndex, setFocusedIndex] = useState(-1); + const listRef = useRef(null); + + const collaboratorsOptions = githubRepoCollaboratorsQueryOptions(scope, { + owner, + repo, + }); + const collaboratorsQuery = useQuery({ + ...collaboratorsOptions, + enabled: pickerOpen, + }); + + const prefetchCollaborators = useCallback(() => { + void queryClient.prefetchQuery(collaboratorsOptions); + }, [queryClient, collaboratorsOptions]); + + const collaborators = collaboratorsQuery.data ?? []; + + const selectedLogins = useMemo( + () => new Set(selectedAssignees.map((a) => a.login)), + [selectedAssignees], + ); + + const filtered = useMemo(() => { + const users = collaborators.filter((c) => c.type !== "Bot"); + if (!search) return users; + const q = search.toLowerCase(); + return users.filter((c) => c.login.toLowerCase().includes(q)); + }, [collaborators, search]); + + const scrollToFocused = useCallback((index: number) => { + const el = listRef.current?.querySelector(`[data-index="${index}"]`); + if (el) { + el.scrollIntoView({ block: "nearest" }); + } + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (filtered.length === 0) return; + + if (e.key === "ArrowDown") { + e.preventDefault(); + const next = focusedIndex < filtered.length - 1 ? focusedIndex + 1 : 0; + setFocusedIndex(next); + scrollToFocused(next); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + const next = focusedIndex > 0 ? focusedIndex - 1 : filtered.length - 1; + setFocusedIndex(next); + scrollToFocused(next); + } else if (e.key === "Enter") { + e.preventDefault(); + if (focusedIndex >= 0 && focusedIndex < filtered.length) { + onToggleAssignee(filtered[focusedIndex]); + } + } + }; + + return ( +
+
+

+ Assignees +

+ { + setPickerOpen(open); + if (!open) { + setSearch(""); + setFocusedIndex(-1); + } + }} + > + + + + +
+ + { + setSearch(e.target.value); + setFocusedIndex(-1); + }} + onKeyDown={handleKeyDown} + placeholder="Search people..." + className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> +
+
+ {collaboratorsQuery.isLoading ? ( +

+ Loading… +

+ ) : filtered.length === 0 ? ( +

+ {search ? "No people found" : "No collaborators available"} +

+ ) : ( + filtered.map((collaborator, i) => { + const isSelected = selectedLogins.has(collaborator.login); + return ( + + ); + }) + )} +
+
+
+
+ {selectedAssignees.length > 0 ? ( +
+ {selectedAssignees.map((assignee) => ( +
+ {assignee.login} + + {assignee.login} + + +
+ ))} +
+ ) : ( +

No one assigned

+ )} +
+ ); +} diff --git a/apps/dashboard/src/components/repo/repo-activity-cards.tsx b/apps/dashboard/src/components/repo/repo-activity-cards.tsx index 03f3ba3..6c79c42 100644 --- a/apps/dashboard/src/components/repo/repo-activity-cards.tsx +++ b/apps/dashboard/src/components/repo/repo-activity-cards.tsx @@ -3,6 +3,7 @@ import { CommentIcon, GitPullRequestIcon, IssuesIcon, + PlusSignIcon, } from "@diffkit/icons"; import { cn } from "@diffkit/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; @@ -93,6 +94,7 @@ export function RepoActivityCards({ items={issuesQuery.data} count={repoData.openIssueCount} viewAllHref={`/${owner}/${repo}/issues`} + actionHref={`/${owner}/${repo}/issues/new`} renderItem={(issue) => } /> {repoData.hasDiscussions && ( @@ -117,6 +119,7 @@ function ActivityCard({ items, count, viewAllHref, + actionHref, renderItem, }: { title: string; @@ -128,6 +131,7 @@ function ActivityCard({ items: T[] | undefined; count?: number; viewAllHref: string; + actionHref?: string; renderItem: (item: T) => React.ReactNode; }) { return ( @@ -142,6 +146,14 @@ function ActivityCard({ {count} )} + {actionHref && ( + + + + )}
{!items ? ( diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index 27976d2..74391b5 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -5296,6 +5296,52 @@ export const createComment = createServerFn({ method: "POST" }) } }); +export type CreateIssueInput = { + owner: string; + repo: string; + title: string; + body?: string; + labels?: string[]; + assignees?: string[]; +}; + +export type CreateIssueResult = + | { ok: true; issueNumber: number } + | { ok: false; error: string; installUrl?: string }; + +export const createIssue = createServerFn({ method: "POST" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubUserContextForRepository(data); + if (!context) { + return { ok: false, error: "Not authenticated" }; + } + + try { + const response = await context.octokit.rest.issues.create({ + owner: data.owner, + repo: data.repo, + title: data.title, + body: data.body, + labels: data.labels, + assignees: data.assignees, + }); + + await bumpGitHubCacheNamespaces([ + githubRevalidationSignalKeys.issuesMine, + githubRevalidationSignalKeys.repoMeta({ + owner: data.owner, + repo: data.repo, + }), + ]); + + return { ok: true, issueNumber: response.data.number }; + } catch (error) { + const result = toMutationError("create issue", error); + return { ok: false, error: result.ok ? "" : result.error }; + } + }); + export type RepoCollaboratorsInput = { owner: string; repo: string; diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index 237a541..fb817ec 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -30,6 +30,7 @@ import { Route as ApiGithubAppCallbackRouteImport } from './routes/api/github/ap import { Route as ApiGithubAppAuthorizeRouteImport } from './routes/api/github/app/authorize' import { Route as ProtectedOwnerRepoReviewPullIdRouteImport } from './routes/_protected/$owner/$repo/review.$pullId' import { Route as ProtectedOwnerRepoPullPullIdRouteImport } from './routes/_protected/$owner/$repo/pull.$pullId' +import { Route as ProtectedOwnerRepoIssuesNewRouteImport } from './routes/_protected/$owner/$repo/issues.new' import { Route as ProtectedOwnerRepoIssuesIssueIdRouteImport } from './routes/_protected/$owner/$repo/issues.$issueId' const TermsRoute = TermsRouteImport.update({ @@ -139,6 +140,12 @@ const ProtectedOwnerRepoPullPullIdRoute = path: '/$owner/$repo/pull/$pullId', getParentRoute: () => ProtectedRoute, } as any) +const ProtectedOwnerRepoIssuesNewRoute = + ProtectedOwnerRepoIssuesNewRouteImport.update({ + id: '/$owner/$repo/issues/new', + path: '/$owner/$repo/issues/new', + getParentRoute: () => ProtectedRoute, + } as any) const ProtectedOwnerRepoIssuesIssueIdRoute = ProtectedOwnerRepoIssuesIssueIdRouteImport.update({ id: '/$owner/$repo/issues/$issueId', @@ -166,6 +173,7 @@ export interface FileRoutesByFullPath { '/api/github/app/callback': typeof ApiGithubAppCallbackRoute '/$owner/$repo/': typeof ProtectedOwnerRepoIndexRoute '/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute + '/$owner/$repo/issues/new': typeof ProtectedOwnerRepoIssuesNewRoute '/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute '/$owner/$repo/review/$pullId': typeof ProtectedOwnerRepoReviewPullIdRoute } @@ -188,6 +196,7 @@ export interface FileRoutesByTo { '/api/github/app/callback': typeof ApiGithubAppCallbackRoute '/$owner/$repo': typeof ProtectedOwnerRepoIndexRoute '/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute + '/$owner/$repo/issues/new': typeof ProtectedOwnerRepoIssuesNewRoute '/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute '/$owner/$repo/review/$pullId': typeof ProtectedOwnerRepoReviewPullIdRoute } @@ -213,6 +222,7 @@ export interface FileRoutesById { '/api/github/app/callback': typeof ApiGithubAppCallbackRoute '/_protected/$owner/$repo/': typeof ProtectedOwnerRepoIndexRoute '/_protected/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute + '/_protected/$owner/$repo/issues/new': typeof ProtectedOwnerRepoIssuesNewRoute '/_protected/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute '/_protected/$owner/$repo/review/$pullId': typeof ProtectedOwnerRepoReviewPullIdRoute } @@ -238,6 +248,7 @@ export interface FileRouteTypes { | '/api/github/app/callback' | '/$owner/$repo/' | '/$owner/$repo/issues/$issueId' + | '/$owner/$repo/issues/new' | '/$owner/$repo/pull/$pullId' | '/$owner/$repo/review/$pullId' fileRoutesByTo: FileRoutesByTo @@ -260,6 +271,7 @@ export interface FileRouteTypes { | '/api/github/app/callback' | '/$owner/$repo' | '/$owner/$repo/issues/$issueId' + | '/$owner/$repo/issues/new' | '/$owner/$repo/pull/$pullId' | '/$owner/$repo/review/$pullId' id: @@ -284,6 +296,7 @@ export interface FileRouteTypes { | '/api/github/app/callback' | '/_protected/$owner/$repo/' | '/_protected/$owner/$repo/issues/$issueId' + | '/_protected/$owner/$repo/issues/new' | '/_protected/$owner/$repo/pull/$pullId' | '/_protected/$owner/$repo/review/$pullId' fileRoutesById: FileRoutesById @@ -450,6 +463,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProtectedOwnerRepoPullPullIdRouteImport parentRoute: typeof ProtectedRoute } + '/_protected/$owner/$repo/issues/new': { + id: '/_protected/$owner/$repo/issues/new' + path: '/$owner/$repo/issues/new' + fullPath: '/$owner/$repo/issues/new' + preLoaderRoute: typeof ProtectedOwnerRepoIssuesNewRouteImport + parentRoute: typeof ProtectedRoute + } '/_protected/$owner/$repo/issues/$issueId': { id: '/_protected/$owner/$repo/issues/$issueId' path: '/$owner/$repo/issues/$issueId' @@ -482,6 +502,7 @@ interface ProtectedRouteChildren { ProtectedOwnerIndexRoute: typeof ProtectedOwnerIndexRoute ProtectedOwnerRepoIndexRoute: typeof ProtectedOwnerRepoIndexRoute ProtectedOwnerRepoIssuesIssueIdRoute: typeof ProtectedOwnerRepoIssuesIssueIdRoute + ProtectedOwnerRepoIssuesNewRoute: typeof ProtectedOwnerRepoIssuesNewRoute ProtectedOwnerRepoPullPullIdRoute: typeof ProtectedOwnerRepoPullPullIdRoute ProtectedOwnerRepoReviewPullIdRoute: typeof ProtectedOwnerRepoReviewPullIdRoute } @@ -495,6 +516,7 @@ const ProtectedRouteChildren: ProtectedRouteChildren = { ProtectedOwnerIndexRoute: ProtectedOwnerIndexRoute, ProtectedOwnerRepoIndexRoute: ProtectedOwnerRepoIndexRoute, ProtectedOwnerRepoIssuesIssueIdRoute: ProtectedOwnerRepoIssuesIssueIdRoute, + ProtectedOwnerRepoIssuesNewRoute: ProtectedOwnerRepoIssuesNewRoute, ProtectedOwnerRepoPullPullIdRoute: ProtectedOwnerRepoPullPullIdRoute, ProtectedOwnerRepoReviewPullIdRoute: ProtectedOwnerRepoReviewPullIdRoute, } diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/issues.new.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/issues.new.tsx new file mode 100644 index 0000000..a50167d --- /dev/null +++ b/apps/dashboard/src/routes/_protected/$owner/$repo/issues.new.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { NewIssuePage } from "#/components/issues/new/new-issue-page"; +import { buildSeo, formatPageTitle } from "#/lib/seo"; + +export const Route = createFileRoute("/_protected/$owner/$repo/issues/new")({ + ssr: false, + head: ({ match, params }) => + buildSeo({ + path: match.pathname, + title: formatPageTitle(`New Issue · ${params.owner}/${params.repo}`), + description: `Create a new issue in ${params.owner}/${params.repo}.`, + robots: "noindex", + }), + component: NewIssuePage, +});