502 lines
17 KiB
TypeScript
502 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
type Dispatch,
|
|
type SetStateAction,
|
|
} from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@kandev/ui/dialog";
|
|
import { Button } from "@kandev/ui/button";
|
|
import { Input } from "@kandev/ui/input";
|
|
import { Label } from "@kandev/ui/label";
|
|
import { Switch } from "@kandev/ui/switch";
|
|
import type { TFunction } from "i18next";
|
|
import type {
|
|
WorkflowSyncController,
|
|
WorkflowSyncFormState,
|
|
} from "@/hooks/domains/settings/use-workflow-sync";
|
|
import type { WorkflowSyncProvider } from "@/lib/types/workflow-sync";
|
|
import { RemoteRepoProviderTabs } from "@/components/task-create-dialog-remote-repo-provider-tabs";
|
|
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
|
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
|
|
import {
|
|
MobileConfirmationHost,
|
|
MobileConfirmationHostBody,
|
|
} from "@/components/confirmation/mobile-confirmation-host";
|
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
|
import type { RemoteRepositoryProvider } from "@/hooks/domains/integrations/use-remote-repositories";
|
|
|
|
const WORKFLOW_EXPORT_FORMAT = "kandev_workflow";
|
|
const WORKFLOW_EXPORT_EXTENSIONS = ".yml/.yaml/.json";
|
|
const SYNC_PROVIDERS: RemoteRepositoryProvider[] = ["github", "gitlab"];
|
|
const REMOVE_TEST_ID = "workflow-sync-remove";
|
|
const REMOVE_CONFIRMATION_TEST_ID = "workflow-sync-remove-confirmation";
|
|
const REMOVE_CONFIRM_TEST_ID = "workflow-sync-remove-confirm";
|
|
|
|
type ProviderFieldProps = {
|
|
provider: WorkflowSyncProvider;
|
|
onChange: (provider: WorkflowSyncProvider) => void;
|
|
};
|
|
|
|
// ProviderField lets the user pick which host workflow sync reads from.
|
|
// Only one source syncs per workspace — picking a different provider than
|
|
// the currently-saved config replaces it on the next save (see the warning
|
|
// surfaced in WorkflowSyncDialog).
|
|
function ProviderField({ provider, onChange }: ProviderFieldProps) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<Label>{t("workflows:syncProviderLabel")}</Label>
|
|
<div className="overflow-hidden rounded-md border">
|
|
<RemoteRepoProviderTabs
|
|
providers={SYNC_PROVIDERS}
|
|
value={provider}
|
|
onChange={(next) => onChange(next as WorkflowSyncProvider)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type RepoUrlFieldProps = {
|
|
provider: WorkflowSyncProvider;
|
|
url: string;
|
|
invalid: boolean;
|
|
resolved: string;
|
|
onChange: (value: string) => void;
|
|
};
|
|
|
|
// RepoUrlField captures the repository identity — owner/repo for GitHub,
|
|
// project_path for GitLab. Pasting a full link (including a /tree/.../-/tree/
|
|
// suffix) still convenience-fills Branch and Directory below, but those are
|
|
// their own directly-editable fields: a branch name can itself contain
|
|
// slashes (e.g. "features/my-ticket"), which makes splitting a combined
|
|
// "project/branch/path" string ambiguous by construction — no client-side
|
|
// parse can always place that boundary correctly. If a paste gets it wrong,
|
|
// fix it directly in Branch/Directory rather than needing a different link.
|
|
// GitLab sync has no host of its own (it always uses the workspace's
|
|
// configured GitLab connection, including self-managed instances), so a
|
|
// saved GitLab config redisplays as a bare "group/project" reference.
|
|
function RepoUrlField({ provider, url, invalid, resolved, onChange }: RepoUrlFieldProps) {
|
|
const { t } = useTranslation();
|
|
const providerLabel = provider === "gitlab" ? "GitLab" : "GitHub";
|
|
// GitLab's placeholder is a bare path, not a URL: a full link (any host —
|
|
// gitlab.com or a self-managed instance) is accepted, but nothing here
|
|
// needs a host at all, since syncing always uses this workspace's own
|
|
// connected GitLab host regardless of what's typed in this field. Showing
|
|
// a "gitlab.com" example would wrongly suggest a self-managed host has to
|
|
// match it.
|
|
const placeholder = provider === "gitlab" ? "group/project" : "https://github.com/kdlbs/kandev";
|
|
const hint =
|
|
provider === "gitlab"
|
|
? t("workflows:pasteGitLabPathHint")
|
|
: t("workflows:pasteRepositoryLinkHint", { provider: providerLabel });
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="workflow-sync-url">
|
|
{provider === "gitlab" ? t("workflows:projectPathOrLink") : t("workflows:repositoryLink")}
|
|
</Label>
|
|
<Input
|
|
id="workflow-sync-url"
|
|
data-testid="workflow-sync-url-input"
|
|
placeholder={placeholder}
|
|
value={url}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
aria-invalid={invalid}
|
|
/>
|
|
{invalid ? (
|
|
<p className="text-xs text-destructive">
|
|
{t("workflows:notARecognizedRepositoryLink", { provider: providerLabel })}
|
|
</p>
|
|
) : (
|
|
<p className="text-xs text-muted-foreground" data-testid="workflow-sync-resolved">
|
|
{resolved || hint}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type FieldsProps = {
|
|
form: WorkflowSyncFormState;
|
|
update: <K extends keyof WorkflowSyncFormState>(key: K, value: WorkflowSyncFormState[K]) => void;
|
|
};
|
|
|
|
// BranchDirectoryFields exposes branch and directory as their own inputs
|
|
// rather than only ever deriving them from a parsed link. A pasted link
|
|
// still convenience-fills both, but a branch name with a slash in it (a
|
|
// common convention — "features/TICKET-123") can't always be told apart
|
|
// from the directory that follows it, so these fields are the way to
|
|
// directly correct a wrong guess instead of needing a differently-shaped
|
|
// link.
|
|
function BranchDirectoryFields({ form, update }: FieldsProps) {
|
|
const { t } = useTranslation();
|
|
const directoryPlaceholder = ".kandev/workflows";
|
|
return (
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="workflow-sync-branch">{t("workflows:branchLabel")}</Label>
|
|
<Input
|
|
id="workflow-sync-branch"
|
|
data-testid="workflow-sync-branch-input"
|
|
placeholder="main"
|
|
value={form.branch}
|
|
onChange={(e) => update("branch", e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="workflow-sync-directory">{t("workflows:directoryLabel")}</Label>
|
|
<Input
|
|
id="workflow-sync-directory"
|
|
data-testid="workflow-sync-directory-input"
|
|
placeholder={directoryPlaceholder}
|
|
value={form.path}
|
|
onChange={(e) => update("path", e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// PollFields is a single compact row: the auto-sync switch and, when on, the
|
|
// interval right beside it.
|
|
function PollFields({ form, update }: FieldsProps) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<Switch
|
|
id="workflow-sync-poll-toggle"
|
|
data-testid="workflow-sync-poll-toggle"
|
|
checked={form.poll_enabled}
|
|
onCheckedChange={(checked) => update("poll_enabled", checked)}
|
|
className="cursor-pointer"
|
|
/>
|
|
<Label htmlFor="workflow-sync-poll-toggle" className="cursor-pointer">
|
|
{t("workflows:autoSync")}
|
|
</Label>
|
|
{form.poll_enabled && (
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<Label htmlFor="workflow-sync-interval" className="sr-only">
|
|
{t("workflows:pollIntervalSeconds")}
|
|
</Label>
|
|
<Input
|
|
id="workflow-sync-interval"
|
|
data-testid="workflow-sync-interval-input"
|
|
type="number"
|
|
min={60}
|
|
className="w-24"
|
|
value={form.interval_seconds}
|
|
onChange={(e) => update("interval_seconds", Number(e.target.value) || 0)}
|
|
/>
|
|
<span className="text-xs text-muted-foreground">{t("workflows:seconds")}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{form.poll_enabled ? t("workflows:pollEnabledHint") : t("workflows:pollDisabledHint")}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function resolvedTarget(t: TFunction, form: WorkflowSyncFormState): string {
|
|
const directory = form.path || t("workflows:repositoryRoot");
|
|
// `main` is git's default branch name, not copy.
|
|
const branch = form.branch || "main";
|
|
if (form.provider === "gitlab") {
|
|
return form.project_path
|
|
? t("workflows:syncResolvedTargetGitLab", {
|
|
projectPath: form.project_path,
|
|
branch,
|
|
directory,
|
|
})
|
|
: "";
|
|
}
|
|
return form.repo_owner
|
|
? t("workflows:syncResolvedTarget", {
|
|
owner: form.repo_owner,
|
|
repo: form.repo_name,
|
|
branch,
|
|
directory,
|
|
})
|
|
: "";
|
|
}
|
|
|
|
function isSaveDisabled(sync: WorkflowSyncController, intervalInvalid: boolean): boolean {
|
|
const targetMissing =
|
|
sync.form.provider === "gitlab"
|
|
? !sync.form.project_path.trim()
|
|
: !sync.form.repo_owner.trim() || !sync.form.repo_name.trim();
|
|
return sync.saving || sync.loading || sync.urlInvalid || intervalInvalid || targetMissing;
|
|
}
|
|
|
|
type WorkflowSyncRemovalActionsProps = {
|
|
targetKey: string;
|
|
subject: string;
|
|
hasConfig: boolean;
|
|
confirming: boolean;
|
|
saving: boolean;
|
|
onRequest: () => void;
|
|
onCancel: () => void;
|
|
onConfirm: () => void | Promise<void>;
|
|
};
|
|
|
|
function WorkflowSyncRemovalActions({
|
|
targetKey,
|
|
subject,
|
|
hasConfig,
|
|
confirming,
|
|
saving,
|
|
onRequest,
|
|
onCancel,
|
|
onConfirm,
|
|
}: WorkflowSyncRemovalActionsProps) {
|
|
const { t } = useTranslation();
|
|
const { isMobile } = useResponsiveBreakpoint();
|
|
const anchorRef = useRef<HTMLButtonElement>(null);
|
|
if (!hasConfig) return null;
|
|
|
|
const removeLabel = t("workflows:remove");
|
|
const fallback = (
|
|
<InlineConfirmActions
|
|
density="touch"
|
|
testId={REMOVE_CONFIRMATION_TEST_ID}
|
|
ariaLabel={removeLabel}
|
|
description={t("workflows:removeSyncConfirm")}
|
|
cancelLabel={t("common:cancel")}
|
|
confirmLabel={removeLabel}
|
|
confirmAriaLabel={removeLabel}
|
|
confirmTestId={REMOVE_CONFIRM_TEST_ID}
|
|
onCancel={onCancel}
|
|
onConfirm={onConfirm}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{(isMobile || !confirming) && (
|
|
<Button
|
|
ref={anchorRef}
|
|
type="button"
|
|
variant="destructive"
|
|
onClick={onRequest}
|
|
disabled={saving}
|
|
className="sm:mr-auto cursor-pointer"
|
|
data-testid={REMOVE_TEST_ID}
|
|
>
|
|
{removeLabel}
|
|
</Button>
|
|
)}
|
|
<MobileActionConfirmation
|
|
open={confirming}
|
|
targetKey={targetKey}
|
|
title={removeLabel}
|
|
subject={subject}
|
|
description={t("workflows:removeSyncConfirm")}
|
|
cancelLabel={t("common:cancel")}
|
|
confirmLabel={removeLabel}
|
|
confirmAriaLabel={removeLabel}
|
|
confirmTestId={REMOVE_CONFIRM_TEST_ID}
|
|
testId={REMOVE_CONFIRMATION_TEST_ID}
|
|
onOpenChange={(open) => {
|
|
if (!open) onCancel();
|
|
}}
|
|
onConfirm={onConfirm}
|
|
completionPolicy="await-with-retry"
|
|
focusReturnRef={anchorRef}
|
|
fallback={fallback}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function useClearWorkflowSyncRemovalConfirmation(
|
|
open: boolean,
|
|
sync: WorkflowSyncController,
|
|
setRemoveConfirming: Dispatch<SetStateAction<boolean>>,
|
|
) {
|
|
// Clear a pending confirmation when the dialog closes or when its configured
|
|
// target changes. Status-only refreshes leave target fields unchanged, so
|
|
// they do not interrupt an action the user is already confirming.
|
|
useEffect(() => {
|
|
setRemoveConfirming(false);
|
|
}, [
|
|
open,
|
|
sync.config?.workspace_id,
|
|
sync.config?.provider,
|
|
sync.config?.repo_owner,
|
|
sync.config?.repo_name,
|
|
sync.config?.project_path,
|
|
sync.config?.branch,
|
|
sync.config?.path,
|
|
sync.form.provider,
|
|
sync.form.repo_owner,
|
|
sync.form.repo_name,
|
|
sync.form.project_path,
|
|
sync.form.branch,
|
|
sync.form.path,
|
|
setRemoveConfirming,
|
|
]);
|
|
}
|
|
|
|
type WorkflowSyncDialogProps = {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
sync: WorkflowSyncController;
|
|
};
|
|
|
|
function WorkflowSyncFields({ sync }: { sync: WorkflowSyncController }) {
|
|
const { t } = useTranslation();
|
|
const switchingProvider = sync.config && sync.config.provider !== sync.form.provider;
|
|
return (
|
|
<div className="space-y-4">
|
|
<ProviderField provider={sync.form.provider} onChange={sync.setProvider} />
|
|
{switchingProvider && (
|
|
<p className="text-xs text-destructive" data-testid="workflow-sync-provider-switch-warning">
|
|
{t("workflows:switchProviderWarning")}
|
|
</p>
|
|
)}
|
|
<RepoUrlField
|
|
provider={sync.form.provider}
|
|
url={sync.url}
|
|
invalid={sync.urlInvalid}
|
|
resolved={resolvedTarget(t, sync.form)}
|
|
onChange={sync.setUrlInput}
|
|
/>
|
|
<BranchDirectoryFields form={sync.form} update={sync.update} />
|
|
<PollFields form={sync.form} update={sync.update} />
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("workflows:syncDirectoryHelp", {
|
|
format: WORKFLOW_EXPORT_FORMAT,
|
|
extensions: WORKFLOW_EXPORT_EXTENSIONS,
|
|
})}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// WorkflowSyncDialog holds the workflow sync configuration form. It closes
|
|
// itself after a successful save or removal; failures keep it open with the
|
|
// error surfaced via toast.
|
|
export function WorkflowSyncDialog({ open, onOpenChange, sync }: WorkflowSyncDialogProps) {
|
|
const { t } = useTranslation();
|
|
const [removeConfirming, setRemoveConfirming] = useState(false);
|
|
const hasConfig = !!sync.config;
|
|
const intervalInvalid =
|
|
sync.form.poll_enabled &&
|
|
(!Number.isInteger(sync.form.interval_seconds) || sync.form.interval_seconds < 60);
|
|
const disableSave = isSaveDisabled(sync, intervalInvalid);
|
|
const targetKey = workflowSyncConfirmationTarget(sync);
|
|
const generation = useRef(0);
|
|
useLayoutEffect(() => {
|
|
generation.current += 1;
|
|
return () => {
|
|
generation.current += 1;
|
|
};
|
|
}, [open, targetKey]);
|
|
|
|
useClearWorkflowSyncRemovalConfirmation(open, sync, setRemoveConfirming);
|
|
|
|
const handleSave = async () => {
|
|
if (await sync.handleSave()) onOpenChange(false);
|
|
};
|
|
const handleRemove = async () => {
|
|
const requestGeneration = generation.current;
|
|
const removed = await sync.handleDelete();
|
|
if (generation.current !== requestGeneration) return;
|
|
if (removed) {
|
|
onOpenChange(false);
|
|
return;
|
|
}
|
|
// The confirmation owns retry state; the controller owns failure feedback.
|
|
return Promise.reject();
|
|
};
|
|
const handleDialogOpenChange = (nextOpen: boolean) => {
|
|
if (!nextOpen) setRemoveConfirming(false);
|
|
onOpenChange(nextOpen);
|
|
};
|
|
|
|
return (
|
|
<MobileConfirmationHost open={open}>
|
|
{({ active, contentProps }) => (
|
|
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
|
|
<DialogContent
|
|
className="sm:max-w-lg max-h-[calc(100dvh-2rem)] flex flex-col overflow-hidden"
|
|
data-testid="workflow-sync-dialog"
|
|
enterConfirms={!removeConfirming}
|
|
showCloseButton={!active}
|
|
onEscapeKeyDown={(event) => {
|
|
if (!removeConfirming) return;
|
|
event.preventDefault();
|
|
setRemoveConfirming(false);
|
|
}}
|
|
{...contentProps}
|
|
>
|
|
<MobileConfirmationHostBody>
|
|
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("workflows:syncTitle")}</DialogTitle>
|
|
<DialogDescription>{t("workflows:syncDescription")}</DialogDescription>
|
|
</DialogHeader>
|
|
<WorkflowSyncFields sync={sync} />
|
|
<DialogFooter>
|
|
<WorkflowSyncRemovalActions
|
|
targetKey={targetKey}
|
|
subject={resolvedTarget(t, sync.config ?? sync.form)}
|
|
hasConfig={hasConfig}
|
|
confirming={removeConfirming}
|
|
saving={sync.saving}
|
|
onRequest={() => setRemoveConfirming(true)}
|
|
onCancel={() => setRemoveConfirming(false)}
|
|
onConfirm={handleRemove}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
onClick={handleSave}
|
|
disabled={disableSave || removeConfirming}
|
|
className="cursor-pointer"
|
|
data-testid="workflow-sync-save"
|
|
data-dialog-default-action
|
|
>
|
|
{saveLabel(t, sync.saving, hasConfig)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</div>
|
|
</MobileConfirmationHostBody>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
</MobileConfirmationHost>
|
|
);
|
|
}
|
|
|
|
function workflowSyncConfirmationTarget(sync: WorkflowSyncController) {
|
|
const fields = (config: WorkflowSyncFormState | null) =>
|
|
config && [
|
|
config.provider,
|
|
config.repo_owner,
|
|
config.repo_name,
|
|
config.project_path,
|
|
config.branch,
|
|
config.path,
|
|
];
|
|
return JSON.stringify([sync.config?.workspace_id, fields(sync.config), fields(sync.form)]);
|
|
}
|
|
|
|
function saveLabel(t: TFunction, saving: boolean, hasConfig: boolean): string {
|
|
if (saving) return t("workflows:saving");
|
|
return hasConfig ? t("workflows:update") : t("workflows:save");
|
|
}
|