FN-6044: fix GitHub import mobile preview scrolling
Keep GitHub import previews fully readable and scrollable on mobile while preserving desktop truncation. - allow the active mobile preview pane to size and scroll correctly within the modal - show full issue and pull request bodies on mobile while keeping desktop previews truncated - add regression coverage for mobile scrolling, no-description fallbacks, and mobile/desktop body rendering Files changed: packages/dashboard/app/components/GitHubImportModal.css | 18 +++ packages/dashboard/app/components/GitHubImportModal.tsx | 18 ++- packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx | 160 +++++++++++++++++++++ 3 files changed, 190 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-6044 Fusion-Task-Lineage: 43b6e686-a34f-4fec-9929-b4d28c12ac7a
This commit is contained in:
@@ -777,6 +777,9 @@
|
||||
}
|
||||
|
||||
.github-import-modal__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: var(--space-md);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
@@ -844,6 +847,12 @@
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.github-import-workspace {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Mobile pane visibility - show one at a time */
|
||||
.github-import-list-pane.mobile {
|
||||
display: none;
|
||||
@@ -864,7 +873,16 @@
|
||||
.github-import-preview-pane.mobile.active {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.github-import-preview-pane.mobile.active .github-import-pane-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* Back button styles */
|
||||
|
||||
@@ -39,6 +39,16 @@ function clampListPaneWidth(width: number) {
|
||||
return Math.max(GITHUB_IMPORT_LIST_PANE_MIN_WIDTH, Math.min(GITHUB_IMPORT_LIST_PANE_MAX_WIDTH, width));
|
||||
}
|
||||
|
||||
function formatPreviewBody(body: string | null | undefined, isMobile: boolean) {
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
if (isMobile) {
|
||||
return body;
|
||||
}
|
||||
return body.slice(0, 200) + (body.length > 200 ? "…" : "");
|
||||
}
|
||||
|
||||
export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId }: GitHubImportModalProps) {
|
||||
useMobileScrollLock(isOpen);
|
||||
const { t } = useTranslation("app");
|
||||
@@ -797,9 +807,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
<div className="preview-meta">{t("git.previewIssueMeta", "Issue #{{number}}", { number: selectedIssue.number })}</div>
|
||||
<div className="preview-title">{selectedIssue.title}</div>
|
||||
<div className="preview-body">
|
||||
{selectedIssue.body
|
||||
? selectedIssue.body.slice(0, 200) + (selectedIssue.body.length > 200 ? "…" : "")
|
||||
: t("git.noDescription", "(no description)")}
|
||||
{formatPreviewBody(selectedIssue.body, isMobile) || t("git.noDescription", "(no description)")}
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === "issues" ? (
|
||||
@@ -820,9 +828,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
<strong>{t("git.branchLabel", "Branch:")}</strong> {selectedPull.headBranch} → {selectedPull.baseBranch}
|
||||
</div>
|
||||
<div className="preview-body">
|
||||
{selectedPull.body
|
||||
? selectedPull.body.slice(0, 200) + (selectedPull.body.length > 200 ? "…" : "")
|
||||
: t("git.noDescription", "(no description)")}
|
||||
{formatPreviewBody(selectedPull.body, isMobile) || t("git.noDescription", "(no description)")}
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === "pulls" ? (
|
||||
|
||||
@@ -83,6 +83,12 @@ describe("GitHubImportModal", () => {
|
||||
expect(source).toContain("Some hardcoded colors below");
|
||||
});
|
||||
|
||||
it("keeps mobile preview content vertically scrollable inside the active pane", () => {
|
||||
const source = readFileSync(resolve(__dirname, "../GitHubImportModal.css"), "utf8");
|
||||
expect(source).toContain(".github-import-preview-pane.mobile.active {\n display: flex;\n flex: 1;\n min-height: 0;\n max-height: none;\n overflow: hidden;");
|
||||
expect(source).toContain(".github-import-preview-pane.mobile.active .github-import-pane-content {\n flex: 1;\n min-height: 0;\n overflow-y: auto;\n overscroll-behavior: contain;");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fetchGitRemotes).mockReset();
|
||||
@@ -183,6 +189,30 @@ describe("GitHubImportModal", () => {
|
||||
expect(screen.queryByTestId("github-import-preview-empty")).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves the no-description fallback for empty and null issue bodies", async () => {
|
||||
const issues = [
|
||||
{ number: 1, title: "Empty Issue", body: "", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
|
||||
{ number: 2, title: "Null Issue", body: null, html_url: "https://github.com/owner/repo/issues/2", labels: [] },
|
||||
];
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Empty Issue")).toBeTruthy();
|
||||
expect(screen.getByText("Null Issue")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
|
||||
let previewCard = await screen.findByTestId("github-import-preview-card");
|
||||
expect(within(previewCard).getByText("(no description)")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: /Select issue #2/i }));
|
||||
previewCard = await screen.findByTestId("github-import-preview-card");
|
||||
expect(within(previewCard).getByText("(no description)")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has optional labels input with filter placeholder", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
@@ -662,6 +692,136 @@ describe("GitHubImportModal", () => {
|
||||
expect(previewPane.classList.contains("active")).toBe(false);
|
||||
});
|
||||
|
||||
it("renders long selected issue body in full on mobile without a truncation ellipsis", async () => {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 480,
|
||||
});
|
||||
|
||||
const beyondPreviousCutoff = "visible body text after the old cutoff";
|
||||
const longBody = `${"A".repeat(210)} ${beyondPreviousCutoff}`;
|
||||
const issues = [
|
||||
{ number: 1, title: "Long Issue", body: longBody, html_url: "https://github.com/owner/repo/issues/1", labels: [] },
|
||||
];
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Long Issue")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
|
||||
|
||||
const previewPane = screen.getByTestId("github-import-preview-pane");
|
||||
await waitFor(() => {
|
||||
expect(previewPane.classList.contains("active")).toBe(true);
|
||||
});
|
||||
|
||||
const previewCard = await screen.findByTestId("github-import-preview-card");
|
||||
expect(within(previewCard).getByText((content) => content.includes(beyondPreviousCutoff))).toBeTruthy();
|
||||
expect(previewCard.textContent).toContain(longBody);
|
||||
expect(previewCard.textContent).not.toContain(`${"A".repeat(200)}…`);
|
||||
});
|
||||
|
||||
it("renders long selected pull request body in full on mobile without a truncation ellipsis", async () => {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 480,
|
||||
});
|
||||
|
||||
const beyondPreviousCutoff = "visible pull request body text after the old cutoff";
|
||||
const longBody = `${"P".repeat(210)} ${beyondPreviousCutoff}`;
|
||||
const pulls = [
|
||||
{ number: 1, title: "Long PR", body: longBody, html_url: "https://github.com/owner/repo/pull/1", headBranch: "feature", baseBranch: "main" },
|
||||
];
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(pulls);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /Pull Requests/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Long PR")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: /Select pull request #1/i }));
|
||||
|
||||
const previewPane = screen.getByTestId("github-import-preview-pane");
|
||||
await waitFor(() => {
|
||||
expect(previewPane.classList.contains("active")).toBe(true);
|
||||
});
|
||||
|
||||
const previewCard = await screen.findByTestId("github-import-preview-card");
|
||||
expect(within(previewCard).getByText((content) => content.includes(beyondPreviousCutoff))).toBeTruthy();
|
||||
expect(previewCard.textContent).toContain(longBody);
|
||||
expect(previewCard.textContent).not.toContain(`${"P".repeat(200)}…`);
|
||||
});
|
||||
|
||||
it("truncates long selected issue body on desktop", async () => {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 1200,
|
||||
});
|
||||
|
||||
const beyondDesktopCutoff = "desktop issue text after the cutoff";
|
||||
const longBody = `${"I".repeat(210)} ${beyondDesktopCutoff}`;
|
||||
const issues = [
|
||||
{ number: 1, title: "Long Desktop Issue", body: longBody, html_url: "https://github.com/owner/repo/issues/1", labels: [] },
|
||||
];
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Long Desktop Issue")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
|
||||
|
||||
const previewCard = await screen.findByTestId("github-import-preview-card");
|
||||
expect(previewCard.textContent).toContain(`${"I".repeat(200)}…`);
|
||||
expect(previewCard.textContent).not.toContain(beyondDesktopCutoff);
|
||||
expect(previewCard.textContent).not.toContain(longBody);
|
||||
});
|
||||
|
||||
it("truncates long selected pull request body on desktop", async () => {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 1200,
|
||||
});
|
||||
|
||||
const beyondDesktopCutoff = "desktop pull request text after the cutoff";
|
||||
const longBody = `${"R".repeat(210)} ${beyondDesktopCutoff}`;
|
||||
const pulls = [
|
||||
{ number: 1, title: "Long Desktop PR", body: longBody, html_url: "https://github.com/owner/repo/pull/1", headBranch: "feature", baseBranch: "main" },
|
||||
];
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(pulls);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /Pull Requests/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Long Desktop PR")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: /Select pull request #1/i }));
|
||||
|
||||
const previewCard = await screen.findByTestId("github-import-preview-card");
|
||||
expect(previewCard.textContent).toContain(`${"R".repeat(200)}…`);
|
||||
expect(previewCard.textContent).not.toContain(beyondDesktopCutoff);
|
||||
expect(previewCard.textContent).not.toContain(longBody);
|
||||
});
|
||||
|
||||
it("returns to list view on mobile after successful import", async () => {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
|
||||
Reference in New Issue
Block a user