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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/server/src/routes/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ function SearchRoute() {
renderMediaItem={(media) => <MediaGridItem media={media} />}
renderNavActions={({ openMobileFilters }) => (
<Button
class="border-white text-white hover:bg-sky-700 md:hidden"
class="size-11 border-input text-foreground hover:bg-accent md:hidden"
onClick={openMobileFilters}
size="icon"
variant="outline"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,9 @@ export function SourceMediaPage() {
const handleToggleSelect = (mediaId: string) => {
setIsBulkSelectMode(true);
setSelectedMediaIds((prev) => {
const next = prev.includes(mediaId)
return prev.includes(mediaId)
? prev.filter((id) => id !== mediaId)
: [...prev, mediaId];
if (next.length === 0) {
setIsBulkSelectMode(false);
}
return next;
});
};

Expand Down Expand Up @@ -133,6 +129,7 @@ export function SourceMediaPage() {
onBulkAction={() => setIsBulkActionOpen(true)}
onClearSelection={handleCancelSelect}
selectedCount={() => selectedMediaIds().length}
onEnterBulkSelectMode={() => setIsBulkSelectMode(true)}
renderItem={(media, options) => (
<MediaGridItem
media={media}
Expand All @@ -148,15 +145,26 @@ export function SourceMediaPage() {
/>

{/* 一括選択ツールバー */}
<Show when={isBulkSelectMode() && selectedMediaIds().length > 0}>
<div class="fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-4 border border-primary/20 bg-background/95 px-6 py-3 shadow-lg backdrop-blur rounded-full">
<span class="font-medium text-sm">
<Show when={isBulkSelectMode()}>
<div
class="fixed bottom-[calc(1rem+env(safe-area-inset-bottom))] left-1/2 z-50 flex w-[calc(100%-2rem)] max-w-md -translate-x-1/2 flex-wrap items-center justify-center gap-2 rounded-2xl border border-primary/20 bg-background/95 px-3 py-3 shadow-lg backdrop-blur sm:bottom-[calc(1.5rem+env(safe-area-inset-bottom))] sm:w-auto sm:max-w-none sm:flex-nowrap sm:gap-4 sm:rounded-full sm:px-6"
data-testid="bulk-actions-bar"
>
<span class="w-full text-center font-medium text-sm sm:w-auto">
{selectedMediaIds().length} 件選択中
</span>
<Button onClick={() => setIsBulkActionOpen(true)} size="sm">
<Button
class="flex-1 sm:flex-none"
disabled={selectedMediaIds().length === 0}
onClick={() => setIsBulkActionOpen(true)}
>
一括操作を実行
</Button>
<Button onClick={handleCancelSelect} variant="outline" size="sm">
<Button
class="flex-1 sm:flex-none"
onClick={handleCancelSelect}
variant="outline"
>
解除
</Button>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
E2E_SOURCE_NAME,
getE2eMediaDir,
getFixtureMediaPath,
sourcePath,
} from "./support/fixture";
import { expect, test, waitForAppHydration } from "./support/test";

Expand Down Expand Up @@ -39,9 +40,12 @@ test("global search preserves the mobile filter dialog, input value, and focus a
await fileNameInput.fill("e2e");
await fileNameInput.focus();
await expect(fileNameInput).toBeFocused();
await expect(
page.locator("p").filter({ hasText: /^2 件の結果$/ }),
).toBeVisible();
const resultCount = page.locator("p").filter({ hasText: /^\d+ 件の結果$/ });
await expect(resultCount).toBeVisible();
const initialResultCount = Number.parseInt(
(await resultCount.textContent()) ?? "0",
10,
);

const syncedFileName = `e2e-global-sse-${randomUUID()}.png`;
await copyFile(
Expand All @@ -64,9 +68,79 @@ test("global search preserves the mobile filter dialog, input value, and focus a
await sourceCard.getByTestId("sync-source-btn").click();
await syncResponse;

await expect(
page.locator("p").filter({ hasText: /^3 件の結果$/ }),
).toBeVisible({ timeout: 30_000 });
await expect
.poll(
async () => Number.parseInt((await resultCount.textContent()) ?? "0", 10),
{ timeout: 30_000 },
)
.toBeGreaterThan(initialResultCount);
await expect(filterDialog).toBeVisible();
await expect(fileNameInput).toHaveValue("e2e");
await expect(fileNameInput).toBeFocused();
});

test("source media preserves the mobile filter draft and focus after an SSE refresh", async ({
context,
page,
}, testInfo) => {
test.skip(
!["responsive-320", "responsive-375"].includes(testInfo.project.name),
"The mobile filter dialog is only rendered below the md breakpoint.",
);

const sourceEventsConnected = page.waitForResponse(
(response) =>
sourceEventsEndpoint.test(new URL(response.url()).pathname) &&
response.status() === 200,
);
await page.goto(sourcePath());
await expect(page.getByRole("button", { name: "Add media" })).toBeVisible();
await waitForAppHydration(page);
await sourceEventsConnected;

await page.getByRole("button", { name: "Filter results" }).click();
const filterDialog = page.getByRole("dialog");
const fileNameInput = filterDialog.getByPlaceholder("ファイル名を入力...");
await expect(filterDialog).toBeVisible();
await fileNameInput.fill("e2e");
await fileNameInput.focus();
await expect(fileNameInput).toBeFocused();
const resultCount = page.locator("p").filter({
hasText: /^\d+ 件の結果$/,
});
await expect(resultCount).toBeVisible();
const initialMediaCount = Number.parseInt(
(await resultCount.textContent()) ?? "0",
10,
);

const syncedFileName = `e2e-source-filter-sse-${randomUUID()}.png`;
await copyFile(
getFixtureMediaPath(E2E_PRIMARY_FILE_NAME),
path.join(getE2eMediaDir(), syncedFileName),
);

const syncPage = await context.newPage();
await syncPage.setViewportSize({ width: 1440, height: 900 });
await syncPage.goto("/sources");
const sourceCard = syncPage
.getByTestId("source-card")
.filter({ hasText: E2E_SOURCE_NAME });
await expect(sourceCard).toBeVisible();
await waitForAppHydration(syncPage);
const syncResponse = syncPage.waitForResponse((response) => {
const url = new URL(response.url());
return url.pathname === "/api/rpc/sources/sync" && response.ok();
});
await sourceCard.getByTestId("sync-source-btn").click();
await syncResponse;

await expect
.poll(
async () => Number.parseInt((await resultCount.textContent()) ?? "0", 10),
{ timeout: 30_000 },
)
.toBeGreaterThan(initialMediaCount);
await expect(filterDialog).toBeVisible();
await expect(fileNameInput).toHaveValue("e2e");
await expect(fileNameInput).toBeFocused();
Expand Down
57 changes: 54 additions & 3 deletions apps/server/src/tests/e2e/search.responsive.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,64 @@ test("search keeps controls usable without horizontal overflow", async ({
);
if (usesMobileFilterDialog) {
await page.getByRole("button", { name: "Filter results" }).click();
await expect(page.getByRole("dialog")).toBeVisible();
const filterDialog = page.getByRole("dialog");
await expect(filterDialog).toBeVisible();
await expect(
page.getByRole("heading", { name: "検索フィルター", exact: true }),
filterDialog.getByRole("heading", {
name: "検索フィルター",
exact: true,
}),
).toBeVisible();
await expect(
page.getByRole("button", { name: "簡易", exact: true }),
filterDialog.getByRole("button", { name: "簡易", exact: true }),
).toBeVisible();
const conditionSummary = filterDialog.getByRole("status");
await expect(conditionSummary).toContainText("現在の条件");
const resultCount = page.getByText(/^\d+ 件の結果$/);
const initialResultCount = await resultCount.textContent();

let fileNameInput = filterDialog.getByPlaceholder("ファイル名を入力...");
await fileNameInput.fill("e2e");
await expect(conditionSummary).toContainText("ファイル名: e2e");
await page.keyboard.press("Escape");
await expect(filterDialog).toBeHidden();
await expect(resultCount).toHaveText(initialResultCount ?? "");

await page.getByRole("button", { name: "Filter results" }).click();
await expect(filterDialog).toBeVisible();
fileNameInput = filterDialog.getByPlaceholder("ファイル名を入力...");
await expect(fileNameInput).toHaveValue("");
await expect(filterDialog.getByRole("status")).not.toContainText(
"ファイル名:",
);

await fileNameInput.fill("e2e");
await filterDialog.getByRole("button", { name: "Dismiss" }).click();
await expect(filterDialog).toBeHidden();
await expect(resultCount).toHaveText(initialResultCount ?? "");

await page.getByRole("button", { name: "Filter results" }).click();
await expect(filterDialog).toBeVisible();
fileNameInput = filterDialog.getByPlaceholder("ファイル名を入力...");
await expect(fileNameInput).toHaveValue("");
const applyButton = filterDialog.getByRole("button", {
name: "適用",
exact: true,
});
await filterDialog
.getByRole("button", { name: "ベクトル類似", exact: true })
.click();
await expect(applyButton).toBeDisabled();
await filterDialog
.getByRole("button", { name: "条件をクリア", exact: true })
.click();
await expect(applyButton).toBeEnabled();
await expect(fileNameInput).toHaveValue("");
await expect(conditionSummary).toContainText("条件は指定されていません。");

await fileNameInput.fill("e2e");
await applyButton.click();
await expect(filterDialog).toBeHidden();
} else {
await expect(
page.getByRole("heading", { name: "検索フィルター", exact: true }),
Expand Down
Loading