Files
kandev/apps/web/hooks/domains/settings/use-workflow-sync.ts
T
João Salavisa 03d5f98cb6 feat(i18n): localize Settings → Workflows (#2201)
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:30:27 +02:00

272 lines
8.5 KiB
TypeScript

"use client";
import { useCallback, useEffect, useState } from "react";
import { t } from "@/lib/i18n";
import { useToast } from "@/components/toast-provider";
import { useRouter } from "@/lib/routing/client-router";
import { INTEGRATION_STATUS_REFRESH_MS } from "@/hooks/domains/integrations/use-integration-availability";
import {
getWorkflowSyncConfig,
setWorkflowSyncConfig,
deleteWorkflowSyncConfig,
forceWorkflowSync,
} from "@/lib/api/domains/workflow-sync-api";
import type { WorkflowSyncConfig, WorkflowSyncSetConfigRequest } from "@/lib/types/workflow-sync";
import { buildGitHubRepoUrl, parseGitHubRepoUrl } from "@/lib/utils/github-repo-url";
export type WorkflowSyncFormState = {
repo_owner: string;
repo_name: string;
branch: string;
path: string;
interval_seconds: number;
poll_enabled: boolean;
};
const DEFAULT_FORM: WorkflowSyncFormState = {
repo_owner: "",
repo_name: "",
branch: "main",
path: ".kandev/workflows",
interval_seconds: 300,
poll_enabled: true,
};
function configToForm(cfg: WorkflowSyncConfig | null): WorkflowSyncFormState {
if (!cfg) return DEFAULT_FORM;
return {
repo_owner: cfg.repo_owner,
repo_name: cfg.repo_name,
branch: cfg.branch,
path: cfg.path,
interval_seconds: cfg.interval_seconds,
poll_enabled: cfg.poll_enabled,
};
}
// Background refresh so the status banner picks up new poller results
// (last_ok / last_error / last_warnings) without requiring a page reload. We
// re-fetch the config rather than the loud full `load()` to avoid flashing
// the form while the user is editing it.
function useWorkflowSyncConfigRefresh(
workspaceId: string,
setConfig: (cfg: WorkflowSyncConfig | null) => void,
) {
useEffect(() => {
let cancelled = false;
const id = setInterval(() => {
getWorkflowSyncConfig({ workspaceId })
.then((cfg) => {
if (!cancelled) setConfig(cfg);
})
.catch(() => {
/* transient failures are fine — next tick retries */
});
}, INTEGRATION_STATUS_REFRESH_MS);
return () => {
cancelled = true;
clearInterval(id);
};
}, [workspaceId, setConfig]);
}
// useWorkflowSyncForm owns the editable form state. The repository link is
// the primary input: owner, repo, and directory only change through it (or a
// loaded config), while branch and interval remain individually editable via
// `update`. Branch and directory are only overwritten when the link actually
// carried them (/tree/... or /blob/... forms), so a bare repo URL keeps the
// current values.
function useWorkflowSyncForm() {
const [form, setForm] = useState<WorkflowSyncFormState>(DEFAULT_FORM);
const [url, setUrl] = useState("");
const update = useCallback(
<K extends keyof WorkflowSyncFormState>(key: K, value: WorkflowSyncFormState[K]) =>
setForm((prev) => ({ ...prev, [key]: value })),
[],
);
const setUrlInput = useCallback((value: string) => {
setUrl(value);
const parsed = parseGitHubRepoUrl(value);
if (!parsed) return;
setForm((prev) => ({
...prev,
repo_owner: parsed.owner,
repo_name: parsed.repo,
branch: parsed.branch ?? prev.branch,
path: parsed.path ?? prev.path,
}));
}, []);
// reset re-derives both the structured form and the displayed link from a
// loaded/saved config (or clears them when the config was removed).
const reset = useCallback((cfg: WorkflowSyncConfig | null) => {
setForm(configToForm(cfg));
setUrl(
cfg
? buildGitHubRepoUrl({
owner: cfg.repo_owner,
repo: cfg.repo_name,
branch: cfg.branch,
path: cfg.path,
})
: "",
);
}, []);
const urlInvalid = !!url.trim() && !parseGitHubRepoUrl(url);
return { form, url, urlInvalid, update, setUrlInput, reset };
}
type SyncToast = { description: string; variant?: "success" | "error" | "default" };
function syncOutcomeToast(error: string | undefined, warnings: string[] | undefined): SyncToast {
if (error)
return { description: t("workflows:syncFailedWithError", { error }), variant: "error" };
if (warnings?.length) {
return { description: t("workflows:syncCompletedWithWarnings"), variant: "default" };
}
return { description: t("workflows:workflowSyncCompleted"), variant: "success" };
}
type InitialLoadDeps = {
setConfig: (cfg: WorkflowSyncConfig | null) => void;
reset: (cfg: WorkflowSyncConfig | null) => void;
setLoading: (loading: boolean) => void;
toast: ReturnType<typeof useToast>["toast"];
};
// useWorkflowSyncInitialLoad fetches the stored config on mount / workspace
// change. It guards against stale responses: if the hook re-renders for a
// different workspace while a fetch is in flight, the old workspace's config
// must not populate the new one's form.
function useWorkflowSyncInitialLoad(
workspaceId: string,
{ setConfig, reset, setLoading, toast }: InitialLoadDeps,
) {
useEffect(() => {
let cancelled = false;
setLoading(true);
getWorkflowSyncConfig({ workspaceId })
.then((cfg) => {
if (cancelled) return;
setConfig(cfg);
reset(cfg);
})
.catch((err) => {
if (cancelled) return;
toast({
description: t("workflows:failedToLoadSyncConfig", { error: String(err) }),
variant: "error",
});
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceId]);
}
export function useWorkflowSync(workspaceId: string) {
const { toast } = useToast();
const router = useRouter();
const [config, setConfig] = useState<WorkflowSyncConfig | null>(null);
const { form, url, urlInvalid, update, setUrlInput, reset } = useWorkflowSyncForm();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [syncing, setSyncing] = useState(false);
useWorkflowSyncInitialLoad(workspaceId, { setConfig, reset, setLoading, toast });
useWorkflowSyncConfigRefresh(workspaceId, setConfig);
const handleSave = useCallback(async () => {
setSaving(true);
try {
const payload: WorkflowSyncSetConfigRequest = {
repo_owner: form.repo_owner.trim(),
repo_name: form.repo_name.trim(),
branch: form.branch.trim(),
path: form.path.trim(),
interval_seconds: form.interval_seconds,
poll_enabled: form.poll_enabled,
};
const saved = await setWorkflowSyncConfig(payload, { workspaceId });
setConfig(saved);
reset(saved);
toast({ description: t("workflows:syncConfigSaved"), variant: "success" });
return true;
} catch (err) {
toast({ description: t("workflows:saveFailed", { error: String(err) }), variant: "error" });
return false;
} finally {
setSaving(false);
}
}, [workspaceId, form, toast, reset]);
const handleDelete = useCallback(async () => {
if (!confirm(t("workflows:removeSyncConfirm"))) {
return false;
}
try {
await deleteWorkflowSyncConfig({ workspaceId });
setConfig(null);
reset(null);
toast({ description: t("workflows:syncRemoved") });
// Released workflows lose their read-only state server-side; reload the
// page data so the cards unlock without a manual refresh.
router.refresh();
return true;
} catch (err) {
toast({
description: t("workflows:syncRemoveFailed", { error: String(err) }),
variant: "error",
});
return false;
}
}, [workspaceId, toast, reset, router]);
const handleSyncNow = useCallback(async () => {
setSyncing(true);
try {
const res = await forceWorkflowSync({ workspaceId });
setConfig(res.config);
reset(res.config);
toast(syncOutcomeToast(res.error, res.result?.warnings));
// Reload the workflow list when the sync actually changed something so
// created/updated/deleted workflows show up without a manual refresh.
if (res.result && !res.result.unchanged) {
router.refresh();
}
} catch (err) {
toast({
description: t("workflows:syncFailedWithError", { error: String(err) }),
variant: "error",
});
} finally {
setSyncing(false);
}
}, [workspaceId, toast, reset, router]);
return {
config,
form,
url,
urlInvalid,
loading,
saving,
syncing,
update,
setUrlInput,
handleSave,
handleDelete,
handleSyncNow,
};
}
export type WorkflowSyncController = ReturnType<typeof useWorkflowSync>;