Unverified 提交 d01ef2e9 authored 作者: wwwillchen-bot's avatar wwwillchen-bot 提交者: GitHub

Move favorite button from app list to app details page (#2704)

## Summary - Move the favorite toggle button from the app list sidebar to the app details page header - Update AppItem component to show a small filled star indicator for favorited apps (instead of an interactive button) - Simplify AppList by removing favorite-related props and hooks - Update E2E tests to test favorite functionality from the app details page ## Test plan - Open an app's details page and verify the favorite button appears next to the app name - Click the favorite button and verify the star fills in (app is favorited) - Click again to unfavorite and verify the star becomes unfilled - Return to the app list and verify favorited apps show a small filled star indicator - Run E2E tests: `npx playwright test favorite_app.spec.ts` 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2704" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarWill Chen <willchen90@gmail.com> Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com>
上级 da660b2a
...@@ -2,7 +2,7 @@ import { test, Timeout } from "./helpers/test_helper"; ...@@ -2,7 +2,7 @@ import { test, Timeout } from "./helpers/test_helper";
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
test.describe("Favorite App Tests", () => { test.describe("Favorite App Tests", () => {
test("Add app to favorite", async ({ po }) => { test("Add app to favorite from app details", async ({ po }) => {
await po.setUp({ autoApprove: true }); await po.setUp({ autoApprove: true });
// Create a test app // Create a test app
...@@ -13,32 +13,29 @@ test.describe("Favorite App Tests", () => { ...@@ -13,32 +13,29 @@ test.describe("Favorite App Tests", () => {
const appItems = await po.page.getByTestId(/^app-list-item-/).all(); const appItems = await po.page.getByTestId(/^app-list-item-/).all();
expect(appItems.length).toBeGreaterThan(0); expect(appItems.length).toBeGreaterThan(0);
const firstAppItem = appItems[0]; const firstAppItem = appItems[0];
const testId = await firstAppItem.getAttribute("data-testid");
const appName = testId!.replace("app-list-item-", ""); // Click on the app to go to app details
await firstAppItem.click();
// Get the app item (assuming it's not favorited initially)
const appItem = po.page.locator(`[data-testid="app-list-item-${appName}"]`); // Wait for app details page to load
await expect(appItem).toBeVisible(); const appDetailsPage = po.page.getByTestId("app-details-page");
await expect(appDetailsPage).toBeVisible({ timeout: Timeout.MEDIUM });
// Click the favorite button — hover first like a real user would,
// then wait for the app to finish starting before the click resolves. // Click the favorite button in app details
const favoriteButton = appItem const favoriteButton = appDetailsPage.locator(
.locator("xpath=..") '[data-testid="favorite-button"]',
.locator('[data-testid="favorite-button"]'); );
await expect(favoriteButton).toBeVisible(); await expect(favoriteButton).toBeVisible();
await appItem.hover();
await favoriteButton.click(); await favoriteButton.click();
// Check that the star is filled (favorited). // Check that the star is filled (favorited)
// Use a longer timeout because the addToFavorite IPC call may be waiting
// for the app startup lock to release.
const star = favoriteButton.locator("svg"); const star = favoriteButton.locator("svg");
await expect(star).toHaveClass(/(?:^|\s)fill-\[#6c55dc\]/, { await expect(star).toHaveClass(/(?:^|\s)fill-\[#6c55dc\]/, {
timeout: Timeout.MEDIUM, timeout: Timeout.MEDIUM,
}); });
}); });
test("Remove app from favorite", async ({ po }) => { test("Remove app from favorite from app details", async ({ po }) => {
await po.setUp({ autoApprove: true }); await po.setUp({ autoApprove: true });
// Create a test app // Create a test app
...@@ -49,17 +46,18 @@ test.describe("Favorite App Tests", () => { ...@@ -49,17 +46,18 @@ test.describe("Favorite App Tests", () => {
const appItems = await po.page.getByTestId(/^app-list-item-/).all(); const appItems = await po.page.getByTestId(/^app-list-item-/).all();
expect(appItems.length).toBeGreaterThan(0); expect(appItems.length).toBeGreaterThan(0);
const firstAppItem = appItems[0]; const firstAppItem = appItems[0];
const testId = await firstAppItem.getAttribute("data-testid");
const appName = testId!.replace("app-list-item-", "");
// Get the app item // Click on the app to go to app details
const appItem = po.page.locator(`[data-testid="app-list-item-${appName}"]`); await firstAppItem.click();
// Wait for app details page to load
const appDetailsPage = po.page.getByTestId("app-details-page");
await expect(appDetailsPage).toBeVisible({ timeout: Timeout.MEDIUM });
// First, add to favorite // First, add to favorite
const favoriteButton = appItem const favoriteButton = appDetailsPage.locator(
.locator("xpath=..") '[data-testid="favorite-button"]',
.locator('[data-testid="favorite-button"]'); );
await appItem.hover();
await favoriteButton.click(); await favoriteButton.click();
// Check that the star is filled (favorited) // Check that the star is filled (favorited)
...@@ -69,15 +67,9 @@ test.describe("Favorite App Tests", () => { ...@@ -69,15 +67,9 @@ test.describe("Favorite App Tests", () => {
}); });
// Now, remove from favorite // Now, remove from favorite
const unfavoriteButton = appItem await favoriteButton.click();
.locator("xpath=..")
.locator('[data-testid="favorite-button"]');
await expect(unfavoriteButton).toBeVisible();
await appItem.hover();
await unfavoriteButton.click();
// Check that the star is not filled (unfavorited) // Check that the star is not filled (unfavorited)
// Match fill-[#6c55dc] only at start or after whitespace (not as part of hover:fill-...)
await expect(star).not.toHaveClass(/(?:^|\s)fill-\[#6c55dc\]/, { await expect(star).not.toHaveClass(/(?:^|\s)fill-\[#6c55dc\]/, {
timeout: Timeout.MEDIUM, timeout: Timeout.MEDIUM,
}); });
......
{ {
"name": "dyad", "name": "dyad",
"version": "0.37.0-beta.1", "version": "0.37.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dyad", "name": "dyad",
"version": "0.37.0-beta.1", "version": "0.37.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.46", "@ai-sdk/amazon-bedrock": "^4.0.46",
......
...@@ -13,15 +13,12 @@ import { selectedChatIdAtom } from "@/atoms/chatAtoms"; ...@@ -13,15 +13,12 @@ import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { useLoadApps } from "@/hooks/useLoadApps"; import { useLoadApps } from "@/hooks/useLoadApps";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { AppSearchDialog } from "./AppSearchDialog"; import { AppSearchDialog } from "./AppSearchDialog";
import { useAddAppToFavorite } from "@/hooks/useAddAppToFavorite";
import { AppItem } from "./appItem"; import { AppItem } from "./appItem";
export function AppList({ show }: { show?: boolean }) { export function AppList({ show }: { show?: boolean }) {
const navigate = useNavigate(); const navigate = useNavigate();
const [selectedAppId, setSelectedAppId] = useAtom(selectedAppIdAtom); const [selectedAppId, setSelectedAppId] = useAtom(selectedAppIdAtom);
const setSelectedChatId = useSetAtom(selectedChatIdAtom); const setSelectedChatId = useSetAtom(selectedChatIdAtom);
const { apps, loading, error } = useLoadApps(); const { apps, loading, error } = useLoadApps();
const { toggleFavorite, isLoading: isFavoriteLoading } =
useAddAppToFavorite();
// search dialog state // search dialog state
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false); const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false);
...@@ -66,11 +63,6 @@ export function AppList({ show }: { show?: boolean }) { ...@@ -66,11 +63,6 @@ export function AppList({ show }: { show?: boolean }) {
// We'll eventually need a create app workflow // We'll eventually need a create app workflow
}; };
const handleToggleFavorite = (appId: number, e: React.MouseEvent) => {
e.stopPropagation();
toggleFavorite(appId);
};
return ( return (
<> <>
<SidebarGroup <SidebarGroup
...@@ -113,16 +105,20 @@ export function AppList({ show }: { show?: boolean }) { ...@@ -113,16 +105,20 @@ export function AppList({ show }: { show?: boolean }) {
) : ( ) : (
<SidebarMenu className="space-y-1" data-testid="app-list"> <SidebarMenu className="space-y-1" data-testid="app-list">
<SidebarGroupLabel>Favorite apps</SidebarGroupLabel> <SidebarGroupLabel>Favorite apps</SidebarGroupLabel>
{favoriteApps.map((app) => ( {favoriteApps.length === 0 ? (
<AppItem <div className="px-4 text-xs text-gray-500 italic">
key={app.id} Star an app from its details page to pin it here
app={app} </div>
handleAppClick={handleAppClick} ) : (
selectedAppId={selectedAppId} favoriteApps.map((app) => (
handleToggleFavorite={handleToggleFavorite} <AppItem
isFavoriteLoading={isFavoriteLoading} key={app.id}
/> app={app}
))} handleAppClick={handleAppClick}
selectedAppId={selectedAppId}
/>
))
)}
<SidebarGroupLabel>Other apps</SidebarGroupLabel> <SidebarGroupLabel>Other apps</SidebarGroupLabel>
{nonFavoriteApps.map((app) => ( {nonFavoriteApps.map((app) => (
<AppItem <AppItem
...@@ -130,8 +126,6 @@ export function AppList({ show }: { show?: boolean }) { ...@@ -130,8 +126,6 @@ export function AppList({ show }: { show?: boolean }) {
app={app} app={app}
handleAppClick={handleAppClick} handleAppClick={handleAppClick}
selectedAppId={selectedAppId} selectedAppId={selectedAppId}
handleToggleFavorite={handleToggleFavorite}
isFavoriteLoading={isFavoriteLoading}
/> />
))} ))}
</SidebarMenu> </SidebarMenu>
......
...@@ -8,17 +8,9 @@ type AppItemProps = { ...@@ -8,17 +8,9 @@ type AppItemProps = {
app: ListedApp; app: ListedApp;
handleAppClick: (id: number) => void; handleAppClick: (id: number) => void;
selectedAppId: number | null; selectedAppId: number | null;
handleToggleFavorite: (appId: number, e: React.MouseEvent) => void;
isFavoriteLoading: boolean;
}; };
export function AppItem({ export function AppItem({ app, handleAppClick, selectedAppId }: AppItemProps) {
app,
handleAppClick,
selectedAppId,
handleToggleFavorite,
isFavoriteLoading,
}: AppItemProps) {
return ( return (
<SidebarMenuItem className="mb-1 relative "> <SidebarMenuItem className="mb-1 relative ">
<div className="flex w-[206px] items-center" title={app.name}> <div className="flex w-[206px] items-center" title={app.name}>
...@@ -33,7 +25,15 @@ export function AppItem({ ...@@ -33,7 +25,15 @@ export function AppItem({
data-testid={`app-list-item-${app.name}`} data-testid={`app-list-item-${app.name}`}
> >
<div className="flex flex-col w-4/5"> <div className="flex flex-col w-4/5">
<span className="truncate">{app.name}</span> <div className="flex items-center gap-1">
<span className="truncate">{app.name}</span>
{app.isFavorite && (
<Star
size={12}
className="fill-[#6c55dc] text-[#6c55dc] flex-shrink-0"
/>
)}
</div>
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500">
{formatDistanceToNow(new Date(app.createdAt), { {formatDistanceToNow(new Date(app.createdAt), {
addSuffix: true, addSuffix: true,
...@@ -41,26 +41,6 @@ export function AppItem({ ...@@ -41,26 +41,6 @@ export function AppItem({
</span> </span>
</div> </div>
</Button> </Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => handleToggleFavorite(app.id, e)}
disabled={isFavoriteLoading}
className="absolute top-1 right-1 p-1 mx-1 h-6 w-6 z-10"
key={app.id}
data-testid="favorite-button"
>
<Star
size={12}
className={
app.isFavorite
? "fill-[#6c55dc] text-[#6c55dc]"
: selectedAppId === app.id
? "hover:fill-black hover:text-black"
: "hover:fill-[#6c55dc] hover:stroke-[#6c55dc] hover:text-[#6c55dc]"
}
/>
</Button>
</div> </div>
</SidebarMenuItem> </SidebarMenuItem>
); );
......
...@@ -12,6 +12,7 @@ import { ...@@ -12,6 +12,7 @@ import {
MessageCircle, MessageCircle,
Pencil, Pencil,
Folder, Folder,
Star,
} from "lucide-react"; } from "lucide-react";
import { import {
Popover, Popover,
...@@ -44,6 +45,7 @@ import { useCheckName } from "@/hooks/useCheckName"; ...@@ -44,6 +45,7 @@ import { useCheckName } from "@/hooks/useCheckName";
import { AppUpgrades } from "@/components/AppUpgrades"; import { AppUpgrades } from "@/components/AppUpgrades";
import { CapacitorControls } from "@/components/CapacitorControls"; import { CapacitorControls } from "@/components/CapacitorControls";
import { GithubCollaboratorManager } from "@/components/GithubCollaboratorManager"; import { GithubCollaboratorManager } from "@/components/GithubCollaboratorManager";
import { useAddAppToFavorite } from "@/hooks/useAddAppToFavorite";
export default function AppDetailsPage() { export default function AppDetailsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
...@@ -75,6 +77,8 @@ export default function AppDetailsPage() { ...@@ -75,6 +77,8 @@ export default function AppDetailsPage() {
debouncedNewCopyAppName, debouncedNewCopyAppName,
); );
const nameExists = checkNameResult?.exists ?? false; const nameExists = checkNameResult?.exists ?? false;
const { toggleFavorite, isLoading: isFavoriteLoading } =
useAddAppToFavorite();
// Get the appId from search params and find the corresponding app // Get the appId from search params and find the corresponding app
const appId = search.appId ? Number(search.appId) : null; const appId = search.appId ? Number(search.appId) : null;
...@@ -284,6 +288,33 @@ export default function AppDetailsPage() { ...@@ -284,6 +288,33 @@ export default function AppDetailsPage() {
<div className="w-full max-w-2xl mx-auto mt-10 p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm relative"> <div className="w-full max-w-2xl mx-auto mt-10 p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm relative">
<div className="flex items-center mb-3"> <div className="flex items-center mb-3">
<h2 className="text-2xl font-bold">{selectedApp.name}</h2> <h2 className="text-2xl font-bold">{selectedApp.name}</h2>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="sm"
className="ml-1 p-0.5 h-auto"
onClick={() => appId && toggleFavorite(appId)}
disabled={isFavoriteLoading}
data-testid="favorite-button"
/>
}
>
<Star
className={`h-4 w-4 ${
selectedApp.isFavorite
? "fill-[#6c55dc] text-[#6c55dc]"
: "hover:fill-[#6c55dc] hover:text-[#6c55dc]"
}`}
/>
</TooltipTrigger>
<TooltipContent>
{selectedApp.isFavorite
? "Remove from favorites"
: "Add to favorites"}
</TooltipContent>
</Tooltip>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论