Skip to content

Commit

Permalink
feat(settings-project): add general and danger zone (#403)
Browse files Browse the repository at this point in the history
  • Loading branch information
RemiBonnet committed Dec 1, 2022
1 parent 8574d8a commit 2c904ad
Show file tree
Hide file tree
Showing 12 changed files with 471 additions and 12 deletions.
74 changes: 70 additions & 4 deletions libs/domains/projects/src/lib/slices/projects.slice.ts
@@ -1,12 +1,21 @@
import { PayloadAction, createAsyncThunk, createEntityAdapter, createSelector, createSlice } from '@reduxjs/toolkit'
import { Project, ProjectRequest, ProjectsApi } from 'qovery-typescript-axios'
import {
PayloadAction,
Update,
createAsyncThunk,
createEntityAdapter,
createSelector,
createSlice,
} from '@reduxjs/toolkit'
import { Project, ProjectMainCallsApi, ProjectRequest, ProjectsApi } from 'qovery-typescript-axios'
import { ProjectsState } from '@qovery/shared/interfaces'
import { ToastEnum, toast, toastError } from '@qovery/shared/toast'
import { addOneToManyRelation, getEntitiesByIds } from '@qovery/shared/utils'
import { RootState } from '@qovery/store'

export const PROJECTS_FEATURE_KEY = 'projects'

const projectsApi = new ProjectsApi()
const projectMainCalls = new ProjectMainCallsApi()

export const projectsAdapter = createEntityAdapter<Project>()

Expand All @@ -16,7 +25,7 @@ export const fetchProjects = createAsyncThunk<Project[], { organizationId: strin
})

export const postProject = createAsyncThunk<Project, { organizationId: string } & ProjectRequest>(
'projects/post',
'project/post',
async (data, { rejectWithValue }) => {
const { organizationId, ...fields } = data

Expand All @@ -29,6 +38,20 @@ export const postProject = createAsyncThunk<Project, { organizationId: string }
}
)

export const editProject = createAsyncThunk(
'project/edit',
async (payload: { projectId: string; data: Partial<Project> }) => {
const cloneProject = Object.assign({}, payload.data)
const response = await projectMainCalls.editProject(payload.projectId, cloneProject as ProjectRequest)

return response.data as Project
}
)

export const deleteProject = createAsyncThunk('project/delete', async (payload: { projectId: string }) => {
return await projectMainCalls.deleteProject(payload.projectId)
})

export const initialProjectsState: ProjectsState = projectsAdapter.getInitialState({
loadingStatus: 'not loaded',
error: null,
Expand Down Expand Up @@ -60,7 +83,7 @@ export const projectsSlice = createSlice({
state.loadingStatus = 'error'
state.error = action.error.message
})
// post
// post project
.addCase(postProject.pending, (state: ProjectsState) => {
state.loadingStatus = 'loading'
})
Expand All @@ -72,6 +95,42 @@ export const projectsSlice = createSlice({
state.loadingStatus = 'error'
state.error = action.error.message
})
// edit project
.addCase(editProject.pending, (state: ProjectsState) => {
state.loadingStatus = 'loading'
})
.addCase(editProject.fulfilled, (state: ProjectsState, action) => {
const update: Update<Project> = {
id: action.meta.arg.projectId,
changes: {
...action.payload,
},
}
projectsAdapter.updateOne(state, update)
state.error = null
state.loadingStatus = 'loaded'
toast(ToastEnum.SUCCESS, 'Project updated')
})
.addCase(editProject.rejected, (state: ProjectsState, action) => {
state.loadingStatus = 'error'
toastError(action.error)
state.error = action.error.message
})
// delete project
.addCase(deleteProject.pending, (state: ProjectsState) => {
state.loadingStatus = 'loading'
})
.addCase(deleteProject.fulfilled, (state: ProjectsState, action) => {
projectsAdapter.removeOne(state, action.meta.arg.projectId)
state.loadingStatus = 'loaded'
state.error = null
toast(ToastEnum.SUCCESS, `Your project has been deleted`)
})
.addCase(deleteProject.rejected, (state: ProjectsState, action) => {
state.loadingStatus = 'error'
state.error = action.error.message
toastError(action.error)
})
},
})

Expand All @@ -91,3 +150,10 @@ export const selectProjectsEntitiesByOrgId = (state: RootState, organizationId:
const projectState = getProjectsState(state)
return getEntitiesByIds<Project>(projectState.entities, projectState?.joinOrganizationProject[organizationId])
}

