Files
kandev/apps/web/components/azure-devops/azure-devops-default-queries.tsx
T
2026-08-16 12:08:59 +01:00

525 lines
18 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconPlus, IconRefresh, IconTrash } 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, TabsContent, TabsList, TabsTrigger } from "@kandev/ui/tabs";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsSection } from "@/components/settings/settings-section";
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
import { useToast } from "@/components/toast-provider";
import {
getAzureDevOpsWorkspaceSettings,
updateAzureDevOpsWorkspaceSettings,
} from "@/lib/api/domains/azure-devops-api";
import type { AzureDevOpsQueryPreset } from "@/lib/types/azure-devops";
import {
DEFAULT_AZURE_PULL_REQUEST_QUERIES,
DEFAULT_AZURE_WORK_ITEM_QUERIES,
} from "./azure-devops-workspace-defaults";
/**
* Wire discriminant for the two query kinds. These used to be the English
* phrases "Work item" / "Pull request", which made the same value both a
* `===` comparand and display copy; they are now identifiers, and the labels
* resolve through the key maps below.
*/
type QueryKind = "work-item" | "pull-request";
/** Title-cased kind noun, e.g. "Work item query label 1". */
const KIND_TITLE_KEYS: Record<QueryKind, string> = {
"work-item": "azuredevops:workItemKindTitle",
"pull-request": "azuredevops:pullRequestKindTitle",
};
/** Lower-cased kind noun for mid-sentence use, e.g. "Remove work item query 1". */
const KIND_LOWER_KEYS: Record<QueryKind, string> = {
"work-item": "azuredevops:workItemKindLower",
"pull-request": "azuredevops:pullRequestKindLower",
};
/** Azure DevOps identity shorthand accepted by the creator/reviewer filters. */
const AZURE_ME_TOKEN = "@me";
/** Route these presets drive; a path, so it is interpolated rather than translated. */
const AZURE_DEVOPS_ROUTE = "/azure-devops";
// `label` is PERSISTED as part of `AzureDevOpsQueryPreset` and is editable in
// the row below, so it must stay locale-neutral — the same contract as
// `newPreset` in components/github/default-queries-section.tsx. The seeded WIQL
// is Azure DevOps' query language, not copy.
function newQuery(kind: QueryKind): AzureDevOpsQueryPreset {
// i18n-exempt: persisted, editable preset label. See the comment above.
return {
id: `query_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
label: "New query",
group: "inbox",
filters:
kind === "work-item"
? {
wiql: "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project",
top: 50,
}
: { status: "active", creator: "", reviewer: "" },
};
}
function stringFilter(preset: AzureDevOpsQueryPreset, key: string, fallback = ""): string {
return typeof preset.filters[key] === "string" ? preset.filters[key] : fallback;
}
function numberFilter(preset: AzureDevOpsQueryPreset, key: string, fallback: number): number {
return typeof preset.filters[key] === "number" ? preset.filters[key] : fallback;
}
function QueryGroupSelect({
kind,
index,
value,
dirty,
onChange,
}: {
kind: QueryKind;
index: number;
value: string;
dirty: boolean;
onChange: (value: string) => void;
}) {
const { t } = useTranslation();
return (
<Select value={value === "created" ? "created" : "inbox"} onValueChange={onChange}>
<SelectTrigger
className="h-11 w-full cursor-pointer sm:h-8"
aria-label={t("azuredevops:queryGroupAria", {
kind: t(KIND_TITLE_KEYS[kind]),
index: index + 1,
})}
data-settings-dirty={dirty}
>
<SelectValue />
</SelectTrigger>
{/* `value` is the persisted preset group; only the label is copy. */}
<SelectContent>
<SelectItem value="inbox" className="cursor-pointer">
{t("azuredevops:groupInbox")}
</SelectItem>
<SelectItem value="created" className="cursor-pointer">
{t("azuredevops:groupCreated")}
</SelectItem>
</SelectContent>
</Select>
);
}
function QueryField({
label,
className,
children,
}: {
label: string;
className?: string;
children: React.ReactNode;
}) {
return (
<label className={`flex min-w-0 flex-col gap-0.5 ${className ?? ""}`}>
<span className="text-[10px] text-muted-foreground">{label}</span>
{children}
</label>
);
}
type QueryRowProps = {
preset: AzureDevOpsQueryPreset;
baseline?: AzureDevOpsQueryPreset;
index: number;
onPatch: (patch: Partial<AzureDevOpsQueryPreset>) => void;
onPatchFilters: (patch: Record<string, unknown>) => void;
onRemove: () => void;
};
function RemoveQueryButton({
kind,
index,
onRemove,
}: {
kind: QueryKind;
index: number;
onRemove: () => void;
}) {
const { t } = useTranslation();
return (
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-full cursor-pointer text-destructive sm:h-8 sm:w-8"
aria-label={t("azuredevops:removeQueryAria", {
kind: t(KIND_LOWER_KEYS[kind]),
index: index + 1,
})}
onClick={onRemove}
>
<IconTrash className="h-4 w-4" />
</Button>
);
}
function WorkItemQueryRow({
preset,
baseline,
index,
onPatch,
onPatchFilters,
onRemove,
}: QueryRowProps) {
const { t } = useTranslation();
const kind = t(KIND_TITLE_KEYS["work-item"]);
return (
<div
className="grid grid-cols-2 gap-2 rounded-md border p-3 sm:grid-cols-[10rem_minmax(0,1fr)_5rem_7rem_auto] sm:items-end sm:p-2"
data-settings-dirty={JSON.stringify(preset) !== JSON.stringify(baseline)}
data-settings-dirty-level="container"
>
<QueryField label={t("azuredevops:label")}>
<Input
className="h-11 w-full sm:h-8"
value={preset.label}
aria-label={t("azuredevops:queryLabelAria", { kind, index: index + 1 })}
data-settings-dirty={preset.label !== baseline?.label}
onChange={(event) => onPatch({ label: event.target.value })}
/>
</QueryField>
{/* WIQL is the Azure DevOps query language, not copy. */}
<QueryField label="WIQL" className="col-span-2 row-start-2 sm:col-span-1 sm:row-auto">
<Input
className="h-11 w-full font-mono text-xs sm:h-8"
value={stringFilter(preset, "wiql")}
aria-label={t("azuredevops:queryWiqlAria", { kind, index: index + 1 })}
data-settings-dirty={
stringFilter(preset, "wiql") !== (baseline ? stringFilter(baseline, "wiql") : undefined)
}
onChange={(event) => onPatchFilters({ wiql: event.target.value })}
/>
</QueryField>
<QueryField label={t("azuredevops:top")}>
<Input
type="number"
min={1}
max={200}
className="h-11 w-full sm:h-8"
value={numberFilter(preset, "top", 50)}
aria-label={t("azuredevops:queryTopAria", { kind, index: index + 1 })}
onChange={(event) => onPatchFilters({ top: Number(event.target.value) || 1 })}
/>
</QueryField>
<QueryField label={t("azuredevops:group")}>
<QueryGroupSelect
kind="work-item"
index={index}
value={preset.group}
dirty={preset.group !== baseline?.group}
onChange={(group) => onPatch({ group })}
/>
</QueryField>
<RemoveQueryButton kind="work-item" index={index} onRemove={onRemove} />
</div>
);
}
function PullRequestQueryRow({
preset,
baseline,
index,
onPatch,
onPatchFilters,
onRemove,
}: QueryRowProps) {
const { t } = useTranslation();
const kind = t(KIND_TITLE_KEYS["pull-request"]);
const identityPlaceholder = t("azuredevops:identityIdPlaceholder", { token: AZURE_ME_TOKEN });
return (
<div
className="grid grid-cols-2 gap-2 rounded-md border p-3 sm:grid-cols-[10rem_8rem_minmax(0,1fr)_minmax(0,1fr)_7rem_auto] sm:items-end sm:p-2"
data-settings-dirty={JSON.stringify(preset) !== JSON.stringify(baseline)}
data-settings-dirty-level="container"
>
<QueryField label={t("azuredevops:label")}>
<Input
className="h-11 w-full sm:h-8"
value={preset.label}
aria-label={t("azuredevops:queryLabelAria", { kind, index: index + 1 })}
data-settings-dirty={preset.label !== baseline?.label}
onChange={(event) => onPatch({ label: event.target.value })}
/>
</QueryField>
<QueryField label={t("azuredevops:status")}>
<Select
value={stringFilter(preset, "status", "active")}
onValueChange={(status) => onPatchFilters({ status })}
>
<SelectTrigger
className="h-11 w-full cursor-pointer sm:h-8"
aria-label={t("azuredevops:queryStatusAria", { kind, index: index + 1 })}
>
<SelectValue />
</SelectTrigger>
{/* `value` is the Azure DevOps pull-request status sent on the wire;
only the item label is copy. */}
<SelectContent>
<SelectItem value="active">{t("azuredevops:statusActive")}</SelectItem>
<SelectItem value="completed">{t("azuredevops:statusCompleted")}</SelectItem>
<SelectItem value="abandoned">{t("azuredevops:statusAbandoned")}</SelectItem>
<SelectItem value="all">{t("azuredevops:statusAll")}</SelectItem>
</SelectContent>
</Select>
</QueryField>
<QueryField label={t("azuredevops:creator")} className="col-span-2 sm:col-span-1">
<Input
className="h-11 w-full sm:h-8"
value={stringFilter(preset, "creator")}
aria-label={t("azuredevops:queryCreatorAria", { kind, index: index + 1 })}
placeholder={identityPlaceholder}
onChange={(event) => onPatchFilters({ creator: event.target.value })}
/>
</QueryField>
<QueryField label={t("azuredevops:reviewer")} className="col-span-2 sm:col-span-1">
<Input
className="h-11 w-full sm:h-8"
value={stringFilter(preset, "reviewer")}
aria-label={t("azuredevops:queryReviewerAria", { kind, index: index + 1 })}
placeholder={identityPlaceholder}
onChange={(event) => onPatchFilters({ reviewer: event.target.value })}
/>
</QueryField>
<QueryField label={t("azuredevops:group")}>
<QueryGroupSelect
kind="pull-request"
index={index}
value={preset.group}
dirty={preset.group !== baseline?.group}
onChange={(group) => onPatch({ group })}
/>
</QueryField>
<RemoveQueryButton kind="pull-request" index={index} onRemove={onRemove} />
</div>
);
}
function QueryEditor({
kind,
queries,
baseline,
onChange,
}: {
kind: QueryKind;
queries: AzureDevOpsQueryPreset[];
baseline: AzureDevOpsQueryPreset[];
onChange: (queries: AzureDevOpsQueryPreset[]) => void;
}) {
const { t } = useTranslation();
const patch = (index: number, values: Partial<AzureDevOpsQueryPreset>) =>
onChange(
queries.map((query, current) => (current === index ? { ...query, ...values } : query)),
);
const patchFilters = (index: number, values: Record<string, unknown>) =>
patch(index, { filters: { ...queries[index].filters, ...values } });
const Row = kind === "work-item" ? WorkItemQueryRow : PullRequestQueryRow;
return (
<div className="space-y-2">
{queries.map((query, index) => (
<Row
key={query.id}
preset={query}
baseline={baseline.find((candidate) => candidate.id === query.id)}
index={index}
onPatch={(values) => patch(index, values)}
onPatchFilters={(values) => patchFilters(index, values)}
onRemove={() => onChange(queries.filter((_, current) => current !== index))}
/>
))}
<Button
type="button"
size="sm"
variant="outline"
className="h-11 w-full cursor-pointer sm:h-8 sm:w-auto"
onClick={() => onChange([...queries, newQuery(kind)])}
>
<IconPlus className="h-4 w-4" />{" "}
{kind === "pull-request" ? t("azuredevops:addPrQuery") : t("azuredevops:addWorkItemQuery")}
</Button>
</div>
);
}
function useQueryDrafts(workspaceId: string) {
const [workItems, setWorkItems] = useState(DEFAULT_AZURE_WORK_ITEM_QUERIES);
const [pullRequests, setPullRequests] = useState(DEFAULT_AZURE_PULL_REQUEST_QUERIES);
const [baseline, setBaseline] = useState({ workItems, pullRequests });
const [loading, setLoading] = useState(true);
const [resetRequested, setResetRequested] = useState(false);
const dirty = useMemo(
() =>
resetRequested || JSON.stringify({ workItems, pullRequests }) !== JSON.stringify(baseline),
[baseline, pullRequests, resetRequested, workItems],
);
useEffect(() => {
let current = true;
void getAzureDevOpsWorkspaceSettings(workspaceId)
.then((settings) => {
if (!current) return;
setWorkItems(settings.workItemQueries);
setPullRequests(settings.pullRequestQueries);
setBaseline({
workItems: settings.workItemQueries,
pullRequests: settings.pullRequestQueries,
});
})
.catch(() => undefined)
.finally(() => current && setLoading(false));
return () => {
current = false;
};
}, [workspaceId]);
const save = useCallback(async () => {
const response = await updateAzureDevOpsWorkspaceSettings(
workspaceId,
resetRequested
? { pullRequestQueries: null, workItemQueries: null }
: { pullRequestQueries: pullRequests, workItemQueries: workItems },
);
setWorkItems(response.workItemQueries);
setPullRequests(response.pullRequestQueries);
setBaseline({
workItems: response.workItemQueries,
pullRequests: response.pullRequestQueries,
});
setResetRequested(false);
}, [pullRequests, resetRequested, workItems, workspaceId]);
const reset = useCallback(() => {
setWorkItems(DEFAULT_AZURE_WORK_ITEM_QUERIES);
setPullRequests(DEFAULT_AZURE_PULL_REQUEST_QUERIES);
setResetRequested(true);
}, []);
const discard = useCallback(() => {
setWorkItems(baseline.workItems);
setPullRequests(baseline.pullRequests);
setResetRequested(false);
}, [baseline]);
return {
workItems,
pullRequests,
setWorkItems,
setPullRequests,
baseline,
loading,
dirty,
save,
reset,
discard,
};
}
function validQueries(queries: AzureDevOpsQueryPreset[], kind: QueryKind): boolean {
return (
queries.length > 0 &&
queries.every(
(query) =>
query.label.trim() &&
(kind === "pull-request"
? stringFilter(query, "status", "active")
: stringFilter(query, "wiql")),
)
);
}
export function AzureDevOpsDefaultQueriesSection({ workspaceId }: { workspaceId: string }) {
const { t } = useTranslation();
const drafts = useQueryDrafts(workspaceId);
const { toast } = useToast();
const valid =
validQueries(drafts.pullRequests, "pull-request") &&
validQueries(drafts.workItems, "work-item");
const save = useCallback(async () => {
try {
await drafts.save();
toast({ description: t("azuredevops:defaultQueriesSaved"), variant: "success" });
} catch (error) {
toast({
description:
error instanceof Error ? error.message : t("azuredevops:failedToSaveDefaultQueries"),
variant: "error",
});
throw error;
}
}, [drafts, t, toast]);
useSettingsSaveContributor({
id: `azure-devops-default-queries:${workspaceId}`,
revision: JSON.stringify([drafts.pullRequests, drafts.workItems]),
isDirty: drafts.dirty,
canSave: !drafts.loading && valid,
invalidReason: valid ? undefined : t("azuredevops:defaultQueryInvalid"),
save,
discard: drafts.discard,
});
return (
<SettingsSection
title={t("azuredevops:defaultQueries")}
description={t("azuredevops:defaultQueriesDescription", { path: AZURE_DEVOPS_ROUTE })}
action={
<Button
type="button"
size="sm"
variant="outline"
className="h-11 w-full cursor-pointer sm:h-8 sm:w-auto"
disabled={drafts.loading}
onClick={drafts.reset}
>
<IconRefresh className="h-4 w-4" /> {t("common:reset")}
</Button>
}
>
<SettingsCard isDirty={drafts.dirty} data-testid="azure-devops-default-queries-card">
<CardContent className="pt-4 sm:pt-6">
<fieldset disabled={drafts.loading} className="contents">
<Tabs defaultValue="pull-request">
<TabsList className="w-full sm:w-auto">
<TabsTrigger value="pull-request" className="flex-1 cursor-pointer sm:flex-none">
{t("azuredevops:pullRequestsTab")}
</TabsTrigger>
<TabsTrigger value="work-item" className="flex-1 cursor-pointer sm:flex-none">
{t("azuredevops:workItemsTab")}
</TabsTrigger>
</TabsList>
<TabsContent value="pull-request">
<QueryEditor
kind="pull-request"
queries={drafts.pullRequests}
baseline={drafts.baseline.pullRequests}
onChange={drafts.setPullRequests}
/>
</TabsContent>
<TabsContent value="work-item">
<QueryEditor
kind="work-item"
queries={drafts.workItems}
baseline={drafts.baseline.workItems}
onChange={drafts.setWorkItems}
/>
</TabsContent>
</Tabs>
</fieldset>
</CardContent>
</SettingsCard>
</SettingsSection>
);
}