Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
491 changes: 283 additions & 208 deletions dashboard/src/v2/components/sprints/SprintIssueImportModal.tsx

Large diffs are not rendered by default.

621 changes: 363 additions & 258 deletions dashboard/src/v2/components/sprints/SprintJiraImportModal.tsx

Large diffs are not rendered by default.

170 changes: 170 additions & 0 deletions dashboard/src/v2/components/sprints/importer/IssueImportFields.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import type { ComponentChildren, ComponentProps, FunctionComponent } from "preact";
import type { IssueImportProviderMetadata } from "../../../lib/issue-import-view-models.js";

interface IssueImportFilterSectionProps {
title: string;
description?: string;
children: ComponentChildren;
action?: ComponentChildren;
compact?: boolean;
}

interface IssueImportFieldProps {
label: string;
hint?: string;
children: ComponentChildren;
disabled?: boolean;
required?: boolean;
className?: string;
}

type IssueImportTextInputProps = ComponentProps<"input"> & {
provider: IssueImportProviderMetadata;
};

type IssueImportTextareaProps = ComponentProps<"textarea"> & {
provider: IssueImportProviderMetadata;
};

type IssueImportSelectProps = ComponentProps<"select"> & {
provider: IssueImportProviderMetadata;
};

const normalizeClassName = (className: ComponentProps<"input">["className"]): string => (
typeof className === "string" ? className : ""
);

export const issueImportInputClassName = (
provider: IssueImportProviderMetadata,
className = "",
): string => [
"min-h-11 w-full min-w-0 rounded-[1rem] border border-black/[0.07] bg-black/[0.025] px-3 text-sm text-slate-700 outline-none transition-colors placeholder:text-slate-400 focus:border-signal-500 focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/[0.08] dark:bg-white/[0.035] dark:text-slate-200 dark:placeholder:text-slate-600",
provider.accent.focusRingClassName,
className,
].filter(Boolean).join(" ");

export const issueImportSelectClassName = (
provider: IssueImportProviderMetadata,
className = "",
): string => [
"min-h-11 w-full min-w-0 rounded-[1rem] border border-black/[0.07] bg-black/[0.025] px-3 text-xs font-bold uppercase tracking-[0.12em] text-slate-500 outline-none transition-colors focus:border-signal-500 focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/[0.08] dark:bg-white/[0.035] dark:text-slate-300",
provider.accent.focusRingClassName,
className,
].filter(Boolean).join(" ");

export const issueImportTextareaClassName = (
provider: IssueImportProviderMetadata,
className = "",
): string => [
"min-h-28 w-full min-w-0 resize-y rounded-[1rem] border border-black/[0.07] bg-black/[0.025] px-3 py-3 text-sm leading-relaxed text-slate-700 outline-none transition-colors placeholder:text-slate-400 focus:border-signal-500 focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/[0.08] dark:bg-white/[0.035] dark:text-slate-200 dark:placeholder:text-slate-600",
provider.accent.focusRingClassName,
className,
].filter(Boolean).join(" ");

export const IssueImportFilterSection: FunctionComponent<IssueImportFilterSectionProps> = ({
title,
description,
children,
action,
compact = false,
}) => (
<section className={`grid gap-4 rounded-[1.25rem] border border-black/[0.06] bg-white/62 dark:border-white/[0.06] dark:bg-white/[0.025] ${compact ? "p-3" : "p-4"}`}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-[10px] font-black uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400">
{title}
</h3>
{description && (
<p className="mt-1 text-xs leading-relaxed text-slate-400 dark:text-slate-500">
{description}
</p>
)}
</div>
{action}
</div>
{children}
</section>
);

export const IssueImportField: FunctionComponent<IssueImportFieldProps> = ({
label,
hint,
children,
disabled = false,
required = false,
className = "",
}) => (
<label className={`grid min-w-0 gap-1.5 ${disabled ? "opacity-50" : ""} ${className}`}>
<span className="text-[10px] font-black uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
{label}
{required && (
<>
<span className="text-status-red" aria-hidden="true"> *</span>
<span className="sr-only"> Required</span>
</>
)}
</span>
{children}
{hint && <span className="text-xs leading-relaxed text-slate-400 dark:text-slate-500">{hint}</span>}
</label>
);

