From 16a06b2fbcbfeb23192cb813b0bfcfe7484422cf Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Wed, 14 Jan 2026 17:58:51 +0100 Subject: [PATCH 01/15] feat(new-nav): basis for Project overview page --- .../header/breadcrumbs/breadcrumbs.tsx | 51 ++++- .../src/app/components/header/header.tsx | 3 + apps/console-v5/src/routeTree.gen.ts | 100 +++++++++ .../environment/$environmentId/index.tsx | 19 ++ .../environment/$environmentId/overview.tsx | 31 +++ .../project/$projectId/index.tsx | 13 ++ .../project/$projectId/overview.tsx | 211 ++++++++++++++++++ .../_authenticated/organization/route.tsx | 67 +++++- .../create-clone-environment-modal.tsx | 11 +- .../hooks/use-environment/use-environment.ts | 4 +- .../use-environments/use-environments.ts | 4 +- .../src/lib/hooks/use-project/use-project.ts | 4 +- .../src/lib/project-list/project-list.tsx | 9 +- .../lib/hooks/use-services/use-services.ts | 4 +- libs/shared/ui/src/index.ts | 1 + .../components/env-type/env-type.stories.tsx | 58 +++++ .../src/lib/components/env-type/env-type.tsx | 43 ++++ .../table-primitives/table-primitives.tsx | 4 +- 18 files changed, 601 insertions(+), 36 deletions(-) create mode 100644 apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/index.tsx create mode 100644 apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview.tsx create mode 100644 apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/index.tsx create mode 100644 apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx create mode 100644 libs/shared/ui/src/lib/components/env-type/env-type.stories.tsx create mode 100644 libs/shared/ui/src/lib/components/env-type/env-type.tsx diff --git a/apps/console-v5/src/app/components/header/breadcrumbs/breadcrumbs.tsx b/apps/console-v5/src/app/components/header/breadcrumbs/breadcrumbs.tsx index 567de536c7b..a9434dcaa87 100644 --- a/apps/console-v5/src/app/components/header/breadcrumbs/breadcrumbs.tsx +++ b/apps/console-v5/src/app/components/header/breadcrumbs/breadcrumbs.tsx @@ -2,32 +2,37 @@ import { useParams, useRouter } from '@tanstack/react-router' import { useMemo } from 'react' import { ClusterAvatar, useClusters } from '@qovery/domains/clusters/feature' import { useOrganization, useOrganizations } from '@qovery/domains/organizations/feature' +import { useProjects } from '@qovery/domains/projects/feature' import { Avatar } from '@qovery/shared/ui' import { Separator } from '../header' import { BreadcrumbItem, type BreadcrumbItemData } from './breadcrumb-item' export function Breadcrumbs() { const { buildLocation } = useRouter() - const { organizationId, clusterId } = useParams({ strict: false }) + const { organizationId, clusterId, projectId } = useParams({ strict: false }) const { data: organizations = [] } = useOrganizations({ enabled: true, + suspense: true, }) - const { data: organization } = useOrganization({ organizationId, enabled: !!organizationId }) - const { data: clusters = [] } = useClusters({ organizationId }) + const { data: organization } = useOrganization({ organizationId, enabled: !!organizationId, suspense: true }) + const { data: clusters = [] } = useClusters({ organizationId, suspense: true }) + const { data: projects = [] } = useProjects({ organizationId, suspense: true }) // Necessary to keep the organization from client by Qovery team const allOrganizations = organizations.find((org) => org.id !== organizationId) && organization - ? [...organizations, organization] + ? [...organizations.filter((org) => org.id !== organizationId), organization] : organizations - const orgItems: BreadcrumbItemData[] = allOrganizations.map((organization) => ({ - id: organization.id, - label: organization.name, - path: buildLocation({ to: '/organization/$organizationId', params: { organizationId: organization.id } }).href, - logo_url: organization.logo_url ?? undefined, - })) + const orgItems: BreadcrumbItemData[] = allOrganizations + .sort((a, b) => a.name.trim().localeCompare(b.name.trim())) + .map((organization) => ({ + id: organization.id, + label: organization.name, + path: buildLocation({ to: '/organization/$organizationId', params: { organizationId: organization.id } }).href, + logo_url: organization.logo_url ?? undefined, + })) const currentOrg = useMemo( () => orgItems.find((organization) => organization.id === organizationId), @@ -43,11 +48,27 @@ export function Breadcrumbs() { }).href, })) + const projectItems: BreadcrumbItemData[] = projects + .sort((a, b) => a.name.trim().localeCompare(b.name.trim())) + .map((project) => ({ + id: project.id, + label: project.name, + path: buildLocation({ + to: '/organization/$organizationId/project/$projectId/overview', + params: { organizationId, projectId: project.id }, + }).href, + })) + const currentCluster = useMemo( () => clusterItems.find((cluster) => cluster.id === clusterId), [clusterId, clusterItems] ) + const currentProject = useMemo( + () => projectItems.find((project) => project.id === projectId), + [projectId, projectItems] + ) + const breadcrumbData: Array<{ item: BreadcrumbItemData; items: BreadcrumbItemData[] }> = [] if (currentOrg) { @@ -78,6 +99,16 @@ export function Breadcrumbs() { }) } + if (currentProject) { + breadcrumbData.push({ + item: { + ...currentProject, + // prefix: project.id === projectId)} size="sm" />, + }, + items: projectItems, + }) + } + return (
{breadcrumbData.map((data, index) => ( diff --git a/apps/console-v5/src/app/components/header/header.tsx b/apps/console-v5/src/app/components/header/header.tsx index e536eee1039..1f936256cee 100644 --- a/apps/console-v5/src/app/components/header/header.tsx +++ b/apps/console-v5/src/app/components/header/header.tsx @@ -1,3 +1,4 @@ +import { Suspense } from 'react' import { LogoIcon } from '@qovery/shared/ui' import { Breadcrumbs } from './breadcrumbs/breadcrumbs' import { UserMenu } from './user-menu/user-menu' @@ -21,7 +22,9 @@ export function Header() {
+ {/* Loading...
}> */} + {/* */}
diff --git a/apps/console-v5/src/routeTree.gen.ts b/apps/console-v5/src/routeTree.gen.ts index 7714ebdb6b7..1409d681791 100644 --- a/apps/console-v5/src/routeTree.gen.ts +++ b/apps/console-v5/src/routeTree.gen.ts @@ -28,7 +28,9 @@ import { Route as AuthenticatedOrganizationOrganizationIdAuditLogsRouteImport } import { Route as AuthenticatedOrganizationOrganizationIdAlertsRouteImport } from './routes/_authenticated/organization/$organizationId/alerts' import { Route as AuthenticatedOrganizationOrganizationIdClusterIdIndexRouteImport } from './routes/_authenticated/organization/$organizationId/$clusterId/index' import { Route as AuthenticatedOrganizationOrganizationIdClusterNewRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/new' +import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdIndexRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/index' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdIndexRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/index' +import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdOverviewRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/overview' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdOverviewRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/overview' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdClusterLogsRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/cluster-logs' import { Route as AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/create/$slug/route' @@ -45,6 +47,8 @@ import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSetting import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsDangerZoneRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/danger-zone' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsCredentialsRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/advanced-settings' +import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdIndexRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/index' +import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdOverviewRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview' const AuthenticatedRoute = AuthenticatedRouteImport.update({ id: '/_authenticated', @@ -155,6 +159,14 @@ const AuthenticatedOrganizationOrganizationIdClusterNewRoute = path: '/cluster/new', getParentRoute: () => AuthenticatedOrganizationOrganizationIdRouteRoute, } as any) +const AuthenticatedOrganizationOrganizationIdProjectProjectIdIndexRoute = + AuthenticatedOrganizationOrganizationIdProjectProjectIdIndexRouteImport.update( + { + id: '/project/$projectId/', + path: '/project/$projectId/', + getParentRoute: () => AuthenticatedOrganizationOrganizationIdRouteRoute, + } as any, + ) const AuthenticatedOrganizationOrganizationIdClusterClusterIdIndexRoute = AuthenticatedOrganizationOrganizationIdClusterClusterIdIndexRouteImport.update( { @@ -163,6 +175,14 @@ const AuthenticatedOrganizationOrganizationIdClusterClusterIdIndexRoute = getParentRoute: () => AuthenticatedOrganizationOrganizationIdRouteRoute, } as any, ) +const AuthenticatedOrganizationOrganizationIdProjectProjectIdOverviewRoute = + AuthenticatedOrganizationOrganizationIdProjectProjectIdOverviewRouteImport.update( + { + id: '/project/$projectId/overview', + path: '/project/$projectId/overview', + getParentRoute: () => AuthenticatedOrganizationOrganizationIdRouteRoute, + } as any, + ) const AuthenticatedOrganizationOrganizationIdClusterClusterIdOverviewRoute = AuthenticatedOrganizationOrganizationIdClusterClusterIdOverviewRouteImport.update( { @@ -303,6 +323,22 @@ const AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSet AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRoute, } as any, ) +const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdIndexRoute = + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdIndexRouteImport.update( + { + id: '/project/$projectId/environment/$environmentId/', + path: '/project/$projectId/environment/$environmentId/', + getParentRoute: () => AuthenticatedOrganizationOrganizationIdRouteRoute, + } as any, + ) +const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdOverviewRoute = + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdOverviewRouteImport.update( + { + id: '/project/$projectId/environment/$environmentId/overview', + path: '/project/$projectId/environment/$environmentId/overview', + getParentRoute: () => AuthenticatedOrganizationOrganizationIdRouteRoute, + } as any, + ) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -327,7 +363,9 @@ export interface FileRoutesByFullPath { '/organization/$organizationId/cluster/create/$slug': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRouteWithChildren '/organization/$organizationId/cluster/$clusterId/cluster-logs': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdClusterLogsRoute '/organization/$organizationId/cluster/$clusterId/overview': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdOverviewRoute + '/organization/$organizationId/project/$projectId/overview': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdOverviewRoute '/organization/$organizationId/cluster/$clusterId': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdIndexRoute + '/organization/$organizationId/project/$projectId': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdIndexRoute '/organization/$organizationId/cluster/$clusterId/settings/advanced-settings': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRoute '/organization/$organizationId/cluster/$clusterId/settings/credentials': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsCredentialsRoute '/organization/$organizationId/cluster/$clusterId/settings/danger-zone': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsDangerZoneRoute @@ -340,6 +378,8 @@ export interface FileRoutesByFullPath { '/organization/$organizationId/cluster/create/$slug/resources': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugResourcesRoute '/organization/$organizationId/cluster/$clusterId/settings/': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsIndexRoute '/organization/$organizationId/cluster/create/$slug/': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugIndexRoute + '/organization/$organizationId/project/$projectId/environment/$environmentId/overview': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdOverviewRoute + '/organization/$organizationId/project/$projectId/environment/$environmentId': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -360,7 +400,9 @@ export interface FileRoutesByTo { '/organization/$organizationId/$clusterId': typeof AuthenticatedOrganizationOrganizationIdClusterIdIndexRoute '/organization/$organizationId/cluster/$clusterId/cluster-logs': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdClusterLogsRoute '/organization/$organizationId/cluster/$clusterId/overview': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdOverviewRoute + '/organization/$organizationId/project/$projectId/overview': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdOverviewRoute '/organization/$organizationId/cluster/$clusterId': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdIndexRoute + '/organization/$organizationId/project/$projectId': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdIndexRoute '/organization/$organizationId/cluster/$clusterId/settings/advanced-settings': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRoute '/organization/$organizationId/cluster/$clusterId/settings/credentials': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsCredentialsRoute '/organization/$organizationId/cluster/$clusterId/settings/danger-zone': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsDangerZoneRoute @@ -373,6 +415,8 @@ export interface FileRoutesByTo { '/organization/$organizationId/cluster/create/$slug/resources': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugResourcesRoute '/organization/$organizationId/cluster/$clusterId/settings': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsIndexRoute '/organization/$organizationId/cluster/create/$slug': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugIndexRoute + '/organization/$organizationId/project/$projectId/environment/$environmentId/overview': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdOverviewRoute + '/organization/$organizationId/project/$projectId/environment/$environmentId': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -399,7 +443,9 @@ export interface FileRoutesById { '/_authenticated/organization/$organizationId/cluster/create/$slug': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRouteWithChildren '/_authenticated/organization/$organizationId/cluster/$clusterId/cluster-logs': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdClusterLogsRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/overview': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdOverviewRoute + '/_authenticated/organization/$organizationId/project/$projectId/overview': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdOverviewRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdIndexRoute + '/_authenticated/organization/$organizationId/project/$projectId/': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdIndexRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/advanced-settings': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsCredentialsRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/danger-zone': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsDangerZoneRoute @@ -412,6 +458,8 @@ export interface FileRoutesById { '/_authenticated/organization/$organizationId/cluster/create/$slug/resources': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugResourcesRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsIndexRoute '/_authenticated/organization/$organizationId/cluster/create/$slug/': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugIndexRoute + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdOverviewRoute + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -438,7 +486,9 @@ export interface FileRouteTypes { | '/organization/$organizationId/cluster/create/$slug' | '/organization/$organizationId/cluster/$clusterId/cluster-logs' | '/organization/$organizationId/cluster/$clusterId/overview' + | '/organization/$organizationId/project/$projectId/overview' | '/organization/$organizationId/cluster/$clusterId' + | '/organization/$organizationId/project/$projectId' | '/organization/$organizationId/cluster/$clusterId/settings/advanced-settings' | '/organization/$organizationId/cluster/$clusterId/settings/credentials' | '/organization/$organizationId/cluster/$clusterId/settings/danger-zone' @@ -451,6 +501,8 @@ export interface FileRouteTypes { | '/organization/$organizationId/cluster/create/$slug/resources' | '/organization/$organizationId/cluster/$clusterId/settings/' | '/organization/$organizationId/cluster/create/$slug/' + | '/organization/$organizationId/project/$projectId/environment/$environmentId/overview' + | '/organization/$organizationId/project/$projectId/environment/$environmentId' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -471,7 +523,9 @@ export interface FileRouteTypes { | '/organization/$organizationId/$clusterId' | '/organization/$organizationId/cluster/$clusterId/cluster-logs' | '/organization/$organizationId/cluster/$clusterId/overview' + | '/organization/$organizationId/project/$projectId/overview' | '/organization/$organizationId/cluster/$clusterId' + | '/organization/$organizationId/project/$projectId' | '/organization/$organizationId/cluster/$clusterId/settings/advanced-settings' | '/organization/$organizationId/cluster/$clusterId/settings/credentials' | '/organization/$organizationId/cluster/$clusterId/settings/danger-zone' @@ -484,6 +538,8 @@ export interface FileRouteTypes { | '/organization/$organizationId/cluster/create/$slug/resources' | '/organization/$organizationId/cluster/$clusterId/settings' | '/organization/$organizationId/cluster/create/$slug' + | '/organization/$organizationId/project/$projectId/environment/$environmentId/overview' + | '/organization/$organizationId/project/$projectId/environment/$environmentId' id: | '__root__' | '/' @@ -509,7 +565,9 @@ export interface FileRouteTypes { | '/_authenticated/organization/$organizationId/cluster/create/$slug' | '/_authenticated/organization/$organizationId/cluster/$clusterId/cluster-logs' | '/_authenticated/organization/$organizationId/cluster/$clusterId/overview' + | '/_authenticated/organization/$organizationId/project/$projectId/overview' | '/_authenticated/organization/$organizationId/cluster/$clusterId/' + | '/_authenticated/organization/$organizationId/project/$projectId/' | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/advanced-settings' | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials' | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/danger-zone' @@ -522,6 +580,8 @@ export interface FileRouteTypes { | '/_authenticated/organization/$organizationId/cluster/create/$slug/resources' | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/' | '/_authenticated/organization/$organizationId/cluster/create/$slug/' + | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview' + | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -666,6 +726,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdClusterNewRouteImport parentRoute: typeof AuthenticatedOrganizationOrganizationIdRouteRoute } + '/_authenticated/organization/$organizationId/project/$projectId/': { + id: '/_authenticated/organization/$organizationId/project/$projectId/' + path: '/project/$projectId' + fullPath: '/organization/$organizationId/project/$projectId' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdIndexRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdRouteRoute + } '/_authenticated/organization/$organizationId/cluster/$clusterId/': { id: '/_authenticated/organization/$organizationId/cluster/$clusterId/' path: '/cluster/$clusterId' @@ -673,6 +740,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdIndexRouteImport parentRoute: typeof AuthenticatedOrganizationOrganizationIdRouteRoute } + '/_authenticated/organization/$organizationId/project/$projectId/overview': { + id: '/_authenticated/organization/$organizationId/project/$projectId/overview' + path: '/project/$projectId/overview' + fullPath: '/organization/$organizationId/project/$projectId/overview' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdOverviewRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdRouteRoute + } '/_authenticated/organization/$organizationId/cluster/$clusterId/overview': { id: '/_authenticated/organization/$organizationId/cluster/$clusterId/overview' path: '/cluster/$clusterId/overview' @@ -785,6 +859,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRouteImport parentRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRoute } + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/': { + id: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/' + path: '/project/$projectId/environment/$environmentId' + fullPath: '/organization/$organizationId/project/$projectId/environment/$environmentId' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdIndexRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdRouteRoute + } + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview': { + id: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview' + path: '/project/$projectId/environment/$environmentId/overview' + fullPath: '/organization/$organizationId/project/$projectId/environment/$environmentId/overview' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdOverviewRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdRouteRoute + } } } @@ -861,7 +949,11 @@ interface AuthenticatedOrganizationOrganizationIdRouteRouteChildren { AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRoute: typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRouteWithChildren AuthenticatedOrganizationOrganizationIdClusterClusterIdClusterLogsRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdClusterLogsRoute AuthenticatedOrganizationOrganizationIdClusterClusterIdOverviewRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdOverviewRoute + AuthenticatedOrganizationOrganizationIdProjectProjectIdOverviewRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdOverviewRoute AuthenticatedOrganizationOrganizationIdClusterClusterIdIndexRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdIndexRoute + AuthenticatedOrganizationOrganizationIdProjectProjectIdIndexRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdIndexRoute + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdOverviewRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdOverviewRoute + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdIndexRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdIndexRoute } const AuthenticatedOrganizationOrganizationIdRouteRouteChildren: AuthenticatedOrganizationOrganizationIdRouteRouteChildren = @@ -890,8 +982,16 @@ const AuthenticatedOrganizationOrganizationIdRouteRouteChildren: AuthenticatedOr AuthenticatedOrganizationOrganizationIdClusterClusterIdClusterLogsRoute, AuthenticatedOrganizationOrganizationIdClusterClusterIdOverviewRoute: AuthenticatedOrganizationOrganizationIdClusterClusterIdOverviewRoute, + AuthenticatedOrganizationOrganizationIdProjectProjectIdOverviewRoute: + AuthenticatedOrganizationOrganizationIdProjectProjectIdOverviewRoute, AuthenticatedOrganizationOrganizationIdClusterClusterIdIndexRoute: AuthenticatedOrganizationOrganizationIdClusterClusterIdIndexRoute, + AuthenticatedOrganizationOrganizationIdProjectProjectIdIndexRoute: + AuthenticatedOrganizationOrganizationIdProjectProjectIdIndexRoute, + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdOverviewRoute: + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdOverviewRoute, + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdIndexRoute: + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdIndexRoute, } const AuthenticatedOrganizationOrganizationIdRouteRouteWithChildren = diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/index.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/index.tsx new file mode 100644 index 00000000000..fe5c800f582 --- /dev/null +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/index.tsx @@ -0,0 +1,19 @@ +import { Navigate, createFileRoute, useParams } from '@tanstack/react-router' + +export const Route = createFileRoute( + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/' +)({ + component: RouteComponent, +}) + +function RouteComponent() { + const { organizationId, projectId, environmentId } = useParams({ strict: false }) + + return ( + + ) +} diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview.tsx new file mode 100644 index 00000000000..8f21d6b85cc --- /dev/null +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview.tsx @@ -0,0 +1,31 @@ +import { createFileRoute, useParams } from '@tanstack/react-router' +import { Suspense } from 'react' +import { useEnvironment } from '@qovery/domains/environments/feature' +import { LoaderSpinner } from '@qovery/shared/ui' + +export const Route = createFileRoute( + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview' +)({ + component: RouteComponent, +}) + +function EnvironmentOverview() { + const { environmentId } = useParams({ strict: false }) + const { data: environment } = useEnvironment({ environmentId, suspense: true }) + + return
Environment: {environment?.name}
+} + +function RouteComponent() { + return ( + + +
+ } + > + + + ) +} diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/index.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/index.tsx new file mode 100644 index 00000000000..120ef4af74e --- /dev/null +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/index.tsx @@ -0,0 +1,13 @@ +import { Navigate, createFileRoute, useParams } from '@tanstack/react-router' + +export const Route = createFileRoute('/_authenticated/organization/$organizationId/project/$projectId/')({ + component: RouteComponent, +}) + +function RouteComponent() { + const { organizationId, projectId } = useParams({ strict: false }) + + return ( + + ) +} diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx new file mode 100644 index 00000000000..854d6cbd02f --- /dev/null +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx @@ -0,0 +1,211 @@ +import { Link, createFileRoute, useParams } from '@tanstack/react-router' +import { type Environment, type EnvironmentModeEnum } from 'qovery-typescript-axios' +import { Suspense, useMemo } from 'react' +import { match } from 'ts-pattern' +import { ClusterAvatar } from '@qovery/domains/clusters/feature' +import { useClusters } from '@qovery/domains/clusters/feature' +import { CreateCloneEnvironmentModal, useEnvironments } from '@qovery/domains/environments/feature' +import { useProject } from '@qovery/domains/projects/feature' +import { useServices } from '@qovery/domains/services/feature' +import { Button, EnvType, Heading, Icon, LoaderSpinner, Section, TablePrimitives, useModal } from '@qovery/shared/ui' +import { pluralize } from '@qovery/shared/util-js' + +const { Table } = TablePrimitives + +export const Route = createFileRoute('/_authenticated/organization/$organizationId/project/$projectId/overview')({ + component: RouteComponent, +}) + +function EnvRow({ environment }: { environment: Environment }) { + const { organizationId, projectId } = useParams({ strict: false }) + const { data: clusters } = useClusters({ organizationId, suspense: true }) + const { data: services } = useServices({ environmentId: environment.id, suspense: true }) + + return ( + + +
+ + {environment.name} + + + {services?.length} {pluralize(services?.length ?? 0, 'service')} + +
+
+ + +
+ cluster.id === environment.cluster_id)} size="sm" /> + {clusters?.find((cluster) => cluster.id === environment.cluster_id)?.name} +
+
+ + +
+ ) +} + +function EnvironmentSection({ + type, + items, + onCreateEnvClicked, +}: { + type: 'production' | 'staging' | 'development' | 'ephemeral' + items: Environment[] + onCreateEnvClicked: () => void +}) { + const title = match(type) + .with('production', () => 'Production') + .with('staging', () => 'Staging') + .with('development', () => 'Development') + .with('ephemeral', () => 'Ephemeral') + .exhaustive() + + return ( +
+
+ + {title} +
+ {items.length === 0 ? ( +
+ + No {title.toLowerCase()} environment created yet + +
+ ) : ( +
+ + +
+ } + > + + + + Environment + + Last operation + + + Cluster + + + Last update + + + Actions + + + + + + {items.map((environment) => ( + + ))} + + + + + )} +
+ ) +} + +function ProjectOverview() { + const { openModal, closeModal } = useModal() + const { organizationId, projectId } = useParams({ strict: false }) + const { data: project } = useProject({ organizationId, projectId, suspense: true }) + const { data: environments } = useEnvironments({ projectId, suspense: true }) + + const groupedEnvs = useMemo(() => { + return environments?.reduce((acc, env) => { + acc.set(env.mode, [...(acc.get(env.mode) || []), env]) + return acc + }, new Map()) + }, [environments]) + + const onCreateEnvClicked = () => { + openModal({ + content: ( + + ), + options: { + fakeModal: true, + }, + }) + } + + return ( +
+
+
+
+ {project?.name} + +
+
+
+
+ + + + +
+
+
+ ) +} + +function RouteComponent() { + return ( + + + + } + > + + + ) +} diff --git a/apps/console-v5/src/routes/_authenticated/organization/route.tsx b/apps/console-v5/src/routes/_authenticated/organization/route.tsx index 9909b48001d..28756feb2f1 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/route.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/route.tsx @@ -1,6 +1,7 @@ import { type IconName } from '@fortawesome/fontawesome-common-types' import { Outlet, createFileRoute, useLocation, useMatches, useParams } from '@tanstack/react-router' -import { Icon, Navbar } from '@qovery/shared/ui' +import { Suspense } from 'react' +import { Icon, LoaderSpinner, Navbar } from '@qovery/shared/ui' import { queries } from '@qovery/state/util-queries' import Header from '../../../app/components/header/header' import { type FileRouteTypes } from '../../../routeTree.gen' @@ -85,6 +86,24 @@ const CLUSTER_TABS: NavigationTab[] = [ }, ] +const PROJECT_TABS: NavigationTab[] = [ + { + id: 'overview', + label: 'Overview', + iconName: 'table-layout', + routeId: '/_authenticated/organization/$organizationId/project/$projectId/overview', + }, +] + +const ENVIRONMENT_TABS: NavigationTab[] = [ + { + id: 'overview', + label: 'Overview', + iconName: 'table-layout', + routeId: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview', + }, +] + function createRoutePatternRegex(routeIdPattern: string): RegExp { const patternPath = routeIdPattern.replace('/_authenticated/organization', '/organization') return new RegExp('^' + patternPath.replace(/\$(\w+)/g, '[^/]+') + '(/.*)?$') @@ -107,6 +126,18 @@ const NAVIGATION_CONTEXTS: Array<{ tabs: NavigationTab[] paramNames: string[] }> = [ + { + type: 'environment', + routeIdPattern: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId', + tabs: ENVIRONMENT_TABS, + paramNames: ['organizationId', 'projectId', 'environmentId'], + }, + { + type: 'project', + routeIdPattern: '/_authenticated/organization/$organizationId/project/$projectId', + tabs: PROJECT_TABS, + paramNames: ['organizationId', 'projectId'], + }, { type: 'cluster', routeIdPattern: '/_authenticated/organization/$organizationId/cluster/$clusterId', @@ -259,6 +290,14 @@ function useBypassLayout(): boolean { ) } +function MainLoader() { + return ( +
+ +
+ ) +} + function OrganizationRoute() { const navigationContext = useNavigationContext() const activeTabId = useActiveTabId(navigationContext) @@ -273,17 +312,21 @@ function OrganizationRoute() {
{/* TODO: Conflicts with body main:not(.h-screen, .layout-onboarding) */}
-
- -
- - {navigationContext && } - -
- -
- -
+ }> + <> +
+ +
+ + {navigationContext && } + +
+ +
+ +
+ +
) diff --git a/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.tsx b/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.tsx index 31a50d0aa91..eea068143c8 100644 --- a/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.tsx +++ b/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.tsx @@ -1,3 +1,4 @@ +import { useNavigate } from '@tanstack/react-router' import { type CreateEnvironmentModeEnum, type Environment, @@ -6,11 +7,9 @@ import { } from 'qovery-typescript-axios' import { type FormEvent } from 'react' import { Controller, FormProvider, useForm } from 'react-hook-form' -import { useNavigate } from 'react-router-dom' import { P, match } from 'ts-pattern' import { useClusters } from '@qovery/domains/clusters/feature' import { useProjects } from '@qovery/domains/projects/feature' -import { SERVICES_GENERAL_URL, SERVICES_URL } from '@qovery/shared/routes' import { ExternalLink, Icon, InputSelect, InputText, ModalCrud, useModal } from '@qovery/shared/ui' import { EnvironmentMode } from '../environment-mode/environment-mode' import { useCloneEnvironment } from '../hooks/use-clone-environment/use-clone-environment' @@ -60,7 +59,9 @@ export function CreateCloneEnvironmentModal({ }, }) - navigate(SERVICES_URL(organizationId, project_id, result.id) + SERVICES_GENERAL_URL) + navigate({ + to: `/organization/${organizationId}/project/${project_id}/environment/${result.id}/overview`, + }) } else { const result = await createEnvironment({ projectId: project_id, @@ -70,7 +71,9 @@ export function CreateCloneEnvironmentModal({ cluster: cluster, }, }) - navigate(SERVICES_URL(organizationId, project_id, result.id) + SERVICES_GENERAL_URL) + navigate({ + to: `/organization/${organizationId}/project/${project_id}/environment/${result.id}/overview`, + }) } onClose() }) diff --git a/libs/domains/environments/feature/src/lib/hooks/use-environment/use-environment.ts b/libs/domains/environments/feature/src/lib/hooks/use-environment/use-environment.ts index eaa8d5b54cb..34b91c2f523 100644 --- a/libs/domains/environments/feature/src/lib/hooks/use-environment/use-environment.ts +++ b/libs/domains/environments/feature/src/lib/hooks/use-environment/use-environment.ts @@ -3,13 +3,15 @@ import { queries } from '@qovery/state/util-queries' export interface UseEnvironmentProps { environmentId?: string + suspense?: boolean } -export function useEnvironment({ environmentId }: UseEnvironmentProps) { +export function useEnvironment({ environmentId, suspense = false }: UseEnvironmentProps) { return useQuery({ // eslint-disable-next-line @typescript-eslint/no-extra-non-null-assertion ...queries.environments.details({ environmentId: environmentId!! }), enabled: Boolean(environmentId), + suspense, }) } diff --git a/libs/domains/environments/feature/src/lib/hooks/use-environments/use-environments.ts b/libs/domains/environments/feature/src/lib/hooks/use-environments/use-environments.ts index f2edd603f1a..a830235a316 100644 --- a/libs/domains/environments/feature/src/lib/hooks/use-environments/use-environments.ts +++ b/libs/domains/environments/feature/src/lib/hooks/use-environments/use-environments.ts @@ -6,9 +6,10 @@ import { queries } from '@qovery/state/util-queries' export interface UseEnvironmentsProps { projectId: string + suspense?: boolean } -export function useEnvironments({ projectId }: UseEnvironmentsProps) { +export function useEnvironments({ projectId, suspense = false }: UseEnvironmentsProps) { const { data: environments, isLoading: isEnvironmentsLoading, @@ -21,6 +22,7 @@ export function useEnvironments({ projectId }: UseEnvironmentsProps) { }, enabled: projectId !== '', retry: 3, + suspense, }) const runningStatusResults = useQueries({ diff --git a/libs/domains/projects/feature/src/lib/hooks/use-project/use-project.ts b/libs/domains/projects/feature/src/lib/hooks/use-project/use-project.ts index 57f7b4315b7..4068bc6e006 100644 --- a/libs/domains/projects/feature/src/lib/hooks/use-project/use-project.ts +++ b/libs/domains/projects/feature/src/lib/hooks/use-project/use-project.ts @@ -4,12 +4,14 @@ import { queries } from '@qovery/state/util-queries' export interface UseProjectProps { organizationId: string projectId: string + suspense?: boolean } -export function useProject({ organizationId, projectId }: UseProjectProps) { +export function useProject({ organizationId, projectId, suspense = false }: UseProjectProps) { return useQuery({ ...queries.projects.list({ organizationId }), select: (data) => data?.find((project) => project.id === projectId), + suspense, }) } diff --git a/libs/domains/projects/feature/src/lib/project-list/project-list.tsx b/libs/domains/projects/feature/src/lib/project-list/project-list.tsx index 40fe5267724..84150b74047 100644 --- a/libs/domains/projects/feature/src/lib/project-list/project-list.tsx +++ b/libs/domains/projects/feature/src/lib/project-list/project-list.tsx @@ -1,4 +1,4 @@ -import { useParams } from '@tanstack/react-router' +import { Link, useParams } from '@tanstack/react-router' import clsx from 'clsx' import { Button, EmptyState, Heading, Icon, Section, useModal } from '@qovery/shared/ui' import { twMerge } from '@qovery/shared/util-js' @@ -10,7 +10,6 @@ export function ProjectList() { const { organizationId = '' }: { organizationId: string } = useParams({ strict: false }) const { data: projects = [] } = useProjects({ organizationId, suspense: true }) const { openModal, closeModal } = useModal() - const createProjectModal = () => { openModal({ content: , @@ -48,8 +47,10 @@ export function ProjectList() { )} > {projects?.map((project) => ( - + ))} )} diff --git a/libs/domains/services/feature/src/lib/hooks/use-services/use-services.ts b/libs/domains/services/feature/src/lib/hooks/use-services/use-services.ts index 9295277de16..e62969706a9 100644 --- a/libs/domains/services/feature/src/lib/hooks/use-services/use-services.ts +++ b/libs/domains/services/feature/src/lib/hooks/use-services/use-services.ts @@ -6,9 +6,10 @@ import { queries } from '@qovery/state/util-queries' export interface UseServicesProps { environmentId?: string + suspense?: boolean } -export function useServices({ environmentId }: UseServicesProps) { +export function useServices({ environmentId, suspense = false }: UseServicesProps) { const { data: services, isLoading: isServicesLoading } = useQuery({ ...queries.services.list(environmentId!), select(services) { @@ -16,6 +17,7 @@ export function useServices({ environmentId }: UseServicesProps) { return services }, enabled: Boolean(environmentId), + suspense, }) const runningStatusResults = useQueries({ diff --git a/libs/shared/ui/src/index.ts b/libs/shared/ui/src/index.ts index f789f19750b..555de70a27c 100644 --- a/libs/shared/ui/src/index.ts +++ b/libs/shared/ui/src/index.ts @@ -119,6 +119,7 @@ export * from './lib/components/multiple-selector/multiple-selector' export * from './lib/components/logo/logo' export * from './lib/components/logo-branded/logo-branded' export * from './lib/components/sidebar/sidebar' +export * from './lib/components/env-type/env-type' export * from './lib/utils/toast' export * from './lib/utils/toast-error' export * from './lib/utils/ansi' diff --git a/libs/shared/ui/src/lib/components/env-type/env-type.stories.tsx b/libs/shared/ui/src/lib/components/env-type/env-type.stories.tsx new file mode 100644 index 00000000000..e2eb73bb417 --- /dev/null +++ b/libs/shared/ui/src/lib/components/env-type/env-type.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta } from '@storybook/react-webpack5' +import { EnvType } from './env-type' + +const Story: Meta = { + component: EnvType, + title: 'EnvType', + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} +export default Story + +export const Primary = { + render: () => ( +
+
+
+ + Production +
+
+ + Staging +
+
+ + Development +
+
+ + Ephemeral +
+
+
+
+ + Production +
+
+ + Staging +
+
+ + Development +
+
+ + Ephemeral +
+
+
+ ), +} diff --git a/libs/shared/ui/src/lib/components/env-type/env-type.tsx b/libs/shared/ui/src/lib/components/env-type/env-type.tsx new file mode 100644 index 00000000000..0a5d17db39c --- /dev/null +++ b/libs/shared/ui/src/lib/components/env-type/env-type.tsx @@ -0,0 +1,43 @@ +import { type VariantProps, cva } from 'class-variance-authority' +import { type ComponentPropsWithoutRef } from 'react' +import { match } from 'ts-pattern' +import { twMerge } from '@qovery/shared/util-js' + +const _envTypeVariants = cva( + ['inline-flex', 'items-center', 'justify-center', 'border', 'border-neutral', 'font-semibold'], + { + variants: { + type: { + production: ['border-negative-strong', 'bg-surface-negative-subtle', 'text-negative'], + ephemeral: ['border-accent1-strong', 'bg-surface-accent1-component', 'text-accent1'], + staging: ['border-neutral-strong', 'bg-surface-neutral-component', 'text-neutral'], + development: ['border-neutral-component', 'bg-surface-neutral-subtle', 'text-neutral-subtle'], + }, + size: { + sm: ['text-2xs', 'h-4', 'w-4', 'rounded-[3px]'], + lg: ['text-xs', 'h-6', 'w-6', 'rounded-[4px]'], + }, + }, + defaultVariants: { + type: 'production', + size: 'sm', + }, + } +) + +export interface EnvTypeProps extends VariantProps, ComponentPropsWithoutRef<'div'> { + type: 'production' | 'staging' | 'development' | 'ephemeral' +} + +export const EnvType = ({ type, size, className }: EnvTypeProps) => { + return ( +
+ {match(type) + .with('production', () => 'P') + .with('staging', () => 'S') + .with('development', () => 'D') + .with('ephemeral', () => 'E') + .exhaustive()} +
+ ) +} diff --git a/libs/shared/ui/src/lib/components/table-primitives/table-primitives.tsx b/libs/shared/ui/src/lib/components/table-primitives/table-primitives.tsx index de21b85204f..99161d71169 100644 --- a/libs/shared/ui/src/lib/components/table-primitives/table-primitives.tsx +++ b/libs/shared/ui/src/lib/components/table-primitives/table-primitives.tsx @@ -12,7 +12,7 @@ const TableRoot = forwardRef, TableRootProps>(function Table return ( {children} @@ -86,7 +86,7 @@ const TableColumnHeaderCell = forwardRef, TableColumnHeaderCell ref ) { return ( - ) From feacffcdb9266356ea3ea1da56a569ef45a13022 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Mon, 19 Jan 2026 14:50:49 +0100 Subject: [PATCH 02/15] impr: remove duplicated component --- .../project/$projectId/overview.tsx | 26 ++++----- .../lib/environment-mode/environment-mode.tsx | 2 +- libs/shared/ui/src/index.ts | 1 - .../components/env-type/env-type.stories.tsx | 58 ------------------- .../src/lib/components/env-type/env-type.tsx | 43 -------------- 5 files changed, 14 insertions(+), 116 deletions(-) delete mode 100644 libs/shared/ui/src/lib/components/env-type/env-type.stories.tsx delete mode 100644 libs/shared/ui/src/lib/components/env-type/env-type.tsx diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx index 854d6cbd02f..da88d3373bf 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx @@ -4,10 +4,10 @@ import { Suspense, useMemo } from 'react' import { match } from 'ts-pattern' import { ClusterAvatar } from '@qovery/domains/clusters/feature' import { useClusters } from '@qovery/domains/clusters/feature' -import { CreateCloneEnvironmentModal, useEnvironments } from '@qovery/domains/environments/feature' +import { CreateCloneEnvironmentModal, EnvironmentMode, useEnvironments } from '@qovery/domains/environments/feature' import { useProject } from '@qovery/domains/projects/feature' import { useServices } from '@qovery/domains/services/feature' -import { Button, EnvType, Heading, Icon, LoaderSpinner, Section, TablePrimitives, useModal } from '@qovery/shared/ui' +import { Button, Heading, Icon, LoaderSpinner, Section, TablePrimitives, useModal } from '@qovery/shared/ui' import { pluralize } from '@qovery/shared/util-js' const { Table } = TablePrimitives @@ -55,26 +55,26 @@ function EnvironmentSection({ items, onCreateEnvClicked, }: { - type: 'production' | 'staging' | 'development' | 'ephemeral' + type: EnvironmentModeEnum items: Environment[] onCreateEnvClicked: () => void }) { const title = match(type) - .with('production', () => 'Production') - .with('staging', () => 'Staging') - .with('development', () => 'Development') - .with('ephemeral', () => 'Ephemeral') + .with('PRODUCTION', () => 'Production') + .with('STAGING', () => 'Staging') + .with('DEVELOPMENT', () => 'Development') + .with('PREVIEW', () => 'Ephemeral') .exhaustive() return (
- + {title}
{items.length === 0 ? (
- + No {title.toLowerCase()} environment created yet
diff --git a/libs/domains/environments/feature/src/lib/environment-mode/environment-mode.tsx b/libs/domains/environments/feature/src/lib/environment-mode/environment-mode.tsx index 20575f5b13f..979bf0aa3af 100644 --- a/libs/domains/environments/feature/src/lib/environment-mode/environment-mode.tsx +++ b/libs/domains/environments/feature/src/lib/environment-mode/environment-mode.tsx @@ -57,7 +57,7 @@ export const EnvironmentMode = forwardRef, EnvironmentM className={twMerge(className, environmentModeVariants({ variant }))} {...props} > - {variant === 'full' ? 'Preview' : 'V'} + {variant === 'full' ? 'Ephemeral' : 'E'} )) .with('STAGING', () => ( diff --git a/libs/shared/ui/src/index.ts b/libs/shared/ui/src/index.ts index 555de70a27c..f789f19750b 100644 --- a/libs/shared/ui/src/index.ts +++ b/libs/shared/ui/src/index.ts @@ -119,7 +119,6 @@ export * from './lib/components/multiple-selector/multiple-selector' export * from './lib/components/logo/logo' export * from './lib/components/logo-branded/logo-branded' export * from './lib/components/sidebar/sidebar' -export * from './lib/components/env-type/env-type' export * from './lib/utils/toast' export * from './lib/utils/toast-error' export * from './lib/utils/ansi' diff --git a/libs/shared/ui/src/lib/components/env-type/env-type.stories.tsx b/libs/shared/ui/src/lib/components/env-type/env-type.stories.tsx deleted file mode 100644 index e2eb73bb417..00000000000 --- a/libs/shared/ui/src/lib/components/env-type/env-type.stories.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import type { Meta } from '@storybook/react-webpack5' -import { EnvType } from './env-type' - -const Story: Meta = { - component: EnvType, - title: 'EnvType', - decorators: [ - (Story) => ( -
- -
- ), - ], -} -export default Story - -export const Primary = { - render: () => ( -
-
-
- - Production -
-
- - Staging -
-
- - Development -
-
- - Ephemeral -
-
-
-
- - Production -
-
- - Staging -
-
- - Development -
-
- - Ephemeral -
-
-
- ), -} diff --git a/libs/shared/ui/src/lib/components/env-type/env-type.tsx b/libs/shared/ui/src/lib/components/env-type/env-type.tsx deleted file mode 100644 index 0a5d17db39c..00000000000 --- a/libs/shared/ui/src/lib/components/env-type/env-type.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { type VariantProps, cva } from 'class-variance-authority' -import { type ComponentPropsWithoutRef } from 'react' -import { match } from 'ts-pattern' -import { twMerge } from '@qovery/shared/util-js' - -const _envTypeVariants = cva( - ['inline-flex', 'items-center', 'justify-center', 'border', 'border-neutral', 'font-semibold'], - { - variants: { - type: { - production: ['border-negative-strong', 'bg-surface-negative-subtle', 'text-negative'], - ephemeral: ['border-accent1-strong', 'bg-surface-accent1-component', 'text-accent1'], - staging: ['border-neutral-strong', 'bg-surface-neutral-component', 'text-neutral'], - development: ['border-neutral-component', 'bg-surface-neutral-subtle', 'text-neutral-subtle'], - }, - size: { - sm: ['text-2xs', 'h-4', 'w-4', 'rounded-[3px]'], - lg: ['text-xs', 'h-6', 'w-6', 'rounded-[4px]'], - }, - }, - defaultVariants: { - type: 'production', - size: 'sm', - }, - } -) - -export interface EnvTypeProps extends VariantProps, ComponentPropsWithoutRef<'div'> { - type: 'production' | 'staging' | 'development' | 'ephemeral' -} - -export const EnvType = ({ type, size, className }: EnvTypeProps) => { - return ( -
- {match(type) - .with('production', () => 'P') - .with('staging', () => 'S') - .with('development', () => 'D') - .with('ephemeral', () => 'E') - .exhaustive()} -
- ) -} From e3bfd73a575a97fa0fc453ebc254c23f1addbe9c Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Mon, 19 Jan 2026 16:32:26 +0100 Subject: [PATCH 03/15] impr: UI tweaks for 'Dismiss' modal --- .../ui/src/lib/components/modal-alert/modal-alert.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/shared/ui/src/lib/components/modal-alert/modal-alert.tsx b/libs/shared/ui/src/lib/components/modal-alert/modal-alert.tsx index c1550878b65..a1720ba60c7 100644 --- a/libs/shared/ui/src/lib/components/modal-alert/modal-alert.tsx +++ b/libs/shared/ui/src/lib/components/modal-alert/modal-alert.tsx @@ -16,11 +16,11 @@ export function ModalAlert(props: ModalAlertProps) {
-

Discard changes?

-

Are you sure you want to discard your changes?

+

Discard changes?

+

Are you sure you want to discard your changes?

- + + {timeAgo(new Date(environment.updated_at ?? Date.now()))} ago + ) @@ -82,7 +85,7 @@ function EnvironmentSection({
) : ( -
+
From a64f17bde929dbef23bf63a8af8a3addc0ed8d6a Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Wed, 21 Jan 2026 18:45:36 +0100 Subject: [PATCH 05/15] feat: implement new environment overview API endpoint --- .../project/$projectId/overview.tsx | 140 ++++++++++-------- .../organization/$organizationId/route.tsx | 36 ++++- .../src/lib/cluster-avatar/cluster-avatar.tsx | 4 +- .../src/lib/domains-projects-data-access.ts | 8 + libs/domains/projects/feature/src/index.ts | 1 + .../use-environments-overview.ts | 18 +++ package.json | 2 +- yarn.lock | 10 +- 8 files changed, 148 insertions(+), 71 deletions(-) create mode 100644 libs/domains/projects/feature/src/lib/hooks/use-environments-overview/use-environments-overview.ts diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx index 181ab74bc66..a68cbc1c42f 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx @@ -1,15 +1,24 @@ import { Link, createFileRoute, useParams } from '@tanstack/react-router' -import { type Environment, type EnvironmentModeEnum } from 'qovery-typescript-axios' +import { type EnvironmentModeEnum, type EnvironmentOverviewResponse } from 'qovery-typescript-axios' import { Suspense, useMemo } from 'react' import { match } from 'ts-pattern' import { ClusterAvatar } from '@qovery/domains/clusters/feature' -import { useClusters } from '@qovery/domains/clusters/feature' import { CreateCloneEnvironmentModal, EnvironmentMode, useEnvironments } from '@qovery/domains/environments/feature' -import { useProject } from '@qovery/domains/projects/feature' -import { useServices } from '@qovery/domains/services/feature' -import { Button, Heading, Icon, LoaderSpinner, Section, TablePrimitives, useModal } from '@qovery/shared/ui' +import { useEnvironmentsOverview, useProject } from '@qovery/domains/projects/feature' +import { + Button, + Heading, + Icon, + LoaderSpinner, + Section, + StatusChip, + TablePrimitives, + Tooltip, + Truncate, + useModal, +} from '@qovery/shared/ui' import { timeAgo } from '@qovery/shared/util-dates' -import { pluralize } from '@qovery/shared/util-js' +import { pluralize, twMerge } from '@qovery/shared/util-js' const { Table } = TablePrimitives @@ -17,38 +26,57 @@ export const Route = createFileRoute('/_authenticated/organization/$organization component: RouteComponent, }) -function EnvRow({ environment }: { environment: Environment }) { +const gridLayoutClassName = 'grid grid-cols-[3fr_2fr_2fr_180px_100px]' + +function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) { const { organizationId, projectId } = useParams({ strict: false }) - const { data: clusters } = useClusters({ organizationId, suspense: true }) - const { data: services } = useServices({ environmentId: environment.id, suspense: true }) + const { data: environments = [] } = useEnvironments({ projectId, suspense: true }) + const environment = environments.find((env) => env.id === overview.id) + const runningStatus = environment?.runningStatus return ( - + -
+
- {environment.name} + - - {services?.length} {pluralize(services?.length ?? 0, 'service')} - +
+ + {overview.service_count} {pluralize(overview.service_count, 'service')} + + {runningStatus && ( + + + + )} +
- -
- cluster.id === environment.cluster_id)} size="sm" /> - {clusters?.find((cluster) => cluster.id === environment.cluster_id)?.name} +
+ {timeAgo(new Date(overview.deployment_status?.last_deployment_date ?? Date.now()))} ago +
- {timeAgo(new Date(environment.updated_at ?? Date.now()))} ago + {overview.cluster && ( +
+ + {overview.cluster?.name} +
+ )} +
+ +
{timeAgo(new Date(overview.updated_at ?? Date.now()))} ago
+
+ +
- ) } @@ -59,7 +87,7 @@ function EnvironmentSection({ onCreateEnvClicked, }: { type: EnvironmentModeEnum - items: Environment[] + items: EnvironmentOverviewResponse[] onCreateEnvClicked: () => void }) { const title = match(type) @@ -86,39 +114,33 @@ function EnvironmentSection({
) : (
- - -
- } - > - - - - Environment - - Last operation - - - Cluster - - - Last update - - - Actions - - - + + + + + Environment + + + Last operation + + + Cluster + + + Last update + + + Actions + + + - - {items.map((environment) => ( - - ))} - - - + + {items.map((environmentOverview) => ( + + ))} + +
)}
@@ -129,14 +151,14 @@ function ProjectOverview() { const { openModal, closeModal } = useModal() const { organizationId, projectId } = useParams({ strict: false }) const { data: project } = useProject({ organizationId, projectId, suspense: true }) - const { data: environments } = useEnvironments({ projectId, suspense: true }) + const { data: environmentsOverview } = useEnvironmentsOverview({ projectId, suspense: true }) const groupedEnvs = useMemo(() => { - return environments?.reduce((acc, env) => { + return environmentsOverview?.reduce((acc, env) => { acc.set(env.mode, [...(acc.get(env.mode) || []), env]) return acc - }, new Map()) - }, [environments]) + }, new Map()) + }, [environmentsOverview]) const onCreateEnvClicked = () => { openModal({ diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/route.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/route.tsx index f19e951f9f9..638ed9fb947 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/route.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/route.tsx @@ -1,6 +1,9 @@ -import { Outlet, createFileRoute } from '@tanstack/react-router' +import { Outlet, createFileRoute, useParams } from '@tanstack/react-router' import { Suspense } from 'react' +import { memo } from 'react' +import { useClusters } from '@qovery/domains/clusters/feature' import { LoaderSpinner } from '@qovery/shared/ui' +import { StatusWebSocketListener } from '@qovery/shared/util-web-sockets' import { queries } from '@qovery/state/util-queries' export const Route = createFileRoute('/_authenticated/organization/$organizationId')({ @@ -28,10 +31,35 @@ const Loader = () => { ) } +const StatusWebSocketListenerMemo = memo(StatusWebSocketListener) + function RouteComponent() { + const { organizationId = '', projectId = '', environmentId = '', versionId = '' } = useParams({ strict: false }) + const { data: clusters } = useClusters({ organizationId }) + return ( - }> - - + <> + }> + + + + {/** + * Here we are limited by the websocket API which requires a clusterId + * We need to instantiate one hook per clusterId to get the complete environment statuses of the page + */ + clusters?.map( + ({ id }) => + organizationId && ( + + ) + )} + ) } diff --git a/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.tsx b/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.tsx index d3d9e87ebf0..6d6edc26c26 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.tsx @@ -1,11 +1,11 @@ -import { type CloudProviderEnum, type Cluster } from 'qovery-typescript-axios' +import { type CloudProviderEnum, type Cluster, type ClusterOverviewResponse } from 'qovery-typescript-axios' import { type ComponentPropsWithoutRef, type ElementRef, forwardRef } from 'react' import { match } from 'ts-pattern' import { Avatar, Icon } from '@qovery/shared/ui' export interface ClusterAvatarProps extends Omit, 'fallback'> { cloudProvider?: CloudProviderEnum - cluster?: Cluster + cluster?: Cluster | ClusterOverviewResponse } export const ClusterAvatar = forwardRef, ClusterAvatarProps>(function ClusterAvatar( diff --git a/libs/domains/projects/data-access/src/lib/domains-projects-data-access.ts b/libs/domains/projects/data-access/src/lib/domains-projects-data-access.ts index f356b03b533..d5bf7177019 100644 --- a/libs/domains/projects/data-access/src/lib/domains-projects-data-access.ts +++ b/libs/domains/projects/data-access/src/lib/domains-projects-data-access.ts @@ -1,5 +1,6 @@ import { createQueryKeys } from '@lukemorales/query-key-factory' import { + EnvironmentsApi, ProjectDeploymentRuleApi, type ProjectDeploymentRuleRequest, type ProjectDeploymentRulesPriorityOrderRequest, @@ -11,6 +12,7 @@ import { const projectsApi = new ProjectsApi() const projectMainCalls = new ProjectMainCallsApi() const deploymentRulesApi = new ProjectDeploymentRuleApi() +const environmentsApi = new EnvironmentsApi() export const projects = createQueryKeys('projects', { list: ({ organizationId }: { organizationId: string }) => ({ @@ -19,6 +21,12 @@ export const projects = createQueryKeys('projects', { return (await projectsApi.listProject(organizationId)).data.results }, }), + environmentsOverview: ({ projectId }: { projectId: string }) => ({ + queryKey: [projectId, 'environments-overview'], + async queryFn() { + return (await environmentsApi.getProjectEnvironmentsOverview(projectId)).data.results + }, + }), listDeploymentRules: ({ projectId }: { projectId: string }) => ({ queryKey: [projectId], async queryFn() { diff --git a/libs/domains/projects/feature/src/index.ts b/libs/domains/projects/feature/src/index.ts index 1e512ee3b14..c01f5f7c528 100644 --- a/libs/domains/projects/feature/src/index.ts +++ b/libs/domains/projects/feature/src/index.ts @@ -12,3 +12,4 @@ export * from './lib/hooks/use-create-deployment-rule/use-create-deployment-rule export * from './lib/hooks/use-edit-deployment-rule/use-edit-deployment-rule' export * from './lib/hooks/use-delete-deployment-rule/use-delete-deployment-rule' export * from './lib/hooks/use-edit-deployment-rules-priority-order/use-edit-deployment-rules-priority-order' +export * from './lib/hooks/use-environments-overview/use-environments-overview' diff --git a/libs/domains/projects/feature/src/lib/hooks/use-environments-overview/use-environments-overview.ts b/libs/domains/projects/feature/src/lib/hooks/use-environments-overview/use-environments-overview.ts new file mode 100644 index 00000000000..e923feb0fb4 --- /dev/null +++ b/libs/domains/projects/feature/src/lib/hooks/use-environments-overview/use-environments-overview.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query' +import { queries } from '@qovery/state/util-queries' + +interface UseEnvironmentsOverviewProps { + projectId: string + enabled?: boolean + suspense?: boolean +} + +export function useEnvironmentsOverview({ projectId, enabled, suspense = false }: UseEnvironmentsOverviewProps) { + return useQuery({ + ...queries.projects.environmentsOverview({ projectId }), + enabled, + suspense, + }) +} + +export default useEnvironmentsOverview diff --git a/package.json b/package.json index e850bf079aa..405fc561168 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "mermaid": "^11.6.0", "monaco-editor": "0.53.0", "posthog-js": "^1.260.1", - "qovery-typescript-axios": "^1.1.804", + "qovery-typescript-axios": "^1.1.811", "react": "18.3.1", "react-country-flag": "^3.0.2", "react-datepicker": "^4.12.0", diff --git a/yarn.lock b/yarn.lock index d9995031405..f90059155fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6214,7 +6214,7 @@ __metadata: prettier: ^3.2.5 prettier-plugin-tailwindcss: ^0.5.14 pretty-quick: ^4.0.0 - qovery-typescript-axios: ^1.1.804 + qovery-typescript-axios: ^1.1.811 qovery-ws-typescript-axios: ^0.1.420 react: 18.3.1 react-country-flag: ^3.0.2 @@ -25894,12 +25894,12 @@ __metadata: languageName: node linkType: hard -"qovery-typescript-axios@npm:^1.1.804": - version: 1.1.804 - resolution: "qovery-typescript-axios@npm:1.1.804" +"qovery-typescript-axios@npm:^1.1.811": + version: 1.1.811 + resolution: "qovery-typescript-axios@npm:1.1.811" dependencies: axios: 1.12.2 - checksum: 0f978e5b4cebe257b7934cb97cdbc09671d716f7da8e5842cd37b1548489d9bbc769ac143ead236f144b1e05eb893fefa35b8c6f02962d971f8c5259fc9329e1 + checksum: 4bce8b26cccbdeea0b6782a0e5d98dc0734704ca114532aa1a329f39c8a4d1c91e95a30ad2e18498c6be6927c9f8921a2d233359c68805506432bf102576a2c0 languageName: node linkType: hard From 55bd12552e9b98aa537398915e6aa1f4909423ff Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Thu, 22 Jan 2026 13:15:39 +0100 Subject: [PATCH 06/15] impr: add last operation to environment table --- .../$organizationId/project/$projectId/overview.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx index a68cbc1c42f..69c6ad3697c 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx @@ -18,7 +18,7 @@ import { useModal, } from '@qovery/shared/ui' import { timeAgo } from '@qovery/shared/util-dates' -import { pluralize, twMerge } from '@qovery/shared/util-js' +import { pluralize, twMerge, upperCaseFirstLetter } from '@qovery/shared/util-js' const { Table } = TablePrimitives @@ -59,7 +59,14 @@ function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) {
- {timeAgo(new Date(overview.deployment_status?.last_deployment_date ?? Date.now()))} ago +
+ + {upperCaseFirstLetter(overview.deployment_status?.last_deployment_state.replace('_', ' '))} + + + {timeAgo(new Date(overview.deployment_status?.last_deployment_date ?? Date.now()))} ago + +
From a51a511ab5112d089243b7c3982dc91fdacdabc0 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Thu, 22 Jan 2026 15:03:03 +0100 Subject: [PATCH 07/15] impr(new-nav): add actions to table --- .../project/$projectId/overview.tsx | 31 ++++++++++++++++--- .../environment-action-toolbar.tsx | 11 +++---- .../use-cancel-deployment-environment.ts | 4 +-- .../use-delete-environment.ts | 4 +-- .../use-deploy-environment.ts | 4 +-- .../use-stop-environment.ts | 4 +-- .../use-uninstall-environment.ts | 4 +-- 7 files changed, 41 insertions(+), 21 deletions(-) diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx index 69c6ad3697c..c47f76f4c3f 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx @@ -3,9 +3,16 @@ import { type EnvironmentModeEnum, type EnvironmentOverviewResponse } from 'qove import { Suspense, useMemo } from 'react' import { match } from 'ts-pattern' import { ClusterAvatar } from '@qovery/domains/clusters/feature' -import { CreateCloneEnvironmentModal, EnvironmentMode, useEnvironments } from '@qovery/domains/environments/feature' +import { + CreateCloneEnvironmentModal, + EnvironmentMode, + MenuManageDeployment, + MenuOtherActions, + useEnvironments, +} from '@qovery/domains/environments/feature' import { useEnvironmentsOverview, useProject } from '@qovery/domains/projects/feature' import { + ActionToolbar, Button, Heading, Icon, @@ -26,7 +33,7 @@ export const Route = createFileRoute('/_authenticated/organization/$organization component: RouteComponent, }) -const gridLayoutClassName = 'grid grid-cols-[3fr_2fr_2fr_180px_100px]' +const gridLayoutClassName = 'grid grid-cols-[3fr_2fr_2fr_180px_106px]' function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) { const { organizationId, projectId } = useParams({ strict: false }) @@ -72,17 +79,31 @@ function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) { {overview.cluster && ( -
+ {overview.cluster?.name} -
+ )}
{timeAgo(new Date(overview.updated_at ?? Date.now()))} ago
-
+
+ {environment && overview.deployment_status && overview.service_count > 0 && ( + + + + + )} +
) diff --git a/libs/domains/environments/feature/src/lib/environment-action-toolbar/environment-action-toolbar.tsx b/libs/domains/environments/feature/src/lib/environment-action-toolbar/environment-action-toolbar.tsx index cb6b6a9cb45..efacf243517 100644 --- a/libs/domains/environments/feature/src/lib/environment-action-toolbar/environment-action-toolbar.tsx +++ b/libs/domains/environments/feature/src/lib/environment-action-toolbar/environment-action-toolbar.tsx @@ -39,7 +39,7 @@ import { UpdateAllModal } from '../update-all-modal/update-all-modal' type ActionToolbarVariant = 'default' | 'deployment' -function MenuManageDeployment({ +export function MenuManageDeployment({ environment, deploymentStatus, variant, @@ -163,15 +163,14 @@ function MenuManageDeployment({
{match(state) .with('DEPLOYING', 'RESTARTING', 'BUILDING', 'DELETING', 'CANCELING', 'STOPPING', () => ( - + )) .with('DEPLOYMENT_QUEUED', 'DELETE_QUEUED', 'STOP_QUEUED', 'RESTART_QUEUED', () => ( - + )) .otherwise(() => ( - + ))} -
@@ -243,7 +242,7 @@ function MenuManageDeployment({ ) } -function MenuOtherActions({ state, environment }: { state: StateEnum; environment: Environment }) { +export function MenuOtherActions({ state, environment }: { state: StateEnum; environment: Environment }) { const { openModal, closeModal } = useModal() const { openModalConfirmation } = useModalConfirmation() const { mutate: deleteEnvironment } = useDeleteEnvironment({ projectId: environment.project.id }) diff --git a/libs/domains/environments/feature/src/lib/hooks/use-cancel-deployment-environment/use-cancel-deployment-environment.ts b/libs/domains/environments/feature/src/lib/hooks/use-cancel-deployment-environment/use-cancel-deployment-environment.ts index 6e298682de3..df0e67ec633 100644 --- a/libs/domains/environments/feature/src/lib/hooks/use-cancel-deployment-environment/use-cancel-deployment-environment.ts +++ b/libs/domains/environments/feature/src/lib/hooks/use-cancel-deployment-environment/use-cancel-deployment-environment.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' -import { useNavigate } from 'react-router-dom' +import { useNavigate } from '@tanstack/react-router' import { mutations } from '@qovery/domains/environments/data-access' import { queries } from '@qovery/state/util-queries' @@ -26,7 +26,7 @@ export function useCancelDeploymentEnvironment({ projectId, logsLink }: { projec ...(logsLink ? { labelAction: 'See deployment logs', - callback: () => navigate(logsLink), + callback: () => navigate({ to: logsLink }), } : {}), }, diff --git a/libs/domains/environments/feature/src/lib/hooks/use-delete-environment/use-delete-environment.ts b/libs/domains/environments/feature/src/lib/hooks/use-delete-environment/use-delete-environment.ts index 88819dd6b68..85072fd99fc 100644 --- a/libs/domains/environments/feature/src/lib/hooks/use-delete-environment/use-delete-environment.ts +++ b/libs/domains/environments/feature/src/lib/hooks/use-delete-environment/use-delete-environment.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' -import { useNavigate } from 'react-router-dom' +import { useNavigate } from '@tanstack/react-router' import { mutations } from '@qovery/domains/environments/data-access' import { queries } from '@qovery/state/util-queries' @@ -22,7 +22,7 @@ export function useDeleteEnvironment({ projectId, logsLink }: { projectId: strin ...(logsLink ? { labelAction: 'See deployment logs', - callback: () => navigate(logsLink), + callback: () => navigate({ to: logsLink }), } : {}), }, diff --git a/libs/domains/environments/feature/src/lib/hooks/use-deploy-environment/use-deploy-environment.ts b/libs/domains/environments/feature/src/lib/hooks/use-deploy-environment/use-deploy-environment.ts index e36af1a2469..bc528ccbd24 100644 --- a/libs/domains/environments/feature/src/lib/hooks/use-deploy-environment/use-deploy-environment.ts +++ b/libs/domains/environments/feature/src/lib/hooks/use-deploy-environment/use-deploy-environment.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' -import { useNavigate } from 'react-router-dom' +import { useNavigate } from '@tanstack/react-router' import { mutations } from '@qovery/domains/environments/data-access' import { queries } from '@qovery/state/util-queries' @@ -26,7 +26,7 @@ export function useDeployEnvironment({ projectId, logsLink }: { projectId: strin ...(logsLink ? { labelAction: 'See deployment logs', - callback: () => navigate(logsLink), + callback: () => navigate({ to: logsLink }), } : {}), }, diff --git a/libs/domains/environments/feature/src/lib/hooks/use-stop-environment/use-stop-environment.ts b/libs/domains/environments/feature/src/lib/hooks/use-stop-environment/use-stop-environment.ts index 359d515d0ad..67b2ad2692d 100644 --- a/libs/domains/environments/feature/src/lib/hooks/use-stop-environment/use-stop-environment.ts +++ b/libs/domains/environments/feature/src/lib/hooks/use-stop-environment/use-stop-environment.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' -import { useNavigate } from 'react-router-dom' +import { useNavigate } from '@tanstack/react-router' import { mutations } from '@qovery/domains/environments/data-access' import { queries } from '@qovery/state/util-queries' @@ -26,7 +26,7 @@ export function useStopEnvironment({ projectId, logsLink }: { projectId: string; ...(logsLink ? { labelAction: 'See deployment logs', - callback: () => navigate(logsLink), + callback: () => navigate({ to: logsLink }), } : {}), }, diff --git a/libs/domains/environments/feature/src/lib/hooks/use-uninstall-environment/use-uninstall-environment.ts b/libs/domains/environments/feature/src/lib/hooks/use-uninstall-environment/use-uninstall-environment.ts index 3de23d9d943..d7ed7a345d3 100644 --- a/libs/domains/environments/feature/src/lib/hooks/use-uninstall-environment/use-uninstall-environment.ts +++ b/libs/domains/environments/feature/src/lib/hooks/use-uninstall-environment/use-uninstall-environment.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' -import { useNavigate } from 'react-router-dom' +import { useNavigate } from '@tanstack/react-router' import { mutations } from '@qovery/domains/environments/data-access' import { queries } from '@qovery/state/util-queries' @@ -26,7 +26,7 @@ export function useUninstallEnvironment({ projectId, logsLink }: { projectId: st ...(logsLink ? { labelAction: 'See deployment logs', - callback: () => navigate(logsLink), + callback: () => navigate({ to: logsLink }), } : {}), }, From 37483d113e775611fcaad90bc301fd8ebc04e96c Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Fri, 23 Jan 2026 17:53:34 +0100 Subject: [PATCH 08/15] impr: add new icons and statuses for "last operation" --- .../project/$projectId/overview.tsx | 7 +- libs/shared/ui/src/index.ts | 1 + .../deployment-action/deployment-action.tsx | 189 ++++++++++++++++++ 3 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 libs/shared/ui/src/lib/components/deployment-action/deployment-action.tsx diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx index c47f76f4c3f..18d944bcc0f 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx @@ -14,6 +14,7 @@ import { useEnvironmentsOverview, useProject } from '@qovery/domains/projects/fe import { ActionToolbar, Button, + DeploymentAction, Heading, Icon, LoaderSpinner, @@ -25,7 +26,7 @@ import { useModal, } from '@qovery/shared/ui' import { timeAgo } from '@qovery/shared/util-dates' -import { pluralize, twMerge, upperCaseFirstLetter } from '@qovery/shared/util-js' +import { pluralize, twMerge } from '@qovery/shared/util-js' const { Table } = TablePrimitives @@ -67,9 +68,7 @@ function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) {
- - {upperCaseFirstLetter(overview.deployment_status?.last_deployment_state.replace('_', ' '))} - + {timeAgo(new Date(overview.deployment_status?.last_deployment_date ?? Date.now()))} ago diff --git a/libs/shared/ui/src/index.ts b/libs/shared/ui/src/index.ts index f789f19750b..0977c3a8edc 100644 --- a/libs/shared/ui/src/index.ts +++ b/libs/shared/ui/src/index.ts @@ -122,3 +122,4 @@ export * from './lib/components/sidebar/sidebar' export * from './lib/utils/toast' export * from './lib/utils/toast-error' export * from './lib/utils/ansi' +export * from './lib/components/deployment-action/deployment-action' diff --git a/libs/shared/ui/src/lib/components/deployment-action/deployment-action.tsx b/libs/shared/ui/src/lib/components/deployment-action/deployment-action.tsx new file mode 100644 index 00000000000..8fdd0f90396 --- /dev/null +++ b/libs/shared/ui/src/lib/components/deployment-action/deployment-action.tsx @@ -0,0 +1,189 @@ +import { StateEnum } from 'qovery-typescript-axios' +import { forwardRef } from 'react' +import { match } from 'ts-pattern' +import { twMerge } from '@qovery/shared/util-js' +import { type IconSVGProps } from '../icon/icon' + +export const DeployIcon = forwardRef(function ( + { className = '', ...props }, + forwardedRef +) { + return ( + + + + + + + + + + + ) +}) + +export const RestartIcon = forwardRef(function ( + { className = '', ...props }, + forwardedRef +) { + return ( + + + + + + + + + + + ) +}) + +export const DeleteIcon = forwardRef(function ( + { className = '', ...props }, + forwardedRef +) { + return ( + + + + + + + + + + + ) +}) + +export const StopIcon = forwardRef(function ({ className = '', ...props }, forwardedRef) { + return ( + + + + + + + + + + + ) +}) + +export const getDeploymentAction = (status: StateEnum | undefined) => { + return match(status) + .with( + StateEnum.QUEUED, + StateEnum.WAITING_RUNNING, + StateEnum.DEPLOYING, + StateEnum.DEPLOYED, + StateEnum.DEPLOYMENT_ERROR, + StateEnum.DEPLOYMENT_QUEUED, + // Other states categorized as "deploy" + StateEnum.BUILDING, + StateEnum.BUILD_ERROR, + StateEnum.CANCELING, + StateEnum.CANCELED, + StateEnum.EXECUTING, + StateEnum.READY, + StateEnum.RECAP, + undefined, + () => ({ + status: 'Deploy', + icon: , + }) + ) + .with( + StateEnum.RESTARTED, + StateEnum.RESTARTING, + StateEnum.RESTART_ERROR, + StateEnum.RESTART_QUEUED, + StateEnum.WAITING_RESTARTING, + () => ({ + status: 'Restart', + icon: , + }) + ) + .with( + StateEnum.DELETED, + StateEnum.DELETE_ERROR, + StateEnum.DELETE_QUEUED, + StateEnum.DELETING, + StateEnum.WAITING_DELETING, + () => ({ + status: 'Delete', + icon: , + }) + ) + .with( + StateEnum.STOPPED, + StateEnum.STOPPING, + StateEnum.STOP_ERROR, + StateEnum.STOP_QUEUED, + StateEnum.WAITING_STOPPING, + () => ({ + status: 'Stop', + icon: , + }) + ) + .exhaustive() +} + +export const DeploymentAction = ({ status }: { status: StateEnum | undefined }) => { + const action = getDeploymentAction(status) + if (!status || !action) return null + + return ( +
+ {action.icon} + {action.status} +
+ ) +} From 33b57567094d9ab61d52c693ad7acadd2c1e4486 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Mon, 26 Jan 2026 17:14:43 +0100 Subject: [PATCH 09/15] impr: corrected TS warnings --- .../project/$projectId/overview.tsx | 39 ++++++++----------- .../deployment-action/deployment-action.tsx | 8 ++-- 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx index 18d944bcc0f..323cccf6b8c 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx @@ -21,7 +21,6 @@ import { Section, StatusChip, TablePrimitives, - Tooltip, Truncate, useModal, } from '@qovery/shared/ui' @@ -54,18 +53,14 @@ function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) {
- + {overview.service_count} {pluralize(overview.service_count, 'service')} - {runningStatus && ( - - - - )} + {runningStatus && }
- +
@@ -76,7 +71,7 @@ function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) {
- + {overview.cluster && ( )} - +
{timeAgo(new Date(overview.updated_at ?? Date.now()))} ago
- +
{environment && overview.deployment_status && overview.service_count > 0 && ( @@ -131,38 +126,38 @@ function EnvironmentSection({ {title}
{items.length === 0 ? ( -
+
- No {title.toLowerCase()} environment created yet + No {title.toLowerCase()} environment created yet
) : ( -
- +
+ - + Environment - + Last operation - + Cluster - + Last update - + Actions - + {items.map((environmentOverview) => ( ))} @@ -216,7 +211,7 @@ function ProjectOverview() { New Environment
-
+
(function ( xmlns="http://www.w3.org/2000/svg" {...props} > - + (function ( xmlns="http://www.w3.org/2000/svg" {...props} > - + (function ( xmlns="http://www.w3.org/2000/svg" {...props} > - + (function ({ clas xmlns="http://www.w3.org/2000/svg" {...props} > - + Date: Tue, 27 Jan 2026 09:20:14 +0100 Subject: [PATCH 10/15] feat: add monochrome and size variants for StatusChip --- .../project/$projectId/overview.tsx | 2 +- .../components/status-chip/status-chip.tsx | 54 ++++++++++--------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx index 323cccf6b8c..5e6f44202c2 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx @@ -68,7 +68,7 @@ function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) { {timeAgo(new Date(overview.deployment_status?.last_deployment_date ?? Date.now()))} ago
- +
diff --git a/libs/shared/ui/src/lib/components/status-chip/status-chip.tsx b/libs/shared/ui/src/lib/components/status-chip/status-chip.tsx index 47910ff02ad..27d7ce88fe8 100644 --- a/libs/shared/ui/src/lib/components/status-chip/status-chip.tsx +++ b/libs/shared/ui/src/lib/components/status-chip/status-chip.tsx @@ -9,7 +9,7 @@ import { } from 'qovery-typescript-axios' import { match } from 'ts-pattern' import { type RunningState } from '@qovery/shared/enums' -import { upperCaseFirstLetter } from '@qovery/shared/util-js' +import { twMerge, upperCaseFirstLetter } from '@qovery/shared/util-js' import { BuildErrorIcon, BuildingIcon, @@ -45,6 +45,8 @@ export interface StatusChipProps { className?: string appendTooltipMessage?: string disabledTooltip?: boolean + variant?: 'default' | 'monochrome' + size?: 'sm' | 'xs' } export function StatusChip({ @@ -52,12 +54,17 @@ export function StatusChip({ className = '', appendTooltipMessage = '', disabledTooltip = false, + variant = 'default', + size = 'sm', }: StatusChipProps) { + const iconClass = twMerge(variant === 'monochrome' && 'text-neutral-subtle', size === 'xs' && 'h-full w-full') + const wrapperClassName = twMerge(className, size === 'xs' && 'h-[14px] w-[14px]') + if (!status) return ( -
- +
+
) @@ -68,9 +75,9 @@ export function StatusChip({ const icon = match(status) // success - .with('READY', () => ) - .with('DEPLOYED', 'RUNNING', 'COMPLETED', 'SUCCESS', 'DONE', 'DEPLOY', () => ) - .with('RESTARTED', 'RESTART', () => ) + .with('READY', () => ) + .with('DEPLOYED', 'RUNNING', 'COMPLETED', 'SUCCESS', 'DONE', 'DEPLOY', () => ) + .with('RESTARTED', 'RESTART', () => ) // spinner .with( 'QUEUED', @@ -89,21 +96,21 @@ export function StatusChip({ 'TERRAFORM_FORCE_UNLOCK_STATE', () => ) - .with('DEPLOYING', 'STARTING', 'ONGOING', 'DRY_RUN', 'EXECUTING', () => ) - .with('RESTARTING', () => ) - .with('BUILDING', () => ) - .with('STOPPING', () => ) - .with('CANCELING', () => ) - .with('DELETING', () => ) + .with('DEPLOYING', 'STARTING', 'ONGOING', 'DRY_RUN', 'EXECUTING', () => ) + .with('RESTARTING', () => ) + .with('BUILDING', () => ) + .with('STOPPING', () => ) + .with('CANCELING', () => ) + .with('DELETING', () => ) // stopped - .with('STOPPED', 'STOP', () => ) - .with('CANCELED', 'CANCEL', () => ) - .with('SKIP', 'SKIPPED', () => ) - .with('DELETED', 'DELETE', () => ) + .with('STOPPED', 'STOP', () => ) + .with('CANCELED', 'CANCEL', () => ) + .with('SKIP', 'SKIPPED', () => ) + .with('DELETED', 'DELETE', () => ) // unknow / error / warning - .with('UNKNOWN', 'NEVER', () => ) - .with('BUILD_ERROR', () => ) - .with('WARNING', () => ) + .with('UNKNOWN', 'NEVER', () => ) + .with('BUILD_ERROR', () => ) + .with('WARNING', () => ) .with( 'DEPLOYMENT_ERROR', 'STOP_ERROR', @@ -112,18 +119,13 @@ export function StatusChip({ 'ERROR', 'INVALID_CREDENTIALS', 'RECAP', - () => ( - - - - - ) + () => ) .exhaustive() return ( -
{icon}
+
{icon}
) } From c22b9aeb014f18d5fc1f2f7c2845227a5e935ad4 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Wed, 28 Jan 2026 10:23:42 +0100 Subject: [PATCH 11/15] impr: UI tweaks --- .../project/$projectId/overview.tsx | 93 ++++++++++++------- .../create-clone-environment-modal.tsx | 4 +- .../lib/environment-mode/environment-mode.tsx | 2 +- .../components/icon/icons-status/deleted.tsx | 2 +- .../components/icon/icons-status/stopped.tsx | 2 +- .../components/status-chip/status-chip.tsx | 8 +- 6 files changed, 66 insertions(+), 45 deletions(-) diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx index 5e6f44202c2..673ce7160c7 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx @@ -1,6 +1,6 @@ import { Link, createFileRoute, useParams } from '@tanstack/react-router' -import { type EnvironmentModeEnum, type EnvironmentOverviewResponse } from 'qovery-typescript-axios' -import { Suspense, useMemo } from 'react' +import { EnvironmentModeEnum, type EnvironmentOverviewResponse } from 'qovery-typescript-axios' +import { type ReactNode, Suspense, useMemo } from 'react' import { match } from 'ts-pattern' import { ClusterAvatar } from '@qovery/domains/clusters/feature' import { @@ -42,7 +42,7 @@ function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) { const runningStatus = environment?.runningStatus return ( - +
- + {overview.service_count} {pluralize(overview.service_count, 'service')} {runningStatus && }
- +
@@ -71,21 +71,21 @@ function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) {
- + {overview.cluster && ( - {overview.cluster?.name} + {overview.cluster?.name} )} - +
{timeAgo(new Date(overview.updated_at ?? Date.now()))} ago
- +
{environment && overview.deployment_status && overview.service_count > 0 && ( @@ -110,7 +110,7 @@ function EnvironmentSection({ }: { type: EnvironmentModeEnum items: EnvironmentOverviewResponse[] - onCreateEnvClicked: () => void + onCreateEnvClicked?: () => void }) { const title = match(type) .with('PRODUCTION', () => 'Production') @@ -119,6 +119,30 @@ function EnvironmentSection({ .with('PREVIEW', () => 'Ephemeral') .exhaustive() + const EmptyState = () => + match(type) + .with(EnvironmentModeEnum.PREVIEW, () => { + return ( +
+ No ephemeral environment configured + + An ephemeral environment will be created automatically when a pull request is opened. + +
+ ) + }) + .otherwise(() => { + return ( + <> + No {title.toLowerCase()} environment created yet + + + ) + }) + return (
@@ -126,38 +150,34 @@ function EnvironmentSection({ {title}
{items.length === 0 ? ( -
+
- No {title.toLowerCase()} environment created yet - +
) : ( -
- +
+ - + Environment - + Last operation - + Cluster - + Last update - + Actions - + {items.map((environmentOverview) => ( ))} @@ -182,10 +202,15 @@ function ProjectOverview() { }, new Map()) }, [environmentsOverview]) - const onCreateEnvClicked = () => { + const onCreateEnvClicked = (type?: EnvironmentModeEnum) => { openModal({ content: ( - + ), options: { fakeModal: true, @@ -211,29 +236,25 @@ function ProjectOverview() { New Environment
-
+
onCreateEnvClicked('PRODUCTION')} /> onCreateEnvClicked('STAGING')} /> - onCreateEnvClicked('DEVELOPMENT')} /> +
diff --git a/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.tsx b/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.tsx index eea068143c8..ee37e56132e 100644 --- a/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.tsx +++ b/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.tsx @@ -20,6 +20,7 @@ export interface CreateCloneEnvironmentModalProps { organizationId: string environmentToClone?: Environment onClose: () => void + type?: EnvironmentModeEnum } export function CreateCloneEnvironmentModal({ @@ -27,6 +28,7 @@ export function CreateCloneEnvironmentModal({ organizationId, environmentToClone, onClose, + type, }: CreateCloneEnvironmentModalProps) { const navigate = useNavigate() const { enableAlertClickOutside } = useModal() @@ -41,7 +43,7 @@ export function CreateCloneEnvironmentModal({ defaultValues: { name: environmentToClone?.name ? environmentToClone.name + '-clone' : '', cluster: clusters.find(({ is_default }) => is_default)?.id, - mode: EnvironmentModeEnum.DEVELOPMENT, + mode: type ?? EnvironmentModeEnum.DEVELOPMENT, project_id: projectId, }, }) diff --git a/libs/domains/environments/feature/src/lib/environment-mode/environment-mode.tsx b/libs/domains/environments/feature/src/lib/environment-mode/environment-mode.tsx index 979bf0aa3af..e2b5e9c2c83 100644 --- a/libs/domains/environments/feature/src/lib/environment-mode/environment-mode.tsx +++ b/libs/domains/environments/feature/src/lib/environment-mode/environment-mode.tsx @@ -8,7 +8,7 @@ import { twMerge } from '@qovery/shared/util-js' const environmentModeVariants = cva('', { variants: { variant: { - shrink: ['flex', 'h-4', 'w-4', 'justify-center', 'p-0', 'font-semibold'], + shrink: ['flex', 'h-4', 'w-4', 'justify-center', 'p-0', 'font-semibold', 'rounded'], full: [], }, }, diff --git a/libs/shared/ui/src/lib/components/icon/icons-status/deleted.tsx b/libs/shared/ui/src/lib/components/icon/icons-status/deleted.tsx index 5ae1299881c..3cc7998b78c 100644 --- a/libs/shared/ui/src/lib/components/icon/icons-status/deleted.tsx +++ b/libs/shared/ui/src/lib/components/icon/icons-status/deleted.tsx @@ -17,7 +17,7 @@ export const DeletedIcon = forwardRef(function Dele ref={forwardedRef} {...props} > - + diff --git a/libs/shared/ui/src/lib/components/icon/icons-status/stopped.tsx b/libs/shared/ui/src/lib/components/icon/icons-status/stopped.tsx index 89bb10acc91..e6c8e0f5929 100644 --- a/libs/shared/ui/src/lib/components/icon/icons-status/stopped.tsx +++ b/libs/shared/ui/src/lib/components/icon/icons-status/stopped.tsx @@ -8,7 +8,7 @@ export const StoppedIcon = forwardRef(function Stop ) { return ( + () => ) .with('DEPLOYING', 'STARTING', 'ONGOING', 'DRY_RUN', 'EXECUTING', () => ) .with('RESTARTING', () => ) From 2906efaf3ae9eb1f6842ec610f21348355aecd1d Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Wed, 28 Jan 2026 10:25:47 +0100 Subject: [PATCH 12/15] impr: deploy icon UI tweaks --- .../ui/src/lib/components/icon/icons-status/deploying.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/shared/ui/src/lib/components/icon/icons-status/deploying.tsx b/libs/shared/ui/src/lib/components/icon/icons-status/deploying.tsx index deb2bbcc218..f876acd937d 100644 --- a/libs/shared/ui/src/lib/components/icon/icons-status/deploying.tsx +++ b/libs/shared/ui/src/lib/components/icon/icons-status/deploying.tsx @@ -8,7 +8,7 @@ export const DeployingIcon = forwardRef, IconSVGP forwardedRef ) { return ( - + Date: Wed, 28 Jan 2026 11:46:26 +0100 Subject: [PATCH 13/15] impr: responsiveness of the table --- .../project/$projectId/overview.tsx | 55 +++++++++++-------- package.json | 1 + yarn.lock | 47 +++++++++++++++- 3 files changed, 80 insertions(+), 23 deletions(-) diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx index 673ce7160c7..0937b6273da 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx @@ -1,6 +1,7 @@ import { Link, createFileRoute, useParams } from '@tanstack/react-router' import { EnvironmentModeEnum, type EnvironmentOverviewResponse } from 'qovery-typescript-axios' -import { type ReactNode, Suspense, useMemo } from 'react' +import { Suspense, useMemo } from 'react' +import { useMediaQuery } from 'react-responsive' import { match } from 'ts-pattern' import { ClusterAvatar } from '@qovery/domains/clusters/feature' import { @@ -33,26 +34,34 @@ export const Route = createFileRoute('/_authenticated/organization/$organization component: RouteComponent, }) -const gridLayoutClassName = 'grid grid-cols-[3fr_2fr_2fr_180px_106px]' +const gridLayoutClassName = + 'grid w-full grid-cols-[1fr_20%_min(20%,160px)_min(15%,120px)_max(10%,106px)] xl:grid-cols-[1fr_25%_min(20%,220px)_160px_106px]' function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) { const { organizationId, projectId } = useParams({ strict: false }) const { data: environments = [] } = useEnvironments({ projectId, suspense: true }) const environment = environments.find((env) => env.id === overview.id) const runningStatus = environment?.runningStatus + const cellClassName = 'h-auto border-l border-neutral py-2' + const isDesktopOrLaptop = useMediaQuery({ + query: '(min-width: 1280px)', + }) + const isVeryLargeScreen = useMediaQuery({ + query: '(min-width: 1536px)', + }) return ( - -
+ +
- + -
+
{overview.service_count} {pluralize(overview.service_count, 'service')} @@ -60,32 +69,34 @@ function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) {
- +
-
+
{timeAgo(new Date(overview.deployment_status?.last_deployment_date ?? Date.now()))} ago
- +
- - {overview.cluster && ( - - - {overview.cluster?.name} - - )} + +
+ {overview.cluster && ( + + + + + )} +
- +
{timeAgo(new Date(overview.updated_at ?? Date.now()))} ago
- +
{environment && overview.deployment_status && overview.service_count > 0 && ( diff --git a/package.json b/package.json index 405fc561168..de9d42580e4 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "react-hot-toast": "^2.4.0", "react-markdown": "^9.0.3", "react-payment-inputs": "^1.1.9", + "react-responsive": "^10.0.1", "react-router-dom": "^6.4.0", "react-select": "^5.3.2", "react-syntax-highlighter": "^15.6.1", diff --git a/yarn.lock b/yarn.lock index f90059155fa..e933d5df2ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6229,6 +6229,7 @@ __metadata: react-markdown: ^9.0.3 react-payment-inputs: ^1.1.9 react-refresh: ^0.14.0 + react-responsive: ^10.0.1 react-router-dom: ^6.4.0 react-select: ^5.3.2 react-select-event: ^5.5.1 @@ -13844,6 +13845,13 @@ __metadata: languageName: node linkType: hard +"css-mediaquery@npm:^0.1.2": + version: 0.1.2 + resolution: "css-mediaquery@npm:0.1.2" + checksum: 8e26ae52d8aaaa71893f82fc485363ff0fab494b7d3b3464572aaed50714b8b538d33dbdaa69f0c02cf7f80d1f4d9a77519306c0492223ce91b3987475031a69 + languageName: node + linkType: hard + "css-minimizer-webpack-plugin@npm:^5.0.0": version: 5.0.1 resolution: "css-minimizer-webpack-plugin@npm:5.0.1" @@ -18591,6 +18599,13 @@ __metadata: languageName: node linkType: hard +"hyphenate-style-name@npm:^1.0.0": + version: 1.1.0 + resolution: "hyphenate-style-name@npm:1.1.0" + checksum: b9ed74e29181d96bd58a2d0e62fc4a19879db591dba268275829ff0ae595fcdf11faafaeaa63330a45c3004664d7db1f0fc7cdb372af8ee4615ed8260302c207 + languageName: node + linkType: hard + "iconv-lite@npm:0.4.24, iconv-lite@npm:^0.4.24": version: 0.4.24 resolution: "iconv-lite@npm:0.4.24" @@ -21901,6 +21916,15 @@ __metadata: languageName: node linkType: hard +"matchmediaquery@npm:^0.4.2": + version: 0.4.2 + resolution: "matchmediaquery@npm:0.4.2" + dependencies: + css-mediaquery: ^0.1.2 + checksum: 0d495dd20a4bdd4c6485c43fe9363d41de6c8cbe3bb4c259f39b885f1d2627f102ac21a759b6108a313d0dbd3445edb9847b734cff2f06b5b19fed62dbb7856f + languageName: node + linkType: hard + "math-intrinsics@npm:^1.1.0": version: 1.1.0 resolution: "math-intrinsics@npm:1.1.0" @@ -25782,7 +25806,7 @@ __metadata: languageName: node linkType: hard -"prop-types@npm:^15.6.0, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": +"prop-types@npm:^15.6.0, prop-types@npm:^15.6.1, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": version: 15.8.1 resolution: "prop-types@npm:15.8.1" dependencies: @@ -26341,6 +26365,20 @@ __metadata: languageName: node linkType: hard +"react-responsive@npm:^10.0.1": + version: 10.0.1 + resolution: "react-responsive@npm:10.0.1" + dependencies: + hyphenate-style-name: ^1.0.0 + matchmediaquery: ^0.4.2 + prop-types: ^15.6.1 + shallow-equal: ^3.1.0 + peerDependencies: + react: ">=16.8.0" + checksum: 76fb5da155b40b22df2901b5b49edf3038e6f85cbe1a6d64bf2b7a8bec142b7cb38ce1f559c02c21a6d5813c29903448d1a5352ac5d358a758d7f35091858b4c + languageName: node + linkType: hard + "react-router-dom@npm:^6.4.0": version: 6.23.0 resolution: "react-router-dom@npm:6.23.0" @@ -28107,6 +28145,13 @@ __metadata: languageName: node linkType: hard +"shallow-equal@npm:^3.1.0": + version: 3.1.0 + resolution: "shallow-equal@npm:3.1.0" + checksum: 7eab4ae9fbd79c8913a88d6af873548b6ce619baab2e9e0a2f11696881cdaae289d51423837f6aca38de874581609c8e5e2fa052d8b79c10b55ac9fe7bd2f2f2 + languageName: node + linkType: hard + "shallowequal@npm:^1.1.0": version: 1.1.0 resolution: "shallowequal@npm:1.1.0" From ccd3967ca20d39ee3fad81b549de3091a752ca14 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Wed, 28 Jan 2026 14:12:55 +0100 Subject: [PATCH 14/15] impr: change demo cluster icon --- .../feature/src/lib/cluster-avatar/cluster-avatar.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.tsx b/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.tsx index 6d6edc26c26..10ed95a8863 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.tsx @@ -15,9 +15,7 @@ export const ClusterAvatar = forwardRef, ClusterAvatar const localCloudProvider = cloudProvider ?? cluster?.cloud_provider const fallback = match({ cluster, localCloudProvider }) .with({ cluster: { is_demo: true } }, () => ( -
- -
+ )) .with({ localCloudProvider: 'ON_PREMISE' }, () => ) .otherwise(() => ) From 929246dbe2b0150fcf63d9fe2f1b5634bf398390 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Wed, 28 Jan 2026 15:43:37 +0100 Subject: [PATCH 15/15] impr: split components --- .../project/$projectId/overview.tsx | 275 +----------------- .../domains/environments/feature/src/index.ts | 1 + .../environment-section.tsx | 189 ++++++++++++ .../environments-table/environments-table.tsx | 79 +++++ 4 files changed, 274 insertions(+), 270 deletions(-) create mode 100644 libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.tsx create mode 100644 libs/domains/environments/feature/src/lib/environments-table/environments-table.tsx diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx index 0937b6273da..cb91257bd21 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx @@ -1,277 +1,12 @@ -import { Link, createFileRoute, useParams } from '@tanstack/react-router' -import { EnvironmentModeEnum, type EnvironmentOverviewResponse } from 'qovery-typescript-axios' -import { Suspense, useMemo } from 'react' -import { useMediaQuery } from 'react-responsive' -import { match } from 'ts-pattern' -import { ClusterAvatar } from '@qovery/domains/clusters/feature' -import { - CreateCloneEnvironmentModal, - EnvironmentMode, - MenuManageDeployment, - MenuOtherActions, - useEnvironments, -} from '@qovery/domains/environments/feature' -import { useEnvironmentsOverview, useProject } from '@qovery/domains/projects/feature' -import { - ActionToolbar, - Button, - DeploymentAction, - Heading, - Icon, - LoaderSpinner, - Section, - StatusChip, - TablePrimitives, - Truncate, - useModal, -} from '@qovery/shared/ui' -import { timeAgo } from '@qovery/shared/util-dates' -import { pluralize, twMerge } from '@qovery/shared/util-js' - -const { Table } = TablePrimitives +import { createFileRoute } from '@tanstack/react-router' +import { Suspense } from 'react' +import { EnvironmentsTable } from '@qovery/domains/environments/feature' +import { LoaderSpinner } from '@qovery/shared/ui' export const Route = createFileRoute('/_authenticated/organization/$organizationId/project/$projectId/overview')({ component: RouteComponent, }) -const gridLayoutClassName = - 'grid w-full grid-cols-[1fr_20%_min(20%,160px)_min(15%,120px)_max(10%,106px)] xl:grid-cols-[1fr_25%_min(20%,220px)_160px_106px]' - -function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) { - const { organizationId, projectId } = useParams({ strict: false }) - const { data: environments = [] } = useEnvironments({ projectId, suspense: true }) - const environment = environments.find((env) => env.id === overview.id) - const runningStatus = environment?.runningStatus - const cellClassName = 'h-auto border-l border-neutral py-2' - const isDesktopOrLaptop = useMediaQuery({ - query: '(min-width: 1280px)', - }) - const isVeryLargeScreen = useMediaQuery({ - query: '(min-width: 1536px)', - }) - - return ( - - -
- - - -
- - {overview.service_count} {pluralize(overview.service_count, 'service')} - - {runningStatus && } -
-
-
- -
-
- - - {timeAgo(new Date(overview.deployment_status?.last_deployment_date ?? Date.now()))} ago - -
- -
-
- -
- {overview.cluster && ( - - - - - )} -
-
- -
{timeAgo(new Date(overview.updated_at ?? Date.now()))} ago
-
- -
- {environment && overview.deployment_status && overview.service_count > 0 && ( - - - - - )} -
-
-
- ) -} - -function EnvironmentSection({ - type, - items, - onCreateEnvClicked, -}: { - type: EnvironmentModeEnum - items: EnvironmentOverviewResponse[] - onCreateEnvClicked?: () => void -}) { - const title = match(type) - .with('PRODUCTION', () => 'Production') - .with('STAGING', () => 'Staging') - .with('DEVELOPMENT', () => 'Development') - .with('PREVIEW', () => 'Ephemeral') - .exhaustive() - - const EmptyState = () => - match(type) - .with(EnvironmentModeEnum.PREVIEW, () => { - return ( -
- No ephemeral environment configured - - An ephemeral environment will be created automatically when a pull request is opened. - -
- ) - }) - .otherwise(() => { - return ( - <> - No {title.toLowerCase()} environment created yet - - - ) - }) - - return ( -
-
- - {title} -
- {items.length === 0 ? ( -
- - -
- ) : ( -
- - - - - Environment - - - Last operation - - - Cluster - - - Last update - - - Actions - - - - - - {items.map((environmentOverview) => ( - - ))} - - -
- )} -
- ) -} - -function ProjectOverview() { - const { openModal, closeModal } = useModal() - const { organizationId, projectId } = useParams({ strict: false }) - const { data: project } = useProject({ organizationId, projectId, suspense: true }) - const { data: environmentsOverview } = useEnvironmentsOverview({ projectId, suspense: true }) - - const groupedEnvs = useMemo(() => { - return environmentsOverview?.reduce((acc, env) => { - acc.set(env.mode, [...(acc.get(env.mode) || []), env]) - return acc - }, new Map()) - }, [environmentsOverview]) - - const onCreateEnvClicked = (type?: EnvironmentModeEnum) => { - openModal({ - content: ( - - ), - options: { - fakeModal: true, - }, - }) - } - - return ( -
-
-
-
- {project?.name} - -
-
-
-
- onCreateEnvClicked('PRODUCTION')} - /> - onCreateEnvClicked('STAGING')} - /> - onCreateEnvClicked('DEVELOPMENT')} - /> - -
-
-
- ) -} - function RouteComponent() { return ( } > - + ) } diff --git a/libs/domains/environments/feature/src/index.ts b/libs/domains/environments/feature/src/index.ts index e2295044e2f..476d5f1d27a 100644 --- a/libs/domains/environments/feature/src/index.ts +++ b/libs/domains/environments/feature/src/index.ts @@ -35,3 +35,4 @@ export * from './lib/hooks/use-lifecycle-templates/use-lifecycle-templates' export * from './lib/hooks/use-lifecycle-template/use-lifecycle-template' export * from './lib/hooks/use-deploy-all-services/use-deploy-all-services' export * from './lib/hooks/use-service-count/use-service-count' +export * from './lib/environments-table/environments-table' diff --git a/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.tsx b/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.tsx new file mode 100644 index 00000000000..677610cef89 --- /dev/null +++ b/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.tsx @@ -0,0 +1,189 @@ +import { Link, useParams } from '@tanstack/react-router' +import { EnvironmentModeEnum, type EnvironmentOverviewResponse } from 'qovery-typescript-axios' +import { useMediaQuery } from 'react-responsive' +import { match } from 'ts-pattern' +import { ClusterAvatar } from '@qovery/domains/clusters/feature' +import { + ActionToolbar, + Button, + DeploymentAction, + Heading, + Icon, + Section, + StatusChip, + TablePrimitives, + Truncate, +} from '@qovery/shared/ui' +import { timeAgo } from '@qovery/shared/util-dates' +import { pluralize, twMerge } from '@qovery/shared/util-js' +import { MenuManageDeployment, MenuOtherActions } from '../../environment-action-toolbar/environment-action-toolbar' +import EnvironmentMode from '../../environment-mode/environment-mode' +import useEnvironments from '../../hooks/use-environments/use-environments' + +const { Table } = TablePrimitives + +const gridLayoutClassName = + 'grid w-full grid-cols-[1fr_20%_min(20%,160px)_min(15%,120px)_max(10%,106px)] xl:grid-cols-[1fr_25%_min(20%,220px)_160px_106px]' + +function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) { + const { organizationId, projectId } = useParams({ strict: false }) + const { data: environments = [] } = useEnvironments({ projectId, suspense: true }) + const environment = environments.find((env) => env.id === overview.id) + const runningStatus = environment?.runningStatus + const cellClassName = 'h-auto border-l border-neutral py-2' + const isDesktopOrLaptop = useMediaQuery({ + query: '(min-width: 1280px)', + }) + const isVeryLargeScreen = useMediaQuery({ + query: '(min-width: 1536px)', + }) + + return ( + + +
+ + + +
+ + {overview.service_count} {pluralize(overview.service_count, 'service')} + + {runningStatus && } +
+
+
+ +
+
+ + + {timeAgo(new Date(overview.deployment_status?.last_deployment_date ?? Date.now()))} ago + +
+ +
+
+ +
+ {overview.cluster && ( + + + + + )} +
+
+ +
{timeAgo(new Date(overview.updated_at ?? Date.now()))} ago
+
+ +
+ {environment && overview.deployment_status && overview.service_count > 0 && ( + + + + + )} +
+
+
+ ) +} + +export function EnvironmentSection({ + type, + items, + onCreateEnvClicked, +}: { + type: EnvironmentModeEnum + items: EnvironmentOverviewResponse[] + onCreateEnvClicked?: () => void +}) { + const title = match(type) + .with('PRODUCTION', () => 'Production') + .with('STAGING', () => 'Staging') + .with('DEVELOPMENT', () => 'Development') + .with('PREVIEW', () => 'Ephemeral') + .exhaustive() + + const EmptyState = () => + match(type) + .with(EnvironmentModeEnum.PREVIEW, () => { + return ( +
+ No ephemeral environment configured + + An ephemeral environment will be created automatically when a pull request is opened. + +
+ ) + }) + .otherwise(() => { + return ( + <> + No {title.toLowerCase()} environment created yet + + + ) + }) + + return ( +
+
+ + {title} +
+ {items.length === 0 ? ( +
+ + +
+ ) : ( +
+ + + + + Environment + + + Last operation + + + Cluster + + + Last update + + + Actions + + + + + + {items.map((environmentOverview) => ( + + ))} + + +
+ )} +
+ ) +} diff --git a/libs/domains/environments/feature/src/lib/environments-table/environments-table.tsx b/libs/domains/environments/feature/src/lib/environments-table/environments-table.tsx new file mode 100644 index 00000000000..276b6e1caa4 --- /dev/null +++ b/libs/domains/environments/feature/src/lib/environments-table/environments-table.tsx @@ -0,0 +1,79 @@ +import { useParams } from '@tanstack/react-router' +import type { EnvironmentModeEnum, EnvironmentOverviewResponse } from 'qovery-typescript-axios' +import { useMemo } from 'react' +import { useEnvironmentsOverview, useProject } from '@qovery/domains/projects/feature' +import { Button, Heading, Icon, Section, useModal } from '@qovery/shared/ui' +import CreateCloneEnvironmentModal from '../create-clone-environment-modal/create-clone-environment-modal' +import { EnvironmentSection } from './environment-section/environment-section' + +export function EnvironmentsTable() { + const { openModal, closeModal } = useModal() + const { organizationId, projectId } = useParams({ strict: false }) + const { data: project } = useProject({ organizationId, projectId, suspense: true }) + const { data: environmentsOverview } = useEnvironmentsOverview({ projectId, suspense: true }) + + const groupedEnvs = useMemo(() => { + return environmentsOverview?.reduce((acc, env) => { + acc.set(env.mode, [...(acc.get(env.mode) || []), env]) + return acc + }, new Map()) + }, [environmentsOverview]) + + const onCreateEnvClicked = (type?: EnvironmentModeEnum) => { + openModal({ + content: ( + + ), + options: { + fakeModal: true, + }, + }) + } + + return ( +
+
+
+
+ {project?.name} + +
+
+
+
+ onCreateEnvClicked('PRODUCTION')} + /> + onCreateEnvClicked('STAGING')} + /> + onCreateEnvClicked('DEVELOPMENT')} + /> + +
+
+
+ ) +}
+ {children}