mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
fix(desktop): complete project empty and context states (#6980)
## Summary First-time project users can now create a project directly from the empty state instead of reaching a dead end. The right-side project context also uses the intended Activity label and aligns its first row with the channel header. The populated and empty project views share one creation flow, so navigation, success feedback, and compatibility warnings remain consistent. ### Related issue Related: #6939 ### Testing - Desktop check, TypeScript typecheck, and repository file-size gate - 10 focused project overview context unit tests - E2E-mode build and 3 focused Playwright scenarios covering empty-state creation, right-sidebar alignment, and Activity context Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
@@ -121,6 +121,7 @@ export default defineConfig({
|
||||
"**/send-channel-binding.spec.ts",
|
||||
"**/project-cold-start.spec.ts",
|
||||
"**/project-commit-detail.spec.ts",
|
||||
"**/project-empty-state-alignment.spec.ts",
|
||||
"**/project-inbox.spec.ts",
|
||||
"**/projects-v3-screenshots.spec.ts",
|
||||
"**/project-issue-comments.spec.ts",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Folders,
|
||||
GitCommit,
|
||||
GitPullRequest,
|
||||
Plus,
|
||||
TerminalSquare,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
@@ -304,7 +305,11 @@ function RepositoryUnavailableIndicator({
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState() {
|
||||
export function EmptyState({
|
||||
onCreateProject,
|
||||
}: {
|
||||
onCreateProject: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-16 text-center">
|
||||
<Folders className="h-10 w-10 text-muted-foreground/40" />
|
||||
@@ -314,6 +319,10 @@ export function EmptyState() {
|
||||
Projects published to this relay will appear here.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onCreateProject} size="sm" type="button">
|
||||
<Plus className="h-4 w-4" />
|
||||
Create project
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
36
desktop/src/features/projects/ui/ProjectCreationDialog.tsx
Normal file
36
desktop/src/features/projects/ui/ProjectCreationDialog.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useCreateProjectMutation } from "@/features/projects/useCreateProject";
|
||||
import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog";
|
||||
|
||||
/** Shared project-creation flow for populated and first-run project views. */
|
||||
export function ProjectCreationDialog({
|
||||
onOpenChange,
|
||||
open,
|
||||
}: {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean;
|
||||
}) {
|
||||
const { goProject } = useAppNavigation();
|
||||
const createProjectMutation = useCreateProjectMutation();
|
||||
|
||||
return (
|
||||
<CreateProjectDialog
|
||||
isCreating={createProjectMutation.isPending}
|
||||
onCreate={async (input) => {
|
||||
const result = await createProjectMutation.mutateAsync(input);
|
||||
if (result.compatibilityWarning) {
|
||||
toast.warning("Created as a standalone project", {
|
||||
description: result.compatibilityWarning,
|
||||
});
|
||||
} else {
|
||||
toast.success(`Project "${result.project.name}" created.`);
|
||||
}
|
||||
await goProject(result.project.id);
|
||||
}}
|
||||
onOpenChange={onOpenChange}
|
||||
open={open}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -258,7 +258,7 @@ export function ProjectHomeContextPanel({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="space-y-4 px-2 pb-8 pt-1"
|
||||
className="space-y-4 px-2 pb-8 pt-3"
|
||||
data-testid="project-home-context-panel"
|
||||
>
|
||||
<ContextSection testId="project-home-context-workspace">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useManagedAgentsQuery } from "@/features/agents/hooks";
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
useProjectsWorkItemsQuery,
|
||||
} from "@/features/projects/hooks";
|
||||
import { useRepositoryActivitySummariesQuery } from "@/features/projects/repositoryActivityHooks";
|
||||
import { useCreateProjectMutation } from "@/features/projects/useCreateProject";
|
||||
import { isExplicitProject } from "@/features/projects/projectModels";
|
||||
import { projectsWithWorkItemRepositories } from "@/features/projects/projectWorkItems";
|
||||
import { useProjectsRepoSnapshotsQuery } from "@/features/projects/useProjectsRepoSnapshots";
|
||||
@@ -52,7 +50,7 @@ import {
|
||||
ProjectsOverviewProjectItems,
|
||||
ProjectsOverviewRepositoryItems,
|
||||
} from "@/features/projects/ui/ProjectsOverviewItems";
|
||||
import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog";
|
||||
import { ProjectCreationDialog } from "@/features/projects/ui/ProjectCreationDialog";
|
||||
import { CreateProjectIssueDialog } from "@/features/projects/ui/CreateProjectIssueDialog";
|
||||
import { CreatePullRequestDialog } from "@/features/projects/ui/CreatePullRequestDialog";
|
||||
import { ProjectAgentChatPanel } from "@/features/projects/ui/ProjectAgentChatPanel";
|
||||
@@ -183,7 +181,6 @@ export function ProjectsView() {
|
||||
const [createIssueOpen, setCreateIssueOpen] = React.useState(false);
|
||||
const [createPullRequestOpen, setCreatePullRequestOpen] =
|
||||
React.useState(false);
|
||||
const createProjectMutation = useCreateProjectMutation();
|
||||
const [storedViewMode, setStoredViewMode] =
|
||||
React.useState<ProjectsViewMode | null>(() => readStoredViewMode());
|
||||
const [sort, setSort] = React.useState<ProjectsSort>(() => readStoredSort());
|
||||
@@ -566,7 +563,15 @@ export function ProjectsView() {
|
||||
}
|
||||
|
||||
if (projectReadModels.length === 0) {
|
||||
return <EmptyState />;
|
||||
return (
|
||||
<>
|
||||
<ProjectCreationDialog
|
||||
onOpenChange={setCreateProjectOpen}
|
||||
open={createProjectOpen}
|
||||
/>
|
||||
<EmptyState onCreateProject={() => setCreateProjectOpen(true)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const projectItems = (
|
||||
@@ -740,19 +745,7 @@ export function ProjectsView() {
|
||||
overviewDetached ? "projects-overview-content-pod" : undefined
|
||||
}
|
||||
>
|
||||
<CreateProjectDialog
|
||||
isCreating={createProjectMutation.isPending}
|
||||
onCreate={async (input) => {
|
||||
const result = await createProjectMutation.mutateAsync(input);
|
||||
if (result.compatibilityWarning) {
|
||||
toast.warning("Created as a standalone project", {
|
||||
description: result.compatibilityWarning,
|
||||
});
|
||||
} else {
|
||||
toast.success(`Project "${result.project.name}" created.`);
|
||||
}
|
||||
await goProject(result.project.id);
|
||||
}}
|
||||
<ProjectCreationDialog
|
||||
onOpenChange={setCreateProjectOpen}
|
||||
open={createProjectOpen}
|
||||
/>
|
||||
|
||||
@@ -72,7 +72,7 @@ test("activity pod shows workspace details without a create action", () => {
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(context.title, "Projects");
|
||||
assert.equal(context.title, "Activity");
|
||||
assert.equal(context.detailsTitle, "Details");
|
||||
assert.equal(context.action, null);
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -405,7 +405,7 @@ export function projectsOverviewContext(
|
||||
section: "prs",
|
||||
},
|
||||
],
|
||||
title: "Projects",
|
||||
title: "Activity",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1259,6 +1259,8 @@ declare global {
|
||||
__BUZZ_E2E_REPOSITORY_ONLY_PROJECTS__?: boolean;
|
||||
/** Leaves broad project enumeration pending while scoped project queries remain available. */
|
||||
__BUZZ_E2E_DEFER_FULL_PROJECT_QUERIES__?: boolean;
|
||||
/** Omits all seeded project and repository events for empty-state tests. */
|
||||
__BUZZ_E2E_EMPTY_PROJECTS__?: boolean;
|
||||
/** Project-scoped events accepted by the mock relay. */
|
||||
__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__?: Array<{
|
||||
content: string;
|
||||
@@ -5912,6 +5914,10 @@ function writeMockProjectBranch(
|
||||
}
|
||||
|
||||
function buildMockProjectEvents(): RelayEvent[] {
|
||||
if (window.__BUZZ_E2E_EMPTY_PROJECTS__) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const events: RelayEvent[] = [];
|
||||
const daySeconds = 86_400;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
69
desktop/tests/e2e/project-empty-state-alignment.spec.ts
Normal file
69
desktop/tests/e2e/project-empty-state-alignment.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
const ALIGNMENT_TOLERANCE_PX = 2;
|
||||
|
||||
async function enableProjectsFeature(page: import("@playwright/test").Page) {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem(
|
||||
"buzz-feature-overrides-v1",
|
||||
JSON.stringify({ projects: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function addProjectToSidebar(
|
||||
page: import("@playwright/test").Page,
|
||||
dtag: string,
|
||||
) {
|
||||
await page.getByTestId("sidebar-projects-section-label").hover();
|
||||
await page.getByTestId("sidebar-projects-create").click();
|
||||
const browser = page.getByTestId("project-browser-dialog");
|
||||
await browser.getByRole("searchbox", { name: "Search projects" }).fill(dtag);
|
||||
await browser.getByTestId(`project-browser-result-${dtag}`).click();
|
||||
await expect(browser).toBeHidden();
|
||||
await expect(page.getByTestId(`sidebar-project-${dtag}`)).toBeVisible();
|
||||
}
|
||||
|
||||
test("first-time project empty state opens project creation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await enableProjectsFeature(page);
|
||||
await page.addInitScript(() => {
|
||||
window.__BUZZ_E2E_EMPTY_PROJECTS__ = true;
|
||||
});
|
||||
await installMockBridge(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByTestId("open-projects-view").click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("main").getByText("No projects yet"),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Create project" }).click();
|
||||
await expect(page.getByTestId("create-project-dialog")).toBeVisible();
|
||||
});
|
||||
|
||||
test("project home context aligns with the channel header", async ({
|
||||
page,
|
||||
}) => {
|
||||
await enableProjectsFeature(page);
|
||||
await installMockBridge(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await addProjectToSidebar(page, "buzz");
|
||||
await waitForAnimations(page);
|
||||
|
||||
const [headerTitleBox, tasksBox] = await Promise.all([
|
||||
page.getByTestId("chat-title").boundingBox(),
|
||||
page.getByTestId("project-home-context-tasks").boundingBox(),
|
||||
]);
|
||||
expect(headerTitleBox).not.toBeNull();
|
||||
expect(tasksBox).not.toBeNull();
|
||||
const headerTitleCenter =
|
||||
(headerTitleBox?.y ?? 0) + (headerTitleBox?.height ?? 0) / 2;
|
||||
const tasksCenter = (tasksBox?.y ?? 0) + (tasksBox?.height ?? 0) / 2;
|
||||
expect(Math.abs(headerTitleCenter - tasksCenter)).toBeLessThanOrEqual(
|
||||
ALIGNMENT_TOLERANCE_PX,
|
||||
);
|
||||
});
|
||||
@@ -1722,7 +1722,7 @@ test("project overview presents collapsible context beside grouped activity", as
|
||||
page.getByTestId("projects-overview-context-panel"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("projects-overview-context-title")).toHaveText(
|
||||
"Projects",
|
||||
"Activity",
|
||||
);
|
||||
expect(
|
||||
Math.round(
|
||||
@@ -1757,7 +1757,7 @@ test("project overview presents collapsible context beside grouped activity", as
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("projects-overview-context-panel")
|
||||
.getByRole("heading", { name: "Activity" }),
|
||||
.getByRole("heading", { level: 3, name: "Activity" }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page
|
||||
@@ -1932,7 +1932,7 @@ test("project overview presents collapsible context beside grouped activity", as
|
||||
page.getByTestId("projects-overview-context-panel"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("projects-overview-context-title")).toHaveText(
|
||||
"Projects",
|
||||
"Activity",
|
||||
);
|
||||
await expect(page.getByTestId("projects-overview-activity")).toHaveCount(0);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user