diff --git a/webui/react/src/components/ProjectMoveModal.tsx b/webui/react/src/components/ProjectMoveModal.tsx index d91e7ef0334..730b9b05749 100644 --- a/webui/react/src/components/ProjectMoveModal.tsx +++ b/webui/react/src/components/ProjectMoveModal.tsx @@ -1,3 +1,4 @@ +import Form from 'hew/Form'; import Icon from 'hew/Icon'; import { Modal } from 'hew/Modal'; import Select, { Option, SelectValue } from 'hew/Select'; @@ -17,6 +18,8 @@ import { useObservable } from 'utils/observable'; import css from './ProjectMoveModal.module.scss'; +const FORM_ID = 'move-project-form'; + interface Props { onMove?: () => void; project: Project; @@ -76,37 +79,40 @@ const ProjectMoveModalComponent: React.FC = ({ onMove, project }: Props) size="small" submit={{ disabled: !destinationWorkspaceId, + form: FORM_ID, handleError, handler: handleSubmit, text: 'Move Project', }} title="Move Project"> - +
+ +
); }; diff --git a/webui/react/src/e2e/models/components/ProjectCard.ts b/webui/react/src/e2e/models/components/ProjectCard.ts index f476076d587..73645a9ce16 100644 --- a/webui/react/src/e2e/models/components/ProjectCard.ts +++ b/webui/react/src/e2e/models/components/ProjectCard.ts @@ -5,9 +5,9 @@ import { Card } from 'e2e/models/common/hew/Card'; import { ProjectActionDropdown } from './ProjectActionDropdown'; /** - * Represents the ProjectsCard in the WorkspaceProjects component + * Represents the ProjectCard in the WorkspaceProjects component */ -export class ProjectsCard extends Card { +export class ProjectCard extends Card { override readonly actionMenu = new ProjectActionDropdown({ clickThisComponentToOpen: this.actionMenuContainer, root: this.root, @@ -16,4 +16,8 @@ export class ProjectsCard extends Card { parent: this, selector: '[data-testid="archived"]', }); + readonly title = new BaseComponent({ + parent: this, + selector: 'h1', + }); } diff --git a/webui/react/src/e2e/models/components/ProjectMoveModal.ts b/webui/react/src/e2e/models/components/ProjectMoveModal.ts new file mode 100644 index 00000000000..69f6693702b --- /dev/null +++ b/webui/react/src/e2e/models/components/ProjectMoveModal.ts @@ -0,0 +1,12 @@ +import { Modal } from 'e2e/models/common/hew/Modal'; +import { Select } from 'e2e/models/common/hew/Select'; + +/** + * Represents the ProjectMoveModal component in src/components/ProjectMoveModal.tsx + */ +export class ProjectMoveModal extends Modal { + readonly destinationWorkspace = new Select({ + parent: this, + selector: 'input[id="workspace"]', + }); +} diff --git a/webui/react/src/e2e/models/pages/WorkspaceDetails/WorkspaceProjects.ts b/webui/react/src/e2e/models/pages/WorkspaceDetails/WorkspaceProjects.ts index 5c0ce36626f..c57ef4f43e2 100644 --- a/webui/react/src/e2e/models/pages/WorkspaceDetails/WorkspaceProjects.ts +++ b/webui/react/src/e2e/models/pages/WorkspaceDetails/WorkspaceProjects.ts @@ -3,9 +3,26 @@ import { BaseReactFragment } from 'playwright-page-model-base/BaseReactFragment' import { Select } from 'e2e/models/common/hew/Select'; import { Toggle } from 'e2e/models/common/hew/Toggle'; -import { ProjectsCard } from 'e2e/models/components/ProjectCard'; +import { GridListRadioGroup } from 'e2e/models/components/GridListRadioGroup'; +import { ProjectCard } from 'e2e/models/components/ProjectCard'; import { ProjectCreateModal } from 'e2e/models/components/ProjectCreateModal'; import { ProjectDeleteModal } from 'e2e/models/components/ProjectDeleteModal'; +import { ProjectMoveModal } from 'e2e/models/components/ProjectMoveModal'; +import { HeadRow, InteractiveTable, Row } from 'e2e/models/components/Table/InteractiveTable'; + +class ProjectHeadRow extends HeadRow { + readonly name = new BaseComponent({ + parent: this, + selector: '[data-testid="Name"]', + }); +} + +class ProjectRow extends Row { + readonly name = new BaseComponent({ + parent: this, + selector: '[data-testid="name"]', + }); +} /** * Represents the WorkspaceProjects page in src/pages/WorkspaceDetails/WorkspaceProjects.tsx @@ -29,15 +46,31 @@ export class WorkspaceProjects extends BaseReactFragment { parent: this, selector: '[data-testid="newProject"]', }); - // TODO missing grid toggle + readonly gridListRadioGroup = new GridListRadioGroup({ + parent: this, + }); + readonly table = new InteractiveTable({ + parent: this, + tableArgs: { + attachment: '[data-testid="table"]', + headRowType: ProjectHeadRow, + rowType: ProjectRow, + }, + }); + readonly projectCards = new ProjectCard({ + parent: this, + }); readonly createModal = new ProjectCreateModal({ root: this.root, }); readonly deleteModal = new ProjectDeleteModal({ root: this.root, }); - cardByName(name: string): ProjectsCard { - return new ProjectsCard({ + readonly moveModal = new ProjectMoveModal({ + root: this.root, + }); + cardByName(name: string): ProjectCard { + return new ProjectCard({ attachment: `[data-testid="card-${name}"]`, parent: this, }); diff --git a/webui/react/src/e2e/tests/projects.spec.ts b/webui/react/src/e2e/tests/projects.spec.ts new file mode 100644 index 00000000000..5e8ded1e06a --- /dev/null +++ b/webui/react/src/e2e/tests/projects.spec.ts @@ -0,0 +1,248 @@ +import _ from 'lodash'; + +import { expect, test } from 'e2e/fixtures/global-fixtures'; +import { ProjectDetails } from 'e2e/models/pages/ProjectDetails'; +import { WorkspaceDetails } from 'e2e/models/pages/WorkspaceDetails'; +import { WorkspaceProjects } from 'e2e/models/pages/WorkspaceDetails/WorkspaceProjects'; +import { randId, safeName } from 'e2e/utils/naming'; +import { V1Project } from 'services/api-ts-sdk'; + +const getCurrentProjectNames = async (workspaceProjects: WorkspaceProjects) => { + await workspaceProjects.projectCards.pwLocator.nth(0).waitFor(); + + const cardTitles = await workspaceProjects.projectCards.title.pwLocator.all(); + return await Promise.all( + cardTitles.map(async (title) => { + return await title.textContent(); + }), + ); +}; + +test.describe('Project UI CRUD', () => { + const projectIds: number[] = []; + + test.beforeEach(async ({ authedPage, newWorkspace }) => { + const workspaceDetails = new WorkspaceDetails(authedPage); + await workspaceDetails.gotoWorkspace(newWorkspace.response.workspace.id); + await workspaceDetails.workspaceProjects.showArchived.switch.uncheck(); + }); + + test.afterAll(async ({ backgroundApiProject }) => { + for (const project of projectIds) { + await backgroundApiProject.deleteProject(project); + } + }); + + test('Create a Project', async ({ authedPage, newWorkspace }) => { + const projectName = safeName('test-project'); + const workspaceDetails = new WorkspaceDetails(authedPage); + const projectDetails = new ProjectDetails(authedPage); + + const workspaceProjects = workspaceDetails.workspaceProjects; + + await test.step('Create a Project', async () => { + await workspaceProjects.newProject.pwLocator.click(); + await workspaceProjects.createModal.projectName.pwLocator.fill(projectName); + await workspaceProjects.createModal.description.pwLocator.fill(randId()); + await workspaceProjects.createModal.footer.submit.pwLocator.click(); + projectIds.push(await projectDetails.getIdFromUrl()); + await workspaceDetails.gotoWorkspace(newWorkspace.response.workspace.id); + await workspaceProjects.cardByName(projectName).pwLocator.waitFor(); + }); + + await test.step('Delete a Project', async () => { + await workspaceDetails.gotoWorkspace(newWorkspace.response.workspace.id); + await workspaceDetails.projectsTab.pwLocator.click(); + const projectCard = workspaceProjects.cardByName(projectName); + await projectCard.actionMenu.open(); + await projectCard.actionMenu.delete.pwLocator.click(); + await workspaceProjects.deleteModal.nameConfirmation.pwLocator.fill(projectName); + await workspaceProjects.deleteModal.footer.submit.pwLocator.click(); + }); + }); + + test('Archive and Unarchive Project', async ({ + authedPage, + newWorkspace, + backgroundApiProject, + }) => { + const workspaceDetails = new WorkspaceDetails(authedPage); + + const newProject = await backgroundApiProject.createProject( + newWorkspace.response.workspace.id, + backgroundApiProject.new(), + ); + projectIds.push(newProject.project.id); + const projectCard = workspaceDetails.workspaceProjects.cardByName(newProject.project.name); + const archiveMenuItem = projectCard.actionMenu.archive; + + await test.step('Archive', async () => { + await authedPage.reload(); + await projectCard.actionMenu.open(); + await expect(archiveMenuItem.pwLocator).toHaveText('Archive'); + await archiveMenuItem.pwLocator.click(); + await projectCard.pwLocator.waitFor({ state: 'hidden' }); + }); + + await test.step('Unarchive', async () => { + await workspaceDetails.workspaceProjects.showArchived.switch.pwLocator.click(); + await projectCard.archivedBadge.pwLocator.waitFor(); + await projectCard.actionMenu.open(); + await expect(archiveMenuItem.pwLocator).toHaveText('Unarchive'); + await archiveMenuItem.pwLocator.click(); + await projectCard.archivedBadge.pwLocator.waitFor({ state: 'hidden' }); + }); + }); + + test('Move a Project', async ({ + authedPage, + newWorkspace, + backgroundApiWorkspace, + backgroundApiProject, + }) => { + const workspaceDetails = new WorkspaceDetails(authedPage); + + const destinationWorkspace = ( + await backgroundApiWorkspace.createWorkspace(backgroundApiWorkspace.new()) + ).workspace; + + const newProject = await backgroundApiProject.createProject( + newWorkspace.response.workspace.id, + backgroundApiProject.new(), + ); + projectIds.push(newProject.project.id); + + await authedPage.reload(); + + const workspaceProjects = workspaceDetails.workspaceProjects; + const projectCard = workspaceProjects.cardByName(newProject.project.name); + const moveMenuItem = projectCard.actionMenu.move; + + await projectCard.actionMenu.open(); + await moveMenuItem.pwLocator.click(); + await workspaceProjects.moveModal.destinationWorkspace.pwLocator.fill( + destinationWorkspace.name, + ); + await workspaceProjects.moveModal.destinationWorkspace.pwLocator.press('Enter'); + await workspaceProjects.moveModal.footer.submit.pwLocator.click(); + + await workspaceProjects.moveModal.pwLocator.waitFor({ state: 'hidden' }); + await projectCard.pwLocator.waitFor({ state: 'hidden' }); + + await workspaceDetails.gotoWorkspace(destinationWorkspace.id); + + await projectCard.pwLocator.waitFor(); + + await backgroundApiWorkspace.deleteWorkspace(destinationWorkspace.id); + }); +}); + +test.describe('Project List', () => { + const projects: V1Project[] = []; + test.beforeAll(async ({ backgroundApiProject, newWorkspace }) => { + const olderProject = await backgroundApiProject.createProject( + newWorkspace.response.workspace.id, + { + name: safeName('a-test-project'), + workspaceId: newWorkspace.response.workspace.id, + }, + ); + projects.push(olderProject.project); + const newerProject = await backgroundApiProject.createProject( + newWorkspace.response.workspace.id, + { + name: safeName('b-test-project'), + workspaceId: newWorkspace.response.workspace.id, + }, + ); + projects.push(newerProject.project); + }); + + test.beforeEach(async ({ authedPage, newWorkspace }) => { + const workspaceDetails = new WorkspaceDetails(authedPage); + await workspaceDetails.gotoWorkspace(newWorkspace.response.workspace.id); + const workspaceProjects = workspaceDetails.workspaceProjects; + + await workspaceProjects.whoseSelect.selectMenuOption('All Projects'); + await workspaceProjects.sortSelect.selectMenuOption('Newest to Oldest'); + await workspaceProjects.gridListRadioGroup.grid.pwLocator.click(); + }); + + test.afterAll(async ({ backgroundApiProject }) => { + for (const project of projects) { + await backgroundApiProject.deleteProject(project.id); + } + }); + + test('Sort', async ({ authedPage }) => { + const workspaceDetails = new WorkspaceDetails(authedPage); + const workspaceProjects = workspaceDetails.workspaceProjects; + + const namesAfterNewest = await getCurrentProjectNames(workspaceProjects); + const idSortedProjectNames = _.orderBy(projects, 'id', 'desc').map((p) => p.name); + expect(idSortedProjectNames).toEqual( + namesAfterNewest.filter((n) => { + return n && projects.map((p) => p.name).includes(n); + }), + ); + + await workspaceProjects.sortSelect.selectMenuOption('Alphabetical'); + + const namesAfterAlphabetical = await getCurrentProjectNames(workspaceProjects); + const nameSortedProjectNames = _.orderBy(projects, 'name', 'asc').map((p) => p.name); + expect(nameSortedProjectNames).toEqual( + namesAfterAlphabetical.filter((n) => { + return n && projects.map((p) => p.name).includes(n); + }), + ); + }); + + test('Filter', async ({ authedPage, apiProject, newWorkspace }) => { + const workspaceDetails = new WorkspaceDetails(authedPage); + const workspaceProjects = workspaceDetails.workspaceProjects; + + const currentUserProject = ( + await apiProject.createProject(newWorkspace.response.workspace.id, { + name: safeName('current-user-project'), + workspaceId: newWorkspace.response.workspace.id, + }) + ).project; + + const currentUserProjectName = currentUserProject.name; + const otherUserProjectName = projects.map((p) => p.name)[0]; + + await authedPage.reload(); + + const namesAfterAll = await getCurrentProjectNames(workspaceProjects); + expect(namesAfterAll).toContain(otherUserProjectName); + expect(namesAfterAll).toContain(currentUserProjectName); + + await workspaceProjects.whoseSelect.selectMenuOption("Others' Projects"); + const namesAfterOthers = await getCurrentProjectNames(workspaceProjects); + expect(namesAfterOthers).toContain(otherUserProjectName); + expect(namesAfterOthers).not.toContain(currentUserProjectName); + + await workspaceProjects.whoseSelect.selectMenuOption('My Projects'); + const namesAfterMy = await getCurrentProjectNames(workspaceProjects); + expect(namesAfterMy).toContain(currentUserProjectName); + expect(namesAfterMy).not.toContain(otherUserProjectName); + + await apiProject.deleteProject(currentUserProject.id); + }); + + test('View Toggle', async ({ authedPage }) => { + const workspaceDetails = new WorkspaceDetails(authedPage); + const workspaceProjects = workspaceDetails.workspaceProjects; + + const firstCard = workspaceProjects.projectCards.nth(0); + const firstRow = workspaceProjects.table.table.rows.nth(0); + + await workspaceProjects.gridListRadioGroup.list.pwLocator.click(); + await firstCard.pwLocator.waitFor({ state: 'hidden' }); + await firstRow.pwLocator.waitFor(); + + await workspaceProjects.gridListRadioGroup.grid.pwLocator.click(); + await firstRow.pwLocator.waitFor({ state: 'hidden' }); + await firstCard.pwLocator.waitFor(); + }); +}); diff --git a/webui/react/src/e2e/tests/projectsWorkspaces.spec.ts b/webui/react/src/e2e/tests/workspaces.spec.ts similarity index 82% rename from webui/react/src/e2e/tests/projectsWorkspaces.spec.ts rename to webui/react/src/e2e/tests/workspaces.spec.ts index 0dd249af381..db4c81942de 100644 --- a/webui/react/src/e2e/tests/projectsWorkspaces.spec.ts +++ b/webui/react/src/e2e/tests/workspaces.spec.ts @@ -2,7 +2,6 @@ import _ from 'lodash'; import { expect, test } from 'e2e/fixtures/global-fixtures'; import { WorkspaceCreateModal } from 'e2e/models/components/WorkspaceCreateModal'; -import { ProjectDetails } from 'e2e/models/pages/ProjectDetails'; import { WorkspaceDetails } from 'e2e/models/pages/WorkspaceDetails'; import { WorkspaceList } from 'e2e/models/pages/WorkspaceList'; import { randId, safeName } from 'e2e/utils/naming'; @@ -57,6 +56,7 @@ test.describe('Workspace UI CRUD', () => { .or(workspaceList.noMatchingWorkspacesMessage.pwLocator) .waitFor(); await workspaceList.showArchived.switch.uncheck(); + await workspaceList.gridListRadioGroup.grid.pwLocator.click(); }); test.afterAll(async ({ backgroundApiWorkspace }) => { @@ -366,80 +366,3 @@ test.describe('Workspace List', () => { await firstCard.pwLocator.waitFor(); }); }); - -test.describe('Project UI CRUD', () => { - const projectIds: number[] = []; - - test.beforeEach(async ({ authedPage, newWorkspace }) => { - const workspaceDetails = new WorkspaceDetails(authedPage); - await workspaceDetails.gotoWorkspace(newWorkspace.response.workspace.id); - await workspaceDetails.workspaceProjects.showArchived.switch.uncheck(); - }); - - test.afterAll(async ({ backgroundApiProject }) => { - for (const project of projectIds) { - await backgroundApiProject.deleteProject(project); - } - }); - - test('Create a Project', async ({ authedPage, newWorkspace }) => { - const projectName = safeName('test-project'); - const workspaceDetails = new WorkspaceDetails(authedPage); - const projectDetails = new ProjectDetails(authedPage); - - const projects = workspaceDetails.workspaceProjects; - - await test.step('Create a Project', async () => { - await projects.newProject.pwLocator.click(); - await projects.createModal.projectName.pwLocator.fill(projectName); - await projects.createModal.description.pwLocator.fill(randId()); - await projects.createModal.footer.submit.pwLocator.click(); - projectIds.push(await projectDetails.getIdFromUrl()); - await workspaceDetails.gotoWorkspace(newWorkspace.response.workspace.id); - await projects.cardByName(projectName).pwLocator.waitFor(); - }); - - await test.step('Delete a Project', async () => { - await workspaceDetails.gotoWorkspace(newWorkspace.response.workspace.id); - await workspaceDetails.projectsTab.pwLocator.click(); - const projectCard = projects.cardByName(projectName); - await projectCard.actionMenu.open(); - await projectCard.actionMenu.delete.pwLocator.click(); - await projects.deleteModal.nameConfirmation.pwLocator.fill(projectName); - await projects.deleteModal.footer.submit.pwLocator.click(); - }); - }); - - test('Archive and Unarchive Project', async ({ - authedPage, - newWorkspace, - backgroundApiProject, - }) => { - const workspaceDetails = new WorkspaceDetails(authedPage); - - const newProject = await backgroundApiProject.createProject( - newWorkspace.response.workspace.id, - backgroundApiProject.new(), - ); - projectIds.push(newProject.project.id); - const projectCard = workspaceDetails.workspaceProjects.cardByName(newProject.project.name); - const archiveMenuItem = projectCard.actionMenu.archive; - - await test.step('Archive', async () => { - await authedPage.reload(); - await projectCard.actionMenu.open(); - await expect(archiveMenuItem.pwLocator).toHaveText('Archive'); - await archiveMenuItem.pwLocator.click(); - await projectCard.pwLocator.waitFor({ state: 'hidden' }); - }); - - await test.step('Unarchive', async () => { - await workspaceDetails.workspaceProjects.showArchived.switch.pwLocator.click(); - await projectCard.archivedBadge.pwLocator.waitFor(); - await projectCard.actionMenu.open(); - await expect(archiveMenuItem.pwLocator).toHaveText('Unarchive'); - await archiveMenuItem.pwLocator.click(); - await projectCard.archivedBadge.pwLocator.waitFor({ state: 'hidden' }); - }); - }); -}); diff --git a/webui/react/src/pages/WorkspaceDetails/WorkspaceProjects.tsx b/webui/react/src/pages/WorkspaceDetails/WorkspaceProjects.tsx index d4682838eea..fa9477c5ed1 100644 --- a/webui/react/src/pages/WorkspaceDetails/WorkspaceProjects.tsx +++ b/webui/react/src/pages/WorkspaceDetails/WorkspaceProjects.tsx @@ -254,7 +254,7 @@ const WorkspaceProjects: React.FC = ({ workspace, id, pageRef }) => { dataIndex: 'name', defaultWidth: DEFAULT_COLUMN_WIDTHS['name'], key: V1GetWorkspaceProjectsRequestSortBy.NAME, - onCell: onRightClickableCell, + onCell: () => ({ ...onRightClickableCell(), 'data-testid': 'name' }), render: projectNameRenderer, title: 'Name', }, @@ -437,7 +437,11 @@ const WorkspaceProjects: React.FC = ({ workspace, id, pageRef }) => {
- @@ -452,7 +456,11 @@ const WorkspaceProjects: React.FC = ({ workspace, id, pageRef }) => { onChange={switchShowArchived} /> )} -