Files
2026-09-10 18:14:05 +01:00

457 lines
15 KiB
TypeScript

"use client";
import { useCallback, useState } from "react";
import { IconAlertTriangle, IconGitMerge, IconPlus, IconTicket } from "@tabler/icons-react";
import { Alert, AlertDescription } from "@kandev/ui/alert";
import { Button } from "@kandev/ui/button";
import { WatcherSettingsCard } from "@/components/integrations/watcher-settings-card";
import { useWatcherEnabledDrafts } from "@/components/integrations/use-watcher-enabled-drafts";
import { SettingsSection } from "@/components/settings/settings-section";
import { useToast } from "@/components/toast-provider";
import { ResetWatchDialog, useWatchResetController } from "@/components/watches/reset-watch-dialog";
import { useGitLabIssueWatches } from "@/hooks/domains/gitlab/use-gitlab-issue-watches";
import { useGitLabReviewWatches } from "@/hooks/domains/gitlab/use-gitlab-review-watches";
import type { IssueWatch, ReviewWatch } from "@/lib/types/gitlab";
import { IssueWatchDialog } from "./issue-watch-dialog";
import { IssueWatchTable } from "./issue-watch-table";
import { ReviewWatchDialog } from "./review-watch-dialog";
import { ReviewWatchTable } from "./review-watch-table";
import { DeleteWatchDialog } from "./delete-watch-dialog";
import { useTranslation } from "react-i18next";
type ReviewWatches = ReturnType<typeof useGitLabReviewWatches>;
type IssueWatches = ReturnType<typeof useGitLabIssueWatches>;
function errorMessage(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback;
}
function ActionError({ message }: { message: string }) {
if (!message) return null;
return (
<Alert variant="destructive">
<IconAlertTriangle className="h-4 w-4" />
<AlertDescription>{message}</AlertDescription>
</Alert>
);
}
function NewWatchButton({ onClick }: { onClick: () => void }) {
const { t } = useTranslation();
return (
<Button onClick={onClick} className="w-full cursor-pointer sm:w-auto">
<IconPlus className="mr-1 h-4 w-4" />
{t("gitlab:newWatch")}
</Button>
);
}
function useReviewActions(
watches: ReviewWatches,
workspaceId: string,
setError: (message: string) => void,
) {
const { t } = useTranslation();
const { toast } = useToast();
const run = useCallback(
async (watch: ReviewWatch) => {
setError("");
try {
const result = await watches.trigger(watch.id, watch.workspace_id);
toast({
description: result.count
? t("gitlab:foundMatchingMergeRequests", { count: result.count })
: t("gitlab:noNewMergeRequestsMatched"),
variant: "success",
});
} catch (error) {
setError(errorMessage(error, t("gitlab:reviewWatchCheckFailed")));
}
},
[setError, t, toast, watches],
);
const remove = useCallback(
async (watch: ReviewWatch) => {
setError("");
try {
await watches.remove(watch.id, watch.workspace_id);
toast({ description: t("gitlab:reviewWatchDeleted"), variant: "success" });
} catch (error) {
setError(errorMessage(error, t("gitlab:reviewWatchDeletionFailed")));
throw error;
}
},
[setError, t, toast, watches],
);
const reset = useWatchResetController<ReviewWatch>({
preview: (watch) => watches.previewReset(watch.id, watch.workspace_id),
reset: async (watch) => {
setError("");
try {
const result = await watches.reset(watch.id, watch.workspace_id);
toast({
description: t("gitlab:reviewWatchReset", { count: result.tasksDeleted }),
variant: "success",
});
} catch (error) {
setError(errorMessage(error, t("gitlab:reviewWatchResetFailed")));
throw error;
}
},
});
const create = useCallback(
async (request: Parameters<ReviewWatches["create"]>[0]) => {
setError("");
try {
await watches.create(request);
toast({ description: t("gitlab:reviewWatchCreated"), variant: "success" });
} catch (error) {
setError(errorMessage(error, t("gitlab:reviewWatchCreationFailed")));
throw error;
}
},
[setError, t, toast, watches],
);
const update = useCallback(
async (id: string, request: Parameters<ReviewWatches["update"]>[1]) => {
setError("");
try {
await watches.update(id, request, workspaceId);
toast({ description: t("gitlab:reviewWatchUpdated"), variant: "success" });
} catch (error) {
setError(errorMessage(error, t("gitlab:reviewWatchUpdateFailed")));
throw error;
}
},
[setError, t, toast, watches, workspaceId],
);
return { run, remove, reset, create, update };
}
function ReviewDialogs(props: {
workspaceId: string;
open: boolean;
setOpen: (open: boolean) => void;
editing: ReviewWatch | null;
deleting: ReviewWatch | null;
setDeleting: (watch: ReviewWatch | null) => void;
actions: ReturnType<typeof useReviewActions>;
}) {
const { t } = useTranslation();
return (
<>
<ReviewWatchDialog
open={props.open}
onOpenChange={props.setOpen}
watch={props.editing}
workspaceId={props.workspaceId}
onCreate={props.actions.create}
onUpdate={props.actions.update}
/>
{props.actions.reset.resetting && (
<ResetWatchDialog
open
requirePreviewSuccess
onOpenChange={props.actions.reset.onOpenChange}
integrationLabel={t("gitlab:gitlabReviewWatch")}
previewLoader={props.actions.reset.previewLoader}
onConfirm={props.actions.reset.confirmReset}
/>
)}
{props.deleting && (
<DeleteWatchDialog
open
onOpenChange={(open) => {
if (!open) props.setDeleting(null);
}}
watchLabel={t("gitlab:gitlabReviewWatch")}
onConfirm={() => props.actions.remove(props.deleting!)}
/>
)}
</>
);
}
function ReviewWatchSettings({ workspaceId }: { workspaceId: string }) {
const { t } = useTranslation();
const watches = useGitLabReviewWatches(workspaceId);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<ReviewWatch | null>(null);
const [deleting, setDeleting] = useState<ReviewWatch | null>(null);
const [actionError, setActionError] = useState("");
const actions = useReviewActions(watches, workspaceId, setActionError);
const enabled = useWatcherEnabledDrafts({
id: `gitlab-review-watch-enabled-${workspaceId}`,
items: watches.items,
saveEnabled: (watch, value) =>
watches.update(watch.id, { enabled: value }, watch.workspace_id).then(() => undefined),
});
const find = (id: string) => watches.items.find((watch) => watch.id === id);
return (
<SettingsSection
icon={<IconGitMerge className="h-5 w-5" />}
title={t("gitlab:mergeRequestReviewWatches")}
description={t("gitlab:pollGitlabForMergeRequestsAwaiting")}
action={
<NewWatchButton
onClick={() => {
setEditing(null);
setDialogOpen(true);
}}
/>
}
>
<ActionError message={actionError} />
<WatcherSettingsCard
isDirty={enabled.dirtyIds.size > 0}
isLoading={watches.loading}
isEmpty={watches.items.length === 0}
testId="gitlab-review-watches-card"
>
<ReviewWatchTable
watches={enabled.items}
dirtyIds={enabled.dirtyIds}
authoritativeEnabledById={
new Map(watches.items.map((watch) => [watch.id, watch.enabled]))
}
onEdit={(watch) => {
setEditing(watch);
setDialogOpen(true);
}}
onDelete={(id) => {
const watch = find(id);
if (watch) setDeleting(watch);
}}
onTrigger={(id) => {
const watch = find(id);
if (watch) void actions.run(watch);
}}
onReset={(id) => {
const watch = find(id);
if (watch) actions.reset.setResetting(watch);
}}
onToggleEnabled={enabled.toggleEnabled}
/>
</WatcherSettingsCard>
<ReviewDialogs
workspaceId={workspaceId}
open={dialogOpen}
setOpen={setDialogOpen}
editing={editing}
deleting={deleting}
setDeleting={setDeleting}
actions={actions}
/>
</SettingsSection>
);
}
function useIssueActions(
watches: IssueWatches,
workspaceId: string,
setError: (message: string) => void,
) {
const { t } = useTranslation();
const { toast } = useToast();
const run = useCallback(
async (watch: IssueWatch) => {
setError("");
try {
const result = await watches.trigger(watch.id, watch.workspace_id);
toast({
description: result.count
? t("gitlab:foundMatchingIssues", { count: result.count })
: t("gitlab:noNewIssuesMatched"),
variant: "success",
});
} catch (error) {
setError(errorMessage(error, t("gitlab:issueWatchCheckFailed")));
}
},
[setError, t, toast, watches],
);
const remove = useCallback(
async (watch: IssueWatch) => {
setError("");
try {
await watches.remove(watch.id, watch.workspace_id);
toast({ description: t("gitlab:issueWatchDeleted"), variant: "success" });
} catch (error) {
setError(errorMessage(error, t("gitlab:issueWatchDeletionFailed")));
throw error;
}
},
[setError, t, toast, watches],
);
const reset = useWatchResetController<IssueWatch>({
preview: (watch) => watches.previewReset(watch.id, watch.workspace_id),
reset: async (watch) => {
setError("");
try {
const result = await watches.reset(watch.id, watch.workspace_id);
toast({
description: t("gitlab:issueWatchReset", { count: result.tasksDeleted }),
variant: "success",
});
} catch (error) {
setError(errorMessage(error, t("gitlab:issueWatchResetFailed")));
throw error;
}
},
});
const create = useCallback(
async (request: Parameters<IssueWatches["create"]>[0]) => {
setError("");
try {
await watches.create(request);
toast({ description: t("gitlab:issueWatchCreated"), variant: "success" });
} catch (error) {
setError(errorMessage(error, t("gitlab:issueWatchCreationFailed")));
throw error;
}
},
[setError, t, toast, watches],
);
const update = useCallback(
async (id: string, request: Parameters<IssueWatches["update"]>[1]) => {
setError("");
try {
await watches.update(id, request, workspaceId);
toast({ description: t("gitlab:issueWatchUpdated"), variant: "success" });
} catch (error) {
setError(errorMessage(error, t("gitlab:issueWatchUpdateFailed")));
throw error;
}
},
[setError, t, toast, watches, workspaceId],
);
return { run, remove, reset, create, update };
}
function IssueDialogs(props: {
workspaceId: string;
open: boolean;
setOpen: (open: boolean) => void;
editing: IssueWatch | null;
deleting: IssueWatch | null;
setDeleting: (watch: IssueWatch | null) => void;
actions: ReturnType<typeof useIssueActions>;
}) {
const { t } = useTranslation();
return (
<>
<IssueWatchDialog
open={props.open}
onOpenChange={props.setOpen}
watch={props.editing}
workspaceId={props.workspaceId}
onCreate={props.actions.create}
onUpdate={props.actions.update}
/>
{props.actions.reset.resetting && (
<ResetWatchDialog
open
requirePreviewSuccess
onOpenChange={props.actions.reset.onOpenChange}
integrationLabel={t("gitlab:gitlabIssueWatch")}
previewLoader={props.actions.reset.previewLoader}
onConfirm={props.actions.reset.confirmReset}
/>
)}
{props.deleting && (
<DeleteWatchDialog
open
onOpenChange={(open) => {
if (!open) props.setDeleting(null);
}}
watchLabel={t("gitlab:gitlabIssueWatch")}
onConfirm={() => props.actions.remove(props.deleting!)}
/>
)}
</>
);
}
function IssueWatchSettings({ workspaceId }: { workspaceId: string }) {
const { t } = useTranslation();
const watches = useGitLabIssueWatches(workspaceId);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<IssueWatch | null>(null);
const [deleting, setDeleting] = useState<IssueWatch | null>(null);
const [actionError, setActionError] = useState("");
const actions = useIssueActions(watches, workspaceId, setActionError);
const enabled = useWatcherEnabledDrafts({
id: `gitlab-issue-watch-enabled-${workspaceId}`,
items: watches.items,
saveEnabled: (watch, value) =>
watches.update(watch.id, { enabled: value }, watch.workspace_id).then(() => undefined),
});
const find = (id: string) => watches.items.find((watch) => watch.id === id);
return (
<SettingsSection
icon={<IconTicket className="h-5 w-5" />}
title={t("gitlab:issueWatches")}
description={t("gitlab:pollGitlabIssuesAndCreateOne")}
action={
<NewWatchButton
onClick={() => {
setEditing(null);
setDialogOpen(true);
}}
/>
}
>
<ActionError message={actionError} />
<WatcherSettingsCard
isDirty={enabled.dirtyIds.size > 0}
isLoading={watches.loading}
isEmpty={watches.items.length === 0}
testId="gitlab-issue-watches-card"
>
<IssueWatchTable
watches={enabled.items}
dirtyIds={enabled.dirtyIds}
authoritativeEnabledById={
new Map(watches.items.map((watch) => [watch.id, watch.enabled]))
}
onEdit={(watch) => {
setEditing(watch);
setDialogOpen(true);
}}
onDelete={(id) => {
const watch = find(id);
if (watch) setDeleting(watch);
}}
onTrigger={(id) => {
const watch = find(id);
if (watch) void actions.run(watch);
}}
onReset={(id) => {
const watch = find(id);
if (watch) actions.reset.setResetting(watch);
}}
onToggleEnabled={enabled.toggleEnabled}
/>
</WatcherSettingsCard>
<IssueDialogs
workspaceId={workspaceId}
open={dialogOpen}
setOpen={setDialogOpen}
editing={editing}
deleting={deleting}
setDeleting={setDeleting}
actions={actions}
/>
</SettingsSection>
);
}
export function GitLabWatchSettings({ workspaceId }: { workspaceId: string }) {
return (
<>
<ReviewWatchSettings workspaceId={workspaceId} />
<IssueWatchSettings workspaceId={workspaceId} />
</>
);
}