export const selectProjectById = (state: RootState, organizationId: string, projectId: string): Project => {
const projectState = getProjectsState(state)
return getEntitiesByIds<Project>(projectState.entities, projectState?.joinOrganizationProject[organizationId]).find(
(project: Project) => project.id === projectId
) as Project
}
@@ -0,0 +1,9 @@
import { render } from '__tests__/utils/setup-jest'
import PageProjectDangerZoneFeature from './page-project-danger-zone-feature'

describe('PageProjectDangerZoneFeature', () => {
it('should render successfully', () => {
const { baseElement } = render(<PageProjectDangerZoneFeature />)
expect(baseElement).toBeTruthy()
})
})
@@ -0,0 +1,35 @@
import { useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useNavigate, useParams } from 'react-router-dom'
import { deleteProject, selectProjectById } from '@qovery/domains/projects'
import { SETTINGS_URL } from '@qovery/shared/router'
import { useDocumentTitle } from '@qovery/shared/utils'
import { AppDispatch, RootState } from '@qovery/store'
import PageProjectDangerZone from '../../ui/page-project-danger-zone/page-project-danger-zone'

export function PageProjectDangerZoneFeature() {
const { organizationId = '', projectId = '' } = useParams()
useDocumentTitle('Danger zone - Project settings')

const dispatch = useDispatch<AppDispatch>()
const navigate = useNavigate()

const [loading, setLoading] = useState(false)

const project = useSelector((state: RootState) => selectProjectById(state, organizationId, projectId))

const deleteProjectAction = () => {
setLoading(true)

dispatch(deleteProject({ projectId }))
.unwrap()
.then(() => {
setLoading(false)
navigate(SETTINGS_URL(organizationId))
})
}

return <PageProjectDangerZone deleteProject={deleteProjectAction} project={project} loading={loading} />
}

export default PageProjectDangerZoneFeature
@@ -0,0 +1,70 @@
import { act, fireEvent, render } from '__tests__/utils/setup-jest'
import { Project } from 'qovery-typescript-axios'
import * as storeProjects from '@qovery/domains/projects'
import PageProjectGeneralFeature from './page-project-general-feature'

import SpyInstance = jest.SpyInstance

const mockProject: Project = storeProjects.projectsFactoryMock(1)[0]

jest.mock('@qovery/domains/projects', () => {
return {
...jest.requireActual('@qovery/domains/projects'),
editProject: jest.fn(),
selectProjectById: () => {
const currentMockProject = mockProject
mockProject.id = '0'
mockProject.description = 'description'
return currentMockProject
},
}
})

const mockDispatch = jest.fn()
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: () => mockDispatch,
}))

jest.mock('react-router-dom', () => ({
...(jest.requireActual('react-router-dom') as any),
useParams: () => ({ organizationId: '0', projectId: '0' }),
}))

describe('PageProjectGeneral', () => {
it('should render successfully', () => {
const { baseElement } = render(<PageProjectGeneralFeature />)
expect(baseElement).toBeTruthy()
})

it('should dispatch editProject if form is submitted', async () => {
const editProjectSpy: SpyInstance = jest.spyOn(storeProjects, 'editProject')
mockDispatch.mockImplementation(() => ({
unwrap: () =>
Promise.resolve({
data: {},
}),
}))

const { getByTestId } = render(<PageProjectGeneralFeature />)

await act(() => {
const inputName = getByTestId('input-name')
fireEvent.input(inputName, { target: { value: 'hello-world' } })
})

expect(getByTestId('submit-button')).not.toBeDisabled()

await act(() => {
getByTestId('submit-button').click()
})

expect(editProjectSpy).toHaveBeenCalledWith({
data: {
name: 'hello-world',
description: 'description',
},
projectId: '0',
})
})
})
@@ -0,0 +1,56 @@
import { useEffect, useState } from 'react'
import { FormProvider, useForm } from 'react-hook-form'
import { useDispatch, useSelector } from 'react-redux'
import { useParams } from 'react-router-dom'
import { editProject, selectProjectById } from '@qovery/domains/projects'
import { useDocumentTitle } from '@qovery/shared/utils'
import { AppDispatch, RootState } from '@qovery/store'
import PageProjectGeneral from '../../ui/page-project-general/page-project-general'

