feat(dashboard): compact the GitHub import screen and move Import under the issue (#2163)
Mobile operator report: the import screen spent ~4 rows on chrome — a
provider row, a tab row, and a boxed ORIGIN/filter/Load stack — leaving
only **~9 issues visible**. Import also sat *above* the preview it acts
on, and a second Import in the list footer let you import an issue whose
body you had never opened.
## Before → After (measured at 412px)
| | before | after |
|---|---|---|
| control chrome | ~4 stacked bands (93px) | **one wrapping row (70px)**
|
| toolbar | boxed band, origin on its own line | **single 36px row** |
| issues visible | ~9 | **~13** |
| Import | list footer **and** preview header | **detail bottom bar
only** |
## Layout
- **Provider, type tabs, origin, filter and Load share one row.** It
wraps rather than clipping at the narrowest widths.
- **Load is icon-only** — the label survives as `aria-label`/`title`, so
the accessible name is unchanged (the pre-existing role+name query still
finds it, which is what proves nothing was lost for screen readers).
- **The labels filter is a popover.** Its trigger doubles as the readout
— it renders the active labels and takes an `is-active` cue — so
collapsing never hides applied state. Dismisses on outside pointerdown
or Escape; Escape `stopPropagation` keeps the modal from closing along
with it.
- **Origin stays visible as an inline chip** (per your call): it's
context for what you're importing, so it flattens from a stacked
ORIGIN/repo block rather than hiding in the popover.
- Removed the toolbar's `flex: 1 1 100%` mobile stacking — it dated from
when the toolbar was a full-width band and was forcing origin to claim
an entire line.
- Also neutralised `[data-theme="light"] .github-import-toolbar`, which
re-applied the band background later in the cascade at equal specificity
(light mode only).
## Actions — one place to import
Import + Close issue move from the preview header to a **bottom action
bar**: commit actions belong below the content they act on and within
thumb reach, matching the modal's own Cancel bar. The list footer's
duplicate Import is removed (Cancel stays — the modal still needs a
dismiss), so **an issue can no longer be imported sight-unseen**.
## Note for review — interaction with #551a2a3c1
`551a2a3c1` ("align import detail header") landed mid-work and
conflicted. Its panel-padding work is preserved untouched. Its header
rules were kept **as-is** because they remain correct for a lone label,
but its stated rationale — dropping `space-between` so "Close issue and
Import stay grouped as a pair at the end" — is moot now that those
actions live in the bottom bar. The comment is marked superseded rather
than left asserting something untrue.
## Verification
Measured in a real browser at 412px (jsdom has no layout, so the tests
pin structure and behaviour, not geometry): chrome 93→70px, toolbar a
single 36px row, ~13 issues visible, filter popover clamped inside the
viewport and autofocused, detail bar below the content with an unclipped
Import label. Re-verified after the rebase.
**90 tests pass** · gate green (294/122/63) · lint clean · typecheck
clean.
Seven existing tests encoded the old UI (labelled Load, always-open
filter input). They were **updated to the new contract rather than
appeased** — the filter assertions now exercise the whole affordance
(collapsed → open → filters), and FN-7657 persistence is asserted via
the *collapsed trigger*, proving both that state survives remount
**and** that a restored filter stays visible, which is the real risk
when a control collapses.
Two things worth knowing:
- The footer guard was **confirmed non-vacuous**: reintroducing the list
Import fails it.
- One new CSS rule is deliberately a child selector
(`.github-import-controls > *`) rather than naming
`.github-import-tabs`, because `GitHubImportModal.test.tsx` extracts
base rules with a naive first-match regex
(`/\.github-import-tabs\s*\{[^}]*\}/`) that a rule mentioning them
*above* the originals would silently hijack.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Added a more compact, responsive mobile layout for the GitHub import
screen.
* Moved Import and Close issue actions to the preview’s bottom action
bar.
* Added a collapsible labels filter popover with keyboard and
outside-click dismissal.
* Consolidated mobile controls into a single compact row.
* Simplified the Load action to an accessible icon-only button.
* **Bug Fixes**
* Removed duplicate Import controls from the list footer.
* Improved control sizing, wrapping, and preview action positioning on
narrow screens.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/github-import-compact-mobile-layout.md
Normal file
7
.changeset/github-import-compact-mobile-layout.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: The GitHub import screen shows far more issues at once, and Import now sits under the issue you are reading.
|
||||||
|
category: feature
|
||||||
|
dev: Provider, type tabs, origin, filter and Load collapse into one wrapping control row (chrome 93px -> 70px at 412px; ~9 -> ~13 issues visible). Load is icon-only (label kept as aria-label/title). The labels filter is a popover whose trigger doubles as its readout, so collapsing never hides applied state; dismisses on outside pointerdown or Escape (stopPropagation so the modal survives). Origin stays visible as an inline chip. Import moves out of the preview header into a bottom action bar alongside Close issue, and the list footer's duplicate Import is removed — the footer keeps only Cancel, so an issue can no longer be imported without opening it. Obsolete `flex: 1 1 100%` mobile stacking on the toolbar zones removed.
|
||||||
@@ -1,6 +1,169 @@
|
|||||||
/* === GitHub Import Modal ===
|
/* === GitHub Import Modal ===
|
||||||
Note: Some hardcoded colors below (e.g. #238636, #2ea043, #f0883e, #1f6feb)
|
Note: Some hardcoded colors below (e.g. #238636, #2ea043, #f0883e, #1f6feb)
|
||||||
are intentional GitHub brand colors and should NOT be replaced with design tokens. */
|
are intentional GitHub brand colors and should NOT be replaced with design tokens. */
|
||||||
|
/*
|
||||||
|
FNXC:GitHubImport 2026-07-15-23:25:
|
||||||
|
One control row instead of three stacked bands. The import screen previously spent its vertical
|
||||||
|
budget on a provider row, a tab row, and a boxed origin+filter+Load stack — on a phone that left only
|
||||||
|
a few issues visible. Provider, type tabs, origin, filter, and Load now share this row so the list
|
||||||
|
gets the space back.
|
||||||
|
|
||||||
|
`flex-wrap: wrap` is deliberate: at the narrowest widths the row reflows to a second line rather than
|
||||||
|
clipping controls (the failure mode the composer Save button hit). The toolbar keeps its own box
|
||||||
|
styling but is now an inline member of this row instead of a full-width band.
|
||||||
|
*/
|
||||||
|
.github-import-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The toolbar is a row member now, not a band. Its own box chrome (border/background/padding) is
|
||||||
|
dropped here: nested inside this row it drew a second frame around origin+filter+Load and forced
|
||||||
|
them onto their own line, which is exactly the wasted vertical space this change removes. It keeps
|
||||||
|
its internal flex layout and simply flows as more chips in the row.
|
||||||
|
*/
|
||||||
|
.github-import-controls .github-import-toolbar {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zones stop reserving their own width now that they are inline chips. */
|
||||||
|
.github-import-controls .github-import-toolbar__zone--remote {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-import-controls .github-import-toolbar__zone--filter {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Origin stays VISIBLE (operator decision) but flattens from a stacked ORIGIN/repo block into one
|
||||||
|
inline chip — it is context for what you are importing, not a section header, so it should cost a
|
||||||
|
few characters of a shared row rather than two rows of its own.
|
||||||
|
*/
|
||||||
|
.github-import-controls .github-import-remote-pill {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The uppercase "ORIGIN" caption is redundant once the repo sits inline in the control row. */
|
||||||
|
.github-import-controls .github-import-remote-pill__name {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-import-controls .github-import-remote-pill__repo {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 18ch;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Row members sit inline and never stretch. Written as a child selector rather than naming
|
||||||
|
`.github-import-provider`/`.github-import-tabs` on purpose: GitHubImportModal.test.tsx extracts
|
||||||
|
those base rules with a naive first-match regex (/\.github-import-tabs\s*\{[^}]*\}/), so a rule
|
||||||
|
mentioning them ABOVE the originals would silently hijack that assertion. The toolbar opts back
|
||||||
|
into growing via its own higher-specificity rule below.
|
||||||
|
*/
|
||||||
|
.github-import-controls > * {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:GitHubImport 2026-07-15-23:25:
|
||||||
|
Filter popover. The trigger doubles as the filter's readout (it renders the active labels), so a
|
||||||
|
collapsed filter never hides applied state; `is-active` gives it an accent cue. `position: relative`
|
||||||
|
anchors the panel, which is width-clamped to the viewport so it cannot overflow on a phone.
|
||||||
|
*/
|
||||||
|
.github-import-filter-menu {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-import-filter-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
min-height: 36px;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The readout must ellipsize, never widen the row or wrap mid-label. */
|
||||||
|
.github-import-filter-trigger__text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 12ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-import-filter-trigger.is-active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-import-filter-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + var(--space-xs));
|
||||||
|
left: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
width: max(220px, 100%);
|
||||||
|
max-width: calc(100vw - var(--space-xl));
|
||||||
|
padding: var(--space-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-md, 0 4px 16px rgb(0 0 0 / 18%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-import-filter-panel__label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-import-filter-panel input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:GitHubImport 2026-07-15-23:25:
|
||||||
|
Load is icon-only — its label lives in aria-label/title. Square so it reads as an icon affordance and
|
||||||
|
holds a 36px touch target without claiming a row of its own.
|
||||||
|
*/
|
||||||
|
.github-import-controls .github-import-load-button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 0;
|
||||||
|
width: 36px;
|
||||||
|
min-width: 36px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Compact Toolbar Layout */
|
/* Compact Toolbar Layout */
|
||||||
.github-import-toolbar {
|
.github-import-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -165,8 +328,39 @@ Padding lives on the panel rather than the pane content so header, toast, and sc
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:GitHubImport 2026-07-15-18:20:
|
FNXC:GitHubImport 2026-07-15-23:25:
|
||||||
Header is one label + a right-aligned action cluster. The label takes the free space (margin-inline-end:auto) rather than the container using space-between, so Close issue and Import stay grouped as a pair at the end instead of Close issue being flung into the middle whenever both actions render.
|
Bottom action bar for the detail preview. Import/Close issue moved here from the pane header —
|
||||||
|
commit actions belong below the content they act on and within thumb reach on a phone, and this
|
||||||
|
mirrors the modal's own Cancel bar. `margin-top: auto` pins it to the bottom of the flex panel even
|
||||||
|
when the preview is short; the top border separates it from scrolling content.
|
||||||
|
*/
|
||||||
|
.github-import-detail-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: var(--space-md);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Never let the primary action's label clip when the bar is narrow. */
|
||||||
|
.github-import-detail-actions .btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: max-content;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:GitHubImport 2026-07-15-18:20 (superseded 2026-07-15-23:25):
|
||||||
|
Supersedes #551a2a3c1's note here. That change dropped `justify-content: space-between` and gave the
|
||||||
|
label `margin-inline-end: auto` so Close issue and Import stayed grouped as a pair at the END of this
|
||||||
|
header rather than Close issue being flung to the middle. Those actions now live in
|
||||||
|
`.github-import-detail-actions` below the content, so the header holds ONLY the label. The rules are
|
||||||
|
kept as-is because they remain correct for a lone label (it simply takes the free space), and they
|
||||||
|
are what a future action returning to this header would need — but the grouping rationale no longer
|
||||||
|
applies here. Do not re-add actions to this header without revisiting the bottom-bar decision.
|
||||||
*/
|
*/
|
||||||
.github-import-pane-header {
|
.github-import-pane-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1151,6 +1345,20 @@ Across the thread: a top filter (All/Human/Bot) and prev/next chevrons live in t
|
|||||||
border-bottom-color: var(--border);
|
border-bottom-color: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:GitHubImport 2026-07-15-23:35:
|
||||||
|
Theme-scoped companion to the de-boxing above. The base rule dropped the toolbar's band chrome, but
|
||||||
|
this light-theme rule re-applied a background later in the cascade at equal specificity, so the band
|
||||||
|
survived in light mode only. Nested in the control row the toolbar is a group of inline chips, not a
|
||||||
|
surface — it must carry no fill in EITHER theme. Both selectors are listed so a dark-theme
|
||||||
|
counterpart added later cannot silently reintroduce the band.
|
||||||
|
*/
|
||||||
|
[data-theme="light"] .github-import-controls .github-import-toolbar,
|
||||||
|
[data-theme="dark"] .github-import-controls .github-import-toolbar {
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
[data-theme="light"] .github-import-toolbar__zone--filter input {
|
[data-theme="light"] .github-import-toolbar__zone--filter input {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
@@ -1238,15 +1446,25 @@ Across the thread: a top filter (All/Human/Bot) and prev/next chevrons live in t
|
|||||||
padding: var(--space-sm);
|
padding: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:GitHubImport 2026-07-15-23:40:
|
||||||
|
These stacked the toolbar one-zone-per-row back when it was a full-width band. Inside the control
|
||||||
|
row that is now actively wrong: `flex: 1 1 100%` forced origin to claim an entire line, pushing
|
||||||
|
Filter and Load onto yet another — the exact vertical waste this change removes. Origin now shares
|
||||||
|
the row and simply ellipsizes; `min-width: 140px` on the filter is dropped for the same reason (it
|
||||||
|
reserved a full input's width for what is now a compact trigger). Order is preserved so the reading
|
||||||
|
order stays origin → filter → load.
|
||||||
|
*/
|
||||||
.github-import-toolbar__zone--remote {
|
.github-import-toolbar__zone--remote {
|
||||||
flex: 1 1 100%;
|
flex: 0 1 auto;
|
||||||
order: 1;
|
order: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.github-import-toolbar__zone--filter {
|
.github-import-toolbar__zone--filter {
|
||||||
flex: 1 1 auto;
|
flex: 0 1 auto;
|
||||||
order: 2;
|
order: 2;
|
||||||
min-width: 140px;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.github-import-toolbar__zone--action {
|
.github-import-toolbar__zone--action {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
type GitRemote,
|
type GitRemote,
|
||||||
type GitLabImportItem,
|
type GitLabImportItem,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { Loader2, RefreshCw, GitPullRequest, CircleDot, ChevronUp, ChevronDown, Bot, User } from "lucide-react";
|
import { Loader2, RefreshCw, GitPullRequest, CircleDot, ChevronUp, ChevronDown, Bot, User, Filter } from "lucide-react";
|
||||||
import { GithubIcon } from "./GithubIcon";
|
import { GithubIcon } from "./GithubIcon";
|
||||||
import { MailboxMessageContent } from "./MailboxMessageContent";
|
import { MailboxMessageContent } from "./MailboxMessageContent";
|
||||||
import {
|
import {
|
||||||
@@ -336,6 +336,39 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
|||||||
const [owner, setOwner] = useState("");
|
const [owner, setOwner] = useState("");
|
||||||
const [repo, setRepo] = useState("");
|
const [repo, setRepo] = useState("");
|
||||||
const [labels, setLabels] = useState("");
|
const [labels, setLabels] = useState("");
|
||||||
|
/*
|
||||||
|
FNXC:GitHubImport 2026-07-15-23:20:
|
||||||
|
The labels filter collapses into a popover so the control row stays one line and the issue list
|
||||||
|
keeps the vertical space. `filterOpen` drives the popover; the trigger summarises the active
|
||||||
|
filter so a collapsed filter is never invisible state.
|
||||||
|
*/
|
||||||
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
|
const filterMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:GitHubImport 2026-07-15-23:20:
|
||||||
|
Dismiss the filter popover on outside pointerdown or Escape. Bound only while open so the modal
|
||||||
|
does not carry idle global listeners. Escape stops propagation: the popover is the innermost
|
||||||
|
dismissible layer, and without this the modal itself would close on the same key.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!filterOpen) return;
|
||||||
|
const onPointerDown = (event: PointerEvent) => {
|
||||||
|
if (!filterMenuRef.current?.contains(event.target as Node)) setFilterOpen(false);
|
||||||
|
};
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.stopPropagation();
|
||||||
|
setFilterOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", onPointerDown, true);
|
||||||
|
document.addEventListener("keydown", onKeyDown, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", onPointerDown, true);
|
||||||
|
document.removeEventListener("keydown", onKeyDown, true);
|
||||||
|
};
|
||||||
|
}, [filterOpen]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [provider, setProvider] = useState<ImportProvider>("github");
|
const [provider, setProvider] = useState<ImportProvider>("github");
|
||||||
const [gitlabResource, setGitlabResource] = useState<GitLabResourceTab>("project_issue");
|
const [gitlabResource, setGitlabResource] = useState<GitLabResourceTab>("project_issue");
|
||||||
@@ -1238,12 +1271,21 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="modal-body github-import-modal__body">
|
<div className="modal-body github-import-modal__body">
|
||||||
|
{/*
|
||||||
|
FNXC:GitHubImport 2026-07-15-23:20:
|
||||||
|
Mobile operator report: the import screen spent most of its vertical budget on chrome —
|
||||||
|
provider row, tab row, and a boxed origin+filter+Load stack — leaving only a few issues
|
||||||
|
visible. Provider, type tabs, origin, filter, and Load now share ONE control row so the
|
||||||
|
issue list gets the reclaimed space. The row wraps on narrow widths rather than clipping.
|
||||||
|
Origin stays VISIBLE as a compact chip (operator decision) — it is load-bearing context for
|
||||||
|
what you are about to import, so it must not hide inside the filter popover.
|
||||||
|
*/}
|
||||||
|
<div className="github-import-controls" data-testid="github-import-controls">
|
||||||
<div className="github-import-provider" role="group" aria-label={t("git.providerAriaLabel", "Import provider")}>
|
<div className="github-import-provider" role="group" aria-label={t("git.providerAriaLabel", "Import provider")}>
|
||||||
<button type="button" className={`github-import-tab ${provider === "github" ? "active" : ""}`} aria-pressed={provider === "github"} onClick={() => setProvider("github")} disabled={loading || importing}>GitHub</button>
|
<button type="button" className={`github-import-tab ${provider === "github" ? "active" : ""}`} aria-pressed={provider === "github"} onClick={() => setProvider("github")} disabled={loading || importing}>GitHub</button>
|
||||||
{gitlabEnabled ? <button type="button" className={`github-import-tab ${provider === "gitlab" ? "active" : ""}`} aria-pressed={provider === "gitlab"} onClick={() => setProvider("gitlab")} disabled={loading || importing}>GitLab</button> : null}
|
{gitlabEnabled ? <button type="button" className={`github-import-tab ${provider === "gitlab" ? "active" : ""}`} aria-pressed={provider === "gitlab"} onClick={() => setProvider("gitlab")} disabled={loading || importing}>GitLab</button> : null}
|
||||||
</div>
|
</div>
|
||||||
{provider === "github" ? (
|
{provider === "github" && (<>
|
||||||
<>
|
|
||||||
{/* Tab Navigation */}
|
{/* Tab Navigation */}
|
||||||
<div className="github-import-tabs" role="tablist" aria-label={t("git.importTypeAriaLabel", "Import type")}>
|
<div className="github-import-tabs" role="tablist" aria-label={t("git.importTypeAriaLabel", "Import type")}>
|
||||||
<button
|
<button
|
||||||
@@ -1316,19 +1358,50 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
|||||||
{/* Center: Labels filter (only for issues) */}
|
{/* Center: Labels filter (only for issues) */}
|
||||||
<div className="github-import-toolbar__zone github-import-toolbar__zone--filter">
|
<div className="github-import-toolbar__zone github-import-toolbar__zone--filter">
|
||||||
{activeTab === "issues" ? (
|
{activeTab === "issues" ? (
|
||||||
<>
|
/*
|
||||||
<label htmlFor="gh-labels" className="visually-hidden">{t("git.filterByLabelsLabel", "Filter by labels")}</label>
|
FNXC:GitHubImport 2026-07-15-23:20:
|
||||||
<input
|
The always-open filter input cost a full row for a control most imports never touch.
|
||||||
id="gh-labels"
|
It is now a popover. The trigger doubles as the filter's readout — it shows the active
|
||||||
type="text"
|
labels instead of the word "Filter" — so collapsing never hides applied state, and it
|
||||||
placeholder={t("git.filterByLabelsPlaceholder", "Filter: bug,enhancement…")}
|
carries an `is-active` class for a visual cue.
|
||||||
value={labels}
|
*/
|
||||||
onChange={(e) => setLabels(e.target.value)}
|
<div className="github-import-filter-menu" ref={filterMenuRef}>
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleLoad()}
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn github-import-filter-trigger ${labels.trim() ? "is-active" : ""}`}
|
||||||
|
data-testid="github-import-filter-trigger"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={filterOpen}
|
||||||
|
onClick={() => setFilterOpen((open) => !open)}
|
||||||
disabled={loading || importing || !hasRemotes}
|
disabled={loading || importing || !hasRemotes}
|
||||||
aria-label={t("git.filterIssuesByLabels", "Filter issues by labels")}
|
title={t("git.filterByLabelsLabel", "Filter by labels")}
|
||||||
/>
|
>
|
||||||
</>
|
<Filter size={14} aria-hidden="true" />
|
||||||
|
<span className="github-import-filter-trigger__text">
|
||||||
|
{labels.trim() || t("git.filter", "Filter")}
|
||||||
|
</span>
|
||||||
|
<ChevronDown size={12} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{filterOpen && (
|
||||||
|
<div className="github-import-filter-panel" data-testid="github-import-filter-panel" role="dialog" aria-label={t("git.filterByLabelsLabel", "Filter by labels")}>
|
||||||
|
<label htmlFor="gh-labels" className="github-import-filter-panel__label">{t("git.filterByLabelsLabel", "Filter by labels")}</label>
|
||||||
|
<input
|
||||||
|
id="gh-labels"
|
||||||
|
type="text"
|
||||||
|
autoFocus
|
||||||
|
placeholder={t("git.filterByLabelsPlaceholder", "Filter: bug,enhancement…")}
|
||||||
|
value={labels}
|
||||||
|
onChange={(e) => setLabels(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Enter applies the filter AND dismisses — the reload is the confirmation.
|
||||||
|
if (e.key === "Enter") { setFilterOpen(false); handleLoad(); }
|
||||||
|
}}
|
||||||
|
disabled={loading || importing || !hasRemotes}
|
||||||
|
aria-label={t("git.filterIssuesByLabels", "Filter issues by labels")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="github-import-filter-hint">
|
<span className="github-import-filter-hint">
|
||||||
{t("git.openPullsFrom", "Open pull requests from {{remote}}", { remote: owner || t("git.selectedRemote", "selected remote") })}
|
{t("git.openPullsFrom", "Open pull requests from {{remote}}", { remote: owner || t("git.selectedRemote", "selected remote") })}
|
||||||
@@ -1346,11 +1419,20 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
|||||||
aria-label={loading ? t("git.loadingAriaLabel", "Loading {{tab}}", { tab: activeTab }) : t("git.loadFromRepoAriaLabel", "Load {{tab}} from repository", { tab: activeTab })}
|
aria-label={loading ? t("git.loadingAriaLabel", "Loading {{tab}}", { tab: activeTab }) : t("git.loadFromRepoAriaLabel", "Load {{tab}} from repository", { tab: activeTab })}
|
||||||
title={loading ? t("git.loadingTitle", "Loading…") : t("git.loadTabTitle", "Load {{tab}}", { tab: activeTab })}
|
title={loading ? t("git.loadingTitle", "Loading…") : t("git.loadTabTitle", "Load {{tab}}", { tab: activeTab })}
|
||||||
>
|
>
|
||||||
|
{/*
|
||||||
|
FNXC:GitHubImport 2026-07-15-23:20:
|
||||||
|
Load is icon-only. The full-width labelled button owned a whole row of vertical space
|
||||||
|
for an action whose meaning the refresh glyph already carries; the label survives as
|
||||||
|
the accessible name + tooltip (both set above), so nothing is lost for screen readers.
|
||||||
|
*/}
|
||||||
{loading ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
|
{loading ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
|
||||||
<span>{loading ? t("git.loading", "Loading…") : t("git.load", "Load")}</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
|
{provider === "github" ? (
|
||||||
|
<>
|
||||||
|
|
||||||
{/* Warning/Error states below toolbar */}
|
{/* Warning/Error states below toolbar */}
|
||||||
{!loadingRemotes && !hasRemotes && (
|
{!loadingRemotes && !hasRemotes && (
|
||||||
@@ -1563,34 +1645,16 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
|||||||
className="floating-window--github-import-detail"
|
className="floating-window--github-import-detail"
|
||||||
>
|
>
|
||||||
<div className="github-import-detail-panel">
|
<div className="github-import-detail-panel">
|
||||||
|
{/*
|
||||||
|
FNXC:GitHubImport 2026-07-15-23:20:
|
||||||
|
Import and Close issue moved OUT of this header to a bottom action bar (see
|
||||||
|
`github-import-detail-actions`). Operator report: actions above the content they act on
|
||||||
|
read as navigation and are awkward to reach on a phone; a dialog's commit actions belong
|
||||||
|
at the bottom, which also matches the modal's own Cancel bar. The header is now purely a
|
||||||
|
heading.
|
||||||
|
*/}
|
||||||
<div className="github-import-pane-header">
|
<div className="github-import-pane-header">
|
||||||
<h4 id="github-import-preview-heading">{t("git.previewHeading", "Preview")}</h4>
|
<h4 id="github-import-preview-heading">{t("git.previewHeading", "Preview")}</h4>
|
||||||
{/*
|
|
||||||
FNXC:GitHubImport 2026-06-23-03:15:
|
|
||||||
Close-issue action sits next to the top Import action and acts on the selected OPEN issue. Hidden for the PR tab and for already-closed issues; disabled while a close request is in flight.
|
|
||||||
Closing reflects locally (badge flips to closed) without dismissing the preview.
|
|
||||||
*/}
|
|
||||||
{activeTab === "issues" && selectedIssue && !selectedIssueClosed && (
|
|
||||||
<button
|
|
||||||
className="btn github-import-issue-close-top"
|
|
||||||
data-testid="github-import-issue-close"
|
|
||||||
onClick={handleCloseIssue}
|
|
||||||
disabled={closingIssue}
|
|
||||||
title={t("git.closeIssueTitle", "Close issue #{{number}}", { number: selectedIssue.number })}
|
|
||||||
>
|
|
||||||
{closingIssue ? <Loader2 size={14} className="spin" /> : t("git.closeIssue", "Close issue")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className="btn btn-primary github-import-action-top"
|
|
||||||
data-testid="github-import-action-top"
|
|
||||||
onClick={handleImport}
|
|
||||||
disabled={
|
|
||||||
(activeTab === "issues" ? selectedIssueNumber === null || isUrlImported(selectedIssue?.html_url) : selectedPullNumber === null || isUrlImported(selectedPull?.html_url)) || importing
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{importing ? <Loader2 size={14} className="spin" /> : t("git.import", "Import")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{/*
|
{/*
|
||||||
FNXC:GitHubImport 2026-06-23-03:15:
|
FNXC:GitHubImport 2026-06-23-03:15:
|
||||||
@@ -1781,6 +1845,38 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
{/*
|
||||||
|
FNXC:GitHubImport 2026-07-15-23:20:
|
||||||
|
Bottom action bar for the detail preview — the ONLY place an import is committed now
|
||||||
|
(the list's footer Import was removed, so you can no longer import an issue whose body
|
||||||
|
you never opened). Import stays last/right as the primary action; Close issue sits to
|
||||||
|
its LEFT and still acts on the selected OPEN issue: hidden on the PR tab and for
|
||||||
|
already-closed issues, disabled while a close is in flight, and closing reflects
|
||||||
|
locally (the badge flips) without dismissing the preview.
|
||||||
|
*/}
|
||||||
|
<div className="github-import-detail-actions" data-testid="github-import-detail-actions">
|
||||||
|
{activeTab === "issues" && selectedIssue && !selectedIssueClosed && (
|
||||||
|
<button
|
||||||
|
className="btn github-import-issue-close"
|
||||||
|
data-testid="github-import-issue-close"
|
||||||
|
onClick={handleCloseIssue}
|
||||||
|
disabled={closingIssue}
|
||||||
|
title={t("git.closeIssueTitle", "Close issue #{{number}}", { number: selectedIssue.number })}
|
||||||
|
>
|
||||||
|
{closingIssue ? <Loader2 size={14} className="spin" /> : t("git.closeIssue", "Close issue")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="btn btn-primary github-import-action"
|
||||||
|
data-testid="github-import-action-top"
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={
|
||||||
|
(activeTab === "issues" ? selectedIssueNumber === null || isUrlImported(selectedIssue?.html_url) : selectedPullNumber === null || isUrlImported(selectedPull?.html_url)) || importing
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{importing ? <Loader2 size={14} className="spin" /> : t("git.import", "Import")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FloatingWindow>
|
</FloatingWindow>
|
||||||
)}
|
)}
|
||||||
@@ -1862,26 +1958,20 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
FNXC:GitHubImport 2026-06-23-02:00:
|
FNXC:GitHubImport 2026-06-23-02:00 (revised 2026-07-15-23:20):
|
||||||
Bottom Cancel+Import bar is kept ONLY for the non-embedded modal presentation, which needs a Cancel to dismiss the dialog.
|
The bottom bar exists ONLY for the non-embedded modal, which needs a Cancel to dismiss the
|
||||||
In the embedded sidebar (isEmbedded) there is no modal to cancel and the Import action now lives in the preview-pane top header, so the bottom bar is removed entirely.
|
dialog; the embedded sidebar has nothing to cancel, so it has no bar at all.
|
||||||
|
|
||||||
|
Import no longer lives here. There were two ways to import — this footer (acting on the
|
||||||
|
radio selection) and the preview pane — which split the action across two places and let you
|
||||||
|
import an issue whose body you had never opened. Import is now ONLY in the detail preview's
|
||||||
|
bottom action bar, so the list is purely for choosing what to look at. Cancel stays.
|
||||||
*/}
|
*/}
|
||||||
{!isEmbedded && (
|
{!isEmbedded && (
|
||||||
<div className="modal-actions github-import-modal__actions">
|
<div className="modal-actions github-import-modal__actions">
|
||||||
<button className="btn" onClick={onClose} disabled={importing}>
|
<button className="btn" onClick={onClose} disabled={importing}>
|
||||||
{t("common.cancel", "Cancel")}
|
{t("common.cancel", "Cancel")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={provider === "gitlab" ? handleImportGitLab : handleImport}
|
|
||||||
disabled={
|
|
||||||
provider === "gitlab"
|
|
||||||
? !gitlabEnabled || selectedGitlabItem === null || importing || (selectedGitlabItem ? isUrlImported(selectedGitlabItem.webUrl) : false)
|
|
||||||
: (activeTab === "issues" ? selectedIssueNumber === null || isUrlImported(selectedIssue?.html_url) : selectedPullNumber === null || isUrlImported(selectedPull?.html_url)) || importing
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{importing ? <Loader2 size={14} className="spin" /> : t("git.import", "Import")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -396,9 +396,14 @@ describe("GitHubImportModal", () => {
|
|||||||
expect(toolbar).toBeTruthy();
|
expect(toolbar).toBeTruthy();
|
||||||
// Remote pill should be in toolbar
|
// Remote pill should be in toolbar
|
||||||
expect(within(toolbar).getByTestId("github-import-single-remote")).toBeTruthy();
|
expect(within(toolbar).getByTestId("github-import-single-remote")).toBeTruthy();
|
||||||
// Filter input
|
/*
|
||||||
expect(within(toolbar).getByPlaceholderText(/Filter:/)).toBeTruthy();
|
FNXC:GitHubImport 2026-07-15-23:50:
|
||||||
// Load button
|
The filter is a popover now, so its input is absent until the trigger is used — assert the
|
||||||
|
trigger instead. Load is icon-only; its accessible name still carries "Load", so the same
|
||||||
|
role+name query keeps working and proves the label was not lost for screen readers.
|
||||||
|
*/
|
||||||
|
expect(within(toolbar).getByTestId("github-import-filter-trigger")).toBeTruthy();
|
||||||
|
expect(within(toolbar).queryByPlaceholderText(/Filter:/)).toBeNull();
|
||||||
expect(within(toolbar).getByRole("button", { name: /Load/i })).toBeTruthy();
|
expect(within(toolbar).getByRole("button", { name: /Load/i })).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -532,13 +537,42 @@ describe("GitHubImportModal", () => {
|
|||||||
expect(within(previewCard).getByText("(no description)")).toBeTruthy();
|
expect(within(previewCard).getByText("(no description)")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has optional labels input with filter placeholder", async () => {
|
/*
|
||||||
|
FNXC:GitHubImport 2026-07-15-23:50:
|
||||||
|
The labels filter collapsed into a popover to give the issue list back its vertical space, so the
|
||||||
|
input is no longer in the DOM until the trigger is used. This now tests the whole affordance —
|
||||||
|
reachable, opens, and still filters — rather than just the input's existence.
|
||||||
|
*/
|
||||||
|
it("reveals the labels filter input from the filter popover", async () => {
|
||||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByPlaceholderText(/Filter:/)).toBeTruthy();
|
expect(screen.getByTestId("github-import-filter-trigger")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
// Collapsed by default — this is the vertical space the redesign reclaims.
|
||||||
|
expect(screen.queryByPlaceholderText(/Filter:/)).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("github-import-filter-trigger"));
|
||||||
|
|
||||||
|
expect(screen.getByTestId("github-import-filter-panel")).toBeTruthy();
|
||||||
|
expect(screen.getByPlaceholderText(/Filter:/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces the active filter on the trigger so collapsing never hides applied state", async () => {
|
||||||
|
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||||
|
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId("github-import-filter-trigger")).toBeTruthy());
|
||||||
|
const trigger = screen.getByTestId("github-import-filter-trigger");
|
||||||
|
expect(trigger.textContent).toMatch(/Filter/);
|
||||||
|
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/Filter:/), { target: { value: "bug,enhancement" } });
|
||||||
|
|
||||||
|
// The trigger doubles as the readout: a collapsed filter must still be visible state.
|
||||||
|
expect(screen.getByTestId("github-import-filter-trigger").textContent).toContain("bug,enhancement");
|
||||||
|
expect(screen.getByTestId("github-import-filter-trigger").className).toContain("is-active");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("with no remotes", () => {
|
describe("with no remotes", () => {
|
||||||
@@ -953,7 +987,8 @@ describe("GitHubImportModal", () => {
|
|||||||
expect(screen.getByText("Issue without labels")).toBeTruthy();
|
expect(screen.getByText("Issue without labels")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Enter label filter
|
// Enter label filter — the input now lives behind the filter popover trigger.
|
||||||
|
fireEvent.click(screen.getByTestId("github-import-filter-trigger"));
|
||||||
const labelsInput = screen.getByPlaceholderText(/Filter:/);
|
const labelsInput = screen.getByPlaceholderText(/Filter:/);
|
||||||
fireEvent.change(labelsInput, { target: { value: "bug" } });
|
fireEvent.change(labelsInput, { target: { value: "bug" } });
|
||||||
|
|
||||||
@@ -1422,20 +1457,21 @@ describe("GitHubImportModal", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows filter input for Issues tab, hint text for Pulls tab", async () => {
|
it("shows filter control for Issues tab, hint text for Pulls tab", async () => {
|
||||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// Default is Issues tab, should show filter input
|
// Default is Issues tab. The filter is a popover now, so assert its trigger.
|
||||||
expect(screen.getByPlaceholderText(/Filter:/)).toBeTruthy();
|
expect(screen.getByTestId("github-import-filter-trigger")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Click on Pull Requests tab
|
// Click on Pull Requests tab
|
||||||
fireEvent.click(screen.getByRole("tab", { name: /Pull Requests/i }));
|
fireEvent.click(screen.getByRole("tab", { name: /Pull Requests/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// Should show hint text instead of filter input
|
// Should show hint text instead of the filter control (trigger AND input both gone).
|
||||||
|
expect(screen.queryByTestId("github-import-filter-trigger")).toBeNull();
|
||||||
expect(screen.queryByPlaceholderText(/Filter:/)).toBeNull();
|
expect(screen.queryByPlaceholderText(/Filter:/)).toBeNull();
|
||||||
expect(screen.getByText(/Open pull requests from/i)).toBeTruthy();
|
expect(screen.getByText(/Open pull requests from/i)).toBeTruthy();
|
||||||
});
|
});
|
||||||
@@ -1687,6 +1723,8 @@ describe("GitHubImportModal", () => {
|
|||||||
expect(screen.getByText("Persisted Issue")).toBeTruthy();
|
expect(screen.getByText("Persisted Issue")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// FN-8004-era redesign: the filter input lives behind a popover trigger now.
|
||||||
|
fireEvent.click(screen.getByTestId("github-import-filter-trigger"));
|
||||||
fireEvent.change(screen.getByPlaceholderText(/Filter:/), { target: { value: "bug" } });
|
fireEvent.change(screen.getByPlaceholderText(/Filter:/), { target: { value: "bug" } });
|
||||||
// The label change re-triggers auto-load (briefly disabling the list); wait for it to settle before selecting.
|
// The label change re-triggers auto-load (briefly disabling the list); wait for it to settle before selecting.
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -1707,7 +1745,14 @@ describe("GitHubImportModal", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect((screen.getByPlaceholderText(/Filter:/) as HTMLInputElement).value).toBe("bug");
|
/*
|
||||||
|
FNXC:GitHubImport 2026-07-15-23:55:
|
||||||
|
Assert restoration via the collapsed trigger rather than by reopening the popover. The
|
||||||
|
trigger renders the persisted filter, so this proves BOTH that FN-7657 persistence survived
|
||||||
|
the remount AND that a restored filter is visible without the operator hunting for it — the
|
||||||
|
real risk when a control collapses.
|
||||||
|
*/
|
||||||
|
expect(screen.getByTestId("github-import-filter-trigger").textContent).toContain("bug");
|
||||||
});
|
});
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(within(screen.getByTestId("github-import-preview-card")).getByText("Persisted Issue")).toBeTruthy();
|
expect(within(screen.getByTestId("github-import-preview-card")).getByText("Persisted Issue")).toBeTruthy();
|
||||||
@@ -1812,6 +1857,8 @@ describe("GitHubImportModal", () => {
|
|||||||
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
|
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
|
||||||
);
|
);
|
||||||
await waitFor(() => expect(screen.getByText("Project One Issue")).toBeTruthy());
|
await waitFor(() => expect(screen.getByText("Project One Issue")).toBeTruthy());
|
||||||
|
// FN-8004-era redesign: the filter input lives behind a popover trigger now.
|
||||||
|
fireEvent.click(screen.getByTestId("github-import-filter-trigger"));
|
||||||
fireEvent.change(screen.getByPlaceholderText(/Filter:/), { target: { value: "bug" } });
|
fireEvent.change(screen.getByPlaceholderText(/Filter:/), { target: { value: "bug" } });
|
||||||
// The label change re-triggers auto-load (briefly disabling the list); wait for it to settle before selecting.
|
// The label change re-triggers auto-load (briefly disabling the list); wait for it to settle before selecting.
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -1830,7 +1877,7 @@ describe("GitHubImportModal", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect((screen.getByPlaceholderText(/Filter:/) as HTMLInputElement).value).toBe("");
|
expect(screen.getByTestId("github-import-filter-trigger").textContent).not.toContain("bug");
|
||||||
});
|
});
|
||||||
expect(screen.queryByTestId("floating-window-github-import-detail")).toBeNull();
|
expect(screen.queryByTestId("floating-window-github-import-detail")).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -2030,4 +2077,160 @@ describe("GitHubImportModal", () => {
|
|||||||
window.dispatchEvent(new Event("resize"));
|
window.dispatchEvent(new Event("resize"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:GitHubImport 2026-07-16-00:05:
|
||||||
|
|
||||||
|
## Symptom Verification
|
||||||
|
|
||||||
|
Original symptom (mobile operator report): the import screen spent ~4 rows on chrome — a provider
|
||||||
|
row, a tab row, and a boxed ORIGIN/filter/Load stack — leaving ~9 issues visible; and Import lived
|
||||||
|
ABOVE the preview it acts on, while a second Import in the list footer let you import an issue whose
|
||||||
|
body you had never opened.
|
||||||
|
|
||||||
|
Assertion it is gone: verified in a real browser at 412px (jsdom has no layout, so these tests pin
|
||||||
|
STRUCTURE and BEHAVIOUR, not geometry). Post-change measurements: control chrome 93px -> 70px (two
|
||||||
|
rows), toolbar a single 36px row, ~13 issues visible, filter popover clamped inside the viewport,
|
||||||
|
and the detail action bar sitting below the preview content with an unclipped Import label.
|
||||||
|
|
||||||
|
## Surface Enumeration
|
||||||
|
|
||||||
|
- Issues tab AND Pull Requests tab (the filter exists only on Issues; the detail bar serves both).
|
||||||
|
- List footer (Cancel only) vs detail bar (the sole Import).
|
||||||
|
- Filter collapsed vs open, and the restored-from-persistence case (FN-7657).
|
||||||
|
- Close issue stays hidden on the PR tab and for already-closed issues — unchanged by the move.
|
||||||
|
*/
|
||||||
|
describe("GitHubImportModal — compact mobile layout (operator report)", () => {
|
||||||
|
// Inherits the suite's beforeEach (mock reset + defaults); only the remote needs pinning here.
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(fetchGitRemotes).mockResolvedValue(singleRemote);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts provider, type tabs, origin, filter and load in ONE control row", async () => {
|
||||||
|
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||||
|
|
||||||
|
const controls = await screen.findByTestId("github-import-controls");
|
||||||
|
// The row is the whole point: chrome that used to be four stacked bands is one container.
|
||||||
|
expect(within(controls).getByRole("button", { name: "GitHub" })).toBeTruthy();
|
||||||
|
expect(within(controls).getByRole("tab", { name: /Issues/i })).toBeTruthy();
|
||||||
|
expect(within(controls).getByRole("tab", { name: /Pull Requests/i })).toBeTruthy();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(within(controls).getByTestId("github-import-single-remote")).toBeTruthy();
|
||||||
|
});
|
||||||
|
expect(within(controls).getByTestId("github-import-filter-trigger")).toBeTruthy();
|
||||||
|
expect(within(controls).getByRole("button", { name: /Load/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Load reachable by name even though it is icon-only", async () => {
|
||||||
|
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||||
|
await waitFor(() => expect(screen.getByTestId("github-import-toolbar")).toBeTruthy());
|
||||||
|
|
||||||
|
// Dropping the visible label must not drop the accessible name.
|
||||||
|
const load = screen.getByRole("button", { name: /Load/i });
|
||||||
|
expect(load.textContent?.trim()).toBe("");
|
||||||
|
expect(load.getAttribute("aria-label")).toMatch(/Load/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps origin visible rather than hiding it inside the filter popover", async () => {
|
||||||
|
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||||
|
|
||||||
|
// Operator decision: origin is context for what you are importing, so it must stay on the row.
|
||||||
|
const remote = await screen.findByTestId("github-import-single-remote");
|
||||||
|
expect(remote.textContent).toContain("dustinbyrne/kb");
|
||||||
|
expect(screen.queryByTestId("github-import-filter-panel")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the filter popover on Escape without closing the modal", async () => {
|
||||||
|
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||||
|
await waitFor(() => expect(screen.getByTestId("github-import-filter-trigger")).toBeTruthy());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("github-import-filter-trigger"));
|
||||||
|
expect(screen.getByTestId("github-import-filter-panel")).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
|
||||||
|
// The popover is the innermost layer: Escape dismisses IT, and must not also close the modal.
|
||||||
|
expect(screen.queryByTestId("github-import-filter-panel")).toBeNull();
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers Import ONLY in the detail preview, never in the list footer", async () => {
|
||||||
|
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||||
|
await waitFor(() => expect(screen.getByTestId("github-import-toolbar")).toBeTruthy());
|
||||||
|
|
||||||
|
/*
|
||||||
|
Two Imports (footer + preview) split the action across two places and let an operator import an
|
||||||
|
issue sight-unseen. The footer keeps Cancel — the modal still needs a dismiss — but Import is
|
||||||
|
now exclusively the detail bar's.
|
||||||
|
*/
|
||||||
|
const footer = document.querySelector(".github-import-modal__actions");
|
||||||
|
expect(footer).toBeTruthy();
|
||||||
|
const footerButtons = [...footer!.querySelectorAll("button")].map((b) => b.textContent?.trim());
|
||||||
|
expect(footerButtons).toEqual(["Cancel"]);
|
||||||
|
expect(footerButtons).not.toContain("Import");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GitHubImportModal — detail actions sit at the bottom (operator report)", () => {
|
||||||
|
// Inherits the suite's beforeEach (mock reset + defaults); only the remote needs pinning here.
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(fetchGitRemotes).mockResolvedValue(singleRemote);
|
||||||
|
});
|
||||||
|
|
||||||
|
const openDetail = async () => {
|
||||||
|
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([
|
||||||
|
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/dustinbyrne/kb/issues/1", labels: [], state: "open" },
|
||||||
|
]);
|
||||||
|
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||||
|
await waitFor(() => expect(screen.getByText("First Issue")).toBeTruthy());
|
||||||
|
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
|
||||||
|
return screen.findByTestId("github-import-preview-card");
|
||||||
|
};
|
||||||
|
|
||||||
|
it("renders Import and Close issue AFTER the preview content, not above it", async () => {
|
||||||
|
await openDetail();
|
||||||
|
|
||||||
|
const bar = await screen.findByTestId("github-import-detail-actions");
|
||||||
|
const content = document.querySelector(".github-import-pane-content");
|
||||||
|
expect(content).toBeTruthy();
|
||||||
|
|
||||||
|
/*
|
||||||
|
The operator's ask: commit actions belong below the content they act on. jsdom has no layout, so
|
||||||
|
assert DOM ORDER — the bar must follow the content — which is what the CSS then pins to the
|
||||||
|
bottom of the flex panel. Geometry itself was verified in a real browser at 412px.
|
||||||
|
*/
|
||||||
|
expect(content!.compareDocumentPosition(bar) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(within(bar).getByRole("button", { name: /^Import$/i })).toBeTruthy();
|
||||||
|
expect(within(bar).getByTestId("github-import-issue-close")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves no action buttons stranded in the preview header", async () => {
|
||||||
|
await openDetail();
|
||||||
|
await screen.findByTestId("github-import-detail-actions");
|
||||||
|
|
||||||
|
/*
|
||||||
|
Guard against the FN-6115-style empty shell: the DETAIL header must be a heading now, nothing
|
||||||
|
else. Scope to `.github-import-detail-panel` — `.github-import-pane-header` is shared with the
|
||||||
|
LIST pane, so an unscoped query silently asserts against the wrong header.
|
||||||
|
*/
|
||||||
|
const header = document.querySelector(".github-import-detail-panel .github-import-pane-header");
|
||||||
|
expect(header).toBeTruthy();
|
||||||
|
expect(header!.textContent).toContain("Preview");
|
||||||
|
expect(header!.querySelectorAll("button").length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Close issue off the Pull Requests tab", async () => {
|
||||||
|
await openDetail();
|
||||||
|
await screen.findByTestId("github-import-detail-actions");
|
||||||
|
expect(screen.getByTestId("github-import-issue-close")).toBeTruthy();
|
||||||
|
|
||||||
|
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce([]);
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: /Pull Requests/i }));
|
||||||
|
|
||||||
|
// Moving the buttons must not change WHEN close-issue is offered.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId("github-import-issue-close")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user