Files
kandev/apps/web/components/github/default-queries-section.tsx
2026-09-10 18:14:05 +01:00

317 lines
11 KiB
TypeScript

"use client";
import { useState, useCallback, useMemo } from "react";
import { IconPlus, IconTrash, IconRefresh } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { CardContent } from "@kandev/ui/card";
import { Input } from "@kandev/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@kandev/ui/tabs";
import { useToast } from "@/components/toast-provider";
import { SettingsSection } from "@/components/settings/settings-section";
import { SettingsCard } from "@/components/settings/settings-card";
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
import {
useDefaultQueryPresets,
toStored,
type StoredQueryPreset,
} from "@/components/github/my-github/use-default-query-presets";
import {
PR_PRESETS as BUILTIN_PR_PRESETS,
ISSUE_PRESETS as BUILTIN_ISSUE_PRESETS,
} from "@/components/github/my-github/search-bar";
import { useTranslation } from "react-i18next";
import { controlSizingClassName } from "@kandev/ui/control-sizing";
import {
settingsActionClassName,
settingsControlClassName,
} from "@/components/settings/settings-control";
// `label` is PERSISTED as part of `StoredQueryPreset` (github_default_query_presets)
// and is editable in the row below, so it must stay locale-neutral — see
// `newPreset` in action-presets-section.tsx for the same contract.
function newPreset(): StoredQueryPreset {
// i18n-exempt: persisted, editable preset label. See the comment above.
return {
value: `q_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
label: "New query",
filter: "",
group: "inbox",
};
}
function QueryRow({
preset,
baseline,
onPatch,
onRemove,
}: {
preset: StoredQueryPreset;
baseline?: StoredQueryPreset;
onPatch: (patch: Partial<StoredQueryPreset>) => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
return (
<div
className="flex items-center gap-2 rounded-md border p-2"
data-settings-dirty={JSON.stringify(preset) !== JSON.stringify(baseline)}
data-settings-dirty-level="container"
>
<div className="flex flex-col gap-0.5">
<span className="text-[10px] text-muted-foreground">{t("github:label")}</span>
<Input
className={settingsControlClassName("w-36")}
value={preset.label}
data-settings-dirty={preset.label !== baseline?.label}
placeholder={t("github:label")}
onChange={(e) => onPatch({ label: e.target.value })}
/>
</div>
<div className="flex flex-col gap-0.5 flex-1">
<span className="text-[10px] text-muted-foreground">{t("github:query")}</span>
<Input
className={settingsControlClassName("font-mono text-xs")}
value={preset.filter}
data-settings-dirty={preset.filter !== baseline?.filter}
placeholder={t("github:queryExample", { query: "review-requested:@me is:open" })}
onChange={(e) => onPatch({ filter: e.target.value })}
/>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[10px] text-muted-foreground">{t("github:group")}</span>
<Select
value={preset.group}
onValueChange={(v) => onPatch({ group: v as "inbox" | "created" })}
>
<SelectTrigger
className={settingsControlClassName("w-28 cursor-pointer")}
data-settings-dirty={preset.group !== baseline?.group}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="inbox" className="cursor-pointer">
{t("github:inbox")}
</SelectItem>
<SelectItem value="created" className="cursor-pointer">
{t("github:created")}
</SelectItem>
</SelectContent>
</Select>
</div>
<Button
variant="ghost"
size="icon"
className={controlSizingClassName("icon", "cursor-pointer text-destructive mt-3.5")}
onClick={onRemove}
aria-label={t("github:remove")}
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
</div>
);
}
function QueryEditor({
presets,
baseline,
onChange,
addLabel,
}: {
presets: StoredQueryPreset[];
baseline: StoredQueryPreset[];
onChange: (presets: StoredQueryPreset[]) => void;
addLabel: string;
}) {
const patch = useCallback(
(index: number, change: Partial<StoredQueryPreset>) => {
onChange(presets.map((p, i) => (i === index ? { ...p, ...change } : p)));
},
[presets, onChange],
);
const remove = useCallback(
(index: number) => onChange(presets.filter((_, i) => i !== index)),
[presets, onChange],
);
const add = useCallback(() => onChange([...presets, newPreset()]), [presets, onChange]);
return (
<div className="space-y-2">
{presets.map((preset, index) => (
<QueryRow
key={preset.value}
preset={preset}
baseline={baseline.find((candidate) => candidate.value === preset.value)}
onPatch={(p) => patch(index, p)}
onRemove={() => remove(index)}
/>
))}
<Button variant="outline" onClick={add} className={settingsActionClassName("cursor-pointer")}>
<IconPlus className="h-3.5 w-3.5 mr-1" />
{addLabel}
</Button>
</div>
);
}
function useDefaultQueryDrafts(workspaceId?: string) {
const { t } = useTranslation();
const { toast } = useToast();
const { prPresets, issuePresets, save, reset, isCustomized, isReady } = useDefaultQueryPresets(
workspaceId ?? null,
);
const [prDraft, setPrDraft] = useState<StoredQueryPreset[]>(prPresets);
const [issueDraft, setIssueDraft] = useState<StoredQueryPreset[]>(issuePresets);
const [prBaseline, setPrBaseline] = useState(prPresets);
const [issueBaseline, setIssueBaseline] = useState(issuePresets);
const [resetRequested, setResetRequested] = useState(false);
const dirty = useMemo(
() =>
resetRequested ||
JSON.stringify(prBaseline) !== JSON.stringify(prDraft) ||
JSON.stringify(issueBaseline) !== JSON.stringify(issueDraft),
[prBaseline, issueBaseline, prDraft, issueDraft, resetRequested],
);
const [syncedPr, setSyncedPr] = useState(prPresets);
const [syncedIssue, setSyncedIssue] = useState(issuePresets);
if (prPresets !== syncedPr && !dirty) {
setSyncedPr(prPresets);
setPrBaseline(prPresets);
setPrDraft(prPresets);
setResetRequested(false);
}
if (issuePresets !== syncedIssue && !dirty) {
setSyncedIssue(issuePresets);
setIssueBaseline(issuePresets);
setIssueDraft(issuePresets);
setResetRequested(false);
}
const handleSave = useCallback(async () => {
const submittedPr = prDraft;
const submittedIssue = issueDraft;
try {
if (resetRequested) await reset();
else await save({ pr: submittedPr, issue: submittedIssue });
setPrBaseline(submittedPr);
setIssueBaseline(submittedIssue);
setPrDraft((current) =>
JSON.stringify(current) === JSON.stringify(submittedPr) ? submittedPr : current,
);
setIssueDraft((current) =>
JSON.stringify(current) === JSON.stringify(submittedIssue) ? submittedIssue : current,
);
setResetRequested(false);
toast({ description: t("github:defaultQueriesSaved"), variant: "success" });
} catch {
toast({ description: t("github:failedToSaveDefaultQueries"), variant: "error" });
throw new Error("Failed to save default queries");
}
}, [issueDraft, prDraft, reset, resetRequested, save, t, toast]);
const handleReset = useCallback(() => {
setPrDraft(toStored(BUILTIN_PR_PRESETS));
setIssueDraft(toStored(BUILTIN_ISSUE_PRESETS));
setResetRequested(true);
}, []);
const discard = useCallback(() => {
setPrDraft(prBaseline);
setIssueDraft(issueBaseline);
setResetRequested(false);
}, [prBaseline, issueBaseline]);
const defaultPr = toStored(BUILTIN_PR_PRESETS);
const defaultIssue = toStored(BUILTIN_ISSUE_PRESETS);
const atDefaults =
JSON.stringify(prDraft) === JSON.stringify(defaultPr) &&
JSON.stringify(issueDraft) === JSON.stringify(defaultIssue);
useSettingsSaveContributor({
id: `github-default-queries:${workspaceId ?? "global"}`,
revision: JSON.stringify([prDraft, issueDraft]),
isDirty: dirty,
canSave: isReady,
invalidReason: isReady ? undefined : t("github:defaultQueriesAreStillLoading"),
save: handleSave,
discard,
});
return {
prDraft,
issueDraft,
prBaseline,
issueBaseline,
dirty,
setPrDraft: (next: StoredQueryPreset[]) => {
setResetRequested(false);
setPrDraft(next);
},
setIssueDraft: (next: StoredQueryPreset[]) => {
setResetRequested(false);
setIssueDraft(next);
},
reset: handleReset,
atDefaults,
resetDisabled: atDefaults && !isCustomized,
};
}
export function DefaultQueriesSection({ workspaceId }: { workspaceId?: string }) {
const { t } = useTranslation();
const drafts = useDefaultQueryDrafts(workspaceId);
return (
<SettingsSection
title={t("github:defaultQueries")}
description={t("github:sidebarQueriesShownOnGithubFor", { route: "/github" })}
action={
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={drafts.reset}
disabled={drafts.resetDisabled}
className="cursor-pointer"
>
<IconRefresh className="h-3.5 w-3.5 mr-1" />
{t("common:reset")}
</Button>
</div>
}
>
<SettingsCard isDirty={drafts.dirty}>
<CardContent className="pt-6">
<Tabs defaultValue="pr">
<TabsList>
<TabsTrigger value="pr" className="cursor-pointer">
{t("github:pullRequests")}
</TabsTrigger>
<TabsTrigger value="issue" className="cursor-pointer">
{t("github:issues")}
</TabsTrigger>
</TabsList>
<TabsContent value="pr">
<QueryEditor
presets={drafts.prDraft}
baseline={drafts.prBaseline}
onChange={drafts.setPrDraft}
addLabel={t("github:addPrQuery")}
/>
</TabsContent>
<TabsContent value="issue">
<QueryEditor
presets={drafts.issueDraft}
baseline={drafts.issueBaseline}
onChange={drafts.setIssueDraft}
addLabel={t("github:addIssueQuery")}
/>
</TabsContent>
</Tabs>
</CardContent>
</SettingsCard>
</SettingsSection>
);
}