export function PageProjectGeneralFeature() {
const { organizationId = '', projectId = '' } = useParams()
useDocumentTitle('General - Project settings')

const project = useSelector((state: RootState) => selectProjectById(state, organizationId, projectId))

const [loading, setLoading] = useState(false)
const dispatch = useDispatch<AppDispatch>()

const methods = useForm({
mode: 'onChange',
})

useEffect(() => {
methods.reset({
name: project?.name || '',
description: project?.description || '',
})
}, [methods, project?.name, project?.description])

const onSubmit = methods.handleSubmit((data) => {
if (data && project) {
setLoading(true)

dispatch(
editProject({
projectId,
data: {
name: data['name'],
description: data['description'],
},
})
)
.unwrap()
.then(() => setLoading(false))
.catch(() => setLoading(false))
}
})

return (
<FormProvider {...methods}>
<PageProjectGeneral onSubmit={onSubmit} loading={loading} />
</FormProvider>
)
}

export default PageProjectGeneralFeature
13 changes: 5 additions & 8 deletions libs/pages/settings/src/lib/page-settings.tsx
Expand Up @@ -8,6 +8,9 @@ import {
SETTINGS_DANGER_ZONE_URL,
SETTINGS_GENERAL_URL,
SETTINGS_MEMBERS_URL,
SETTINGS_PROJECT_DANGER_ZONE_URL,
SETTINGS_PROJECT_GENERAL_URL,
SETTINGS_PROJECT_URL,
SETTINGS_ROLES_URL,
SETTINGS_URL,
} from '@qovery/shared/router'
Expand Down Expand Up @@ -70,17 +73,11 @@ export function PageSettings() {
subLinks: [
{
title: 'General',
onClick: () =>
window.open(
`https://console.qovery.com/platform/organization/${organizationId}/projects/${project.id}/environments`
),
url: pathSettings + SETTINGS_PROJECT_URL(project.id) + SETTINGS_PROJECT_GENERAL_URL,
},
{
title: 'Danger zone',
onClick: () =>
window.open(
`https://console.qovery.com/platform/organization/${organizationId}/projects/${project.id}/environments`
),
url: pathSettings + SETTINGS_PROJECT_URL(project.id) + SETTINGS_PROJECT_DANGER_ZONE_URL,
},
],
}))
Expand Down
13 changes: 13 additions & 0 deletions libs/pages/settings/src/lib/router/router.tsx
Expand Up @@ -6,6 +6,9 @@ import {
SETTINGS_DANGER_ZONE_URL,
SETTINGS_GENERAL_URL,
SETTINGS_MEMBERS_URL,
SETTINGS_PROJECT_DANGER_ZONE_URL,
SETTINGS_PROJECT_GENERAL_URL,
SETTINGS_PROJECT_URL,
SETTINGS_ROLES_EDIT_URL,
SETTINGS_ROLES_URL,
} from '@qovery/shared/router'
Expand All @@ -16,6 +19,8 @@ import { PageOrganizationGeneralFeature } from '../feature/page-organization-gen
import { PageOrganizationMembersFeature } from '../feature/page-organization-members-feature/page-organization-members-feature'
import { PageOrganizationRolesEditFeature } from '../feature/page-organization-roles-edit-feature/page-organization-roles-edit-feature'
import { PageOrganizationRolesFeature } from '../feature/page-organization-roles-feature/page-organization-roles-feature'
import PageProjectDangerZoneFeature from '../feature/page-project-danger-zone-feature/page-project-danger-zone-feature'
import PageProjectGeneralFeature from '../feature/page-project-general-feature/page-project-general-feature'
import PageSettingsV2 from '../ui/page-settings-v2/page-settings-v2'

export const ROUTER_SETTINGS: Route[] = [
Expand Down Expand Up @@ -51,4 +56,12 @@ export const ROUTER_SETTINGS: Route[] = [
path: SETTINGS_DANGER_ZONE_URL,
component: <PageOrganizationDangerZoneFeature />,
},
{
path: SETTINGS_PROJECT_URL() + SETTINGS_PROJECT_GENERAL_URL,
component: <PageProjectGeneralFeature />,
},
{
path: SETTINGS_PROJECT_URL() + SETTINGS_PROJECT_DANGER_ZONE_URL,
component: <PageProjectDangerZoneFeature />,
},
]
@@ -0,0 +1,14 @@
import { render } from '__tests__/utils/setup-jest'
import PageProjectDangerZone, { PageProjectDangerZoneProps } from './page-project-danger-zone'

const props: PageProjectDangerZoneProps = {
deleteProject: jest.fn(),
loading: false,
}

describe('PageProjectDangerZone', () => {
it('should render successfully', () => {
const { baseElement } = render(<PageProjectDangerZone {...props} />)
expect(baseElement).toBeTruthy()
})
})

0 comments on commit 2c904ad

Please sign in to comment.