diff --git a/eslint.config.js b/eslint.config.js index 79d9d9b1..61f38008 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,6 +3,7 @@ import tseslint from "typescript-eslint"; import react from "eslint-plugin-react"; import reactHooks from "eslint-plugin-react-hooks"; import jsxA11y from "eslint-plugin-jsx-a11y"; +import sonarjs from "eslint-plugin-sonarjs"; import prettier from "eslint-config-prettier"; import globals from "globals"; @@ -34,6 +35,18 @@ export default tseslint.config( react.configs.flat.recommended, react.configs.flat["jsx-runtime"], jsxA11y.flatConfigs.recommended, + // the rules SonarCloud runs on every PR, so they fail here first + sonarjs.configs.recommended, + // Warnings, not errors: an open-task comment is debt to see, not a reason + // to fail the build, and an async function with nothing to await (Sonar's + // S7503, which the plugin leaves to require-await) is common in older tests + // and mocks. SonarCloud flags both in new code only. + { + rules: { + "sonarjs/todo-tag": "warn", + "require-await": "warn", + }, + }, { plugins: { "react-hooks": reactHooks }, rules: reactHooks.configs.recommended.rules, @@ -108,9 +121,16 @@ export default tseslint.config( }, }, { - // Playwright fixtures call a parameter named use(), which is not a hook + // Playwright fixtures call a parameter named use(), which is not a hook. + // Specs assert through shared helpers and page objects, which the + // assertion check does not follow, and `void fixture` asks for a fixture + // that is wanted only for its setup. files: ["integration_tests/**"], - rules: { "react-hooks/rules-of-hooks": "off" }, + rules: { + "react-hooks/rules-of-hooks": "off", + "sonarjs/assertions-in-tests": "off", + "sonarjs/void-use": "off", + }, }, prettier, ); diff --git a/integration_tests/fixtures/index.ts b/integration_tests/fixtures/index.ts index 9031875d..4690bb5a 100644 --- a/integration_tests/fixtures/index.ts +++ b/integration_tests/fixtures/index.ts @@ -5,6 +5,7 @@ import { TestVariationDetailsPage } from "pages/TestVariationDetailsPage"; import { TestVariationListPage } from "pages/TestVariationListPage"; import { UserListPage } from "pages/UserListPage"; import type { User } from "~client/types"; +import { mockGetFavoriteProjects } from "utils/mocks"; type Options = { // the role the signed-in user has; override with test.use({ userRole }) @@ -113,6 +114,8 @@ export const test = base.extend({ }, JSON.stringify({ ...AUTH_USER, role: userRole }), ); + // every signed-in page asks for them; a test sets its own after this + await mockGetFavoriteProjects(page, []); await use(); }, diff --git a/integration_tests/pages/ProjectListPage.ts b/integration_tests/pages/ProjectListPage.ts index 92cbc88f..955e6a25 100644 --- a/integration_tests/pages/ProjectListPage.ts +++ b/integration_tests/pages/ProjectListPage.ts @@ -2,9 +2,11 @@ import { BasePage } from "./BasePage"; export class ProjectListPage extends BasePage { projects = this.page.locator("#projectlist-1"); - addBtn = this.page.getByRole("button", { name: "add" }); + // exact: the cards' "Add to favourites" would match a part of the name + addBtn = this.page.getByRole("button", { name: "Add project", exact: true }); deleteBtn = this.page.getByTestId("delete"); editBtn = this.page.getByTestId("EditIcon"); + search = this.page.getByRole("searchbox", { name: "Search projects" }); // ProjectForm, inside the create or update dialog dialog = this.modal.dialog; @@ -56,6 +58,19 @@ export class ProjectListPage extends BasePage { }); } + // "Favourites", "Recently opened", "Other projects" + section(name: string) { + return this.page.getByRole("region", { name }); + } + + starBtn(name: string) { + return this.project(name).getByRole("button", { name: /favourites/ }); + } + + buildsLink(name: string) { + return this.project(name).getByRole("link", { name: "Builds" }); + } + async selectImageComparison(value: string) { await this.imageComparison.click(); await this.page.getByRole("option", { name: value, exact: true }).click(); diff --git a/integration_tests/pages/ProjectPage.ts b/integration_tests/pages/ProjectPage.ts index a3b7260c..987fb313 100644 --- a/integration_tests/pages/ProjectPage.ts +++ b/integration_tests/pages/ProjectPage.ts @@ -12,6 +12,8 @@ export class ProjectPage extends BasePage { buildList: BuildList; details: TestDetailsDialog; selectProject = this.page.locator(`#${LOCATOR_PROJECT_PAGE_SELECT_PROJECT}`); + // the switcher's field: typing narrows the projects, Enter opens one + projectField = this.page.getByRole("combobox", { name: "Project" }); detailsDialog = this.page.getByRole("dialog"); constructor(page: Page) { diff --git a/integration_tests/test/buildMenu.spec.ts-snapshots/build-edit-dialog.png b/integration_tests/test/buildMenu.spec.ts-snapshots/build-edit-dialog.png index fc7b7550..0c20b417 100644 Binary files a/integration_tests/test/buildMenu.spec.ts-snapshots/build-edit-dialog.png and b/integration_tests/test/buildMenu.spec.ts-snapshots/build-edit-dialog.png differ diff --git a/integration_tests/test/buildMenu.spec.ts-snapshots/build-menu.png b/integration_tests/test/buildMenu.spec.ts-snapshots/build-menu.png index 03638993..42796172 100644 Binary files a/integration_tests/test/buildMenu.spec.ts-snapshots/build-menu.png and b/integration_tests/test/buildMenu.spec.ts-snapshots/build-menu.png differ diff --git a/integration_tests/test/colorMode.spec.ts-snapshots/project-page-dark.png b/integration_tests/test/colorMode.spec.ts-snapshots/project-page-dark.png index b4b11c39..d4a38bd0 100644 Binary files a/integration_tests/test/colorMode.spec.ts-snapshots/project-page-dark.png and b/integration_tests/test/colorMode.spec.ts-snapshots/project-page-dark.png differ diff --git a/integration_tests/test/colorMode.spec.ts-snapshots/projects-list-page-dark.png b/integration_tests/test/colorMode.spec.ts-snapshots/projects-list-page-dark.png index 1f905b82..e2dede63 100644 Binary files a/integration_tests/test/colorMode.spec.ts-snapshots/projects-list-page-dark.png and b/integration_tests/test/colorMode.spec.ts-snapshots/projects-list-page-dark.png differ diff --git a/integration_tests/test/errors.spec.ts-snapshots/project-page-builds-error.png b/integration_tests/test/errors.spec.ts-snapshots/project-page-builds-error.png index 61d649e3..2065d76b 100644 Binary files a/integration_tests/test/errors.spec.ts-snapshots/project-page-builds-error.png and b/integration_tests/test/errors.spec.ts-snapshots/project-page-builds-error.png differ diff --git a/integration_tests/test/oidc.spec.ts-snapshots/profile-page-sso.png b/integration_tests/test/oidc.spec.ts-snapshots/profile-page-sso.png index ce0d5c29..8016c46e 100644 Binary files a/integration_tests/test/oidc.spec.ts-snapshots/profile-page-sso.png and b/integration_tests/test/oidc.spec.ts-snapshots/profile-page-sso.png differ diff --git a/integration_tests/test/profile.spec.ts b/integration_tests/test/profile.spec.ts index 04084597..7108f390 100644 --- a/integration_tests/test/profile.spec.ts +++ b/integration_tests/test/profile.spec.ts @@ -7,6 +7,7 @@ import { mockGetProjects, mockUpdateUser, } from "utils/mocks"; +import { waitForPage } from "utils/waitForPage"; const project = TEST_PROJECT; @@ -16,6 +17,7 @@ test.beforeEach(async ({ page }) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars test("renders", async ({ profilePage, page }) => { + await waitForPage(page); await expect(page).toHaveScreenshot("profile-page.png"); }); diff --git a/integration_tests/test/profile.spec.ts-snapshots/profile-page.png b/integration_tests/test/profile.spec.ts-snapshots/profile-page.png index 2f2c18d8..3f8a8d72 100644 Binary files a/integration_tests/test/profile.spec.ts-snapshots/profile-page.png and b/integration_tests/test/profile.spec.ts-snapshots/profile-page.png differ diff --git a/integration_tests/test/projec.spec.ts b/integration_tests/test/projec.spec.ts index dfb5da46..4489dd46 100644 --- a/integration_tests/test/projec.spec.ts +++ b/integration_tests/test/projec.spec.ts @@ -392,13 +392,30 @@ test("can download images", async ({ openProjectPage }) => { ); }); +test("switches to another project by typing its name", async ({ + openProjectPage, + page, +}) => { + const other = { ...project, id: "other-project-id", name: "Other project" }; + await mockGetProjects(page, [project, other]); + await mockGetBuilds(page, other.id, []); + const projectPage = await openProjectPage(project.id); + await expect(projectPage.projectField).toHaveValue(project.name); + + await projectPage.projectField.fill("other"); + await page.getByRole("option", { name: "Other project" }).click(); + + await expect(page).toHaveURL(new RegExp(`/${other.id}$`)); + await expect(projectPage.projectField).toHaveValue("Other project"); +}); + test("lines the Project label up with the select under it", async ({ openProjectPage, page, }) => { await openProjectPage(project.id); - const label = await page.locator("#projectSelect").boundingBox(); + const label = await page.locator("#project-select-label").boundingBox(); const select = await page.locator("#project-select").boundingBox(); expect(label.x).toBe(select.x); @@ -458,8 +475,7 @@ const overlaidOpacity = (page) => .opacity(); }); -const diffSwitch = (page) => - page.getByRole("switch", { name: "Toggle diff" }); +const diffSwitch = (page) => page.getByRole("switch", { name: "Toggle diff" }); test("keeps both panes and fades the image over the baseline", async ({ openProjectPage, diff --git a/integration_tests/test/projec.spec.ts-snapshots/project-page-test-run-list.png b/integration_tests/test/projec.spec.ts-snapshots/project-page-test-run-list.png index e28c8ec7..b9649e35 100644 Binary files a/integration_tests/test/projec.spec.ts-snapshots/project-page-test-run-list.png and b/integration_tests/test/projec.spec.ts-snapshots/project-page-test-run-list.png differ diff --git a/integration_tests/test/projectList.spec.ts b/integration_tests/test/projectList.spec.ts index 8887095e..eac82f5a 100644 --- a/integration_tests/test/projectList.spec.ts +++ b/integration_tests/test/projectList.spec.ts @@ -7,13 +7,19 @@ import { PIXELMATCH_DEFAULT_CONFIG, } from "~client/constants/project"; import { ImageComparison } from "~client/types/imageComparison"; +import { ProjectListPage } from "pages"; +import type { Project } from "~client/types"; import { API_URL, mockDeleteProject, mockDinov2Status, + mockGetBuilds, + mockGetFavoriteProjects, mockGetProjects, mockSaveProject, + mockToggleFavoriteProject, } from "utils/mocks"; +import { waitForPage } from "utils/waitForPage"; const project = TEST_PROJECT; @@ -23,6 +29,7 @@ test.beforeEach(async ({ page }) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars test("renders", async ({ projectListPage, page }) => { + await waitForPage(page); await expect(page).toHaveScreenshot("projects-list-page.png"); }); @@ -158,6 +165,154 @@ test("edits a project", async ({ projectListPage, page }) => { await expect(projectListPage.project(project.name)).toHaveCount(0); }); +const named = ( + id: string, + name: string, + mainBranchName = "master", +): Project => ({ + ...project, + id, + name, + mainBranchName, +}); +const ALPHA = named("alpha-id", "Alpha"); +const BETA = named("beta-id", "Beta", "develop"); +const GAMMA = named("gamma-id", "Gamma"); + +// opened straight, with the API's projects and favourites set up first +const openWith = async ( + page: Page, + projects: Project[], + favorites: string[] = [], + path = "/projects", +) => { + await mockGetProjects(page, projects); + await mockGetFavoriteProjects(page, favorites); + await page.goto(path); + const projectListPage = new ProjectListPage(page); + await expect(projectListPage.projects.first()).toBeVisible(); + return projectListPage; +}; + +test.describe("favourites and search", () => { + test("stars a project, tells the API and lists it first", async ({ + page, + authUser, + }) => { + void authUser; + const projectListPage = await openWith(page, [ALPHA, BETA]); + await mockToggleFavoriteProject(page); + + const [request] = await Promise.all([ + page.waitForRequest(`${API_URL}/projects/${BETA.id}/favorite`), + projectListPage.starBtn("Beta").click(), + ]); + + expect(request.method()).toBe("PUT"); + await expect( + projectListPage + .section("Favourites") + .getByRole("heading", { name: "Beta" }), + ).toBeVisible(); + await expect(projectListPage.starBtn("Beta")).toHaveAttribute( + "aria-pressed", + "true", + ); + }); + + test("takes a project out of the favourites", async ({ page, authUser }) => { + void authUser; + const projectListPage = await openWith(page, [ALPHA, BETA], [BETA.id]); + await mockToggleFavoriteProject(page); + + const [request] = await Promise.all([ + page.waitForRequest(`${API_URL}/projects/${BETA.id}/favorite`), + projectListPage.starBtn("Beta").click(), + ]); + + expect(request.method()).toBe("DELETE"); + await expect(projectListPage.section("Favourites")).toHaveCount(0); + }); + + test("lists the favourites the API keeps above the other projects", async ({ + page, + authUser, + }) => { + void authUser; + const projectListPage = await openWith( + page, + [ALPHA, BETA, GAMMA], + [GAMMA.id], + ); + + await expect( + projectListPage.section("Favourites").getByRole("heading", { level: 3 }), + ).toHaveText(["Gamma"]); + await expect( + projectListPage + .section("Other projects") + .getByRole("heading", { level: 3 }), + ).toHaveText(["Alpha", "Beta"]); + }); + + test("narrows the projects to the search and keeps it in the link", async ({ + page, + authUser, + }) => { + void authUser; + const projectListPage = await openWith(page, [ALPHA, BETA, GAMMA]); + + await projectListPage.search.fill("develop"); + + await expect(projectListPage.projects).toHaveCount(1); + await expect(projectListPage.project("Beta")).toBeVisible(); + await expect(page).toHaveURL(/[?&]q=develop/); + }); + + test("opens with the search a link carries", async ({ page, authUser }) => { + void authUser; + const projectListPage = await openWith( + page, + [ALPHA, BETA], + [], + "/projects?q=alp", + ); + + await expect(projectListPage.search).toHaveValue("alp"); + await expect(projectListPage.projects).toHaveCount(1); + }); + + test("puts the cursor in the search on /", async ({ page, authUser }) => { + void authUser; + const projectListPage = await openWith(page, [ALPHA, BETA]); + + await page.keyboard.press("/"); + + await expect(projectListPage.search).toBeFocused(); + await expect(projectListPage.search).toHaveValue(""); + }); + + test("opens a project's builds without reloading the app", async ({ + page, + authUser, + }) => { + void authUser; + const projectListPage = await openWith(page, [ALPHA]); + await mockGetBuilds(page, ALPHA.id, []); + // a full page load would start over without this + await page.evaluate(() => { + (window as unknown as { kept?: boolean }).kept = true; + }); + + await projectListPage.buildsLink("Alpha").click(); + + await expect(page).toHaveURL(new RegExp(`/${ALPHA.id}$`)); + expect( + await page.evaluate(() => (window as unknown as { kept?: boolean }).kept), + ).toBe(true); + }); +}); + // The rendering-noise filter is switched on in the library's config, where a // library that supports it offers it; the backend applies it to every library. const savedConfig = (request: Request) => diff --git a/integration_tests/test/projectList.spec.ts-snapshots/projects-list-page.png b/integration_tests/test/projectList.spec.ts-snapshots/projects-list-page.png index d5ca2b1c..255d112f 100644 Binary files a/integration_tests/test/projectList.spec.ts-snapshots/projects-list-page.png and b/integration_tests/test/projectList.spec.ts-snapshots/projects-list-page.png differ diff --git a/integration_tests/test/register.spec.ts b/integration_tests/test/register.spec.ts index d43fb0a8..ad6d89d8 100644 --- a/integration_tests/test/register.spec.ts +++ b/integration_tests/test/register.spec.ts @@ -1,9 +1,11 @@ import { test } from "fixtures"; import { expect } from "@playwright/test"; import { API_URL, mockApiError } from "utils/mocks"; +import { waitForPage } from "utils/waitForPage"; // eslint-disable-next-line @typescript-eslint/no-unused-vars test("renders", async ({ registerPage, page }) => { + await waitForPage(page); await expect(page).toHaveScreenshot("register-page.png"); }); diff --git a/integration_tests/test/screens.spec.ts b/integration_tests/test/screens.spec.ts index 02ae10b9..7b4667d2 100644 --- a/integration_tests/test/screens.spec.ts +++ b/integration_tests/test/screens.spec.ts @@ -1,5 +1,5 @@ import { expect, Page } from "@playwright/test"; -import { test as base } from "fixtures"; +import { AUTH_USER, test as base } from "fixtures"; import { LoginPage, ProjectListPage } from "pages"; import { EDITOR_USER, @@ -23,6 +23,7 @@ import { mockApiError, mockGetBuildDetails, mockGetBuilds, + mockGetFavoriteProjects, mockGetProjects, mockGetTestRuns, mockGetTestVariationDetails, @@ -46,6 +47,26 @@ const project = TEST_PROJECT; const build = TEST_BUILD_FAILED; const SCHEMES = ["light", "dark"] as const; +// a list long enough for favourites, recently opened and the rest +const LISTED_PROJECTS = [ + project, + { + ...project, + id: "checkout-id", + name: "Checkout flow", + mainBranchName: "main", + }, + { + ...project, + id: "mobile-id", + name: "Mobile web", + mainBranchName: "develop", + }, + { ...project, id: "storybook-id", name: "Storybook", mainBranchName: "main" }, +]; +const FAVORITE_ID = "storybook-id"; +const RECENT_ID = "checkout-id"; + // only what the wait for the canvases reads off Konva type KonvaWindow = { Konva?: { @@ -205,6 +226,38 @@ const VIEWS: [name: string, body: Body][] = [ }, ], + [ + "project list with favourites and recently opened", + async ({ page, authUser, shot }) => { + void authUser; + await mockGetProjects(page, LISTED_PROJECTS); + await mockGetFavoriteProjects(page, [FAVORITE_ID]); + await page.addInitScript( + ([key, id]) => localStorage.setItem(key, JSON.stringify([id])), + [`recentProjects:${AUTH_USER.id}`, RECENT_ID], + ); + await page.goto("/projects"); + const projectListPage = new ProjectListPage(page); + + await expect(projectListPage.projects).toHaveCount(4); + await expect(projectListPage.section("Favourites")).toBeVisible(); + await expect(projectListPage.section("Recently opened")).toBeVisible(); + await shot("project-list-favourites", { fullPage: true }); + }, + ], + + [ + "project list search without a match", + async ({ page, authUser, shot }) => { + void authUser; + await mockGetProjects(page, LISTED_PROJECTS); + await page.goto("/projects?q=billing"); + + await expect(page.getByText("No projects match “billing”")).toBeVisible(); + await shot("project-list-no-match"); + }, + ], + [ "create project dialog", async ({ projectListPage, shot }) => { @@ -299,6 +352,21 @@ const VIEWS: [name: string, body: Body][] = [ }, ], + [ + "project switcher open", + async ({ openProjectPage, page, shot }) => { + await mockGetProjects(page, LISTED_PROJECTS); + await mockGetFavoriteProjects(page, [FAVORITE_ID]); + const projectPage = await openProjectPage(project.id, build.id); + await expect(projectPage.testRunList.rows).toHaveCount(4); + + await projectPage.projectField.click(); + + await expect(page.getByRole("option")).toHaveCount(4); + await shot("project-switcher"); + }, + ], + [ "project page as a table", async ({ openProjectPage, shot }) => { diff --git a/integration_tests/test/screens.spec.ts-snapshots/avatar-menu-dark.png b/integration_tests/test/screens.spec.ts-snapshots/avatar-menu-dark.png index a30bfc23..d04b7e40 100644 Binary files a/integration_tests/test/screens.spec.ts-snapshots/avatar-menu-dark.png and b/integration_tests/test/screens.spec.ts-snapshots/avatar-menu-dark.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/avatar-menu-light.png b/integration_tests/test/screens.spec.ts-snapshots/avatar-menu-light.png index 61675503..c55b8751 100644 Binary files a/integration_tests/test/screens.spec.ts-snapshots/avatar-menu-light.png and b/integration_tests/test/screens.spec.ts-snapshots/avatar-menu-light.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/delete-project-dark.png b/integration_tests/test/screens.spec.ts-snapshots/delete-project-dark.png index 7a2f6d57..fa4164b6 100644 Binary files a/integration_tests/test/screens.spec.ts-snapshots/delete-project-dark.png and b/integration_tests/test/screens.spec.ts-snapshots/delete-project-dark.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/help-menu-dark.png b/integration_tests/test/screens.spec.ts-snapshots/help-menu-dark.png index ffbcc3f2..894d4050 100644 Binary files a/integration_tests/test/screens.spec.ts-snapshots/help-menu-dark.png and b/integration_tests/test/screens.spec.ts-snapshots/help-menu-dark.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/help-menu-light.png b/integration_tests/test/screens.spec.ts-snapshots/help-menu-light.png index 17d18a3a..bbd4b352 100644 Binary files a/integration_tests/test/screens.spec.ts-snapshots/help-menu-light.png and b/integration_tests/test/screens.spec.ts-snapshots/help-menu-light.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/profile-dark.png b/integration_tests/test/screens.spec.ts-snapshots/profile-dark.png index 08efad22..1c575eb2 100644 Binary files a/integration_tests/test/screens.spec.ts-snapshots/profile-dark.png and b/integration_tests/test/screens.spec.ts-snapshots/profile-dark.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/profile-light.png b/integration_tests/test/screens.spec.ts-snapshots/profile-light.png index 71ae6815..3f8a8d72 100644 Binary files a/integration_tests/test/screens.spec.ts-snapshots/profile-light.png and b/integration_tests/test/screens.spec.ts-snapshots/profile-light.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/project-list-dark.png b/integration_tests/test/screens.spec.ts-snapshots/project-list-dark.png index 1f905b82..e2dede63 100644 Binary files a/integration_tests/test/screens.spec.ts-snapshots/project-list-dark.png and b/integration_tests/test/screens.spec.ts-snapshots/project-list-dark.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/project-list-favourites-dark.png b/integration_tests/test/screens.spec.ts-snapshots/project-list-favourites-dark.png new file mode 100644 index 00000000..c346375f Binary files /dev/null and b/integration_tests/test/screens.spec.ts-snapshots/project-list-favourites-dark.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/project-list-favourites-light.png b/integration_tests/test/screens.spec.ts-snapshots/project-list-favourites-light.png new file mode 100644 index 00000000..1f2f6943 Binary files /dev/null and b/integration_tests/test/screens.spec.ts-snapshots/project-list-favourites-light.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/project-list-light.png b/integration_tests/test/screens.spec.ts-snapshots/project-list-light.png index d5ca2b1c..255d112f 100644 Binary files a/integration_tests/test/screens.spec.ts-snapshots/project-list-light.png and b/integration_tests/test/screens.spec.ts-snapshots/project-list-light.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/project-list-no-match-dark.png b/integration_tests/test/screens.spec.ts-snapshots/project-list-no-match-dark.png new file mode 100644 index 00000000..59f1d253 Binary files /dev/null and b/integration_tests/test/screens.spec.ts-snapshots/project-list-no-match-dark.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/project-list-no-match-light.png b/integration_tests/test/screens.spec.ts-snapshots/project-list-no-match-light.png new file mode 100644 index 00000000..07084ab3 Binary files /dev/null and b/integration_tests/test/screens.spec.ts-snapshots/project-list-no-match-light.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/project-page-grid-dark.png b/integration_tests/test/screens.spec.ts-snapshots/project-page-grid-dark.png index a41a3261..2eef6928 100644 Binary files a/integration_tests/test/screens.spec.ts-snapshots/project-page-grid-dark.png and b/integration_tests/test/screens.spec.ts-snapshots/project-page-grid-dark.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/project-page-grid-light.png b/integration_tests/test/screens.spec.ts-snapshots/project-page-grid-light.png index 057e296e..b1c13f9a 100644 Binary files a/integration_tests/test/screens.spec.ts-snapshots/project-page-grid-light.png and b/integration_tests/test/screens.spec.ts-snapshots/project-page-grid-light.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/project-page-grouped-dark.png b/integration_tests/test/screens.spec.ts-snapshots/project-page-grouped-dark.png index 47f06b02..c8b32f8c 100644 Binary files a/integration_tests/test/screens.spec.ts-snapshots/project-page-grouped-dark.png and b/integration_tests/test/screens.spec.ts-snapshots/project-page-grouped-dark.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/project-page-grouped-light.png b/integration_tests/test/screens.spec.ts-snapshots/project-page-grouped-light.png index 50b6a457..018f005a 100644 Binary files a/integration_tests/test/screens.spec.ts-snapshots/project-page-grouped-light.png and b/integration_tests/test/screens.spec.ts-snapshots/project-page-grouped-light.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/project-page-no-builds-dark.png b/integration_tests/test/screens.spec.ts-snapshots/project-page-no-builds-dark.png index fe9c2057..ef039252 100644 Binary files a/integration_tests/test/screens.spec.ts-snapshots/project-page-no-builds-dark.png and b/integration_tests/test/screens.spec.ts-snapshots/project-page-no-builds-dark.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/project-page-no-builds-light.png b/integration_tests/test/screens.spec.ts-snapshots/project-page-no-builds-light.png index fdb6a642..97ce9ac3 100644 Binary files a/integration_tests/test/screens.spec.ts-snapshots/project-page-no-builds-light.png and b/integration_tests/test/screens.spec.ts-snapshots/project-page-no-builds-light.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/project-page-selected-dark.png b/integration_tests/test/screens.spec.ts-snapshots/project-page-selected-dark.png index ad455f43..156e0708 100644 Binary files a/integration_tests/test/screens.spec.ts-snapshots/project-page-selected-dark.png and b/integration_tests/test/screens.spec.ts-snapshots/project-page-selected-dark.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/project-page-selected-light.png b/integration_tests/test/screens.spec.ts-snapshots/project-page-selected-light.png index becac1f2..44c9abd7 100644 Binary files a/integration_tests/test/screens.spec.ts-snapshots/project-page-selected-light.png and b/integration_tests/test/screens.spec.ts-snapshots/project-page-selected-light.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/project-page-table-dark.png b/integration_tests/test/screens.spec.ts-snapshots/project-page-table-dark.png index a6aafe83..fdb4d6e1 100644 Binary files a/integration_tests/test/screens.spec.ts-snapshots/project-page-table-dark.png and b/integration_tests/test/screens.spec.ts-snapshots/project-page-table-dark.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/project-page-table-light.png b/integration_tests/test/screens.spec.ts-snapshots/project-page-table-light.png index 438bc882..636b8d09 100644 Binary files a/integration_tests/test/screens.spec.ts-snapshots/project-page-table-light.png and b/integration_tests/test/screens.spec.ts-snapshots/project-page-table-light.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/project-switcher-dark.png b/integration_tests/test/screens.spec.ts-snapshots/project-switcher-dark.png new file mode 100644 index 00000000..e6dc7435 Binary files /dev/null and b/integration_tests/test/screens.spec.ts-snapshots/project-switcher-dark.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/project-switcher-light.png b/integration_tests/test/screens.spec.ts-snapshots/project-switcher-light.png new file mode 100644 index 00000000..56d463f2 Binary files /dev/null and b/integration_tests/test/screens.spec.ts-snapshots/project-switcher-light.png differ diff --git a/integration_tests/test/testRunGrid.spec.ts-snapshots/test-run-grid.png b/integration_tests/test/testRunGrid.spec.ts-snapshots/test-run-grid.png index c2f43475..920676d3 100644 Binary files a/integration_tests/test/testRunGrid.spec.ts-snapshots/test-run-grid.png and b/integration_tests/test/testRunGrid.spec.ts-snapshots/test-run-grid.png differ diff --git a/integration_tests/test/testVariationDetails.spec.ts b/integration_tests/test/testVariationDetails.spec.ts index ac0fab22..bc07c20e 100644 --- a/integration_tests/test/testVariationDetails.spec.ts +++ b/integration_tests/test/testVariationDetails.spec.ts @@ -11,6 +11,7 @@ import { TEST_PROJECT, TEST_VARIATION_ONE, } from "~client/_test/test.data.helper"; +import { waitForPage } from "utils/waitForPage"; test.beforeEach(async ({ page }) => { await mockGetProjects(page, [TEST_PROJECT]); @@ -22,6 +23,7 @@ test.beforeEach(async ({ page }) => { test("renders", async ({ openTestVariationDetailsPage, page }) => { await openTestVariationDetailsPage(TEST_VARIATION_ONE.id); + await waitForPage(page); await expect(page).toHaveScreenshot("test-variation-details-page.png", { fullPage: true, }); diff --git a/integration_tests/test/testVariationList.spec.ts b/integration_tests/test/testVariationList.spec.ts index 24a0ba24..1e908e34 100644 --- a/integration_tests/test/testVariationList.spec.ts +++ b/integration_tests/test/testVariationList.spec.ts @@ -16,6 +16,7 @@ import { TEST_VARIATION_ONE, TEST_VARIATION_TWO, } from "~client/_test/test.data.helper"; +import { waitForPage } from "utils/waitForPage"; test.beforeEach(async ({ page }) => { await mockGetProjects(page, [TEST_PROJECT]); @@ -30,6 +31,7 @@ test.beforeEach(async ({ page }) => { test("renders", async ({ openTestVariationListPage, page }) => { await openTestVariationListPage(TEST_PROJECT.id); + await waitForPage(page); await expect(page).toHaveScreenshot("test-variation-list-page.png"); }); diff --git a/integration_tests/test/userList.spec.ts b/integration_tests/test/userList.spec.ts index e679610f..1f598b80 100644 --- a/integration_tests/test/userList.spec.ts +++ b/integration_tests/test/userList.spec.ts @@ -14,6 +14,7 @@ import { TEST_USER, } from "~client/_test/test.data.helper"; import { Role, User } from "~client/types"; +import { waitForPage } from "utils/waitForPage"; test.beforeEach(async ({ page }) => { await mockGetProjects(page, [TEST_PROJECT]); @@ -22,6 +23,7 @@ test.beforeEach(async ({ page }) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars test("renders", async ({ userListPage, page }) => { + await waitForPage(page); await expect(page).toHaveScreenshot("user-list-page.png"); }); diff --git a/integration_tests/utils/mocks.ts b/integration_tests/utils/mocks.ts index 1755d4ed..87770dd9 100644 --- a/integration_tests/utils/mocks.ts +++ b/integration_tests/utils/mocks.ts @@ -20,6 +20,23 @@ export const mockGetProjects = async (page: Page, projects: Project[]) => { }); }; +// the signed-in user's favourite project ids; the authUser fixture answers none +export const mockGetFavoriteProjects = (page: Page, ids: string[]) => { + return page.route(`${API_URL}/projects/favorites`, (route, request) => { + if (request.method() === "GET") { + return route.fulfill({ body: JSON.stringify(ids) }); + } + return route.fallback(); + }); +}; + +// starring (PUT) and unstarring (DELETE) answer 204 with no body, as the API does +export const mockToggleFavoriteProject = (page: Page) => { + return page.route(`${API_URL}/projects/*/favorite`, (route) => + route.fulfill({ status: 204 }), + ); +}; + // the API answers create and update with the project it saved export const mockSaveProject = async (page: Page) => { return page.route(`${API_URL}/projects`, (route, request) => { diff --git a/integration_tests/utils/waitForPage.ts b/integration_tests/utils/waitForPage.ts new file mode 100644 index 00000000..7908ecef --- /dev/null +++ b/integration_tests/utils/waitForPage.ts @@ -0,0 +1,9 @@ +import { expect, Page } from "@playwright/test"; + +// Routes load lazily: until the page's heading is in, a screenshot catches the +// header over an empty page, and an update run writes that as the baseline. +// The network settling lets list thumbnails and fonts arrive as well. +export const waitForPage = async (page: Page) => { + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await page.waitForLoadState("networkidle"); +}; diff --git a/package-lock.json b/package-lock.json index e6d92d3b..b191113c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "~5.2.0", + "eslint-plugin-sonarjs": "^4.2.2", "globals": "^17.13.0", "husky": "^9.1.7", "jsdom": "^30.1.3", @@ -1596,6 +1597,13 @@ "version": "3.1.0", "license": "MIT" }, + "node_modules/@sonarsource/analyzer-commons-configurations": { + "version": "2.31.0-5284", + "resolved": "https://registry.npmjs.org/@sonarsource/analyzer-commons-configurations/-/analyzer-commons-configurations-2.31.0-5284.tgz", + "integrity": "sha512-ruk3blMs9BE9RNSp9LPpWZAIjxJmq4Cp00E3cELIHz7Kym3WxdWGRlKOMYLRQ5tJg/de7jN5AtEElY3bGIfTcw==", + "dev": true, + "license": "LGPL-3.0-only" + }, "node_modules/@testing-library/dom": { "version": "10.4.2", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", @@ -2560,6 +2568,29 @@ "concat-map": "0.0.1" } }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -3409,6 +3440,71 @@ "semver": "bin/semver.js" } }, + "node_modules/eslint-plugin-sonarjs": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-4.2.2.tgz", + "integrity": "sha512-usNF9GfP89ibwhfxpZaCMW40sC7UEOusDgDCk7lyqAhh4AYdbBaAgLVL5Y/QS0jSed8eJ2ACGH6zzE8ezd/yZQ==", + "dev": true, + "license": "LGPL-3.0-only", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@sonarsource/analyzer-commons-configurations": "^2.31.0-5284", + "builtin-modules": "^3.3.0", + "bytes": "^3.1.2", + "functional-red-black-tree": "^1.0.1", + "globals": "^17.12.0", + "jsx-ast-utils-x": "^0.1.0", + "lodash.merge": "^4.6.2", + "minimatch": "^10.2.6", + "scslre": "^0.3.0", + "semver": "^7.8.5", + "ts-api-utils": "^2.5.0", + "typescript": ">=5 <6.1.0", + "yaml": "^2.9.1" + }, + "peerDependencies": { + "eslint": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -3682,6 +3778,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "dev": true, + "license": "MIT" + }, "node_modules/functions-have-names": { "version": "1.2.3", "dev": true, @@ -4579,6 +4682,16 @@ "node": ">=4.0" } }, + "node_modules/jsx-ast-utils-x": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/jsx-ast-utils-x/-/jsx-ast-utils-x-0.1.0.tgz", + "integrity": "sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/jszip": { "version": "3.10.2", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz", @@ -5836,6 +5949,19 @@ "node": ">=8" } }, + "node_modules/refa": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/refa/-/refa-0.12.1.tgz", + "integrity": "sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.8.0" + }, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5859,6 +5985,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regexp-ast-analysis": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/regexp-ast-analysis/-/regexp-ast-analysis-0.7.1.tgz", + "integrity": "sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.8.0", + "refa": "^0.12.1" + }, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -6052,6 +6192,21 @@ "resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz", "integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA==" }, + "node_modules/scslre": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/scslre/-/scslre-0.3.0.tgz", + "integrity": "sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.8.0", + "refa": "^0.12.0", + "regexp-ast-analysis": "^0.7.0" + }, + "engines": { + "node": "^14.0.0 || >=16.0.0" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -7217,7 +7372,6 @@ "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", "dev": true, "license": "ISC", - "optional": true, "bin": { "yaml": "bin.mjs" }, @@ -8059,6 +8213,12 @@ "@socket.io/component-emitter": { "version": "3.1.0" }, + "@sonarsource/analyzer-commons-configurations": { + "version": "2.31.0-5284", + "resolved": "https://registry.npmjs.org/@sonarsource/analyzer-commons-configurations/-/analyzer-commons-configurations-2.31.0-5284.tgz", + "integrity": "sha512-ruk3blMs9BE9RNSp9LPpWZAIjxJmq4Cp00E3cELIHz7Kym3WxdWGRlKOMYLRQ5tJg/de7jN5AtEElY3bGIfTcw==", + "dev": true + }, "@testing-library/dom": { "version": "10.4.2", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", @@ -8677,6 +8837,18 @@ "concat-map": "0.0.1" } }, + "builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true + }, + "bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true + }, "call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -9269,6 +9441,54 @@ "dev": true, "requires": {} }, + "eslint-plugin-sonarjs": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-4.2.2.tgz", + "integrity": "sha512-usNF9GfP89ibwhfxpZaCMW40sC7UEOusDgDCk7lyqAhh4AYdbBaAgLVL5Y/QS0jSed8eJ2ACGH6zzE8ezd/yZQ==", + "dev": true, + "requires": { + "@eslint-community/regexpp": "^4.12.2", + "@sonarsource/analyzer-commons-configurations": "^2.31.0-5284", + "builtin-modules": "^3.3.0", + "bytes": "^3.1.2", + "functional-red-black-tree": "^1.0.1", + "globals": "^17.12.0", + "jsx-ast-utils-x": "^0.1.0", + "lodash.merge": "^4.6.2", + "minimatch": "^10.2.6", + "scslre": "^0.3.0", + "semver": "^7.8.5", + "ts-api-utils": "^2.5.0", + "typescript": ">=5 <6.1.0", + "yaml": "^2.9.1" + }, + "dependencies": { + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true + }, + "brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "requires": { + "balanced-match": "^4.0.2" + } + }, + "minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "requires": { + "brace-expansion": "^5.0.8" + } + } + } + }, "eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -9436,6 +9656,12 @@ "is-document.all": "^1.0.0" } }, + "functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "dev": true + }, "functions-have-names": { "version": "1.2.3", "dev": true @@ -9978,6 +10204,12 @@ "object.values": "^1.1.6" } }, + "jsx-ast-utils-x": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/jsx-ast-utils-x/-/jsx-ast-utils-x-0.1.0.tgz", + "integrity": "sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw==", + "dev": true + }, "jszip": { "version": "3.10.2", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz", @@ -10660,6 +10892,15 @@ "strip-indent": "^3.0.0" } }, + "refa": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/refa/-/refa-0.12.1.tgz", + "integrity": "sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==", + "dev": true, + "requires": { + "@eslint-community/regexpp": "^4.8.0" + } + }, "reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -10676,6 +10917,16 @@ "which-builtin-type": "^1.2.1" } }, + "regexp-ast-analysis": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/regexp-ast-analysis/-/regexp-ast-analysis-0.7.1.tgz", + "integrity": "sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==", + "dev": true, + "requires": { + "@eslint-community/regexpp": "^4.8.0", + "refa": "^0.12.1" + } + }, "regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -10812,6 +11063,17 @@ "resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz", "integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA==" }, + "scslre": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/scslre/-/scslre-0.3.0.tgz", + "integrity": "sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==", + "dev": true, + "requires": { + "@eslint-community/regexpp": "^4.8.0", + "refa": "^0.12.0", + "regexp-ast-analysis": "^0.7.0" + } + }, "semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -11486,8 +11748,7 @@ "version": "2.9.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", - "dev": true, - "optional": true + "dev": true }, "yocto-queue": { "version": "0.1.0", diff --git a/package.json b/package.json index edac2e00..396f9223 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "~5.2.0", + "eslint-plugin-sonarjs": "^4.2.2", "globals": "^17.13.0", "husky": "^9.1.7", "jsdom": "^30.1.3", diff --git a/src/_helpers/projectList.helper.test.ts b/src/_helpers/projectList.helper.test.ts new file mode 100644 index 00000000..90d91956 --- /dev/null +++ b/src/_helpers/projectList.helper.test.ts @@ -0,0 +1,110 @@ +import { Project } from "../types"; +import { TEST_PROJECT } from "../_test/test.data.helper"; +import { + RECENT_PROJECTS_LIMIT, + groupProjects, + matchesProjectQuery, + readRecentProjectIds, + rememberRecentProject, +} from "./projectList.helper"; + +const project = (id: string, name: string, mainBranchName = "master") => + ({ ...TEST_PROJECT, id, name, mainBranchName }) as Project; + +const ALPHA = project("a-id", "Alpha"); +const BETA = project("b-id", "Beta", "main"); +const GAMMA = project("c-id", "Gamma"); +const DELTA = project("d-id", "Delta"); + +describe("matchesProjectQuery", () => { + it("matches every project when nothing is typed", () => { + expect(matchesProjectQuery(ALPHA, " ")).toBe(true); + }); + + it("matches the name in any case", () => { + expect(matchesProjectQuery(ALPHA, "lph")).toBe(true); + expect(matchesProjectQuery(ALPHA, "ALP")).toBe(true); + expect(matchesProjectQuery(ALPHA, "beta")).toBe(false); + }); + + it("matches the id and the main branch too", () => { + expect(matchesProjectQuery(ALPHA, "a-id")).toBe(true); + expect(matchesProjectQuery(BETA, "main")).toBe(true); + }); +}); + +describe("groupProjects", () => { + const projects = [GAMMA, DELTA, BETA, ALPHA]; + + it("lists everything by name when nothing is favourite or recent", () => { + expect(groupProjects(projects, [], [])).toEqual({ + favorites: [], + recent: [], + others: [ALPHA, BETA, DELTA, GAMMA], + }); + }); + + it("puts favourites first by name, then recent ones as opened, then the rest", () => { + const groups = groupProjects( + projects, + [GAMMA.id, BETA.id], + [DELTA.id, BETA.id], + ); + + expect(groups.favorites).toEqual([BETA, GAMMA]); + // a favourite is shown once, among the favourites + expect(groups.recent).toEqual([DELTA]); + expect(groups.others).toEqual([ALPHA]); + }); + + it("skips ids of projects that are gone", () => { + expect(groupProjects([ALPHA], ["gone"], ["gone-too"])).toEqual({ + favorites: [], + recent: [], + others: [ALPHA], + }); + }); +}); + +describe("recent projects", () => { + afterEach(() => localStorage.clear()); + + it("has none for a user who opened nothing", () => { + expect(readRecentProjectIds("user-1")).toEqual([]); + }); + + it("keeps the last opened first, once, per user", () => { + rememberRecentProject("user-1", "a"); + rememberRecentProject("user-1", "b"); + const ids = rememberRecentProject("user-1", "a"); + + expect(ids).toEqual(["a", "b"]); + expect(readRecentProjectIds("user-1")).toEqual(["a", "b"]); + expect(readRecentProjectIds("user-2")).toEqual([]); + }); + + it(`keeps only the last ${RECENT_PROJECTS_LIMIT}`, () => { + ["1", "2", "3", "4", "5", "6"].forEach((id) => + rememberRecentProject("user-1", id), + ); + + expect(readRecentProjectIds("user-1")).toEqual(["6", "5", "4", "3", "2"]); + }); + + it("reads nothing from a value it did not write", () => { + localStorage.setItem("recentProjects:user-1", "not json"); + + expect(readRecentProjectIds("user-1")).toEqual([]); + }); + + it("still answers when storage cannot be written", () => { + const setItem = vi + .spyOn(Storage.prototype, "setItem") + .mockImplementation(() => { + throw new Error("QuotaExceededError"); + }); + + expect(rememberRecentProject("user-1", "a")).toEqual(["a"]); + setItem.mockRestore(); + }); +}); diff --git a/src/_helpers/projectList.helper.ts b/src/_helpers/projectList.helper.ts new file mode 100644 index 00000000..170f5b13 --- /dev/null +++ b/src/_helpers/projectList.helper.ts @@ -0,0 +1,88 @@ +import { RECENT_PROJECTS_KEY } from "../constants"; +import { Project } from "../types"; + +export const RECENT_PROJECTS_LIMIT = 5; + +export interface ProjectGroups { + favorites: Project[]; + recent: Project[]; + others: Project[]; +} + +// what a reviewer remembers a project by: its name, the id CI is configured +// with, or the branch it compares against +export const matchesProjectQuery = (project: Project, query: string) => { + const needle = query.trim().toLowerCase(); + + return ( + !needle || + [project.name, project.id, project.mainBranchName].some((value) => + value?.toLowerCase().includes(needle), + ) + ); +}; + +const byName = (a: Project, b: Project) => a.name.localeCompare(b.name); + +/** + * Favourites by name, then the recently opened ones in the order they were + * opened, then everything else by name. Each project shows up once, so a + * favourite is not repeated among the recent ones. + */ +export const groupProjects = ( + projects: Project[], + favoriteIds: string[], + recentIds: string[], +): ProjectGroups => { + const byId = new Map(projects.map((project) => [project.id, project])); + const favoriteIdSet = new Set(favoriteIds); + + const favorites = projects + .filter((project) => favoriteIdSet.has(project.id)) + .sort(byName); + const recent = recentIds + .filter((id) => !favoriteIdSet.has(id)) + .map((id) => byId.get(id)) + .filter((project): project is Project => !!project); + const shown = new Set([...favorites, ...recent].map((project) => project.id)); + const others = projects + .filter((project) => !shown.has(project.id)) + .sort(byName); + + return { favorites, recent, others }; +}; + +const storageKey = (userId: string) => `${RECENT_PROJECTS_KEY}:${userId}`; + +// storage can be missing or full (private windows, blocked site data); the +// list is a convenience, so it is then simply empty or not kept +export const readRecentProjectIds = (userId: string): string[] => { + try { + const ids: unknown = JSON.parse( + localStorage.getItem(storageKey(userId)) ?? "[]", + ); + + return Array.isArray(ids) + ? ids.filter((id): id is string => typeof id === "string") + : []; + } catch { + return []; + } +}; + +export const rememberRecentProject = ( + userId: string, + projectId: string, +): string[] => { + const ids = [ + projectId, + ...readRecentProjectIds(userId).filter((id) => id !== projectId), + ].slice(0, RECENT_PROJECTS_LIMIT); + + try { + localStorage.setItem(storageKey(userId), JSON.stringify(ids)); + } catch { + // kept for this session only + } + return ids; +}; diff --git a/src/_helpers/route.helpers.ts b/src/_helpers/route.helpers.ts index 3847fcb7..c5a88e8e 100644 --- a/src/_helpers/route.helpers.ts +++ b/src/_helpers/route.helpers.ts @@ -4,13 +4,16 @@ import qs from "qs"; export const buildProjectPageUrl = (projectId: string) => `${routes.HOME}${projectId}`; -export const buildTestRunLocation = (buildId?: string, testRunId?: string) => ({ - search: buildId - ? testRunId +export const buildTestRunLocation = (buildId?: string, testRunId?: string) => { + if (!buildId) { + return { search: "" }; + } + return { + search: testRunId ? `buildId=${buildId}&testId=${testRunId}` - : `buildId=${buildId}` - : "", -}); + : `buildId=${buildId}`, + }; +}; export interface QueryParams { buildId?: string; diff --git a/src/_helpers/scale.helper.test.ts b/src/_helpers/scale.helper.test.ts index 227c1916..57d33a33 100644 --- a/src/_helpers/scale.helper.test.ts +++ b/src/_helpers/scale.helper.test.ts @@ -7,7 +7,7 @@ import { describe("clampScale", () => { it("allows zooming between a tenth and ten times", () => { - expect(MIN_SCALE).toBe(0.1); + expect(MIN_SCALE).toBeCloseTo(0.1); expect(MAX_SCALE).toBe(10); }); diff --git a/src/components/Header.test.tsx b/src/components/Header.test.tsx index 36508755..1053c923 100644 --- a/src/components/Header.test.tsx +++ b/src/components/Header.test.tsx @@ -179,7 +179,7 @@ describe("Header", () => { ); }); - it("opens a new GitHub issue in another tab", async () => { + it("opens a new GitHub issue in another tab that cannot reach back", async () => { const open = vi.spyOn(window, "open").mockReturnValue(null); const { user } = renderHeader({ user: TEST_USER }); @@ -189,6 +189,7 @@ describe("Header", () => { expect(open).toHaveBeenCalledWith( "https://github.com/Visual-Regression-Tracker/Visual-Regression-Tracker/issues/new", "_blank", + "noopener,noreferrer", ); }); diff --git a/src/components/Header.tsx b/src/components/Header.tsx index a6eeadc2..b0cb2853 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -64,6 +64,8 @@ const Header: FunctionComponent = () => { window.open( "https://github.com/Visual-Regression-Tracker/Visual-Regression-Tracker/issues/new", "_blank", + // the opened page gets no handle on this tab (window.opener) + "noopener,noreferrer", ); }; diff --git a/src/components/ProjectCard.tsx b/src/components/ProjectCard.tsx new file mode 100644 index 00000000..4f35e521 --- /dev/null +++ b/src/components/ProjectCard.tsx @@ -0,0 +1,149 @@ +import React from "react"; +import { Link as RouterLink } from "react-router-dom"; +import { + Box, + Button, + Card, + CardActions, + CardContent, + IconButton, + Stack, + Typography, +} from "@mui/material"; +import { + AccountTree, + Delete, + Edit, + Star, + StarBorder, +} from "@mui/icons-material"; +import { Tooltip } from "./Tooltip"; +import { LOCATOR_PROJECT_LIST_PAGE_PROJECT_LIST, routes } from "../constants"; +import { buildProjectPageUrl } from "../_helpers/route.helpers"; +import { formatDateTime } from "../_helpers/format.helper"; +import { Project } from "../types"; + +export const ProjectCard: React.FunctionComponent<{ + project: Project; + favorite: boolean; + onToggleFavorite: () => void; + // left out for those who may not change projects + onEdit?: () => void; + onDelete?: () => void; +}> = ({ project, favorite, onToggleFavorite, onEdit, onDelete }) => { + const favoriteLabel = favorite + ? "Remove from favourites" + : "Add to favourites"; + + return ( + + + + + + {project.name} + + + + + {project.mainBranchName} + + + + + + {favorite ? ( + + ) : ( + + )} + + + + + + Id + + + {project.id} + + + Created + + + {formatDateTime(project.createdAt)} + + + + + {/* router links: a plain href reloaded the whole app on every click */} + + + + {onEdit && ( + + + + + + )} + {onDelete && ( + + + + + + )} + + + ); +}; diff --git a/src/components/ProjectGrid.tsx b/src/components/ProjectGrid.tsx new file mode 100644 index 00000000..3eb15dc8 --- /dev/null +++ b/src/components/ProjectGrid.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { Box, Typography } from "@mui/material"; + +export const ProjectGrid: React.FunctionComponent<{ + children: React.ReactNode; +}> = ({ children }) => ( + + {children} + +); + +// a titled run of cards: favourites, recently opened, the rest +export const ProjectSection: React.FunctionComponent<{ + title: string; + children: React.ReactNode; +}> = ({ title, children }) => { + const headingId = `project-section-${title.toLowerCase().replace(/\W+/g, "-")}`; + + return ( + + + {title} + + {children} + + ); +}; diff --git a/src/components/ProjectSearchField.tsx b/src/components/ProjectSearchField.tsx new file mode 100644 index 00000000..c9dbcee7 --- /dev/null +++ b/src/components/ProjectSearchField.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { InputAdornment, TextField } from "@mui/material"; +import { Search } from "@mui/icons-material"; +import { isTypingTarget } from "../_helpers/hotkeys.helper"; + +// "/" puts the cursor here, as on GitHub and in most docs +export const ProjectSearchField: React.FunctionComponent<{ + value: string; + onChange: (value: string) => void; +}> = ({ value, onChange }) => { + const inputRef = React.useRef(null); + + React.useEffect(() => { + const focusSearch = (event: KeyboardEvent) => { + if (event.key === "/" && !isTypingTarget(event.target)) { + event.preventDefault(); + inputRef.current?.focus(); + } + }; + document.addEventListener("keydown", focusSearch); + return () => document.removeEventListener("keydown", focusSearch); + }, []); + + return ( + onChange(event.target.value)} + inputRef={inputRef} + sx={{ flex: 1, minWidth: { sm: 280 } }} + slotProps={{ + htmlInput: { "aria-label": "Search projects" }, + input: { + startAdornment: ( + + + + ), + }, + }} + /> + ); +}; diff --git a/src/components/ProjectSelect.test.tsx b/src/components/ProjectSelect.test.tsx index 4fad3c64..69ff1333 100644 --- a/src/components/ProjectSelect.test.tsx +++ b/src/components/ProjectSelect.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { screen } from "@testing-library/react"; +import { screen, within } from "@testing-library/react"; import { renderWithProviders } from "../_test/render"; import { projectsService } from "../services"; import { useProjectState } from "../contexts"; @@ -8,6 +8,7 @@ import ProjectSelect from "./ProjectSelect"; const FIRST = { ...TEST_PROJECT, id: "first-id", name: "First project" }; const SECOND = { ...TEST_PROJECT, id: "second-id", name: "Second project" }; +const THIRD = { ...TEST_PROJECT, id: "third-id", name: "Third project" }; const SelectedProjectId = () => (
@@ -18,22 +19,46 @@ const SelectedProjectId = () => ( const renderProjectSelect = ({ projectId, onProjectSelect = vi.fn(), + projects = [FIRST, SECOND], + favorites = [] as string[], + recent = [] as string[], }: { projectId?: string; onProjectSelect?: (id: string) => void; -} = {}) => - renderWithProviders( + projects?: (typeof FIRST)[]; + favorites?: string[]; + recent?: string[]; +} = {}) => { + vi.spyOn(projectsService, "getAll").mockResolvedValue(projects); + vi.spyOn(projectsService, "getFavorites").mockResolvedValue(favorites); + if (recent.length) { + localStorage.setItem( + `recentProjects:${TEST_USER.id}`, + JSON.stringify(recent), + ); + } + return renderWithProviders( <> , { user: TEST_USER }, ); +}; + +const optionNames = () => + within(screen.getByRole("listbox")) + .getAllByRole("option") + .map((option) => option.textContent); describe("ProjectSelect", () => { + afterEach(() => localStorage.clear()); + it("shows nothing while there are no projects", async () => { const getAll = vi.spyOn(projectsService, "getAll").mockResolvedValue([]); - renderProjectSelect(); + renderWithProviders(, { + user: TEST_USER, + }); await vi.waitFor(() => expect(getAll).toHaveBeenCalled()); @@ -42,33 +67,53 @@ describe("ProjectSelect", () => { }); it("asks to select a project when none is chosen", async () => { - vi.spyOn(projectsService, "getAll").mockResolvedValue([FIRST, SECOND]); renderProjectSelect(); - expect(await screen.findByRole("combobox")).toHaveTextContent( - "Select project", + const field = await screen.findByRole("combobox", { name: "Project" }); + expect(field).toHaveValue(""); + expect(field).toHaveAttribute("placeholder", "Select project"); + }); + + it("lists every project by name to choose from", async () => { + const { user } = renderProjectSelect({ projects: [SECOND, FIRST] }); + + await user.click(await screen.findByRole("combobox")); + + expect(optionNames()).toEqual(["First project", "Second project"]); + }); + + it("lists favourites first, then the recently opened, then the rest", async () => { + const { user } = renderProjectSelect({ + projects: [FIRST, SECOND, THIRD], + favorites: [THIRD.id], + recent: [SECOND.id], + }); + // the favourites arrive after the projects + await vi.waitFor(() => + expect(projectsService.getFavorites).toHaveBeenCalled(), ); - expect(screen.getByText("Project")).toBeVisible(); + + await user.click(await screen.findByRole("combobox")); + + expect(await screen.findByText("Favourites")).toBeVisible(); + expect(screen.getByText("Recently opened")).toBeVisible(); + expect(screen.getByText("Other projects")).toBeVisible(); + expect(optionNames()).toEqual([ + "Third project", + "Second project", + "First project", + ]); }); - it("lists every project to choose from", async () => { - vi.spyOn(projectsService, "getAll").mockResolvedValue([FIRST, SECOND]); + it("narrows the list to what is typed", async () => { const { user } = renderProjectSelect(); - await user.click(await screen.findByRole("combobox")); + await user.type(await screen.findByRole("combobox"), "sec"); - expect(screen.getByRole("option", { name: "First project" })).toBeVisible(); - expect( - screen.getByRole("option", { name: "Second project" }), - ).toBeVisible(); - // the placeholder is shown but cannot be picked - expect( - screen.getByRole("option", { name: "Select project" }), - ).toHaveAttribute("aria-disabled", "true"); + expect(optionNames()).toEqual(["Second project"]); }); it("reports the id of the project picked", async () => { - vi.spyOn(projectsService, "getAll").mockResolvedValue([FIRST, SECOND]); const onProjectSelect = vi.fn(); const { user } = renderProjectSelect({ onProjectSelect }); @@ -79,20 +124,28 @@ describe("ProjectSelect", () => { expect(onProjectSelect).toHaveBeenCalledWith("second-id"); }); + it("picks from the keyboard", async () => { + const onProjectSelect = vi.fn(); + const { user } = renderProjectSelect({ onProjectSelect }); + + await user.type( + await screen.findByRole("combobox"), + "sec{ArrowDown}{Enter}", + ); + + expect(onProjectSelect).toHaveBeenCalledWith("second-id"); + }); + it("selects the project given by id and shows its name", async () => { - vi.spyOn(projectsService, "getAll").mockResolvedValue([FIRST, SECOND]); renderProjectSelect({ projectId: "second-id" }); - expect(await screen.findByRole("combobox")).toHaveTextContent( - "Second project", - ); + expect(await screen.findByRole("combobox")).toHaveValue("Second project"); expect(screen.getByTestId("selectedProjectId")).toHaveTextContent( "second-id", ); }); it("leaves the selection alone when no project id is given", async () => { - vi.spyOn(projectsService, "getAll").mockResolvedValue([FIRST, SECOND]); renderProjectSelect(); await screen.findByRole("combobox"); diff --git a/src/components/ProjectSelect.tsx b/src/components/ProjectSelect.tsx index f201421a..0e3a5140 100644 --- a/src/components/ProjectSelect.tsx +++ b/src/components/ProjectSelect.tsx @@ -1,22 +1,26 @@ import React, { FunctionComponent } from "react"; -import { - FormControl, - InputLabel, - MenuItem, - Select, - type SelectChangeEvent, -} from "@mui/material"; +import { Autocomplete, Box, ListSubheader, TextField } from "@mui/material"; import { useProjectState, useProjectDispatch, selectProject, } from "../contexts"; +import { + groupProjects, + matchesProjectQuery, +} from "../_helpers/projectList.helper"; +import { Project } from "../types"; const ProjectSelect: FunctionComponent<{ projectId?: string; onProjectSelect: (id: string) => void; }> = ({ projectId, onProjectSelect }) => { - const { projectList, selectedProjectId } = useProjectState(); + const { + projectList, + selectedProjectId, + favoriteProjectIds, + recentProjectIds, + } = useProjectState(); const projectDispatch = useProjectDispatch(); React.useEffect(() => { @@ -25,39 +29,80 @@ const ProjectSelect: FunctionComponent<{ } }, [projectId, selectedProjectId, projectDispatch]); + // the same order as the project list: favourites, recently opened, the rest + const { options, groupOf } = React.useMemo(() => { + const groups = groupProjects( + projectList, + favoriteProjectIds, + recentProjectIds, + ); + const groupOf = new Map([ + ...groups.favorites.map((p) => [p.id, "Favourites"] as const), + ...groups.recent.map((p) => [p.id, "Recently opened"] as const), + ...groups.others.map((p) => [p.id, "Other projects"] as const), + ]); + const options = [...groups.favorites, ...groups.recent, ...groups.others]; + // with nothing starred or opened the list is one run without a heading + if (groups.favorites.length + groups.recent.length === 0) { + groupOf.clear(); + } + + return { options, groupOf }; + }, [projectList, favoriteProjectIds, recentProjectIds]); + + const selected = projectList.find( + (project) => project.id === selectedProjectId, + ); + + if (projectList.length === 0) { + return null; + } + return ( - <> - {projectList.length > 0 && ( - - {/* the label is positioned against the form control rather than the - input, so without the input's own inset it sits on the sidebar's - edge */} - - Project - - - + + id="project-select" + options={options} + // the selection is changed, never cleared; until the first one there is + // nothing to show, which the field takes as null + disableClearable + value={selected ?? (null as unknown as Project)} + onChange={(_, project) => onProjectSelect(project.id)} + getOptionLabel={(project) => project.name} + isOptionEqualToValue={(option, value) => option.id === value.id} + // always grouping: switching groupBy on while the list is open (the + // first project picked becomes a recent one) breaks the list + groupBy={(project) => groupOf.get(project.id) ?? ""} + renderGroup={(params) => + params.group ? ( +
  • + {params.group} + + {params.children} + +
  • + ) : ( + {params.children} + ) + } + filterOptions={(projects, { inputValue }) => + projects.filter((project) => matchesProjectQuery(project, inputValue)) + } + noOptionsText="No projects match" + // a long name reads in the list, not cut off at the field's width + slotProps={{ popper: { sx: { minWidth: 280 } } }} + // no top margin: the label sits at the top, where the select this + // replaced had it, and the field keeps its inset below and at the sides + sx={{ mx: 1, mb: 1 }} + renderInput={(params) => ( + )} - + /> ); }; diff --git a/src/components/TestDetailsDialog/DrawArea.tsx b/src/components/TestDetailsDialog/DrawArea.tsx index e8d82c48..8f57630a 100644 --- a/src/components/TestDetailsDialog/DrawArea.tsx +++ b/src/components/TestDetailsDialog/DrawArea.tsx @@ -363,11 +363,7 @@ export const DrawArea: FunctionComponent = ({ // a half-faded difference washes out instead of blacking out // what matches, so the blend ignores the fade opacity={ - underlayImage - ? blendDifference - ? 1 - : overlayOpacity - : undefined + underlayImage && (blendDifference ? 1 : overlayOpacity) } globalCompositeOperation={ blendDifference ? "difference" : undefined diff --git a/src/components/TestRunList/index.tsx b/src/components/TestRunList/index.tsx index a57c156d..e16daa4f 100644 --- a/src/components/TestRunList/index.tsx +++ b/src/components/TestRunList/index.tsx @@ -155,15 +155,25 @@ const bySettled = (a: TestRun, b: TestRun): number => -byAttention(a, b); // the direction applies to the chosen field only: negating the whole comparator // would reverse the tie-break too, so a descending status listed names Z to A +const ascendingBy = ( + sort: TestRunSort, + tagFields: Array, + a: TestRun, + b: TestRun, +): number => { + if (sort.field === "status") { + return bySettled(a, b); + } + if (sort.field === "name") { + return byName(a, b); + } + return tagsOf(a, tagFields).localeCompare(tagsOf(b, tagFields)); +}; + const comparatorFor = (sort: TestRunSort, tagFields: Array) => (a: TestRun, b: TestRun): number => { - const ascending = - sort.field === "status" - ? bySettled(a, b) - : sort.field === "name" - ? byName(a, b) - : tagsOf(a, tagFields).localeCompare(tagsOf(b, tagFields)); + const ascending = ascendingBy(sort, tagFields, a, b); const primary = sort.direction === "asc" ? ascending : -ascending; const tieBreak = sort.field === "name" ? byAttention(a, b) : byName(a, b); diff --git a/src/constants/storage.ts b/src/constants/storage.ts index 0f05b16d..904507e6 100644 --- a/src/constants/storage.ts +++ b/src/constants/storage.ts @@ -1,2 +1,4 @@ export const GO_TO_NEXT_KEY = "goToNextAutomatically"; export const TEST_RUN_SHOW_DIFF_KEY = "testRunShowDiff"; +// suffixed with the user's id: one browser can be shared by several accounts +export const RECENT_PROJECTS_KEY = "recentProjects"; diff --git a/src/contexts/help.context.tsx b/src/contexts/help.context.tsx index b8a3fa9e..0907beea 100644 --- a/src/contexts/help.context.tsx +++ b/src/contexts/help.context.tsx @@ -22,16 +22,13 @@ const initialState: State = { }; export function reducer(state: State, action: ISetStepAction): State { - switch (action.type) { - case "setSteps": - return { - ...state, - helpSteps: action.payload, - }; - - default: - return state; + if (action.type === "setSteps") { + return { + ...state, + helpSteps: action.payload, + }; } + return state; } function HelpProvider({ children }: HelpProviderProps) { diff --git a/src/contexts/project.context.test.ts b/src/contexts/project.context.test.ts index e8d79708..b588ff0a 100644 --- a/src/contexts/project.context.test.ts +++ b/src/contexts/project.context.test.ts @@ -5,6 +5,7 @@ import { projectReducer, selectProject, setProjectEditState, + toggleFavoriteProject, updateProject, } from "./project.context"; import { projectsService } from "../services"; @@ -20,6 +21,8 @@ const initial: State = { selectedProjectId: null, projectList: [], projectEditState: DEFAULT_PROJECT_EDIT_STATE, + favoriteProjectIds: [], + recentProjectIds: [], }; describe("projectReducer", () => { @@ -87,6 +90,52 @@ describe("projectReducer", () => { expect(state.selectedProjectId).toBe("p1"); }); + it("delete drops the project from the favourites", () => { + const state = projectReducer( + { ...initial, projectList: [project("p1")], favoriteProjectIds: ["p1"] }, + { type: "delete", payload: "p1" }, + ); + + expect(state.favoriteProjectIds).toEqual([]); + }); + + it("favorites replaces the favourite project ids", () => { + const state = projectReducer( + { ...initial, favoriteProjectIds: ["p1"] }, + { type: "favorites", payload: ["p2", "p3"] }, + ); + + expect(state.favoriteProjectIds).toEqual(["p2", "p3"]); + }); + + it("favorite adds a project once and takes it out again", () => { + const added = projectReducer(initial, { + type: "favorite", + payload: { id: "p1", favorite: true }, + }); + const twice = projectReducer(added, { + type: "favorite", + payload: { id: "p1", favorite: true }, + }); + const removed = projectReducer(twice, { + type: "favorite", + payload: { id: "p1", favorite: false }, + }); + + expect(added.favoriteProjectIds).toEqual(["p1"]); + expect(twice.favoriteProjectIds).toEqual(["p1"]); + expect(removed.favoriteProjectIds).toEqual([]); + }); + + it("recent replaces the recently opened project ids", () => { + const state = projectReducer(initial, { + type: "recent", + payload: ["p2", "p1"], + }); + + expect(state.recentProjectIds).toEqual(["p2", "p1"]); + }); + it("setProjectEditState stores the project being edited", () => { const editing: ProjectDto = { ...DEFAULT_PROJECT_EDIT_STATE, @@ -235,4 +284,48 @@ describe("project action creators", () => { }); }); }); + + describe("toggleFavoriteProject", () => { + it("shows the star at once, then saves it", async () => { + const dispatch = vi.fn(); + const add = vi.spyOn(projectsService, "addFavorite").mockResolvedValue(); + + await toggleFavoriteProject(dispatch, "p1", true); + + expect(dispatch).toHaveBeenCalledWith({ + type: "favorite", + payload: { id: "p1", favorite: true }, + }); + expect(add).toHaveBeenCalledWith("p1"); + }); + + it("takes the star off through the service", async () => { + const dispatch = vi.fn(); + const remove = vi + .spyOn(projectsService, "removeFavorite") + .mockResolvedValue(); + + await toggleFavoriteProject(dispatch, "p1", false); + + expect(remove).toHaveBeenCalledWith("p1"); + expect(dispatch).toHaveBeenCalledWith({ + type: "favorite", + payload: { id: "p1", favorite: false }, + }); + }); + + it("puts the star back and answers the error when saving fails", async () => { + const dispatch = vi.fn(); + vi.spyOn(projectsService, "addFavorite").mockRejectedValue("Offline"); + + await expect(toggleFavoriteProject(dispatch, "p1", true)).rejects.toBe( + "Offline", + ); + + expect(dispatch).toHaveBeenLastCalledWith({ + type: "favorite", + payload: { id: "p1", favorite: false }, + }); + }); + }); }); diff --git a/src/contexts/project.context.test.tsx b/src/contexts/project.context.test.tsx new file mode 100644 index 00000000..d4487d35 --- /dev/null +++ b/src/contexts/project.context.test.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import { screen } from "@testing-library/react"; +import { renderWithProviders } from "../_test/render"; +import { TEST_USER } from "../_test/test.data.helper"; +import { projectsService } from "../services"; +import { + selectProject, + useProjectDispatch, + useProjectState, +} from "./project.context"; + +const Consumer = () => { + const { favoriteProjectIds, recentProjectIds } = useProjectState(); + const dispatch = useProjectDispatch(); + + return ( + <> +

    {favoriteProjectIds.join(",")}

    +

    {recentProjectIds.join(",")}

    + + + ); +}; + +const RECENT_KEY = `recentProjects:${TEST_USER.id}`; + +describe("ProjectProvider", () => { + beforeEach(() => { + vi.spyOn(projectsService, "getAll").mockResolvedValue([]); + }); + + afterEach(() => localStorage.clear()); + + it("loads the user's favourites after login", async () => { + vi.spyOn(projectsService, "getFavorites").mockResolvedValue(["p1", "p3"]); + + renderWithProviders(, { user: TEST_USER }); + + expect(await screen.findByTestId("favorites")).toHaveTextContent("p1,p3"); + }); + + // an API without favourites (older than this frontend) answers 404: the page + // works without them rather than greeting every login with an error + it("goes on without favourites and without an error when they cannot be loaded", async () => { + const getFavorites = vi + .spyOn(projectsService, "getFavorites") + .mockRejectedValue("Not Found"); + + renderWithProviders(, { user: TEST_USER }); + + await vi.waitFor(() => expect(getFavorites).toHaveBeenCalled()); + expect(screen.getByTestId("favorites")).toBeEmptyDOMElement(); + expect(screen.queryByText("Not Found")).not.toBeInTheDocument(); + }); + + it("does not ask for favourites while logged out", () => { + const getFavorites = vi.spyOn(projectsService, "getFavorites"); + + renderWithProviders(); + + expect(getFavorites).not.toHaveBeenCalled(); + }); + + it("reads the projects the user opened before", async () => { + vi.spyOn(projectsService, "getFavorites").mockResolvedValue([]); + localStorage.setItem(RECENT_KEY, JSON.stringify(["p4", "p1"])); + + renderWithProviders(, { user: TEST_USER }); + + expect(await screen.findByTestId("recent")).toHaveTextContent("p4,p1"); + }); + + it("remembers an opened project as the most recent one", async () => { + vi.spyOn(projectsService, "getFavorites").mockResolvedValue([]); + localStorage.setItem(RECENT_KEY, JSON.stringify(["p1"])); + const { user } = renderWithProviders(, { user: TEST_USER }); + + await user.click(screen.getByRole("button", { name: "Open p2" })); + + expect(await screen.findByTestId("recent")).toHaveTextContent("p2,p1"); + expect(JSON.parse(localStorage.getItem(RECENT_KEY) ?? "[]")).toEqual([ + "p2", + "p1", + ]); + }); +}); diff --git a/src/contexts/project.context.tsx b/src/contexts/project.context.tsx index 564f0fe1..3a34aae1 100644 --- a/src/contexts/project.context.tsx +++ b/src/contexts/project.context.tsx @@ -4,6 +4,10 @@ import { projectsService } from "../services"; import { useSnackbar } from "notistack"; import { useUserState } from "./user.context"; import { DEFAULT_PROJECT_EDIT_STATE } from "../constants"; +import { + readRecentProjectIds, + rememberRecentProject, +} from "../_helpers/projectList.helper"; interface IRequestAction { type: "request"; @@ -35,6 +39,21 @@ interface ISelectAction { payload: string; } +interface IFavoritesAction { + type: "favorites"; + payload: string[]; +} + +interface IFavoriteAction { + type: "favorite"; + payload: { id: string; favorite: boolean }; +} + +interface IRecentAction { + type: "recent"; + payload: string[]; +} + interface ISetProjectEditState { type: "setProjectEditState"; payload: ProjectDto; @@ -47,12 +66,19 @@ type IAction = | ISelectAction | ICreateAction | IDeleteAction - | IUpdateAction; + | IUpdateAction + | IFavoritesAction + | IFavoriteAction + | IRecentAction; type Dispatch = (action: IAction) => void; type State = { selectedProjectId: string | null; projectEditState: ProjectDto; projectList: Project[]; + // the signed-in user's own, kept by the API + favoriteProjectIds: string[]; + // opened last first, kept in this browser + recentProjectIds: string[]; }; type ProjectProviderProps = { children: React.ReactNode; @@ -99,6 +125,33 @@ export function projectReducer(state: State, action: IAction): State { return { ...state, projectList: state.projectList.filter((p) => p.id !== action.payload), + favoriteProjectIds: state.favoriteProjectIds.filter( + (id) => id !== action.payload, + ), + }; + + case "favorites": + return { + ...state, + favoriteProjectIds: action.payload, + }; + + case "favorite": { + const others = state.favoriteProjectIds.filter( + (id) => id !== action.payload.id, + ); + return { + ...state, + favoriteProjectIds: action.payload.favorite + ? [...others, action.payload.id] + : others, + }; + } + + case "recent": + return { + ...state, + recentProjectIds: action.payload, }; case "setProjectEditState": @@ -195,6 +248,24 @@ function deleteProject(dispatch: Dispatch, id: string) { }); } +// the star shows at once and goes back if the API refuses it +function toggleFavoriteProject( + dispatch: Dispatch, + id: string, + favorite: boolean, +): Promise { + dispatch({ type: "favorite", payload: { id, favorite } }); + + const save = favorite + ? projectsService.addFavorite(id) + : projectsService.removeFavorite(id); + + return save.catch((err) => { + dispatch({ type: "favorite", payload: { id, favorite: !favorite } }); + throw err; + }); +} + function setProjectEditState(dispatch: Dispatch, project?: ProjectDto) { dispatch({ type: "setProjectEditState", @@ -207,9 +278,12 @@ function ProjectProvider({ children }: ProjectProviderProps) { selectedProjectId: null, projectList: [], projectEditState: DEFAULT_PROJECT_EDIT_STATE, + favoriteProjectIds: [], + recentProjectIds: [], }; - const { loggedIn } = useUserState(); + const { loggedIn, user } = useUserState(); + const userId = loggedIn ? user?.id : undefined; const [state, dispatch] = React.useReducer(projectReducer, initialState); const { enqueueSnackbar } = useSnackbar(); @@ -223,6 +297,43 @@ function ProjectProvider({ children }: ProjectProviderProps) { } }, [enqueueSnackbar, loggedIn]); + // An API older than this frontend has no favourites and answers 404: the + // lists work without them rather than greeting every login with an error. + React.useEffect(() => { + if (!userId) { + dispatch({ type: "favorites", payload: [] }); + dispatch({ type: "recent", payload: [] }); + return; + } + let ignore = false; + dispatch({ type: "recent", payload: readRecentProjectIds(userId) }); + projectsService + .getFavorites() + .then((ids) => { + if (!ignore) { + dispatch({ type: "favorites", payload: ids }); + } + }) + .catch(() => { + if (!ignore) { + dispatch({ type: "favorites", payload: [] }); + } + }); + return () => { + ignore = true; + }; + }, [userId]); + + // a project counts as opened once it is selected, whichever page did it + React.useEffect(() => { + if (userId && state.selectedProjectId) { + dispatch({ + type: "recent", + payload: rememberRecentProject(userId, state.selectedProjectId), + }); + } + }, [userId, state.selectedProjectId]); + return ( @@ -242,4 +353,5 @@ export { deleteProject, selectProject, setProjectEditState, + toggleFavoriteProject, }; diff --git a/src/contexts/socket.context.tsx b/src/contexts/socket.context.tsx index fbaa7bab..abc73b17 100644 --- a/src/contexts/socket.context.tsx +++ b/src/contexts/socket.context.tsx @@ -44,15 +44,12 @@ const initialState: State = { }; export function socketReducer(state: State, action: IAction): State { - switch (action.type) { - case "connect": - return { - socket: action.payload, - }; - - default: - return state; + if (action.type === "connect") { + return { + socket: action.payload, + }; } + return state; } function SocketProvider({ children }: SocketProviderProps) { diff --git a/src/pages/ProjectListPage.test.tsx b/src/pages/ProjectListPage.test.tsx index a3e055cc..0450da3d 100644 --- a/src/pages/ProjectListPage.test.tsx +++ b/src/pages/ProjectListPage.test.tsx @@ -1,73 +1,191 @@ import React from "react"; -import { render } from "@testing-library/react"; -import { useProjectState, useUserState } from "../contexts"; +import { screen, within } from "@testing-library/react"; +import { renderWithProviders } from "../_test/render"; import { EDITOR_USER, GUEST_USER, TEST_PROJECT, TEST_USER, } from "../_test/test.data.helper"; -import { User } from "../types"; +import { projectsService } from "../services"; +import { Project, User } from "../types"; import ProjectsListPage from "./ProjectListPage"; -vi.mock("../contexts", () => ({ - useUserState: vi.fn(), - useProjectState: vi.fn(), - useProjectDispatch: vi.fn(), - useHelpDispatch: vi.fn(), - setHelpSteps: vi.fn(), - setProjectEditState: vi.fn(), - createProject: vi.fn(), - updateProject: vi.fn(), - deleteProject: vi.fn(), -})); - -const renderAs = (user?: User) => { - vi.mocked(useUserState).mockReturnValue({ - loggedIn: true, - user, - userList: [], - }); - // only what the page reads - vi.mocked(useProjectState).mockReturnValue({ - projectList: [TEST_PROJECT], - projectEditState: TEST_PROJECT, - } as unknown as ReturnType); +const project = (id: string, name: string, mainBranchName = "master") => + ({ ...TEST_PROJECT, id, name, mainBranchName }) as Project; + +const ALPHA = project("alpha-id", "Alpha"); +const BETA = project("beta-id", "Beta", "develop"); +const GAMMA = project("gamma-id", "Gamma"); +const DELTA = project("delta-id", "Delta"); - return render(); +const renderPage = async ({ + user = TEST_USER as User | null, + projects = [TEST_PROJECT] as Project[], + favorites = [] as string[], + recent = [] as string[], + route = "/projects", +} = {}) => { + vi.spyOn(projectsService, "getAll").mockResolvedValue(projects); + vi.spyOn(projectsService, "getFavorites").mockResolvedValue(favorites); + if (user && recent.length) { + localStorage.setItem(`recentProjects:${user.id}`, JSON.stringify(recent)); + } + const result = renderWithProviders(, { user, route }); + await screen.findByRole("heading", { name: projects[0].name }); + return result; }; +// the project cards' titles, top to bottom +const cardNames = () => + screen + .getAllByRole("heading", { level: 3 }) + .map((heading) => heading.textContent); + +const section = (name: string) => screen.getByRole("region", { name }); + describe("ProjectListPage", () => { + afterEach(() => localStorage.clear()); + test.each([ ["admin", TEST_USER], ["editor", EDITOR_USER], - ])("lets %s create, edit and delete projects", (_, user) => { - const page = renderAs(user); + ])("lets %s create, edit and delete projects", async (_, user) => { + await renderPage({ user }); expect( - page.getByRole("heading", { name: TEST_PROJECT.name }), + screen.getByRole("button", { name: /add project/i }), ).toBeInTheDocument(); + expect(screen.getByTestId("EditIcon")).toBeInTheDocument(); + expect(screen.getByTestId("delete")).toBeInTheDocument(); + }); + + test("shows the projects read-only to a guest", async () => { + await renderPage({ user: GUEST_USER }); + + expect(screen.getByRole("link", { name: "Builds" })).toBeInTheDocument(); expect( - page.getByRole("button", { name: /add project/i }), - ).toBeInTheDocument(); - expect(page.getByTestId("EditIcon")).toBeInTheDocument(); - expect(page.getByTestId("delete")).toBeInTheDocument(); + screen.queryByRole("button", { name: /add project/i }), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId("EditIcon")).not.toBeInTheDocument(); + expect(screen.queryByTestId("delete")).not.toBeInTheDocument(); }); - test.each([ - ["guest", GUEST_USER], - ["a user without user data", undefined], - ])("shows the projects read-only to %s", (_, user) => { - const page = renderAs(user); + test("links a project's builds and variations as pages of the app", async () => { + await renderPage(); + + expect(screen.getByRole("link", { name: "Builds" })).toHaveAttribute( + "href", + `/${TEST_PROJECT.id}`, + ); + expect(screen.getByRole("link", { name: "Variations" })).toHaveAttribute( + "href", + `/variations/${TEST_PROJECT.id}`, + ); + }); + + test("lists every project by name when none is starred or opened yet", async () => { + await renderPage({ projects: [GAMMA, ALPHA, BETA] }); + + expect(cardNames()).toEqual(["Alpha", "Beta", "Gamma"]); + expect( + screen.queryByRole("region", { name: "Favourites" }), + ).not.toBeInTheDocument(); + }); + test("puts favourites first, then the recently opened, then the rest", async () => { + await renderPage({ + projects: [ALPHA, BETA, GAMMA, DELTA], + favorites: [GAMMA.id], + recent: [BETA.id, GAMMA.id], + }); + + await within(section("Favourites")).findByRole("heading", { + name: "Gamma", + }); expect( - page.getByRole("heading", { name: TEST_PROJECT.name }), + within(section("Recently opened")).getByRole("heading", { + name: "Beta", + }), ).toBeInTheDocument(); - expect(page.getByRole("link", { name: "Builds" })).toBeInTheDocument(); + expect(cardNames()).toEqual(["Gamma", "Beta", "Alpha", "Delta"]); + }); + + test("stars a project and moves it to the favourites", async () => { + const addFavorite = vi + .spyOn(projectsService, "addFavorite") + .mockResolvedValue(); + const { user } = await renderPage({ projects: [ALPHA, BETA] }); + + await user.click( + within(screen.getByTestId(`project-${BETA.id}`)).getByRole("button", { + name: "Add to favourites", + }), + ); + + expect(addFavorite).toHaveBeenCalledWith(BETA.id); + const star = within(section("Favourites")).getByRole("button", { + name: "Remove from favourites", + }); + expect(star).toHaveAttribute("aria-pressed", "true"); + expect(cardNames()).toEqual(["Beta", "Alpha"]); + }); + + test("takes the star back and says why when starring fails", async () => { + vi.spyOn(projectsService, "addFavorite").mockRejectedValue("Offline"); + const { user } = await renderPage({ projects: [ALPHA] }); + + await user.click(screen.getByRole("button", { name: "Add to favourites" })); + + expect(await screen.findByText("Offline")).toBeInTheDocument(); expect( - page.queryByRole("button", { name: /add project/i }), - ).not.toBeInTheDocument(); - expect(page.queryByTestId("EditIcon")).not.toBeInTheDocument(); - expect(page.queryByTestId("delete")).not.toBeInTheDocument(); + screen.getByRole("button", { name: "Add to favourites" }), + ).toHaveAttribute("aria-pressed", "false"); + }); + + test("filters by name, id or branch as you type", async () => { + const { user } = await renderPage({ projects: [ALPHA, BETA, GAMMA] }); + + await user.type(screen.getByRole("searchbox"), "develop"); + + expect(cardNames()).toEqual(["Beta"]); + }); + + test("keeps the favourites first among the matches", async () => { + const { user } = await renderPage({ + projects: [ALPHA, GAMMA], + favorites: [GAMMA.id], + }); + + await user.type(screen.getByRole("searchbox"), "a"); + + expect(cardNames()).toEqual(["Gamma", "Alpha"]); + }); + + test("opens with the search a link carries", async () => { + await renderPage({ + projects: [ALPHA, BETA], + route: "/projects?q=alp", + }); + + expect(screen.getByRole("searchbox")).toHaveValue("alp"); + expect(cardNames()).toEqual(["Alpha"]); + }); + + test("says so when nothing matches", async () => { + const { user } = await renderPage({ projects: [ALPHA] }); + + await user.type(screen.getByRole("searchbox"), "zzz"); + + expect(screen.getByText("No projects match “zzz”")).toBeInTheDocument(); + }); + + test("puts the cursor in the search on /", async () => { + const { user } = await renderPage({ projects: [ALPHA] }); + + await user.keyboard("/"); + + expect(screen.getByRole("searchbox")).toHaveFocus(); + expect(screen.getByRole("searchbox")).toHaveValue(""); }); }); diff --git a/src/pages/ProjectListPage.tsx b/src/pages/ProjectListPage.tsx index d032ead0..c0211d74 100644 --- a/src/pages/ProjectListPage.tsx +++ b/src/pages/ProjectListPage.tsx @@ -1,15 +1,6 @@ import React, { useEffect } from "react"; -import { - Typography, - Card, - IconButton, - CardContent, - CardActions, - Button, - Box, - Stack, - Container, -} from "@mui/material"; +import { useSearchParams } from "react-router-dom"; +import { Typography, Button, Box, Stack, Container } from "@mui/material"; import { useProjectState, useProjectDispatch, @@ -17,29 +8,25 @@ import { createProject, updateProject, setProjectEditState, + toggleFavoriteProject, useHelpDispatch, setHelpSteps, useUserState, } from "../contexts"; -import { - Delete, - Add, - Edit, - AccountTree, - FolderOpenOutlined, -} from "@mui/icons-material"; -import { Tooltip } from "../components/Tooltip"; +import { Add, FolderOpenOutlined, Search } from "@mui/icons-material"; import { EmptyState } from "../components/EmptyState"; +import { ProjectCard } from "../components/ProjectCard"; +import { ProjectGrid, ProjectSection } from "../components/ProjectGrid"; +import { ProjectSearchField } from "../components/ProjectSearchField"; +import { PROJECT_LIST_PAGE_STEPS } from "../constants"; import { - routes, - LOCATOR_PROJECT_LIST_PAGE_PROJECT_LIST, - PROJECT_LIST_PAGE_STEPS, -} from "../constants"; -import { formatDateTime } from "../_helpers/format.helper"; + groupProjects, + matchesProjectQuery, +} from "../_helpers/projectList.helper"; import { ProjectForm } from "../components/ProjectForm"; import { useSnackbar } from "notistack"; import { BaseModal } from "../components/BaseModal"; -import { User } from "../types"; +import { Project, User } from "../types"; // the roles the API lets create, update and delete projects const PROJECT_EDITOR_ROLES = new Set(["admin", "editor"]); @@ -51,6 +38,11 @@ const ProjectsListPage = () => { const helpDispatch = useHelpDispatch(); const { user } = useUserState(); const canEditProjects = !!user && PROJECT_EDITOR_ROLES.has(user.role); + const { projectList, favoriteProjectIds, recentProjectIds } = projectState; + + // in the URL, so a search can be shared and survives going back + const [searchParams, setSearchParams] = useSearchParams(); + const query = searchParams.get("q") ?? ""; const [createDialogOpen, setCreateDialogOpen] = React.useState(false); const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false); @@ -60,6 +52,26 @@ const ProjectsListPage = () => { setHelpSteps(helpDispatch, PROJECT_LIST_PAGE_STEPS); }); + const groups = React.useMemo( + () => groupProjects(projectList, favoriteProjectIds, recentProjectIds), + [projectList, favoriteProjectIds, recentProjectIds], + ); + const favoriteIdSet = React.useMemo( + () => new Set(favoriteProjectIds), + [favoriteProjectIds], + ); + // searching flattens the sections, keeping their order + const matches = React.useMemo( + () => + [...groups.favorites, ...groups.recent, ...groups.others].filter( + (project) => matchesProjectQuery(project, query), + ), + [groups, query], + ); + + const setQuery = (value: string) => + setSearchParams(value ? { q: value } : {}, { replace: true }); + const toggleCreateDialogOpen = () => { setCreateDialogOpen(!createDialogOpen); }; @@ -72,6 +84,77 @@ const ProjectsListPage = () => { setDeleteDialogOpen(!deleteDialogOpen); }; + const renderCard = (project: Project) => { + const favorite = favoriteIdSet.has(project.id); + + return ( + + toggleFavoriteProject(projectDispatch, project.id, !favorite).catch( + (err) => enqueueSnackbar(err, { variant: "error" }), + ) + } + onEdit={ + canEditProjects + ? () => { + toggleUpdateDialogOpen(); + setProjectEditState(projectDispatch, project); + } + : undefined + } + onDelete={ + canEditProjects + ? () => { + toggleDeleteDialogOpen(); + setProjectEditState(projectDispatch, project); + } + : undefined + } + /> + ); + }; + + const hasSections = groups.favorites.length + groups.recent.length > 0; + + const renderProjects = () => { + if (query.trim()) { + return matches.length ? ( + {matches.map(renderCard)} + ) : ( + } + title={`No projects match “${query.trim()}”`} + description="Search looks at the name, the id and the main branch." + /> + ); + } + if (!hasSections) { + return {groups.others.map(renderCard)}; + } + return ( + + {groups.favorites.length > 0 && ( + + {groups.favorites.map(renderCard)} + + )} + {groups.recent.length > 0 && ( + + {groups.recent.map(renderCard)} + + )} + {groups.others.length > 0 && ( + + {groups.others.map(renderCard)} + + )} + + ); + }; + return ( { Projects - {projectState.projectList.length === 1 + {projectList.length === 1 ? "1 project" - : `${projectState.projectList.length} projects`} + : `${projectList.length} projects`} - {canEditProjects && ( - - )} + + {projectList.length > 0 && ( + + )} + {canEditProjects && ( + + )} + - {projectState.projectList.length === 0 && ( + {projectList.length === 0 ? ( } title="No projects yet" @@ -115,108 +209,9 @@ const ProjectsListPage = () => { : "Projects shared with you will show up here." } /> + ) : ( + renderProjects() )} - - {projectState.projectList.map((project) => ( - - - - {project.name} - - - - - {project.mainBranchName} - - - - - Id - - - {project.id} - - - Created - - - {formatDateTime(project.createdAt)} - - - - - - - - {canEditProjects && ( - <> - - { - toggleUpdateDialogOpen(); - setProjectEditState(projectDispatch, project); - }} - > - - - - - { - toggleDeleteDialogOpen(); - setProjectEditState(projectDispatch, project); - }} - data-testid="delete" - > - - - - - )} - - - ))} - {canEditProjects && ( <> diff --git a/src/services/projects.service.test.ts b/src/services/projects.service.test.ts index d7fabc01..81caa4f9 100644 --- a/src/services/projects.service.test.ts +++ b/src/services/projects.service.test.ts @@ -6,6 +6,15 @@ vi.mock("../_config/env.config", () => ({ API_URL: "http://api.test" })); const AUTH = { Authorization: "Bearer token-1" }; +// the favourite endpoints answer 204 with no body at all +const stubNoContent = () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(null, { status: 204 })); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +}; + describe("projectsService", () => { beforeEach(() => { localStorage.setItem("user", JSON.stringify({ token: "token-1" })); @@ -69,4 +78,38 @@ describe("projectsService", () => { await expect(projectsService.getAll()).rejects.toBe("Forbidden"); }); + + it("getFavorites fetches the ids of the user's favourite projects", async () => { + const fetchMock = stubFetch(["p1", "p2"]); + + const result = await projectsService.getFavorites(); + + expect(result).toEqual(["p1", "p2"]); + expect(fetchMock).toHaveBeenCalledWith(`${API}/projects/favorites`, { + method: "GET", + headers: AUTH, + }); + }); + + it("addFavorite puts the project among the favourites", async () => { + const fetchMock = stubNoContent(); + + await projectsService.addFavorite("p1"); + + expect(fetchMock).toHaveBeenCalledWith(`${API}/projects/p1/favorite`, { + method: "PUT", + headers: AUTH, + }); + }); + + it("removeFavorite takes the project out of the favourites", async () => { + const fetchMock = stubNoContent(); + + await projectsService.removeFavorite("p1"); + + expect(fetchMock).toHaveBeenCalledWith(`${API}/projects/p1/favorite`, { + method: "DELETE", + headers: AUTH, + }); + }); }); diff --git a/src/services/projects.service.ts b/src/services/projects.service.ts index 95b83e55..06bb3d2b 100644 --- a/src/services/projects.service.ts +++ b/src/services/projects.service.ts @@ -54,8 +54,48 @@ async function update(project: Partial): Promise { return handleResponse(response); } +// the signed-in user's own favourites, as project ids +async function getFavorites(): Promise { + const requestOptions = { + method: "GET", + headers: authHeader(), + }; + + const response = await fetch(`${API_URL}/projects/favorites`, requestOptions); + return handleResponse(response); +} + +async function addFavorite(id: string): Promise { + const requestOptions = { + method: "PUT", + headers: authHeader(), + }; + + const response = await fetch( + `${API_URL}/projects/${id}/favorite`, + requestOptions, + ); + return handleResponse(response); +} + +async function removeFavorite(id: string): Promise { + const requestOptions = { + method: "DELETE", + headers: authHeader(), + }; + + const response = await fetch( + `${API_URL}/projects/${id}/favorite`, + requestOptions, + ); + return handleResponse(response); +} + export const projectsService = { getAll, + getFavorites, + addFavorite, + removeFavorite, remove, create, update, diff --git a/vitest.config.ts b/vitest.config.ts index 15d9cc67..02b0c412 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -32,10 +32,10 @@ export default mergeConfig( reporter: ["text-summary", "html", "json-summary"], // a ratchet: raise these when tests are added, never lower them thresholds: { - statements: 72, - branches: 59, - functions: 65, - lines: 73, + statements: 74, + branches: 62, + functions: 68, + lines: 75, }, }, },