5936bb6808
* test: add e2e coverage and mock backends for jira and linear integrations
Adds in-memory MockClient + MockController for both integrations gated by
KANDEV_MOCK_{JIRA,LINEAR}=true, plus apiClient mock helpers and Playwright
specs covering settings and import flows.
Also fixes ValidatedPopover's TriggerButton silently dropping radix Slot's
onClick handler — the popover wouldn't open under any synthetic-click path
(including Playwright). Spreads `...rest` onto the inner Button.
Reworks the integration cleanup hook in test-base as an auto fixture so it
runs for every test using the imported `test` object, not just tests in the
fixture file itself.
* fix: address PR review feedback on jira/linear mocks and e2e specs
- Fix SetAuthResult docstrings: nil simulates an unconfigured state, not the default success.
- Drop unused JSON tags from doneTransitionCall and setStateCall (Go-only test fixtures).
- Track Linear issue insertion order so SearchIssues returns deterministic results instead of map-iteration shuffle.
- Document filterByJQL precedence at the SearchTickets call site so the all-hits-on-no-match fallback is visible.
- Wait for the post-save auth-health probe before forcing a failure in the seeded-failure tests, eliminating a goroutine race.
- Reorder authMethod precedence in setJiraConfig/setLinearConfig to put the spread first so 'caller wins' reads naturally.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
274 lines
8.6 KiB
TypeScript
274 lines
8.6 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useRef, useState, type ButtonHTMLAttributes, type ReactNode } from "react";
|
|
import { Button } from "@kandev/ui/button";
|
|
import { Input } from "@kandev/ui/input";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
|
|
|
// ValidatedPopover is the shared trigger+popover+input+submit skeleton both
|
|
// integrations need for "paste a key/URL → fetch the entity → do something
|
|
// with it" flows (link a task, import a ticket/issue). It owns:
|
|
// - open/close + state reset on close
|
|
// - the keyed Input with autofocus + Enter-to-submit
|
|
// - validation against an integration-supplied regex
|
|
// - loading + error display
|
|
// - the disabled-while-empty / disabled-while-loading submit Button
|
|
//
|
|
// Each integration provides {icon, label, tooltip, placeholder, regex,
|
|
// fetch, onSuccess, validationHint, headline, submitLabel, submittingLabel}.
|
|
//
|
|
// Designing this as one shared skeleton both fixes the existing bug that
|
|
// JiraLinkButton was missing the clear-error-on-close behaviour LinearLinkButton
|
|
// had, and prevents future drift between the four near-identical popovers.
|
|
|
|
export type ValidatedPopoverTriggerStyle = "outline-with-label" | "ghost-icon";
|
|
|
|
export type ValidatedPopoverProps<T> = {
|
|
// Trigger button content + tooltip.
|
|
triggerStyle: ValidatedPopoverTriggerStyle;
|
|
triggerIcon: ReactNode;
|
|
triggerLabel?: string; // Required for "outline-with-label", ignored for "ghost-icon".
|
|
triggerAriaLabel?: string; // Used for "ghost-icon" since there's no visible label.
|
|
triggerDisabled?: boolean;
|
|
// testIdPrefix scopes the data-testid attributes on trigger / input / submit
|
|
// / error so callers can target the right popover when more than one is
|
|
// mounted on the same page (e.g. a Jira import bar next to a Linear one).
|
|
testIdPrefix?: string;
|
|
tooltip: string;
|
|
// PopoverContent layout.
|
|
align?: "start" | "end";
|
|
headline: string;
|
|
placeholder: string;
|
|
// Validation: extract a key from the user's input. Returning null shows the
|
|
// hint as an error. The hint typically reads "Paste a Jira ticket URL or
|
|
// key (PROJ-123)" or similar — integration-specific copy.
|
|
extractKey: (rawValue: string) => string | null;
|
|
validationHint: string;
|
|
// Async work to run after the user submits a valid key. The result is
|
|
// handed to onSuccess; throwing surfaces .message as the error string.
|
|
fetch: (key: string) => Promise<T>;
|
|
onSuccess: (key: string, result: T) => void;
|
|
// Submit button labels.
|
|
submitLabel: string; // e.g. "Link", "Import"
|
|
submittingLabel: string; // e.g. "Linking...", "Loading..."
|
|
};
|
|
|
|
type TriggerButtonProps = Pick<
|
|
ValidatedPopoverProps<unknown>,
|
|
| "triggerStyle"
|
|
| "triggerIcon"
|
|
| "triggerLabel"
|
|
| "triggerAriaLabel"
|
|
| "triggerDisabled"
|
|
| "testIdPrefix"
|
|
> &
|
|
ButtonHTMLAttributes<HTMLButtonElement>;
|
|
|
|
// TriggerButton forwards every prop the parent injects (notably radix Slot's
|
|
// onClick / onPointerDown / aria-expanded / data-state from PopoverTrigger and
|
|
// TooltipTrigger asChild) onto the inner Button. Forgetting `...rest` here
|
|
// silently drops radix's open-toggle handler, which makes the popover
|
|
// unclickable while leaving the tooltip working — easy to miss in dev.
|
|
function TriggerButton({
|
|
triggerStyle,
|
|
triggerIcon,
|
|
triggerLabel,
|
|
triggerAriaLabel,
|
|
triggerDisabled,
|
|
testIdPrefix,
|
|
...rest
|
|
}: TriggerButtonProps) {
|
|
const triggerTestId = testIdPrefix ? `${testIdPrefix}-trigger` : undefined;
|
|
if (triggerStyle === "outline-with-label") {
|
|
return (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={triggerDisabled}
|
|
className="cursor-pointer px-2 gap-1"
|
|
data-testid={triggerTestId}
|
|
{...rest}
|
|
>
|
|
{triggerIcon}
|
|
<span className="text-xs font-medium">{triggerLabel}</span>
|
|
</Button>
|
|
);
|
|
}
|
|
return (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
disabled={triggerDisabled}
|
|
aria-label={triggerAriaLabel}
|
|
className="h-7 w-7 cursor-pointer hover:bg-muted/40 text-slate-400"
|
|
data-testid={triggerTestId}
|
|
{...rest}
|
|
>
|
|
{triggerIcon}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
type PopoverBodyProps = {
|
|
headline: string;
|
|
placeholder: string;
|
|
value: string;
|
|
onChange: (next: string) => void;
|
|
onSubmit: () => void | Promise<void>;
|
|
loading: boolean;
|
|
error: string | null;
|
|
submitLabel: string;
|
|
submittingLabel: string;
|
|
testIdPrefix?: string;
|
|
};
|
|
|
|
function PopoverBody({
|
|
headline,
|
|
placeholder,
|
|
value,
|
|
onChange,
|
|
onSubmit,
|
|
loading,
|
|
error,
|
|
submitLabel,
|
|
submittingLabel,
|
|
testIdPrefix,
|
|
}: PopoverBodyProps) {
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="text-xs font-medium">{headline}</div>
|
|
<Input
|
|
autoFocus
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
className="h-8 text-xs"
|
|
data-testid={testIdPrefix ? `${testIdPrefix}-input` : undefined}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
void onSubmit();
|
|
}
|
|
}}
|
|
/>
|
|
{error && (
|
|
<p
|
|
className="text-[11px] text-destructive"
|
|
role="alert"
|
|
data-testid={testIdPrefix ? `${testIdPrefix}-error` : undefined}
|
|
>
|
|
{error}
|
|
</p>
|
|
)}
|
|
<div className="flex justify-end">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
onClick={() => void onSubmit()}
|
|
disabled={loading || !value.trim()}
|
|
className="h-7 cursor-pointer"
|
|
data-testid={testIdPrefix ? `${testIdPrefix}-submit` : undefined}
|
|
>
|
|
{loading ? submittingLabel : submitLabel}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ValidatedPopover<T>(props: ValidatedPopoverProps<T>) {
|
|
const {
|
|
tooltip,
|
|
align,
|
|
headline,
|
|
placeholder,
|
|
extractKey,
|
|
validationHint,
|
|
fetch,
|
|
onSuccess,
|
|
submitLabel,
|
|
submittingLabel,
|
|
testIdPrefix,
|
|
} = props;
|
|
const [open, setOpen] = useState(false);
|
|
const [value, setValue] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
// Submission token: bumped on every submit and on close, so an in-flight
|
|
// request whose promise resolves after the popover was closed (or
|
|
// re-opened) cannot leak loading/error state into the next session.
|
|
const submissionRef = useRef(0);
|
|
|
|
const submit = useCallback(async () => {
|
|
const key = extractKey(value.trim());
|
|
if (!key) {
|
|
setError(validationHint);
|
|
return;
|
|
}
|
|
const submission = ++submissionRef.current;
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const result = await fetch(key);
|
|
if (submission !== submissionRef.current) return;
|
|
onSuccess(key, result);
|
|
setOpen(false);
|
|
setValue("");
|
|
} catch (err) {
|
|
if (submission !== submissionRef.current) return;
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
if (submission === submissionRef.current) setLoading(false);
|
|
}
|
|
}, [value, extractKey, validationHint, fetch, onSuccess]);
|
|
|
|
// Closing invalidates any in-flight submit (so a late rejection can't
|
|
// re-populate `error` after the user dismissed the popover) and clears
|
|
// local submit state so the next open starts clean.
|
|
const handleOpenChange = (next: boolean) => {
|
|
setOpen(next);
|
|
if (!next) {
|
|
submissionRef.current += 1;
|
|
setLoading(false);
|
|
setError(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={handleOpenChange}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<PopoverTrigger asChild>
|
|
<TriggerButton
|
|
triggerStyle={props.triggerStyle}
|
|
triggerIcon={props.triggerIcon}
|
|
triggerLabel={props.triggerLabel}
|
|
triggerAriaLabel={props.triggerAriaLabel}
|
|
triggerDisabled={props.triggerDisabled}
|
|
testIdPrefix={props.testIdPrefix}
|
|
/>
|
|
</PopoverTrigger>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{tooltip}</TooltipContent>
|
|
</Tooltip>
|
|
<PopoverContent align={align ?? "end"} className="w-80 p-3">
|
|
<PopoverBody
|
|
headline={headline}
|
|
placeholder={placeholder}
|
|
value={value}
|
|
onChange={setValue}
|
|
onSubmit={submit}
|
|
loading={loading}
|
|
error={error}
|
|
submitLabel={submitLabel}
|
|
submittingLabel={submittingLabel}
|
|
testIdPrefix={testIdPrefix}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|