export const IssueImportMultiSelectField: FunctionComponent<IssueImportFieldProps> = (props) => (
<IssueImportField {...props} />
);

export const IssueImportTextInput: FunctionComponent<IssueImportTextInputProps> = ({
provider,
className,
...props
}) => (
<input
{...props}
className={issueImportInputClassName(provider, normalizeClassName(className))}
/>
);

export const IssueImportDateInput: FunctionComponent<IssueImportTextInputProps> = ({
type: _type,
...props
}) => (
<IssueImportTextInput
{...props}
type="date"
/>
);

export const IssueImportNumberInput: FunctionComponent<IssueImportTextInputProps> = ({
type: _type,
...props
}) => (
<IssueImportTextInput
{...props}
type="number"
/>
);

export const IssueImportSelect: FunctionComponent<IssueImportSelectProps> = ({
provider,
className,
children,
...props
}) => (
<select
{...props}
className={issueImportSelectClassName(provider, normalizeClassName(className))}
>
{children}
</select>
);

export const IssueImportTextarea: FunctionComponent<IssueImportTextareaProps> = ({
provider,
className,
...props
}) => (
<textarea
{...props}
className={issueImportTextareaClassName(provider, normalizeClassName(className))}
/>
);
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ interface IssueImportIssueCardProps {
selectionLabel?: string;
modeLabel?: string | null;
icon?: ComponentChildren;
compact?: boolean;
metadataLimit?: number;
onToggle: () => void;
onToggleConversation: () => void;
}
Expand All @@ -39,10 +41,16 @@ export const IssueImportIssueCard: FunctionComponent<IssueImportIssueCardProps>
selectionLabel,
modeLabel,
icon,
compact = false,
metadataLimit,
onToggle,
onToggleConversation,
}) => {
const safeUrl = getSafeUrl(url ?? "");
const visibleMetadataRows = typeof metadataLimit === "number"
? metadataRows.slice(0, Math.max(0, Math.trunc(metadataLimit)))
: metadataRows;
const metadataOverflowCount = Math.max(0, metadataRows.length - visibleMetadataRows.length);
const cardTone = selected
? provider.accent.selectedCardClassName
: "border-black/[0.06] bg-black/[0.02] hover:-translate-y-0.5 hover:border-black/[0.12] hover:bg-white/82 dark:border-white/[0.07] dark:bg-white/[0.03] dark:hover:border-white/[0.14] dark:hover:bg-white/[0.055]";
Expand All @@ -55,9 +63,9 @@ export const IssueImportIssueCard: FunctionComponent<IssueImportIssueCardProps>
type="button"
onClick={onToggle}
aria-pressed={selected}
className={`group rounded-[1.35rem] border p-4 text-left transition-all focus-visible:outline-none focus-visible:ring-2 ${provider.accent.focusRingClassName} ${cardTone}`}
className={`group rounded-[1.25rem] border text-left transition-all focus-visible:outline-none focus-visible:ring-2 ${provider.accent.focusRingClassName} ${compact ? "p-3" : "p-4"} ${cardTone}`}
>
<div className="flex items-start gap-4">
<div className={`flex items-start ${compact ? "gap-3" : "gap-4"}`}>
<div className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-[0.85rem] ${iconTone}`}>
{selected ? <Check className="h-4 w-4" strokeWidth={2.5} aria-hidden="true" /> : icon}
</div>
Expand All @@ -76,14 +84,14 @@ export const IssueImportIssueCard: FunctionComponent<IssueImportIssueCardProps>
{title}
</div>

{bodyPreview && (
{bodyPreview && !compact && (
<p className="mt-2 line-clamp-2 text-xs leading-relaxed text-slate-500 dark:text-slate-400">
{bodyPreview}
</p>
)}

<div className="mt-3 flex flex-wrap items-center gap-1.5">
{metadataRows.map((row) => (
{visibleMetadataRows.map((row) => (
<span
key={row.id}
className="inline-flex max-w-full items-center gap-1 rounded-full bg-slate-100 px-2 py-1 text-[10px] font-semibold text-slate-500 ring-1 ring-black/[0.05] dark:bg-white/[0.06] dark:text-slate-300 dark:ring-white/[0.06]"
Expand All @@ -92,6 +100,7 @@ export const IssueImportIssueCard: FunctionComponent<IssueImportIssueCardProps>
<span className="truncate">{row.value}</span>
</span>
))}
{metadataOverflowCount > 0 && <OverflowPill label={`+${metadataOverflowCount} details`} />}
{assignees.visible.map((assignee) => (
<span
key={assignee}
Expand Down Expand Up @@ -129,7 +138,7 @@ export const IssueImportIssueCard: FunctionComponent<IssueImportIssueCardProps>
Append Conversation
</label>
<span className="inline-flex items-center gap-1 rounded-full border border-black/[0.06] bg-white px-3 py-1.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-slate-400 dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-slate-300">
{selectionLabel ?? (selected ? "Selected" : "Click to select")}
<span aria-live="polite">{selectionLabel ?? (selected ? "Selected" : "Click to select")}</span>
</span>
</div>
</div>
Expand Down
92 changes: 81 additions & 11 deletions dashboard/src/v2/components/sprints/importer/IssueImportShell.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ComponentChildren, FunctionComponent } from "preact";
import { AlertTriangle, Github, Gitlab, X } from "lucide-preact";
import { AlertTriangle, ChevronDown, Github, Gitlab, SlidersHorizontal, X } from "lucide-preact";
import type {
IssueImportErrorCopy,
IssueImportProviderMetadata,
Expand All @@ -14,6 +14,14 @@ interface IssueImportShellProps {
closeLabel: string;
summaryRail?: ComponentChildren;
filters: ComponentChildren;
advancedFilters?: ComponentChildren;
advancedFiltersExpanded?: boolean;
advancedFiltersLabel?: string;
advancedFiltersId?: string;
activeFilterCountLabel?: string;
onAdvancedFiltersToggle?: () => void;
resultStatus?: ComponentChildren;
maxContentWidthClassName?: string;
children: ComponentChildren;
footer: ComponentChildren;
}
Expand Down Expand Up @@ -62,21 +70,42 @@ export const IssueImportShell: FunctionComponent<IssueImportShellProps> = ({
closeLabel,
summaryRail,
filters,
advancedFilters,
advancedFiltersExpanded = false,
advancedFiltersLabel = "Advanced filters",
advancedFiltersId = "issue-import-advanced-filters",
activeFilterCountLabel,
onAdvancedFiltersToggle,
resultStatus,
maxContentWidthClassName = "max-w-6xl",
children,
footer,
}) => (
}) => {
const hasAdvancedFilters = Boolean(advancedFilters);
const canToggleAdvancedFilters = hasAdvancedFilters && Boolean(onAdvancedFiltersToggle);
const advancedFiltersVisible = canToggleAdvancedFilters ? advancedFiltersExpanded : true;
const renderedAdvancedFilters = hasAdvancedFilters && (
<div
id={advancedFiltersId}
className={advancedFiltersVisible ? "mt-4 grid gap-4" : "hidden"}
>
{advancedFilters}
</div>
);

return (
<div className="fixed inset-0 z-[230] flex items-center justify-center bg-black/55 px-3 py-4 backdrop-blur-xl dark:bg-black/75 sm:px-4 sm:py-6">
<div
role="dialog"
aria-modal="true"
aria-labelledby="issue-import-title"
aria-describedby="issue-import-description"
className="flex max-h-[92vh] w-full max-w-7xl overflow-hidden rounded-[2rem] border border-white/70 bg-white shadow-[0_48px_120px_rgba(15,23,42,0.28)] outline-none dark:border-white/[0.08] dark:bg-void-800 dark:shadow-[0_48px_120px_rgba(0,0,0,0.72)]"
className="flex max-h-[92vh] w-full max-w-[92rem] overflow-hidden rounded-[1.75rem] border border-white/70 bg-[#F9F8F4] shadow-[0_48px_120px_rgba(15,23,42,0.28)] outline-none dark:border-white/[0.08] dark:bg-void-800 dark:shadow-[0_48px_120px_rgba(0,0,0,0.72)]"
>
{summaryRail}

<div className="flex min-w-0 flex-1 flex-col">
<header className="flex flex-wrap items-start justify-between gap-4 border-b border-black/[0.06] px-4 py-4 dark:border-white/[0.06] sm:px-6 sm:py-5">
<header className="flex flex-wrap items-start justify-between gap-4 border-b border-black/[0.06] bg-white/70 px-4 py-4 backdrop-blur-2xl dark:border-white/[0.06] dark:bg-void-800/70 sm:px-6">
<div className="min-w-0">
<div className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-[10px] font-bold uppercase tracking-[0.16em] ${provider.accent.badgeClassName}`}>
<ProviderIcon provider={provider} />
Expand All @@ -88,6 +117,11 @@ export const IssueImportShell: FunctionComponent<IssueImportShellProps> = ({
<p id="issue-import-description" className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-500 dark:text-slate-400">
{description}
</p>
{activeFilterCountLabel && (
<div className="mt-3 inline-flex items-center rounded-full border border-black/[0.06] bg-black/[0.03] px-3 py-1 text-[10px] font-bold uppercase tracking-[0.14em] text-slate-500 dark:border-white/[0.06] dark:bg-white/[0.04] dark:text-slate-300">
{activeFilterCountLabel}
</div>
)}
</div>

<button
Expand All @@ -101,21 +135,57 @@ export const IssueImportShell: FunctionComponent<IssueImportShellProps> = ({
</header>

<div className="min-h-0 flex-1 overflow-y-auto">
<div className="border-b border-black/[0.06] p-5 dark:border-white/[0.06] sm:p-7">
{filters}
<div className="border-b border-black/[0.06] bg-white/45 p-4 dark:border-white/[0.06] dark:bg-white/[0.015] sm:p-6">
<div className={`mx-auto w-full ${maxContentWidthClassName}`}>
<div className="grid gap-4">
{filters}
{hasAdvancedFilters && (
<div>
{canToggleAdvancedFilters && (
<button
type="button"
onClick={onAdvancedFiltersToggle}
aria-expanded={advancedFiltersExpanded}
aria-controls={advancedFiltersId}
className={`inline-flex min-h-10 items-center gap-2 rounded-full border border-black/[0.06] bg-white/80 px-4 py-2 text-[10px] font-black uppercase tracking-[0.14em] text-slate-500 transition-colors hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 ${provider.accent.focusRingClassName} dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-slate-300 dark:hover:text-white`}
>
<SlidersHorizontal className="h-3.5 w-3.5" strokeWidth={2.1} aria-hidden="true" />
{advancedFiltersLabel}
<ChevronDown
className={`h-3.5 w-3.5 transition-transform ${advancedFiltersExpanded ? "rotate-180" : ""}`}
strokeWidth={2.1}
aria-hidden="true"
/>
</button>
)}
{renderedAdvancedFilters}
</div>
)}
</div>
</div>
</div>
<div className="p-5 sm:p-7">
{children}
<div className="p-4 sm:p-6">
<div className={`mx-auto w-full ${maxContentWidthClassName}`}>
{resultStatus && (
<div className="mb-4" aria-live="polite">
{resultStatus}
</div>
)}
{children}
</div>
</div>
</div>

<footer className="border-t border-black/[0.06] p-5 dark:border-white/[0.06] sm:p-7">
{footer}
<footer className="border-t border-black/[0.06] bg-white/70 p-4 backdrop-blur-2xl dark:border-white/[0.06] dark:bg-void-800/70 sm:p-6">
<div className={`mx-auto w-full ${maxContentWidthClassName}`}>
{footer}
</div>
</footer>
</div>
</div>
</div>
);
);
};

const ProviderIcon: FunctionComponent<{ provider: IssueImportProviderMetadata }> = ({ provider }) => {
if (provider.icon === "gitlab") {
Expand Down
Loading