Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ✨ bulk actions on projects #1479

Merged
merged 1 commit into from
Nov 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/client/cypress.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export default defineConfig({
}),
)
},
viewportHeight: 1024,
viewportWidth: 1280,
baseUrl: `http://${clientHost}:${clientPort}`,
fixturesFolder: 'cypress/e2e/fixtures',
specPattern: 'cypress/e2e/specs/**/*.{cy,e2e}.{j,t}s',
Expand Down
161 changes: 129 additions & 32 deletions apps/client/cypress/e2e/specs/admin/projects.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import type { Organization, Project, ProjectV2 } from '@cpn-console/shared'
import type { Organization, ProjectV2 } from '@cpn-console/shared'
import { sortArrByObjKeyAsc, statusDict } from '@cpn-console/shared'
import { getModel, getModelById } from '../../support/func.js'

function checkTableRowsLength(length: number) {
if (!length) cy.get('tr:last-child>td:first-child').should('have.text', 'Aucun projet trouvé')
else cy.get('tbody > tr').should('have.length', length)
}
import type { Project } from '@/utils/project-utils.js'

describe('Administration projects', () => {
const admin = getModelById('user', 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566')
Expand Down Expand Up @@ -35,57 +31,158 @@ describe('Administration projects', () => {
it('Should display projects table, loggedIn as admin', () => {
cy.intercept('GET', 'api/v1/projects*').as('getAllProjects')
cy.wait('@getAllProjects')
cy.get('select#tableAdministrationProjectsFilter').select('Tous')
cy.get('select#projectSearchFilter').select('Tous')
cy.getByDataTestid('projectsSearchBtn')
.click()
cy.wait('@getAllProjects').its('response').then((response) => {
const projects = response.body
cy.getByDataTestid('tableAdministrationProjects')
.get('tbody tr')
.should('have.length.at.least', 2)
projects.forEach((project: Project) => {
cy.getByDataTestid(`tr-${project.id}`)
.within(() => {
cy.get('td:nth-of-type(2)').should('contain', getModelById('organization', project.organizationId).label)
cy.get('td:nth-of-type(3)').should('contain', project.name)
cy.get('td:nth-of-type(4)').should('contain', project.owner.email)
cy.get('td:nth-of-type(5)').invoke('attr', 'title').should('contain', statusDict.status[project.status].wording)
cy.get('td:nth-of-type(5)').invoke('attr', 'title').should('contain', statusDict.locked[String(!!project.locked)].wording)
cy.get('td:nth-of-type(6)').should('contain.text', 'il y a')
})
})
})
})

it('Should display handle multi-select and bulk actions, loggedIn as admin', () => {
cy.intercept('GET', 'api/v1/projects*').as('getAllProjects')
cy.wait('@getAllProjects')

// attente que les lignes soient bien rendus
cy.getByDataTestid('tableAdministrationProjects')
.get('tbody tr')
.should('have.length.at.least', 2)
.get('tbody > tr')
.should('not.have.text', 'Chargement...')

cy.getByDataTestid('tableAdministrationProjects')
.within(() => {
projects.forEach((project, index: number) => {
cy.get(`tbody tr:nth-of-type(${index + 1})`).within(() => {
cy.getSettled('td:nth-of-type(1)').should('contain', project.organization)
cy.getSettled('td:nth-of-type(2)').should('contain', project.name)
cy.getSettled('td:nth-of-type(3)').should('contain', project.owner.email)
cy.getSettled('td:nth-of-type(4) svg title').should('contain', `Le projet ${project.name} est ${statusDict.status[project.status].wording}`)
cy.getSettled('td:nth-of-type(5) svg title').should('contain', `Le projet ${project.name} est ${statusDict.locked[String(!!project.locked)].wording}`)
cy.getSettled('td:nth-of-type(6)').should('contain', 'il y a 1 an') // ça va être rigolo quand ce code sera outdated
})
})
})
.get('tbody > tr')
.should('not.have.attr', 'selected')
cy.getByDataTestid('select-all-cbx')
.check()
cy.getByDataTestid('tableAdministrationProjects')
.get('tbody > tr')
.should('have.attr', 'selected')
cy.getByDataTestid('select-all-cbx')
.check()
cy.getByDataTestid(`tr-${projects[1].id}`)
.click()
.should('not.have.attr', 'selected')
cy.getByDataTestid('select-all-cbx')
.should('not.be.checked')
cy.getByDataTestid(`tr-${projects[1].id}`)
.click()
.should('have.attr', 'selected')
cy.getByDataTestid('select-all-cbx')
.should('be.checked')

// count must appear
cy.getByDataTestid('projectSelectedCount')
.should('contain.text', 'projets')
// filter by partial name
cy.getByDataTestid('projectsSearchInput')
.clear()
.type('li')
cy.getByDataTestid('projectsSearchBtn')
.click()
// count must equal only displayed element
cy.getByDataTestid('projectSelectedCount')
.should('contain.text', '3 projets')
// verrouillage des projets
cy.getByDataTestid('selectBulkAction')
.select('lock')
cy.getByDataTestid('validateBulkAction')
.click()
cy.getByDataTestid('snackbar').should('contain', `Traitement en cours`)
cy.getByDataTestid('projectsSearchBtn')
.click()
cy.getByDataTestid('tableAdministrationProjects')
.get('tbody > tr')
.should('not.have.attr', 'selected')
cy.getByDataTestid('tableAdministrationProjects')
.get('tbody > tr > td:nth-of-type(5)')
.invoke('attr', 'title')
.should('contain', statusDict.locked.true.wording)
// annulation de la modification
cy.getByDataTestid('select-all-cbx')
.check()
cy.getByDataTestid('projectSelectedCount')
.should('contain.text', '3 projets')
cy.getByDataTestid('selectBulkAction')
.select('unlock')
cy.getByDataTestid('validateBulkAction')
.click()
cy.getByDataTestid('snackbar').should('contain', `Traitement en cours`)
cy.getByDataTestid('projectsSearchBtn')
.click()
cy.getByDataTestid('tableAdministrationProjects')
.get('tbody > tr')
.should('not.have.attr', 'selected')
cy.getByDataTestid('tableAdministrationProjects')
.get('tbody > tr > td:nth-of-type(5)')
.invoke('attr', 'title')
.should('contain', statusDict.locked.false.wording)
})

it('Should display filtered projects, loggedIn as admin', () => {
cy.intercept('GET', /api\/v1\/projects\?filter=all$/).as('getAllProjects')
cy.intercept('GET', /api\/v1\/projects\?filter=all&search=pr$/).as('searchInputProjects')
cy.intercept('GET', 'api/v1/projects?filter=all&statusNotIn=archived').as('getActiveProjects')
cy.intercept('GET', 'api/v1/projects?filter=all&statusIn=archived').as('getArchivedProjects')
cy.intercept('GET', 'api/v1/projects?filter=all&statusIn=failed').as('getFailedProjects')
cy.intercept('GET', 'api/v1/projects?filter=all&locked=true&statusNotIn=archived').as('getLockedProjects')
cy.intercept('GET', 'api/v1/organizations').as('getOrganizations')

cy.get('select#tableAdministrationProjectsFilter').select('Tous')
cy.get('select#projectSearchFilter').select('Tous')
cy.getByDataTestid('projectsSearchBtn')
.click()
cy.wait('@getAllProjects').its('response').then((response) => {
cy.getByDataTestid('tableAdministrationProjects').within(() => checkTableRowsLength(response.body.length))
cy.checkTableBody('tableAdministrationProjects', response.body.length, 'Aucun projet trouvé')
})

cy.get('select#tableAdministrationProjectsFilter').select('Archivés')
cy.get('select#projectSearchFilter').select('Archivés')
cy.getByDataTestid('projectsSearchBtn')
.click()
cy.wait('@getArchivedProjects').its('response').then((response) => {
cy.getByDataTestid('tableAdministrationProjects').within(() => checkTableRowsLength(response.body.length))
cy.checkTableBody('tableAdministrationProjects', response.body.length, 'Aucun projet trouvé')
})

cy.get('select#tableAdministrationProjectsFilter').select('Non archivés')
cy.get('select#projectSearchFilter').select('Non archivés')
cy.getByDataTestid('projectsSearchBtn')
.click()
cy.wait('@getActiveProjects').its('response').then((response) => {
cy.getByDataTestid('tableAdministrationProjects').within(() => checkTableRowsLength(response.body.length))
cy.checkTableBody('tableAdministrationProjects', response.body.length, 'Aucun projet trouvé')
})

cy.get('select#tableAdministrationProjectsFilter').select('Échoués')
cy.get('select#projectSearchFilter').select('Échoués')
cy.getByDataTestid('projectsSearchBtn')
.click()
cy.wait('@getFailedProjects').its('response').then((response) => {
cy.getByDataTestid('tableAdministrationProjects').within(() => checkTableRowsLength(response.body.length))
cy.checkTableBody('tableAdministrationProjects', response.body.length, 'Aucun projet trouvé')
})

cy.get('select#tableAdministrationProjectsFilter').select('Verrouillés')
cy.get('select#projectSearchFilter').select('Verrouillés')
cy.getByDataTestid('projectsSearchBtn')
.click()
cy.wait('@getLockedProjects').its('response').then((response) => {
cy.getByDataTestid('tableAdministrationProjects').within(() => checkTableRowsLength(response.body.length))
cy.checkTableBody('tableAdministrationProjects', response.body.length, 'Aucun projet trouvé')
})

cy.get('select#projectSearchFilter').select('Tous')
cy.getByDataTestid('projectsSearchInput')
.clear()
.type('pr')
cy.getByDataTestid('projectsSearchBtn')
.click()
cy.wait('@searchInputProjects').its('response').then((response) => {
cy.checkTableBody('tableAdministrationProjects', response.body.length, 'Aucun projet trouvé')
})
})

Expand Down Expand Up @@ -144,7 +241,7 @@ describe('Administration projects', () => {
cy.visit('/admin/projects')
cy.url().should('contain', '/admin/projects')
cy.wait('@getAllProjects')
cy.get('select#tableAdministrationProjectsFilter').select('Tous')
cy.get('select#projectSearchFilter').select('Tous')
cy.getByDataTestid('tableAdministrationProjects').within(() => {
cy.get('tr').contains(projectName)
.click()
Expand Down
12 changes: 12 additions & 0 deletions apps/client/cypress/e2e/support/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,3 +480,15 @@ Cypress.Commands.add('goToAdminListUsers', () => {
cy.url().should('contain', '/admin/users')
}
})

Cypress.Commands.add('checkTableBody', (tableDataTestId: string, rowLength: number, noResultText?: string) => {
if (rowLength) {
cy.getByDataTestid(tableDataTestId)
.get('tbody > tr')
.should('not.have.text', noResultText)
.should('have.length', rowLength)
} else {
cy.get('tr:last-child>td:first-child')
.should('have.text', noResultText)
}
})
2 changes: 0 additions & 2 deletions apps/client/src/utils/func.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@ export function toCodeComponent(value: string) {
}
}

export const bts = (v: boolean) => v ? 'true' : 'false'

const maxDescriptionLength = 60
export function truncateDescription(description: string) {
let innerHTML: string
Expand Down
12 changes: 6 additions & 6 deletions apps/client/src/views/admin/AdminProject.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import { onBeforeMount, ref } from 'vue'
// @ts-ignore '@gouvminint/vue-dsfr' missing types
import { getRandomId } from '@gouvminint/vue-dsfr'
import type { CleanedCluster, Environment, Log, PluginsUpdateBody, ProjectService, ProjectV2, Repo, Zone } from '@cpn-console/shared'
import { bts } from '@cpn-console/shared'
import fr from 'javascript-time-ago/locale/fr'
import TimeAgo from 'javascript-time-ago'
import { useSnackbarStore } from '@/stores/snackbar.js'
import { useUserStore } from '@/stores/user.js'
import { useQuotaStore } from '@/stores/quota.js'
import { useProjectStore } from '@/stores/project.js'
import { useStageStore } from '@/stores/stage.js'
import { bts } from '@/utils/func.js'
import { useLogStore } from '@/stores/log.js'
import router from '@/router/index.js'
import { useClusterStore } from '@/stores/cluster.js'
Expand Down Expand Up @@ -192,7 +192,7 @@ async function getProjectLogs({ offset, limit }: { offset: number, limit: number
<div>
<DsfrCallout
:title="`${project.name} (${project.organization.name})`"
:content="project.description"
:content="project.description ?? ''"
/>
<div
class="w-full flex place-content-evenly fr-mb-2w"
Expand All @@ -217,7 +217,7 @@ async function getProjectLogs({ offset, limit }: { offset: number, limit: number
<DsfrButton
data-testid="replayHooksBtn"
label="Reprovisionner le projet"
:icon="{ name: 'ri:refresh-fill', animation: project.operationsInProgress.includes('replay') ? 'spin' : '' }"
:icon="{ name: 'ri:refresh-fill', animation: project.operationsInProgress.includes('replay') ? 'spin' : undefined }"
:disabled="project.operationsInProgress.includes('replay') || project.locked"
secondary
@click="replayHooks()"
Expand All @@ -228,7 +228,7 @@ async function getProjectLogs({ offset, limit }: { offset: number, limit: number
:icon="project.operationsInProgress.includes('lockHandling')
? { name: 'ri:refresh-fill', animation: 'spin' }
: project.locked ? 'ri:lock-unlock-fill' : 'ri:lock-fill'"
:disabled="project.operationsInProgress.includes('lockHandling')"
:disabled="project.operationsInProgress.includes('lockHandling') || project.status === 'archived'"
secondary
@click="handleProjectLocking"
/>
Expand All @@ -237,7 +237,7 @@ async function getProjectLogs({ offset, limit }: { offset: number, limit: number
data-testid="showArchiveProjectBtn"
label="Supprimer le projet"
secondary
:disabled="project.operationsInProgress.includes('delete')"
:disabled="project.operationsInProgress.includes('delete') || project.locked"
:icon="project.operationsInProgress.includes('delete')
? { name: 'ri:refresh-fill', animation: 'spin' }
: 'ri:delete-bin-7-line'"
Expand Down Expand Up @@ -335,7 +335,7 @@ async function getProjectLogs({ offset, limit }: { offset: number, limit: number
value: quota.id,
}))"
select-id="quota-select"
@update:model-value="(event: string) => updateEnvironmentQuota({ environmentId: env.id, quotaId: event })"
@update:model-value="(event: string | number) => updateEnvironmentQuota({ environmentId: env.id, quotaId: event.toString() })"
/>
</td>
<td>
Expand Down
Loading
Loading