diff --git a/apps/console/src/app/app.tsx b/apps/console/src/app/app.tsx index bf9294a6ed..0d451f0b37 100644 --- a/apps/console/src/app/app.tsx +++ b/apps/console/src/app/app.tsx @@ -15,6 +15,7 @@ import { LOGIN_URL, LOGOUT_URL, ProtectedRoute } from '@qovery/shared/router' import { LoadingScreen } from '@qovery/shared/ui' import { useAuthInterceptor, useDocumentTitle } from '@qovery/shared/utils' import { environment } from '../environments/environment' +import ScrollToTop from './components/scroll-to-top' import { ROUTER } from './router/main.router' export function App() { @@ -73,6 +74,7 @@ export function App() { return ( + } /> } /> diff --git a/apps/console/src/app/components/scroll-to-top.tsx b/apps/console/src/app/components/scroll-to-top.tsx new file mode 100644 index 0000000000..dcc447daa7 --- /dev/null +++ b/apps/console/src/app/components/scroll-to-top.tsx @@ -0,0 +1,12 @@ +import { useEffect } from 'react' +import { useLocation } from 'react-router-dom' + +export default function ScrollToTop() { + const { pathname } = useLocation() + + useEffect(() => { + window.scrollTo(0, 0) + }, [pathname]) + + return null +} diff --git a/libs/domains/projects/src/lib/slices/projects.slice.ts b/libs/domains/projects/src/lib/slices/projects.slice.ts index d516503fed..d82941b683 100644 --- a/libs/domains/projects/src/lib/slices/projects.slice.ts +++ b/libs/domains/projects/src/lib/slices/projects.slice.ts @@ -89,6 +89,9 @@ export const projectsSlice = createSlice({ }) .addCase(postProject.fulfilled, (state: ProjectsState, action: PayloadAction) => { projectsAdapter.upsertOne(state, action.payload) + state.joinOrganizationProject = addOneToManyRelation(action.payload.organization?.id, action.payload.id, { + ...state.joinOrganizationProject, + }) state.loadingStatus = 'loaded' }) .addCase(postProject.rejected, (state: ProjectsState, action) => { diff --git a/libs/pages/application/src/lib/feature/tabs-feature/tabs-feature.tsx b/libs/pages/application/src/lib/feature/tabs-feature/tabs-feature.tsx index 7aa8914494..3942b41c59 100644 --- a/libs/pages/application/src/lib/feature/tabs-feature/tabs-feature.tsx +++ b/libs/pages/application/src/lib/feature/tabs-feature/tabs-feature.tsx @@ -19,7 +19,7 @@ import { ButtonStyle, Icon, IconAwesomeEnum, - MenuItemProps, + MenuData, Skeleton, StatusChip, Tabs, @@ -102,13 +102,7 @@ export function TabsFeature() { APPLICATION_URL(organizationId, projectId, environmentId, applicationId) + APPLICATION_VARIABLES_URL ) - let menuForContentRight: { - items: MenuItemProps[] - title?: string - button?: string - buttonLink?: string - search?: boolean - }[] = [] + let menuForContentRight: MenuData = [] if (matchEnvVariableRoute) { menuForContentRight = [ diff --git a/libs/pages/clusters/src/lib/ui/card-cluster/card-cluster.spec.tsx b/libs/pages/clusters/src/lib/ui/card-cluster/card-cluster.spec.tsx index 6f98bfa087..3c5d22f020 100644 --- a/libs/pages/clusters/src/lib/ui/card-cluster/card-cluster.spec.tsx +++ b/libs/pages/clusters/src/lib/ui/card-cluster/card-cluster.spec.tsx @@ -34,7 +34,7 @@ describe('CardCluster', () => { }) it('should have a status message', () => { - const status = props.cluster.extendedStatus?.status?.status || StateEnum.BUILDING + const status = props.cluster.extendedStatus?.status?.status const { baseElement } = render() diff --git a/libs/pages/layout/src/lib/feature/breadcrumb/breadcrumb.tsx b/libs/pages/layout/src/lib/feature/breadcrumb/breadcrumb.tsx index 2a95a09cc1..3cd705e7e8 100644 --- a/libs/pages/layout/src/lib/feature/breadcrumb/breadcrumb.tsx +++ b/libs/pages/layout/src/lib/feature/breadcrumb/breadcrumb.tsx @@ -5,7 +5,8 @@ import { selectDatabasesEntitiesByEnvId } from '@qovery/domains/database' import { selectEnvironmentsEntitiesByProjectId } from '@qovery/domains/environment' import { selectAllOrganization, selectClustersEntitiesByOrganizationId } from '@qovery/domains/organization' import { selectProjectsEntitiesByOrgId } from '@qovery/domains/projects' -import { Breadcrumb } from '@qovery/shared/ui' +import { CreateProjectModalFeature } from '@qovery/shared/console-shared' +import { Breadcrumb, useModal } from '@qovery/shared/ui' import { RootState } from '@qovery/store' export function BreadcrumbFeature() { @@ -16,6 +17,13 @@ export function BreadcrumbFeature() { const databases = useSelector((state: RootState) => selectDatabasesEntitiesByEnvId(state, environmentId)) const environments = useSelector((state: RootState) => selectEnvironmentsEntitiesByProjectId(state, projectId)) const projects = useSelector((state: RootState) => selectProjectsEntitiesByOrgId(state, organizationId)) + const { openModal, closeModal } = useModal() + + const createProjectModal = () => { + openModal({ + content: , + }) + } return ( ) } diff --git a/libs/pages/settings/src/lib/page-settings.tsx b/libs/pages/settings/src/lib/page-settings.tsx index f6fbd5bccd..c889cf01d3 100644 --- a/libs/pages/settings/src/lib/page-settings.tsx +++ b/libs/pages/settings/src/lib/page-settings.tsx @@ -22,7 +22,6 @@ export function PageSettings() { const { organizationId = '' } = useParams() const pathSettings = SETTINGS_URL(organizationId) - const projects = useSelector((state: RootState) => selectProjectsEntitiesByOrgId(state, organizationId)) const organizationLinks = [ diff --git a/libs/pages/settings/src/lib/ui/container/container.tsx b/libs/pages/settings/src/lib/ui/container/container.tsx index 3c34812cf8..4b12d00cf3 100644 --- a/libs/pages/settings/src/lib/ui/container/container.tsx +++ b/libs/pages/settings/src/lib/ui/container/container.tsx @@ -1,5 +1,7 @@ import { ReactNode } from 'react' -import { NavigationLeft, NavigationLeftLinkProps } from '@qovery/shared/ui' +import { useParams } from 'react-router-dom' +import { CreateProjectModalFeature } from '@qovery/shared/console-shared' +import { NavigationLeft, NavigationLeftLinkProps, useModal } from '@qovery/shared/ui' export interface ContainerProps { organizationLinks: NavigationLeftLinkProps[] @@ -9,7 +11,9 @@ export interface ContainerProps { } export function Container(props: ContainerProps) { + const { organizationId = '' } = useParams() const { organizationLinks, projectLinks, accountLinks, children } = props + const { openModal, closeModal } = useModal() return (
@@ -19,6 +23,14 @@ export function Container(props: ContainerProps) { { + openModal({ + content: , + }) + }, + }} className="py-6 border-t border-element-light-lighter-400" /> { - enableAlertClickOutside(methods.formState.isDirty) - }) + methods.watch(() => enableAlertClickOutside(methods.formState.isDirty)) const dispatch = useDispatch() diff --git a/libs/shared/console-shared/src/lib/create-project-modal/feature/create-project-modal-feature.spec.tsx b/libs/shared/console-shared/src/lib/create-project-modal/feature/create-project-modal-feature.spec.tsx new file mode 100644 index 0000000000..57b918d209 --- /dev/null +++ b/libs/shared/console-shared/src/lib/create-project-modal/feature/create-project-modal-feature.spec.tsx @@ -0,0 +1,69 @@ +import { act, fireEvent, render } from '__tests__/utils/setup-jest' +import * as storeProjects from '@qovery/domains/projects' +import CreateProjectModalFeature, { CreateProjectModalFeatureProps } from './create-project-modal-feature' + +import SpyInstance = jest.SpyInstance + +const mockedUsedNavigate = jest.fn() +jest.mock('react-router-dom', () => ({ + ...(jest.requireActual('react-router-dom') as any), + useNavigate: () => mockedUsedNavigate, +})) + +jest.mock('@qovery/domains/projects', () => ({ + ...jest.requireActual('@qovery/domains/projects'), + postProject: jest.fn().mockImplementation(() => Promise.resolve()), +})) + +const mockDispatch = jest.fn() +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: () => mockDispatch, +})) + +describe('CreateProjectModalFeature', () => { + const props: CreateProjectModalFeatureProps = { + onClose: jest.fn(), + organizationId: '0', + goToEnvironment: false, + } + + it('should render successfully', () => { + const { baseElement } = render() + expect(baseElement).toBeTruthy() + }) + + it('should dispatch postProject if form is submitted', async () => { + const postProjectSpy: SpyInstance = jest.spyOn(storeProjects, 'postProject') + mockDispatch.mockImplementation(() => ({ + unwrap: () => + Promise.resolve({ + data: {}, + }), + })) + + const { getByTestId } = render() + + await act(() => { + const inputName = getByTestId('input-name') + fireEvent.input(inputName, { target: { value: 'hello-world' } }) + }) + + await act(() => { + const inputName = getByTestId('input-description') + fireEvent.input(inputName, { target: { value: 'description' } }) + }) + + expect(getByTestId('submit-button')).not.toBeDisabled() + + await act(() => { + getByTestId('submit-button').click() + }) + + expect(postProjectSpy).toHaveBeenCalledWith({ + name: 'hello-world', + description: 'description', + organizationId: '0', + }) + }) +}) diff --git a/libs/shared/console-shared/src/lib/create-project-modal/feature/create-project-modal-feature.tsx b/libs/shared/console-shared/src/lib/create-project-modal/feature/create-project-modal-feature.tsx new file mode 100644 index 0000000000..aba43da71f --- /dev/null +++ b/libs/shared/console-shared/src/lib/create-project-modal/feature/create-project-modal-feature.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react' +import { FieldValues, FormProvider, useForm } from 'react-hook-form' +import { useDispatch } from 'react-redux' +import { useNavigate } from 'react-router-dom' +import { postProject } from '@qovery/domains/projects' +import { ENVIRONMENTS_GENERAL_URL, ENVIRONMENTS_URL } from '@qovery/shared/router' +import { AppDispatch } from '@qovery/store' +import CreateProjectModal from '../ui/create-project-modal' + +export interface CreateProjectModalFeatureProps { + onClose: () => void + organizationId: string +} + +export function CreateProjectModalFeature(props: CreateProjectModalFeatureProps) { + const { onClose, organizationId } = props + + const navigate = useNavigate() + const dispatch = useDispatch() + const methods = useForm({ + mode: 'onChange', + }) + const [loading, setLoading] = useState(false) + + const onSubmit = methods.handleSubmit((data: FieldValues) => { + setLoading(true) + + dispatch( + postProject({ + organizationId: organizationId, + name: data['name'], + description: data['description'], + }) + ) + .unwrap() + .then((project) => { + navigate(ENVIRONMENTS_URL(organizationId, project.id) + ENVIRONMENTS_GENERAL_URL) + setLoading(false) + onClose() + }) + .catch(() => setLoading(false)) + }) + + return ( + + + + ) +} + +export default CreateProjectModalFeature diff --git a/libs/shared/console-shared/src/lib/create-project-modal/ui/create-project-modal.spec.tsx b/libs/shared/console-shared/src/lib/create-project-modal/ui/create-project-modal.spec.tsx new file mode 100644 index 0000000000..d865aabf6d --- /dev/null +++ b/libs/shared/console-shared/src/lib/create-project-modal/ui/create-project-modal.spec.tsx @@ -0,0 +1,40 @@ +import { act, fireEvent, render, waitFor } from '__tests__/utils/setup-jest' +import { wrapWithReactHookForm } from '__tests__/utils/wrap-with-react-hook-form' +import CreateProjectModal, { CreateProjectModalProps } from './create-project-modal' + +describe('CreateProjectModal', () => { + const props: CreateProjectModalProps = { + onSubmit: jest.fn(), + closeModal: jest.fn(), + loading: false, + } + + it('should render successfully', () => { + const { baseElement } = render(wrapWithReactHookForm()) + expect(baseElement).toBeTruthy() + }) + + it('should submit the form', async () => { + const spy = jest.fn().mockImplementation((e) => e.preventDefault()) + props.onSubmit = spy + + const { getByTestId } = render(wrapWithReactHookForm()) + + const inputName = getByTestId('input-name') + const inputDescription = getByTestId('input-description') + const button = getByTestId('submit-button') + + expect(button).toBeDisabled() + + await act(() => { + fireEvent.input(inputName, { target: { value: 'hello world' } }) + fireEvent.input(inputDescription, { target: { value: 'hello' } }) + }) + + await waitFor(() => { + button.click() + expect(button).not.toBeDisabled() + expect(spy).toHaveBeenCalled() + }) + }) +}) diff --git a/libs/shared/console-shared/src/lib/create-project-modal/ui/create-project-modal.tsx b/libs/shared/console-shared/src/lib/create-project-modal/ui/create-project-modal.tsx new file mode 100644 index 0000000000..f77e30f74f --- /dev/null +++ b/libs/shared/console-shared/src/lib/create-project-modal/ui/create-project-modal.tsx @@ -0,0 +1,59 @@ +import { Controller, useFormContext } from 'react-hook-form' +import { InputText, ModalCrud } from '@qovery/shared/ui' + +export interface CreateProjectModalProps { + onSubmit: () => void + closeModal: () => void + loading: boolean +} + +export function CreateProjectModal(props: CreateProjectModalProps) { + const { onSubmit, closeModal, loading } = props + const { control } = useFormContext() + + return ( + + ( + + )} + /> + ( + + )} + /> + + ) +} + +export default CreateProjectModal diff --git a/libs/shared/console-shared/src/lib/git-repository-settings/ui/git-repository-settings.spec.tsx b/libs/shared/console-shared/src/lib/git-repository-settings/ui/git-repository-settings.spec.tsx index 5ca0689f8d..c8dc3e4a1a 100644 --- a/libs/shared/console-shared/src/lib/git-repository-settings/ui/git-repository-settings.spec.tsx +++ b/libs/shared/console-shared/src/lib/git-repository-settings/ui/git-repository-settings.spec.tsx @@ -1,5 +1,4 @@ -import { act, screen } from '@testing-library/react' -import { render } from '__tests__/utils/setup-jest' +import { act, render, screen } from '__tests__/utils/setup-jest' import { wrapWithReactHookForm } from '__tests__/utils/wrap-with-react-hook-form' import { GitProviderEnum } from 'qovery-typescript-axios' import { authProviderFactoryMock } from '@qovery/domains/organization' diff --git a/libs/shared/ui/src/lib/components/buttons/button-action/button-action.tsx b/libs/shared/ui/src/lib/components/buttons/button-action/button-action.tsx index 26c48e4ef8..655e3ce804 100644 --- a/libs/shared/ui/src/lib/components/buttons/button-action/button-action.tsx +++ b/libs/shared/ui/src/lib/components/buttons/button-action/button-action.tsx @@ -2,8 +2,7 @@ import { useState } from 'react' import { Link } from 'react-router-dom' import { IconEnum } from '@qovery/shared/enums' import { ButtonSize, Icon } from '@qovery/shared/ui' -import Menu, { MenuAlign } from '../../menu/menu' -import { MenuItemProps } from '../../menu/menu-item/menu-item' +import Menu, { MenuAlign, MenuData } from '../../menu/menu' export enum ButtonActionStyle { BASIC = 'basic', @@ -21,7 +20,7 @@ export interface ButtonActionProps { disabled?: boolean className?: string onClick?: () => void - menus?: { items: MenuItemProps[]; title?: string; button?: string; buttonLink?: string; search?: boolean }[] + menus?: MenuData size?: ButtonSize } diff --git a/libs/shared/ui/src/lib/components/layouts/breadcrumb-item/breadcrumb-item.tsx b/libs/shared/ui/src/lib/components/layouts/breadcrumb-item/breadcrumb-item.tsx index 035777d7fb..fee11bde1f 100644 --- a/libs/shared/ui/src/lib/components/layouts/breadcrumb-item/breadcrumb-item.tsx +++ b/libs/shared/ui/src/lib/components/layouts/breadcrumb-item/breadcrumb-item.tsx @@ -1,13 +1,12 @@ import ButtonIcon, { ButtonIconStyle } from '../../buttons/button-icon/button-icon' -import Menu, { MenuAlign } from '../../menu/menu' -import { MenuItemProps } from '../../menu/menu-item/menu-item' +import Menu, { MenuAlign, MenuData } from '../../menu/menu' import BreadcrumbItemValue from './breadcrumb-item-value/breadcrumb-item-value' export interface BreadcrumbItemProps { data: Array | undefined label?: string paramId: string - menuItems: { items: MenuItemProps[]; title?: string; button?: string; buttonLink?: string; search?: boolean }[] + menuItems: MenuData link: string logo?: React.ReactElement isLast?: boolean diff --git a/libs/shared/ui/src/lib/components/layouts/breadcrumb/breadcrumb.tsx b/libs/shared/ui/src/lib/components/layouts/breadcrumb/breadcrumb.tsx index 6505b2db3f..b04cb6c547 100644 --- a/libs/shared/ui/src/lib/components/layouts/breadcrumb/breadcrumb.tsx +++ b/libs/shared/ui/src/lib/components/layouts/breadcrumb/breadcrumb.tsx @@ -30,6 +30,7 @@ import BreadcrumbItem from '../breadcrumb-item/breadcrumb-item' export interface BreadcrumbProps { organizations: Organization[] + createProjectModal: () => void clusters?: ClusterEntity[] projects?: Project[] environments?: Environment[] @@ -38,7 +39,7 @@ export interface BreadcrumbProps { } export function BreadcrumbMemo(props: BreadcrumbProps) { - const { organizations, clusters, projects, environments, applications, databases } = props + const { organizations, clusters, projects, environments, applications, databases, createProjectModal } = props const { organizationId, projectId, environmentId, applicationId, databaseId, clusterId } = useParams() const location = useLocation() @@ -91,6 +92,14 @@ export function BreadcrumbMemo(props: BreadcrumbProps) { { title: 'Projects', search: true, + button: { + label: ( + + New + + ), + onClick: () => createProjectModal(), + }, items: projects ? projects?.map((project: Project) => ({ name: project.name, diff --git a/libs/shared/ui/src/lib/components/menu/menu-group/menu-group.tsx b/libs/shared/ui/src/lib/components/menu/menu-group/menu-group.tsx index edc8b090b2..02669084c7 100644 --- a/libs/shared/ui/src/lib/components/menu/menu-group/menu-group.tsx +++ b/libs/shared/ui/src/lib/components/menu/menu-group/menu-group.tsx @@ -1,15 +1,17 @@ import { MenuDivider } from '@szhsin/react-menu' -import { useEffect, useState } from 'react' -import { Link } from 'react-router-dom' +import { ReactNode, useEffect, useState } from 'react' import InputSearch from '../../inputs/input-search/input-search' import { MenuItem, MenuItemProps } from '../menu-item/menu-item' export interface MenuGroupProps { menu: { items: MenuItemProps[] + label?: string title?: string - button?: string - buttonLink?: string + button?: { + label?: string | ReactNode + onClick?: () => void + } search?: boolean } isLast: boolean @@ -56,12 +58,13 @@ export function MenuGroup(props: MenuGroupProps) { {menu?.title}

)} - {menu?.button && menu?.buttonLink ? ( - - {menu?.button} - - ) : ( - '' + {menu?.button && ( + + {menu?.button.label} + )}
)} diff --git a/libs/shared/ui/src/lib/components/menu/menu.stories.tsx b/libs/shared/ui/src/lib/components/menu/menu.stories.tsx index 80d89ebd05..8a12e2d388 100644 --- a/libs/shared/ui/src/lib/components/menu/menu.stories.tsx +++ b/libs/shared/ui/src/lib/components/menu/menu.stories.tsx @@ -27,9 +27,13 @@ const menus: MenuData = [ { name: 'Test 3', link: { url: '/', external: false }, copy: 'Test 3' }, ], title: 'Test', - button: 'Link', - buttonLink: '/', search: true, + button: { + label: 'Create', + onClick: () => { + alert('Create') + }, + }, }, { items: [ diff --git a/libs/shared/ui/src/lib/components/menu/menu.tsx b/libs/shared/ui/src/lib/components/menu/menu.tsx index 4c4814c0c6..a2b4bf2abc 100644 --- a/libs/shared/ui/src/lib/components/menu/menu.tsx +++ b/libs/shared/ui/src/lib/components/menu/menu.tsx @@ -1,5 +1,5 @@ import { ControlledMenu, MenuCloseEvent } from '@szhsin/react-menu' -import React, { useEffect, useRef, useState } from 'react' +import React, { ReactNode, useEffect, useRef, useState } from 'react' import Tooltip from '../tooltip/tooltip' import MenuGroup from './menu-group/menu-group' import { MenuItemProps } from './menu-item/menu-item' @@ -21,8 +21,10 @@ export type MenuData = { items: MenuItemProps[] label?: string title?: string - button?: string - buttonLink?: string + button?: { + label: string | ReactNode + onClick: () => void + } search?: boolean }[] diff --git a/libs/shared/ui/src/lib/components/navigation-left/navigation-left.stories.tsx b/libs/shared/ui/src/lib/components/navigation-left/navigation-left.stories.tsx index e983648fe7..3dee70043e 100644 --- a/libs/shared/ui/src/lib/components/navigation-left/navigation-left.stories.tsx +++ b/libs/shared/ui/src/lib/components/navigation-left/navigation-left.stories.tsx @@ -17,7 +17,11 @@ const Template: Story = (args) => console.log('on click'), + }, links: [ { title: 'General', diff --git a/libs/shared/ui/src/lib/components/navigation-left/navigation-left.tsx b/libs/shared/ui/src/lib/components/navigation-left/navigation-left.tsx index 008fd5818d..a5bdc7f1f2 100644 --- a/libs/shared/ui/src/lib/components/navigation-left/navigation-left.tsx +++ b/libs/shared/ui/src/lib/components/navigation-left/navigation-left.tsx @@ -1,10 +1,15 @@ import { Link, useLocation } from 'react-router-dom' import { Icon } from '../icon/icon' +import { IconAwesomeEnum } from '../icon/icon-awesome.enum' import NavigationLeftSubLink from './navigation-left-sub-link/navigation-left-sub-link' export interface NavigationLeftProps { links: NavigationLeftLinkProps[] title?: string + link?: { + title: string + onClick: () => void + } className?: string } @@ -28,7 +33,7 @@ export const linkClassName = (pathname: string, url?: string) => }` export function NavigationLeft(props: NavigationLeftProps) { - const { title, links, className = '' } = props + const { title, links, link, className = '' } = props const { pathname } = useLocation() @@ -41,7 +46,15 @@ export function NavigationLeft(props: NavigationLeftProps) { return (
- {title && {title}} +
+ {title && {title}} + {link && ( + link.onClick()}> + {link.title} + + + )} +
{links.map((link, index) => !link.onClick && !link.subLinks && link.url ? (