From d0264ed695937f4987bd33259a7ea25f4691cd25 Mon Sep 17 00:00:00 2001 From: Pierre GB Date: Tue, 13 Oct 2026 15:22:43 +0200 Subject: [PATCH 1/7] feat(clusters): label Operator updates in deployment history (#3098) --- .../deployments/logs/$deploymentId.tsx | 1 + .../cluster-deployment-list.spec.tsx | 41 +++++- .../cluster-deployment-list.tsx | 6 +- .../cluster-last-deployment-section.spec.tsx | 130 +++++++++++++++--- .../cluster-last-deployment-section.tsx | 10 +- .../cluster-header-logs.spec.tsx | 16 ++- .../cluster-header-logs.tsx | 10 +- .../deployment-action.spec.tsx | 35 ++++- .../deployment-action/deployment-action.tsx | 20 ++- package.json | 2 +- yarn.lock | 10 +- 11 files changed, 248 insertions(+), 33 deletions(-) diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/deployments/logs/$deploymentId.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/deployments/logs/$deploymentId.tsx index d64c36585a5..98d1d4e6880 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/deployments/logs/$deploymentId.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/deployments/logs/$deploymentId.tsx @@ -88,6 +88,7 @@ function ClusterDeploymentLogsPage({ triggeredBy={deployment?.auditing_data.triggered_by} actionStatus={deployment?.action_status} totalDuration={deployment?.total_duration} + operation={deployment?.operation} /> {deployment?.action_status === 'ONGOING' && deployment?.reason === 'MAINTENANCE' && ( diff --git a/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-list.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-list.spec.tsx index 50bf60934ef..b5ba9d8433c 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-list.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-list.spec.tsx @@ -1,4 +1,4 @@ -import { type ClusterDeploymentHistory } from 'qovery-typescript-axios' +import { type ClusterDeploymentHistory, ClusterDeploymentHistoryOperationEnum } from 'qovery-typescript-axios' import { type ReactNode } from 'react' import { renderWithProviders, screen } from '@qovery/shared/util-tests' import { ClusterDeploymentList } from './cluster-deployment-list' @@ -19,6 +19,7 @@ const defaultDeploymentHistory: ClusterDeploymentHistory[] = [ status: 'DEPLOYED', action_status: 'SUCCESS', trigger_action: 'DEPLOY', + operation: ClusterDeploymentHistoryOperationEnum.CLUSTER, is_dry_run: false, reason: 'MAINTENANCE', total_duration: 'PT16.503S', @@ -67,6 +68,44 @@ describe('ClusterDeploymentList', () => { expect(screen.getByText('Maintenance')).toBeInTheDocument() }) + it('should render the Operator action with its maintenance badge when operation is OPERATOR_UPDATE', () => { + mockDeploymentHistory = [ + { + ...defaultDeploymentHistory[0], + operation: ClusterDeploymentHistoryOperationEnum.OPERATOR_UPDATE, + }, + ] + + renderWithProviders() + + const action = screen.getByText('Operator').parentElement + expect(screen.queryByText('Deploy')).not.toBeInTheDocument() + expect(screen.getByText('Maintenance')).toBeInTheDocument() + expect(screen.getByRole('link', { name: /Operator update/ }).tagName).toBe('TR') + expect(action?.querySelector('.fa-rotate-right')).toBeInTheDocument() + expect(action?.querySelector('[tabindex]')).toBeNull() + expect(action?.closest('[tabindex]')?.tagName).toBe('TR') + }) + + it('should render the Deploy action when operation is CLUSTER', () => { + renderWithProviders() + + expect(screen.getByText('Deploy')).toBeInTheDocument() + expect(screen.queryByText('Operator')).not.toBeInTheDocument() + expect(screen.getByText('Maintenance')).toBeInTheDocument() + }) + + it('should render the Deploy action when operation is missing from the payload', () => { + const legacyDeployment: Partial = { ...defaultDeploymentHistory[0] } + delete legacyDeployment.operation + mockDeploymentHistory = [legacyDeployment as ClusterDeploymentHistory] + + renderWithProviders() + + expect(screen.getByText('Deploy')).toBeInTheDocument() + expect(screen.queryByText('Operator')).not.toBeInTheDocument() + }) + it('should render a dry run badge when the deployment is a dry run', () => { mockDeploymentHistory = [ { diff --git a/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-list.tsx b/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-list.tsx index 03aabb583da..32746321701 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-list.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-list.tsx @@ -153,8 +153,8 @@ export function ClusterDeploymentList({ organizationId, clusterId }: ClusterDepl return (
-
- +
+ {data.reason === 'MAINTENANCE' && ( Maintenance @@ -166,7 +166,7 @@ export function ClusterDeploymentList({ organizationId, clusterId }: ClusterDepl )}
-
+
{data.action_status === 'ERROR' && ( ({ let mockClusterDeploymentHistoryProps: UseClusterDeploymentHistoryProps | undefined +const defaultDeploymentHistory: ClusterDeploymentHistory[] = [ + { + identifier: { + deployment_id: 'deployment-1', + execution_id: 'execution-1', + cluster_id: 'cluster-1', + }, + auditing_data: { + created_at: '2026-08-10T12:30:00Z', + updated_at: '2026-08-10T12:31:00Z', + }, + status: 'DEPLOYED', + action_status: 'SUCCESS', + trigger_action: 'DEPLOY', + operation: ClusterDeploymentHistoryOperationEnum.CLUSTER, + is_dry_run: false, + reason: 'UNSPECIFIED', + total_duration: 'PT1M', + }, +] + +let mockDeploymentHistory = defaultDeploymentHistory + jest.mock('../hooks/use-cluster-deployment-history/use-cluster-deployment-history', () => ({ useClusterDeploymentHistory: (props: UseClusterDeploymentHistoryProps) => { mockClusterDeploymentHistoryProps = props return { - data: [ - { - identifier: { - deployment_id: 'deployment-1', - execution_id: 'execution-1', - cluster_id: 'cluster-1', - }, - auditing_data: { - created_at: '2026-08-10T12:30:00Z', - }, - status: 'DEPLOYED', - action_status: 'SUCCESS', - trigger_action: 'DEPLOY', - reason: 'UNSPECIFIED', - total_duration: 'PT1M', - }, - ], + data: mockDeploymentHistory, isFetched: true, } }, @@ -101,6 +110,7 @@ describe('ClusterLastDeploymentSection', () => { beforeEach(() => { jest.clearAllMocks() mockClusterDeploymentHistoryProps = undefined + mockDeploymentHistory = defaultDeploymentHistory }) it('renders the latest cluster deployment row linked to its deployment logs', () => { @@ -181,6 +191,92 @@ describe('ClusterLastDeploymentSection', () => { expect(screen.getByText('Maintenance')).toBeInTheDocument() }) + it('renders the Operator action with the reason when the last deployment is an Operator update', () => { + mockDeploymentHistory = [ + { ...defaultDeploymentHistory[0], operation: ClusterDeploymentHistoryOperationEnum.OPERATOR_UPDATE }, + ] + + renderWithProviders( + + ) + + expect(screen.getByText('Operator')).toBeInTheDocument() + expect(screen.queryByText('Deploy')).not.toBeInTheDocument() + expect(screen.getByText('Maintenance')).toBeInTheDocument() + expect(screen.getByText('Operator').closest('a')?.querySelector('[tabindex]')).toBeNull() + expect(screen.getByRole('link', { name: /Operator update/ })).toHaveAttribute( + 'href', + '/organization/org-1/cluster/cluster-1/deployments/logs/deployment-1' + ) + }) + + it('renders the Deploy action when the cluster status has no last execution id', () => { + mockDeploymentHistory = [ + { ...defaultDeploymentHistory[0], operation: ClusterDeploymentHistoryOperationEnum.OPERATOR_UPDATE }, + ] + + renderWithProviders( + + ) + + expect(screen.getByText('Deploy')).toBeInTheDocument() + expect(screen.queryByText('Operator')).not.toBeInTheDocument() + }) + + it('renders the Deploy action when the last deployment is a cluster deployment', () => { + renderWithProviders( + + ) + + expect(screen.getByText('Deploy')).toBeInTheDocument() + expect(screen.queryByText('Operator')).not.toBeInTheDocument() + }) + + it('renders the Deploy action when operation is missing from the payload', () => { + const legacyDeployment: Partial = { ...defaultDeploymentHistory[0] } + delete legacyDeployment.operation + mockDeploymentHistory = [legacyDeployment as ClusterDeploymentHistory] + + renderWithProviders( + + ) + + expect(screen.getByText('Deploy')).toBeInTheDocument() + expect(screen.queryByText('Operator')).not.toBeInTheDocument() + }) + + it('ignores a stale Operator update when the cluster status points to a newer execution', () => { + mockDeploymentHistory = [ + { ...defaultDeploymentHistory[0], operation: ClusterDeploymentHistoryOperationEnum.OPERATOR_UPDATE }, + ] + + renderWithProviders( + + ) + + expect(screen.getByText('Stop')).toBeInTheDocument() + expect(screen.queryByText('Operator')).not.toBeInTheDocument() + }) + it('renders an empty state when the cluster has no deployment information', () => { renderWithProviders( { if (!clusterStatus) return @@ -170,7 +175,10 @@ export function ClusterLastDeploymentSection({ >
- + {deploymentReasonLabel && ( diff --git a/libs/domains/clusters/feature/src/lib/cluster-logs/cluster-header-logs/cluster-header-logs.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-logs/cluster-header-logs/cluster-header-logs.spec.tsx index 0487137548d..d7f94026945 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-logs/cluster-header-logs/cluster-header-logs.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-logs/cluster-header-logs/cluster-header-logs.spec.tsx @@ -1,5 +1,5 @@ import download from 'downloadjs' -import { ClusterStateEnum } from 'qovery-typescript-axios' +import { ClusterDeploymentHistoryOperationEnum, ClusterStateEnum } from 'qovery-typescript-axios' import { type RefObject } from 'react' import { clusterFactoryMock, clusterLogFactoryMock } from '@qovery/shared/factories' import { renderWithProviders, screen } from '@qovery/shared/util-tests' @@ -45,6 +45,20 @@ describe('ClusterHeaderLogs', () => { expect(screen.getByText('Romain Billard')).toBeInTheDocument() }) + it('should render an Operator update badge when operation is OPERATOR_UPDATE', () => { + renderWithProviders( + + ) + + expect(screen.getByText('Operator update')).toBeInTheDocument() + }) + + it('should not render an Operator update badge when operation is CLUSTER', () => { + renderWithProviders() + + expect(screen.queryByText('Operator update')).not.toBeInTheDocument() + }) + it('should trigger scroll up on click', async () => { const { userEvent } = renderWithProviders() const scrollUpButton = screen.getByTestId('scroll-up-button') diff --git a/libs/domains/clusters/feature/src/lib/cluster-logs/cluster-header-logs/cluster-header-logs.tsx b/libs/domains/clusters/feature/src/lib/cluster-logs/cluster-header-logs/cluster-header-logs.tsx index c5d67642b6e..4a94246eb88 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-logs/cluster-header-logs/cluster-header-logs.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-logs/cluster-header-logs/cluster-header-logs.tsx @@ -2,6 +2,7 @@ import download from 'downloadjs' import posthog from 'posthog-js' import { type Cluster, + ClusterDeploymentHistoryOperationEnum, type ClusterLogs, type ClusterStatus, type DeploymentHistoryActionStatus, @@ -9,7 +10,7 @@ import { } from 'qovery-typescript-axios' import { type RefObject, useContext } from 'react' import { DevopsCopilotContext } from '@qovery/shared/devops-copilot/feature' -import { Button, Icon, Tooltip, Truncate } from '@qovery/shared/ui' +import { Badge, Button, Icon, Tooltip, Truncate } from '@qovery/shared/ui' import { dateDifference, dateFullFormat, dateUTCString, formatDuration } from '@qovery/shared/util-dates' import { useIntervalTick } from '@qovery/shared/util-hooks' import { EventOriginIcon } from '@qovery/shared/util-icons' @@ -35,6 +36,7 @@ export interface ClusterHeaderLogsProps { triggeredBy?: string | null actionStatus?: DeploymentHistoryActionStatus totalDuration?: string | null + operation?: ClusterDeploymentHistoryOperationEnum } export function ClusterHeaderLogs({ @@ -49,6 +51,7 @@ export function ClusterHeaderLogs({ triggeredBy, actionStatus, totalDuration, + operation, }: ClusterHeaderLogsProps) { const { setDevopsCopilotOpen, sendMessageRef } = useContext(DevopsCopilotContext) @@ -90,6 +93,11 @@ export function ClusterHeaderLogs({ )} + {operation === ClusterDeploymentHistoryOperationEnum.OPERATOR_UPDATE && ( + + Operator update + + )} {createdAt && ( <> { it('returns undefined without status details', () => { @@ -18,3 +20,34 @@ describe('getTriggerAction', () => { expect(getTriggerAction({})).toBe('UNKNOWN') }) }) + +describe('DeploymentAction', () => { + it('renders the action label derived from the status', () => { + renderWithProviders() + + expect(screen.getByText('Deploy')).toBeInTheDocument() + }) + + it('renders the Operator action with a screen-reader description for an Operator update', () => { + const { container } = renderWithProviders( + + ) + + expect(screen.getByText('Operator')).toHaveAttribute('aria-hidden', 'true') + expect(screen.getByText('Operator update')).toHaveClass('sr-only') + expect(screen.queryByText('Deploy')).not.toBeInTheDocument() + expect(container.querySelector('.fa-rotate-right')).toBeInTheDocument() + expect(container.querySelector('.fa-rocket')).not.toBeInTheDocument() + expect(container.querySelector('[tabindex]')).toBeNull() + }) + + it('renders the status action without a description for a cluster operation', () => { + const { container } = renderWithProviders( + + ) + + expect(screen.getByText('Deploy')).not.toHaveAttribute('aria-hidden') + expect(screen.queryByText('Operator update')).not.toBeInTheDocument() + expect(container.querySelector('.fa-rocket')).toBeInTheDocument() + }) +}) 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 index 21c5181e20c..24e883dc413 100644 --- a/libs/shared/ui/src/lib/components/deployment-action/deployment-action.tsx +++ b/libs/shared/ui/src/lib/components/deployment-action/deployment-action.tsx @@ -1,4 +1,5 @@ import { + ClusterDeploymentHistoryOperationEnum, type ClusterStateEnum, type DeploymentHistoryTriggerAction, type ServiceActionEnum, @@ -137,6 +138,15 @@ export const getDeploymentAction = ( .exhaustive() } +const getDeploymentOperationAction = (operation?: ClusterDeploymentHistoryOperationEnum) => + operation === ClusterDeploymentHistoryOperationEnum.OPERATOR_UPDATE + ? { + status: 'Operator', + description: 'Operator update', + icon: , + } + : undefined + export const getTriggerAction = (statusDetails?: { action?: ServiceActionEnum sub_action?: ServiceSubActionEnum @@ -148,6 +158,7 @@ export const getTriggerAction = (statusDetails?: { export const DeploymentAction = ({ status, + operation, className, iconClassName, textClassName, @@ -161,17 +172,22 @@ export const DeploymentAction = ({ | StageStatusEnum | StepMetricStatusEnum | undefined + operation?: ClusterDeploymentHistoryOperationEnum className?: string iconClassName?: string textClassName?: string }) => { - const action = getDeploymentAction(status) + const operationAction = getDeploymentOperationAction(operation) + const action = operationAction ?? getDeploymentAction(status) if (!status || !action) return null return (
{action.icon} - {action.status} + + {action.status} + + {operationAction && {operationAction.description}}
) } diff --git a/package.json b/package.json index 9cda0d0263e..26248dcb196 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "mermaid": "11.6.0", "monaco-editor": "0.53.0", "posthog-js": "1.345.1", - "qovery-typescript-axios": "1.1.1004", + "qovery-typescript-axios": "1.1.1007", "react": "19.3.0", "react-country-flag": "3.0.2", "react-datepicker": "4.12.0", diff --git a/yarn.lock b/yarn.lock index c8d85d5dfea..6ece0fd2fff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6285,7 +6285,7 @@ __metadata: prettier: 3.2.5 prettier-plugin-tailwindcss: 0.5.14 pretty-quick: 4.0.0 - qovery-typescript-axios: 1.1.1004 + qovery-typescript-axios: 1.1.1007 qovery-ws-typescript-axios: 0.1.666 react: 19.3.0 react-country-flag: 3.0.2 @@ -24656,12 +24656,12 @@ __metadata: languageName: node linkType: hard -"qovery-typescript-axios@npm:1.1.1004": - version: 1.1.1004 - resolution: "qovery-typescript-axios@npm:1.1.1004" +"qovery-typescript-axios@npm:1.1.1007": + version: 1.1.1007 + resolution: "qovery-typescript-axios@npm:1.1.1007" dependencies: axios: 1.20.0 - checksum: 99fca71abb55689b534cb7c3874deabb11fe6c20a564d7bd4b03d034f5a2a37c4daefbb568b46b468141b7ad7a858daae7e1013516fa8ba272046ba9ba635d73 + checksum: f8662f1567d11edcbe0119c2db44ceeb0b1f0181517b31ca70c89173872dde9fde0f07087b48cbd0d81372c4399675191b520378e9021539ecca100182a5bba8 languageName: node linkType: hard From 929a289674e996ed44863ee4a7a6e745eedfc5ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Bonnet?= Date: Tue, 13 Oct 2026 16:02:37 +0200 Subject: [PATCH 2/7] chore(skills): clarify implementation and review guidance (#3101) * chore(skills): clarify implementation and review guidance * chore(skills): refine actionable review rules * chore(skills): synchronize standards rule index * chore(skills): define operation display mismatches * chore(skills): clarify conditional class composition * chore(skills): clarify domain placement and library ownership * chore(skills): align legacy shared library guidance * chore(skills): consolidate implementation and review guidance - Align clsx/twMerge guidance across rules - Move code placement and console-shared rules to project architecture - Compile implementation and review rules into AGENTS.md and SKILL.md - Drop tool-specific cubic section and tighten wording into bullets * chore(skills): reconcile shared helper placement --- .../skills/qovery-console-standards/AGENTS.md | 47 +++++++++++++++++-- .../skills/qovery-console-standards/SKILL.md | 35 +++++++++----- .../rules/implementation-review.md | 38 +++++++++++++++ .../rules/project-architecture.md | 10 +++- .../rules/react-typescript-standards.md | 2 +- .../rules/styling-standards.md | 2 +- .agents/skills/qovery-ui/SKILL.md | 6 ++- .../skills/qovery-ui/references/components.md | 17 +++++++ libs/shared/console-shared/src/index.ts | 5 +- 9 files changed, 142 insertions(+), 20 deletions(-) create mode 100644 .agents/skills/qovery-console-standards/rules/implementation-review.md diff --git a/.agents/skills/qovery-console-standards/AGENTS.md b/.agents/skills/qovery-console-standards/AGENTS.md index f271b8dfdf8..c477865956a 100644 --- a/.agents/skills/qovery-console-standards/AGENTS.md +++ b/.agents/skills/qovery-console-standards/AGENTS.md @@ -33,10 +33,18 @@ libs/ - `data-access` libs contain API wiring only. - `feature` libs contain business components. - `ui` libs contain reusable presentation components. -- Utilities that can be shared must live in `@qovery/shared/util-js` and be imported directly (never re-export via data-access). +- Reusable generic helpers belong in the shared library that owns their concern; use `@qovery/shared/util-js` when no specialized shared library fits. Import them directly, never re-export them via data-access. - Respect Nx architecture (no circular imports). - Use Nx generators to create new components/libs. +### Code Placement + +- Before adding a folder or abstraction, follow comparable code in the same domain. A generic name alone does not make a helper shared. +- Domain feature libs: hooks in `src/lib/hooks/use-/`, domain-specific pure helpers in `src/lib/utils/`. Do not create a second `hooks/` directory inside a feature folder. +- For generic helpers, prefer the existing shared library that owns their concern; otherwise use `@qovery/shared/util-js`. Reuse across callers in one domain does not make a domain-specific helper generic. +- Create a dedicated library only for a cohesive concept (types, behavior, or UI) reused across domains with no existing owner. Choose its Nx layer from its real dependencies and callers. +- `@qovery/shared/console-shared` is legacy: add nothing new to it. Existing usages can stay; do not migrate them as part of an unrelated change. + --- ## 2. React & TypeScript Standards @@ -56,7 +64,7 @@ libs/ - Destructure props directly in parameters. - Props interface named `Props`. - Named exports only (no default exports). -- Use `clsx` or `twMerge` from `@qovery/shared/util-js` for conditional classes. +- Use `clsx` (from `clsx`) for conditional classes and `twMerge` (from `@qovery/shared/util-js`) to resolve conflicting Tailwind classes, such as defaults and a caller's `className`. - Prefer Radix UI components when available. ### Route Parameters @@ -94,7 +102,7 @@ libs/ - TailwindCSS only (no CSS modules or styled-components in new code). - Use utilities from `@qovery/shared/ui` when available. - Mobile-first responsive design. -- Use `clsx` or `twMerge` from `@qovery/shared/util-js` for conditional classes. +- Use `clsx` (from `clsx`) for conditional classes and `twMerge` (from `@qovery/shared/util-js`) to resolve conflicting Tailwind classes, such as defaults and a caller's `className`. ### Component Styling @@ -249,3 +257,36 @@ Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` - All tests must pass. - Code must be properly formatted. - Snapshot changes must be intentional and reviewed. + +--- + +## 8. Implementation & Review Decisions + +### Keep Shared Changes Necessary + +- Every new prop, abstraction, or shared-component change needs a concrete caller or behavior today. No props or tokens for hypothetical future needs. +- Prefer the existing API when it covers the need. When extending, keep existing callers working and check the behavior in their flows, not only in the new screen. + +### Separate Business Rules from Rendering + +- Keep payload construction, API parsing, and business transformations in domain hooks or helpers that can be tested on their own, not in rendering components. +- Prefer typed API fields over parsing opaque payloads. If the contract cannot change, parse and validate once at the domain boundary. +- An action's label, icon, and description must describe the same operation. A neutral icon shared by several operations is fine. +- Name helpers after the resource or capability they identify, not after a version, rollout, or feature flag. + +### Choose State Ownership Explicitly + +- Derive values from props or state instead of copying them into state. +- Use controlled inputs when the caller owns the value; use default values only for initialization. +- Reserve effects for synchronization with external systems. See the `react-useeffect` skill for alternatives. + +### Validate and Review Proportionally + +- Test the behavior and boundaries the change affects, including invalid or legacy API data when relevant. Extract complex pure logic for direct tests; small expressions do not need extraction. +- Classify each finding as a **bug** (give the concrete trigger and consequence), a **convention** departure (cite the rule or comparable code), or an optional **suggestion**. Only bugs are blocking by default. +- Check current callers and comparable implementations before recommending a new abstraction or shared change. +- Treat automated review findings and "resolved" labels as leads, not proof: check the fix against the current diff before closing a finding. + +### Write Actionable Review Rules + +When adding a rule, state its scope, the behavior to flag, why it matters, and legitimate exceptions. Prefer checks lint does not already enforce, and avoid generalizing a rule from a single PR. diff --git a/.agents/skills/qovery-console-standards/SKILL.md b/.agents/skills/qovery-console-standards/SKILL.md index 9c26353ee00..6ca640fa781 100644 --- a/.agents/skills/qovery-console-standards/SKILL.md +++ b/.agents/skills/qovery-console-standards/SKILL.md @@ -9,7 +9,7 @@ metadata: # Qovery Console Standards -Comprehensive coding standards for the Qovery Console React/TypeScript monorepo. Contains 7 rules covering architecture, React patterns, styling, testing, naming, development workflow, and pre-commit quality gates. +Comprehensive coding standards for the Qovery Console React/TypeScript monorepo. Contains 8 rules covering architecture, React patterns, styling, testing, naming, development workflow, pre-commit quality gates, and implementation and review decisions. ## When to Apply @@ -24,15 +24,16 @@ Reference these guidelines when: ## Rule Categories -| Rule | File | Scope | -| -------------------- | ------------------------------- | --------------------------------------------------------- | -| Project Architecture | `project-architecture.md` | Nx monorepo structure, lib organization, layer boundaries | -| React & TypeScript | `react-typescript-standards.md` | Import rules, component patterns, state management | -| Styling | `styling-standards.md` | TailwindCSS, Radix UI, responsive design, accessibility | -| Testing | `testing-standards.md` | Test setup, structure, mocking, coverage | -| Naming Conventions | `naming-conventions.md` | File, variable, component, and constant naming | -| Development Commands | `development-commands.md` | Daily workflow, Nx commands, package management, git | -| Pre-commit Workflow | `pre-commit-workflow.md` | Conventional commits, format/test/lint checks | +| Rule | File | Scope | +| ----------------------- | ------------------------------- | --------------------------------------------------------------- | +| Project Architecture | `project-architecture.md` | Nx monorepo structure, lib organization, layer boundaries | +| React & TypeScript | `react-typescript-standards.md` | Import rules, component patterns, state management | +| Styling | `styling-standards.md` | TailwindCSS, Radix UI, responsive design, accessibility | +| Testing | `testing-standards.md` | Test setup, structure, mocking, coverage | +| Naming Conventions | `naming-conventions.md` | File, variable, component, and constant naming | +| Development Commands | `development-commands.md` | Daily workflow, Nx commands, package management, git | +| Pre-commit Workflow | `pre-commit-workflow.md` | Conventional commits, format/test/lint checks | +| Implementation & Review | `implementation-review.md` | Code placement, shared changes, API boundaries, state ownership | ## Quick Reference @@ -41,13 +42,14 @@ Reference these guidelines when: - Nx monorepo: `apps/` and `libs/` (domains, pages, shared) - `data-access` = API logic, `feature` = business components, `ui` = reusable presentation - No circular imports; use Nx generators for new libs +- Domain hooks in `src/lib/hooks/use-/`, helpers in `src/lib/utils/`; nothing new in `shared/console-shared` (legacy) ### React & TypeScript - Inline type imports: `import { type Foo, bar } from './module'` - Functional components only, no `React.FC`, destructure props in signature - Route-scoped components read URL identifiers with TanStack Router `useParams({ strict: false })`; do not forward route IDs through props solely for data access -- React Query for server state, `clsx`/`twMerge` for conditional classes +- React Query for server state, `clsx` for conditional classes, `twMerge` for conflicting Tailwind classes ### Styling @@ -66,6 +68,12 @@ Reference these guidelines when: - Snake-case files (`user-profile.tsx`), PascalCase components - `Props` suffix for interfaces, `UPPER_SNAKE_CASE` for constants +### Implementation & Review + +- Shared changes need a concrete caller; keep existing callers working +- Business transformations in domain hooks/helpers, not in rendering +- Classify review findings: bug (trigger + consequence), convention, or optional suggestion + ### Workflow - Yarn Berry only (never npm), `nvm use` @@ -80,6 +88,11 @@ Read individual rule files for detailed guidelines: rules/project-architecture.md rules/react-typescript-standards.md rules/styling-standards.md +rules/testing-standards.md +rules/naming-conventions.md +rules/development-commands.md +rules/pre-commit-workflow.md +rules/implementation-review.md ``` ## Full Compiled Document diff --git a/.agents/skills/qovery-console-standards/rules/implementation-review.md b/.agents/skills/qovery-console-standards/rules/implementation-review.md new file mode 100644 index 00000000000..4171d8810fc --- /dev/null +++ b/.agents/skills/qovery-console-standards/rules/implementation-review.md @@ -0,0 +1,38 @@ +--- +title: Implementation and Review Decisions +impact: HIGH +tags: [architecture, review, api, state] +--- + +# Implementation and Review Decisions + +Code placement and library ownership are covered in [project-architecture.md](project-architecture.md#code-placement). + +## Keep Shared Changes Necessary + +- Every new prop, abstraction, or shared-component change needs a concrete caller or behavior today. No props or tokens for hypothetical future needs. +- Prefer the existing API when it covers the need. When extending, keep existing callers working and check the behavior in their flows, not only in the new screen. + +## Separate Business Rules from Rendering + +- Keep payload construction, API parsing, and business transformations in domain hooks or helpers that can be tested on their own, not in rendering components. +- Prefer typed API fields over parsing opaque payloads. If the contract cannot change, parse and validate once at the domain boundary. +- An action's label, icon, and description must describe the same operation. A neutral icon shared by several operations is fine. +- Name helpers after the resource or capability they identify, not after a version, rollout, or feature flag. + +## Choose State Ownership Explicitly + +- Derive values from props or state instead of copying them into state. +- Use controlled inputs when the caller owns the value; use default values only for initialization. +- Reserve effects for synchronization with external systems. See the `react-useeffect` skill for alternatives. + +## Validate and Review Proportionally + +- Test the behavior and boundaries the change affects, including invalid or legacy API data when relevant. Extract complex pure logic for direct tests; small expressions do not need extraction. +- Classify each finding as a **bug** (give the concrete trigger and consequence), a **convention** departure (cite the rule or comparable code), or an optional **suggestion**. Only bugs are blocking by default. +- Check current callers and comparable implementations before recommending a new abstraction or shared change. +- Treat automated review findings and "resolved" labels as leads, not proof: check the fix against the current diff before closing a finding. + +## Write Actionable Review Rules + +When adding a rule, state its scope, the behavior to flag, why it matters, and legitimate exceptions. Prefer checks lint does not already enforce, and avoid generalizing a rule from a single PR. diff --git a/.agents/skills/qovery-console-standards/rules/project-architecture.md b/.agents/skills/qovery-console-standards/rules/project-architecture.md index 6774d415001..c81b4692258 100644 --- a/.agents/skills/qovery-console-standards/rules/project-architecture.md +++ b/.agents/skills/qovery-console-standards/rules/project-architecture.md @@ -35,7 +35,15 @@ libs/ - `data-access` libs contain API wiring only - `feature` libs contain business components - `ui` libs contain reusable presentation components -- Utilities that can be shared must live in `@qovery/shared/util-js` and be imported directly (never re-export via data-access) +- Reusable generic helpers belong in the shared library that owns their concern; use `@qovery/shared/util-js` when no specialized shared library fits. Import them directly, never re-export them via data-access + +## Code Placement + +- Before adding a folder or abstraction, follow comparable code in the same domain. A generic name alone does not make a helper shared. +- Domain feature libs: hooks in `src/lib/hooks/use-/`, domain-specific pure helpers in `src/lib/utils/`. Do not create a second `hooks/` directory inside a feature folder. +- For generic helpers, prefer the existing shared library that owns their concern; otherwise use `@qovery/shared/util-js`. Reuse across callers in one domain does not make a domain-specific helper generic. +- Create a dedicated library only for a cohesive concept (types, behavior, or UI) reused across domains with no existing owner. Choose its Nx layer from its real dependencies and callers. +- `@qovery/shared/console-shared` is legacy: add nothing new to it. Existing usages can stay; do not migrate them as part of an unrelated change. ## Important Rules diff --git a/.agents/skills/qovery-console-standards/rules/react-typescript-standards.md b/.agents/skills/qovery-console-standards/rules/react-typescript-standards.md index 347ea1bda48..10b5edcb0aa 100644 --- a/.agents/skills/qovery-console-standards/rules/react-typescript-standards.md +++ b/.agents/skills/qovery-console-standards/rules/react-typescript-standards.md @@ -21,7 +21,7 @@ tags: [react, typescript, components, imports, state] - Destructure props directly in parameters - Props interface named `Props` - Named exports only (no default exports) -- Use `clsx` or `twMerge` from `@qovery/shared/util-js` for conditional classes +- Use `clsx` (from `clsx`) for conditional classes and `twMerge` (from `@qovery/shared/util-js`) to resolve conflicting Tailwind classes, such as defaults and a caller's `className` - Prefer Radix UI components when available ## Route Parameters diff --git a/.agents/skills/qovery-console-standards/rules/styling-standards.md b/.agents/skills/qovery-console-standards/rules/styling-standards.md index d086fcc35ba..66938e1a16b 100644 --- a/.agents/skills/qovery-console-standards/rules/styling-standards.md +++ b/.agents/skills/qovery-console-standards/rules/styling-standards.md @@ -11,7 +11,7 @@ tags: [tailwind, css, styling, accessibility, radix, responsive] - TailwindCSS only (no CSS modules or styled-components in new code) - Use utilities from `@qovery/shared/ui` when available - Mobile-first responsive design -- Use `clsx` or `twMerge` from `@qovery/shared/util-js` for conditional classes +- Use `clsx` (from `clsx`) for conditional classes and `twMerge` (from `@qovery/shared/util-js`) to resolve conflicting Tailwind classes, such as defaults and a caller's `className` ## Component Styling diff --git a/.agents/skills/qovery-ui/SKILL.md b/.agents/skills/qovery-ui/SKILL.md index 7735b237153..20894210762 100644 --- a/.agents/skills/qovery-ui/SKILL.md +++ b/.agents/skills/qovery-ui/SKILL.md @@ -38,7 +38,7 @@ Design review and implementation guidance for the Qovery Console. Read `.claude/ ## /audit -**Load:** `references/colors.md`, `references/spacing-layout.md`, `references/anti-patterns.md`, `references/accessibility.md` +**Load:** `references/colors.md`, `references/spacing-layout.md`, `references/anti-patterns.md`, `references/accessibility.md`, `references/components.md` Four-phase review. Label every finding: @@ -46,6 +46,8 @@ Four-phase review. Label every finding: - `[important]` — degrades experience, likely to cause design review rejection - `[nit]` — minor inconsistency, low impact +A component substitution or refactor is not `[blocking]` just because it is preferred: state the user impact and check that the proposed shared component fits the interaction. + **Phase 1 — Token hygiene:** hardcoded values, legacy tokens, raw Tailwind palette classes used semantically. **Phase 2 — Semantic color:** status colors used decoratively, color-only state, brand as page wash. **Phase 3 — Spacing & density:** off-grid values, whitespace inflation, button height overrides. @@ -81,6 +83,8 @@ Fix without changing functionality. Output a Before / After / Why table, one row **Load:** `references/components.md` +Also use this reference when extending shared components or reviewing a new interactive flow. Follow its reuse, compatibility, Storybook, and interaction checks for the states affected by the change. + 1. Identify if a shared component already exists — always prefer it 2. Return the import path and correct usage with props 3. If nothing fits, explain why before scaffolding new diff --git a/.agents/skills/qovery-ui/references/components.md b/.agents/skills/qovery-ui/references/components.md index adbadabea9d..b8ce6bfb5a6 100644 --- a/.agents/skills/qovery-ui/references/components.md +++ b/.agents/skills/qovery-ui/references/components.md @@ -2,6 +2,23 @@ All shared components live in `libs/shared/ui/src/lib/components/`. Check here before building anything new. +## Reuse and Extend Shared UI + +- Check `shared/ui` and its existing callers first. Reuse a component when its **behavior** fits; visual similarity alone does not justify swapping in a component with different interaction semantics. +- Nothing new in `shared/console-shared` (legacy). Generic presentation goes to `shared/ui`, business UI stays in its domain. Do not move business logic into the design system just to avoid `console-shared`. +- Extend a shared component only for a concrete need its API cannot cover. Keep the extension generic and existing callers working. +- Multiple styling variants: follow the existing CVA pattern. Simple conditions do not need a variant. +- Classes: `clsx` for conditional composition, `twMerge` (`@qovery/shared/util-js`) when defaults can conflict with a caller's `className`. No nested template strings. +- Add or update Storybook stories for new shared components and new visual states or variants. + +## Verify the Interaction + +- Loading, failure, validation, and saving states give clear feedback. +- Clickable controls look clickable. "Draft being checked" is distinguishable from "changes ready to save". +- Read-only fields stay read-only in nested editors and dialogs. +- Leaving with unsaved changes has deliberate behavior; leave-page protection must not block navigation within the draft. +- Test tooltips and popovers from their actual keyboard focus target, including controls nested in clickable rows or links. A focusable parent does not make a hover tooltip keyboard-accessible. + --- ## Decision Guide diff --git a/libs/shared/console-shared/src/index.ts b/libs/shared/console-shared/src/index.ts index c0f310b63d4..b51a615ca01 100644 --- a/libs/shared/console-shared/src/index.ts +++ b/libs/shared/console-shared/src/index.ts @@ -1,5 +1,6 @@ -// Please use util or domain libraries first, -// then console-shared only as a last resort. +// This library is being phased out: do not add new components, helpers, or exports. +// Use the appropriate shared UI, utility, or domain library, or consider a dedicated library. +// Existing usages can remain; migrations should stay within the change's scope. export * from './lib/settings-heading/settings-heading' export * from './lib/live-elapsed-duration-cell/live-elapsed-duration-cell' export * from './lib/resource-tree-list/resource-tree-list' From 80fbd303f56be682b5902c9c89fa10a8c3d73055 Mon Sep 17 00:00:00 2001 From: Pierre GB Date: Tue, 13 Oct 2026 16:20:37 +0200 Subject: [PATCH 3/7] feat(audit-logs): label Operator update events (#3104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump qovery-typescript-axios to 1.1.1010 for the OPERATOR_UPDATE and MAINTENANCE_OPERATOR audit event types. They read "Operator Update" and "Maintenance · Operator Update", with the icons of their TRIGGER_DEPLOY and MAINTENANCE counterparts, and show up in the Event filter. --- .../src/lib/audit-logs/audit-logs.spec.tsx | 11 ++++++++++ .../src/lib/row-event/row-event.spec.tsx | 22 +++++++++++++++++++ .../feature/src/lib/row-event/row-event.tsx | 14 +++++++----- package.json | 2 +- yarn.lock | 10 ++++----- 5 files changed, 48 insertions(+), 11 deletions(-) diff --git a/libs/domains/audit-logs/feature/src/lib/audit-logs/audit-logs.spec.tsx b/libs/domains/audit-logs/feature/src/lib/audit-logs/audit-logs.spec.tsx index 68d26715f93..766ec71a6a0 100644 --- a/libs/domains/audit-logs/feature/src/lib/audit-logs/audit-logs.spec.tsx +++ b/libs/domains/audit-logs/feature/src/lib/audit-logs/audit-logs.spec.tsx @@ -200,3 +200,14 @@ describe('AuditLogs layering', () => { expect(tableHeader).toHaveClass('z-dropdown') }) }) + +describe('AuditLogs event filter', () => { + it('should list the Operator update event types in the Event filter', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByText('Event')) + + const menuItems = screen.getAllByTestId('menuItem').map((item) => item.textContent) + expect(menuItems).toEqual(expect.arrayContaining(['Operator update', 'Maintenance operator'])) + }) +}) diff --git a/libs/domains/audit-logs/feature/src/lib/row-event/row-event.spec.tsx b/libs/domains/audit-logs/feature/src/lib/row-event/row-event.spec.tsx index 765cbda2d95..5b83acacbf9 100644 --- a/libs/domains/audit-logs/feature/src/lib/row-event/row-event.spec.tsx +++ b/libs/domains/audit-logs/feature/src/lib/row-event/row-event.spec.tsx @@ -127,6 +127,28 @@ describe('RowEvent', () => { } ) + it.each([ + [OrganizationEventType.OPERATOR_UPDATE, 'Operator Update', 'fa-rocket-launch'], + [OrganizationEventType.MAINTENANCE_OPERATOR, 'Maintenance · Operator Update', 'fa-wrench'], + [OrganizationEventType.TRIGGER_DEPLOY, 'Trigger Deploy', 'fa-rocket-launch'], + [OrganizationEventType.MAINTENANCE, 'Maintenance', 'fa-wrench'], + ])('should render %s with its label and icon', (eventType, label, iconClass) => { + renderWithProviders( + + ) + + expect(screen.getByTestId('tag')).toHaveTextContent(new RegExp(`^${label}$`)) + expect(screen.getByTestId('row-event').querySelector(`.${iconClass}`)).toBeInTheDocument() + }) + it('should render Agent task for the agentic workflow target type', () => { renderWithProviders( { - return eventName.split('_').map(upperCaseFirstLetter).join(' ') -} +const formatEventName = (eventType?: OrganizationEventType) => + match(eventType) + .with(OrganizationEventType.MAINTENANCE_OPERATOR, () => 'Maintenance · Operator Update') + .otherwise((type) => (type ?? '').split('_').map(upperCaseFirstLetter).join(' ')) const serviceOverviewUrl = (organizationId: string, projectId: string, environmentId: string, serviceId: string) => `/organization/${organizationId}/project/${projectId}/environment/${environmentId}/service/${serviceId}/overview` @@ -174,6 +175,7 @@ export function RowEvent(props: RowEventProps) { OrganizationEventType.TRIGGER_STOP, OrganizationEventType.TRIGGER_TERRAFORM_FORCE_UNLOCK, OrganizationEventType.TRIGGER_TERRAFORM_MIGRATE_STATE, + OrganizationEventType.OPERATOR_UPDATE, () => ) .with(OrganizationEventType.DELETE, OrganizationEventType.DELETED, () => ( @@ -181,7 +183,9 @@ export function RowEvent(props: RowEventProps) { )) .with(OrganizationEventType.CLONE, () => ) .with(OrganizationEventType.DRY_RUN, () => ) - .with(OrganizationEventType.MAINTENANCE, () => ) + .with(OrganizationEventType.MAINTENANCE, OrganizationEventType.MAINTENANCE_OPERATOR, () => ( + + )) .with(OrganizationEventType.FORCE_RUN_SUCCEEDED, () => ( )) @@ -235,7 +239,7 @@ export function RowEvent(props: RowEventProps) { - {formatEventName(event.event_type ?? '')} + {formatEventName(event.event_type)} {event.sub_target_type && ( diff --git a/package.json b/package.json index 26248dcb196..25c491e7ab9 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "mermaid": "11.6.0", "monaco-editor": "0.53.0", "posthog-js": "1.345.1", - "qovery-typescript-axios": "1.1.1007", + "qovery-typescript-axios": "1.1.1010", "react": "19.3.0", "react-country-flag": "3.0.2", "react-datepicker": "4.12.0", diff --git a/yarn.lock b/yarn.lock index 6ece0fd2fff..06eda2225d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6285,7 +6285,7 @@ __metadata: prettier: 3.2.5 prettier-plugin-tailwindcss: 0.5.14 pretty-quick: 4.0.0 - qovery-typescript-axios: 1.1.1007 + qovery-typescript-axios: 1.1.1010 qovery-ws-typescript-axios: 0.1.666 react: 19.3.0 react-country-flag: 3.0.2 @@ -24656,12 +24656,12 @@ __metadata: languageName: node linkType: hard -"qovery-typescript-axios@npm:1.1.1007": - version: 1.1.1007 - resolution: "qovery-typescript-axios@npm:1.1.1007" +"qovery-typescript-axios@npm:1.1.1010": + version: 1.1.1010 + resolution: "qovery-typescript-axios@npm:1.1.1010" dependencies: axios: 1.20.0 - checksum: f8662f1567d11edcbe0119c2db44ceeb0b1f0181517b31ca70c89173872dde9fde0f07087b48cbd0d81372c4399675191b520378e9021539ecca100182a5bba8 + checksum: a1957c4f49a2c1d131d3826751e0e650f8494c8819e8d1dba0f0989d69f76f7ef6588a3636e950a92cfb5c46b8380fa17863a47ced60d046f181ca31c1e94c01 languageName: node linkType: hard From ac7c13b5519d790b81a699b3df99973e5f67a74b Mon Sep 17 00:00:00 2001 From: Melvin Zottola <37779145+mzottola@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:50:48 +0200 Subject: [PATCH 4/7] feat(qov-2363): Set SAML local storage even if coming from direct link (#3100) * feat(qov-2363) Set SAML local storage even if coming from direct link * refacto: Use the useLocalStorage hook --------- Co-authored-by: Melvin Zottola --- .../components/auth-page/auth-page-utils.spec.ts | 13 +++++++++++++ .../src/app/components/auth-page/auth-page-utils.ts | 12 +++++++++++- .../src/lib/use-local-storage/use-local-storage.ts | 4 ++-- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/apps/console/src/app/components/auth-page/auth-page-utils.spec.ts b/apps/console/src/app/components/auth-page/auth-page-utils.spec.ts index 5ab8793ffee..f7a6b5941d5 100644 --- a/apps/console/src/app/components/auth-page/auth-page-utils.spec.ts +++ b/apps/console/src/app/components/auth-page/auth-page-utils.spec.ts @@ -3,6 +3,7 @@ import { authPageBeforeLoad, clearAuthEntry, readAuthEntry } from './auth-page-u describe('auth entry', () => { beforeEach(() => { sessionStorage.clear() + localStorage.clear() }) afterEach(() => { @@ -22,6 +23,18 @@ describe('auth entry', () => { expect(readAuthEntry()).toEqual({ screen: 'signup', redirect: '/organization/123/overview' }) }) + it('remembers SAML SSO as the last used login of a `?connection=` auto-login', async () => { + localStorage.setItem('lastUsedLogin', 'github') + + await authPageBeforeLoad({ + page: 'login', + auth: { isAuthenticated: false, login: jest.fn().mockResolvedValue(undefined) }, + search: { connection: 'acme' }, + }) + + expect(localStorage.getItem('lastUsedLogin')).toBe('saml_sso') + }) + it('falls back to the login page and the root when nothing was remembered', () => { expect(readAuthEntry()).toEqual({ screen: 'login', redirect: '/' }) }) diff --git a/apps/console/src/app/components/auth-page/auth-page-utils.ts b/apps/console/src/app/components/auth-page/auth-page-utils.ts index cf93580bb60..735be1bd4fc 100644 --- a/apps/console/src/app/components/auth-page/auth-page-utils.ts +++ b/apps/console/src/app/components/auth-page/auth-page-utils.ts @@ -1,6 +1,7 @@ import { redirect } from '@tanstack/react-router' import { z } from 'zod' import { AuthEnum, getSsoConnectionName } from '@qovery/shared/auth' +import { getLocalStorageItem, setLocalStorageItem } from '@qovery/shared/util-hooks' import { type AuthPage } from './auth-page-tracking' export const LAST_USED_LOGIN_STORAGE_KEY = 'lastUsedLogin' @@ -50,12 +51,20 @@ export function clearAuthEntry() { export function getStoredLastUsedLogin() { try { - return window.localStorage.getItem(LAST_USED_LOGIN_STORAGE_KEY) ?? undefined + return getLocalStorageItem(LAST_USED_LOGIN_STORAGE_KEY) ?? undefined } catch { return undefined } } +function storeLastUsedLogin(lastUsedLogin: string) { + try { + setLocalStorageItem(LAST_USED_LOGIN_STORAGE_KEY, lastUsedLogin) + } catch (error) { + console.error(error) + } +} + export type TrackedAuthProvider = 'google' | 'github' | 'saml' | 'bitbucket' | 'gitlab' | 'microsoft' const TRACKED_PROVIDERS: Record = { @@ -87,6 +96,7 @@ export async function authPageBeforeLoad({ const connection = search.connection && getSsoConnectionName(search.connection) if (connection) { rememberAuthEntry(page, search.redirect) + storeLastUsedLogin(SAML_SSO_LOGIN) try { await auth.login(getSafeRedirect(search.redirect), connection) } catch (error) { diff --git a/libs/shared/util-hooks/src/lib/use-local-storage/use-local-storage.ts b/libs/shared/util-hooks/src/lib/use-local-storage/use-local-storage.ts index 8221467d424..6b64233a462 100644 --- a/libs/shared/util-hooks/src/lib/use-local-storage/use-local-storage.ts +++ b/libs/shared/util-hooks/src/lib/use-local-storage/use-local-storage.ts @@ -4,7 +4,7 @@ function dispatchStorageEvent(key: string, newValue: string | null) { window.dispatchEvent(new StorageEvent('storage', { key, newValue })) } -function setLocalStorageItem(key: string, value: T) { +export function setLocalStorageItem(key: string, value: T) { const localStorageValue = typeof value === 'string' ? value : JSON.stringify(value) window.localStorage.setItem(key, localStorageValue) dispatchStorageEvent(key, localStorageValue) @@ -15,7 +15,7 @@ function removeLocalStorageItem(key: string) { dispatchStorageEvent(key, null) } -function getLocalStorageItem(key: string) { +export function getLocalStorageItem(key: string) { return window.localStorage.getItem(key) } From a55195b82ae725f91c6ff3f985c711b76cf45db7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Wed, 14 Oct 2026 10:40:30 +0200 Subject: [PATCH 5/7] feat(environments): add deploy by version (#3014) * feat(environments): add deploy by version * fix(environments): harden deploy by version * fix(environments): keep version menu above modal * fix(environments): pin deploy modal actions * test(environments): clarify deploy modal option coverage * fix(environments): redirect actions to deployment pipeline * fix(environments): share pipeline toast navigation * test(environments): mock tanstack params in redeploy flag * fix(environments): update environment pipeline hook callers * fix(toast): show loading for async actions * fix(shared-ui): catch toast action errors * fix(shared-ui): allow toast callbacks to return values * chore(environments): group pipeline navigation hook and helper * fix(environments): use registry details for version selection --- .../$environmentId/variables/built-in.tsx | 4 +- .../variables/external-secrets.tsx | 4 +- .../$environmentId/variables/index.tsx | 4 +- .../deployment-action-button.spec.tsx | 2 +- .../deployment-action-button.tsx | 10 +- .../environment-pipeline.tsx | 1 + .../deploy-by-version-modal.spec.tsx | 324 ++++++++++++++ .../deploy-by-version-modal.tsx | 238 +++++++++++ .../deploy-by-version.spec.ts | 206 +++++++++ .../deploy-by-version.ts | 276 ++++++++++++ .../service-version-row.tsx | 230 ++++++++++ .../use-deploy-by-version-services.ts | 150 +++++++ .../environment-action-toolbar.spec.tsx.snap | 4 +- .../environment-action-toolbar.spec.tsx | 16 + .../environment-action-toolbar.tsx | 34 +- .../dropdown-services/dropdown-services.tsx | 14 +- .../environment-deployment-list.tsx | 8 +- .../environment-last-deployment-section.tsx | 12 +- .../use-cancel-deployment-environment.ts | 30 +- .../use-delete-environment.ts | 10 +- .../use-deploy-all-services.spec.ts | 54 +++ .../use-deploy-all-services.ts | 26 +- .../use-deploy-environment.ts | 30 +- .../get-latest-environment-deployment-id.ts | 40 ++ .../use-navigate-to-environment-pipeline.ts | 43 ++ .../use-outdated-services.ts | 58 --- .../use-stop-environment.ts | 31 +- .../use-uninstall-environment.ts | 30 +- .../need-redeploy-flag.spec.tsx | 5 +- .../need-redeploy-flag/need-redeploy-flag.tsx | 9 +- .../settings-danger-zone.tsx | 3 +- .../update-all-modal.spec.tsx.snap | 398 ------------------ .../update-all-modal.spec.tsx | 190 --------- .../lib/update-all-modal/update-all-modal.tsx | 286 ------------- .../ui/src/lib/components/toast/toast.tsx | 35 +- libs/shared/ui/src/lib/utils/toast-error.tsx | 4 +- libs/shared/ui/src/lib/utils/toast.tsx | 3 +- 37 files changed, 1751 insertions(+), 1071 deletions(-) create mode 100644 libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version-modal.spec.tsx create mode 100644 libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version-modal.tsx create mode 100644 libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version.spec.ts create mode 100644 libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version.ts create mode 100644 libs/domains/environments/feature/src/lib/deploy-by-version-modal/service-version-row.tsx create mode 100644 libs/domains/environments/feature/src/lib/deploy-by-version-modal/use-deploy-by-version-services.ts create mode 100644 libs/domains/environments/feature/src/lib/hooks/use-deploy-all-services/use-deploy-all-services.spec.ts create mode 100644 libs/domains/environments/feature/src/lib/hooks/use-navigate-to-environment-pipeline/get-latest-environment-deployment-id.ts create mode 100644 libs/domains/environments/feature/src/lib/hooks/use-navigate-to-environment-pipeline/use-navigate-to-environment-pipeline.ts delete mode 100644 libs/domains/environments/feature/src/lib/hooks/use-outdated-services/use-outdated-services.ts delete mode 100644 libs/domains/environments/feature/src/lib/update-all-modal/__snapshots__/update-all-modal.spec.tsx.snap delete mode 100644 libs/domains/environments/feature/src/lib/update-all-modal/update-all-modal.spec.tsx delete mode 100644 libs/domains/environments/feature/src/lib/update-all-modal/update-all-modal.tsx diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/built-in.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/built-in.tsx index 5280f68562b..20e8980de12 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/built-in.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/built-in.tsx @@ -1,7 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' import { useDeployEnvironment } from '@qovery/domains/environments/feature' import { VariableList } from '@qovery/domains/variables/feature' -import { ENVIRONMENT_LOGS_URL, ENVIRONMENT_STAGES_URL } from '@qovery/shared/routes' import { toast } from '@qovery/shared/ui' import { useDocumentTitle } from '@qovery/shared/util-hooks' @@ -16,8 +15,9 @@ function RouteComponent() { useDocumentTitle('Built-in variables - Environment') const { mutate: deployEnvironment } = useDeployEnvironment({ + organizationId, projectId, - logsLink: ENVIRONMENT_LOGS_URL(organizationId, projectId, environmentId) + ENVIRONMENT_STAGES_URL(), + environmentId, }) const toasterCallback = () => { diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/external-secrets.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/external-secrets.tsx index 19533863201..0c4cfc3c4c8 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/external-secrets.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/external-secrets.tsx @@ -4,7 +4,6 @@ import { useFeatureFlagEnabled } from 'posthog-js/react' import { useEffect } from 'react' import { useDeployEnvironment } from '@qovery/domains/environments/feature' import { ExternalSecretsTab, SecretManagerFeatureFlagEntryPoint } from '@qovery/domains/variables/feature' -import { ENVIRONMENT_LOGS_URL, ENVIRONMENT_STAGES_URL } from '@qovery/shared/routes' import { toast } from '@qovery/shared/ui' import { useDocumentTitle } from '@qovery/shared/util-hooks' @@ -29,8 +28,9 @@ function RouteComponent() { }, [organizationId, projectId, environmentId]) const { mutate: deployEnvironment } = useDeployEnvironment({ + organizationId, projectId, - logsLink: ENVIRONMENT_LOGS_URL(organizationId, projectId, environmentId) + ENVIRONMENT_STAGES_URL(), + environmentId, }) const redeployEnvironmentAction = () => { diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/index.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/index.tsx index fc00f4873d0..0843a1d7d64 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/index.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/index.tsx @@ -1,7 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' import { useDeployEnvironment } from '@qovery/domains/environments/feature' import { VariableList, VariablesActionToolbar } from '@qovery/domains/variables/feature' -import { ENVIRONMENT_LOGS_URL, ENVIRONMENT_STAGES_URL } from '@qovery/shared/routes' import { toast } from '@qovery/shared/ui' import { useDocumentTitle } from '@qovery/shared/util-hooks' @@ -16,8 +15,9 @@ function RouteComponent() { useDocumentTitle('Custom variables - Environment') const { mutate: deployEnvironment } = useDeployEnvironment({ + organizationId, projectId, - logsLink: ENVIRONMENT_LOGS_URL(organizationId, projectId, environmentId) + ENVIRONMENT_STAGES_URL(), + environmentId, }) const toasterCallback = () => { diff --git a/libs/domains/environment-logs/feature/src/lib/deployment-action-button/deployment-action-button.spec.tsx b/libs/domains/environment-logs/feature/src/lib/deployment-action-button/deployment-action-button.spec.tsx index 68fa960aebe..c6c0054eeaf 100644 --- a/libs/domains/environment-logs/feature/src/lib/deployment-action-button/deployment-action-button.spec.tsx +++ b/libs/domains/environment-logs/feature/src/lib/deployment-action-button/deployment-action-button.spec.tsx @@ -14,7 +14,6 @@ const mockCancel = jest.fn() const mockOpenModalConfirmation = jest.fn() jest.mock('@qovery/domains/services/feature', () => ({ - useDeployAllServices: () => ({ mutate: mockDeployAllServices }), useRestartAllServices: () => ({ mutate: mockRestartAllServices }), useStopAllServices: () => ({ mutate: mockStopAllServices }), useUninstallAllServices: () => ({ mutate: mockUninstallAllServices }), @@ -24,6 +23,7 @@ jest.mock('@qovery/domains/services/feature', () => ({ jest.mock('@qovery/domains/environments/feature', () => ({ ...jest.requireActual('@qovery/domains/environments/feature'), useCancelDeploymentEnvironment: () => ({ mutate: mockCancel }), + useDeployAllServices: () => ({ mutate: mockDeployAllServices }), })) jest.mock('@qovery/shared/ui', () => ({ diff --git a/libs/domains/environment-logs/feature/src/lib/deployment-action-button/deployment-action-button.tsx b/libs/domains/environment-logs/feature/src/lib/deployment-action-button/deployment-action-button.tsx index b8abda937c0..585bda81570 100644 --- a/libs/domains/environment-logs/feature/src/lib/deployment-action-button/deployment-action-button.tsx +++ b/libs/domains/environment-logs/feature/src/lib/deployment-action-button/deployment-action-button.tsx @@ -1,14 +1,12 @@ import { type DeploymentHistoryEnvironmentV2, type Environment, type StateEnum } from 'qovery-typescript-axios' import { match } from 'ts-pattern' -import { useCancelDeploymentEnvironment } from '@qovery/domains/environments/feature' +import { useCancelDeploymentEnvironment, useDeployAllServices } from '@qovery/domains/environments/feature' import { useDeleteAllServices, - useDeployAllServices, useRestartAllServices, useStopAllServices, useUninstallAllServices, } from '@qovery/domains/services/feature' -import { ENVIRONMENT_LOGS_URL, ENVIRONMENT_STAGES_URL } from '@qovery/shared/routes' import { Button, Icon, Tooltip, useModalConfirmation } from '@qovery/shared/ui' import { isCancelBuildAvailable } from '@qovery/shared/util-js' @@ -21,12 +19,10 @@ export interface DeploymentActionButtonProps { export function DeploymentActionButton({ environment, deploymentHistory, state }: DeploymentActionButtonProps) { const { openModalConfirmation } = useModalConfirmation() - const logsLink = - ENVIRONMENT_LOGS_URL(environment.organization.id, environment.project.id, environment.id) + ENVIRONMENT_STAGES_URL() - const { mutate: cancelDeploymentEnvironment } = useCancelDeploymentEnvironment({ + organizationId: environment.organization.id, projectId: environment.project.id, - logsLink, + environmentId: environment.id, }) const { mutate: deployAllServices } = useDeployAllServices() const { mutate: restartAllServices } = useRestartAllServices() diff --git a/libs/domains/environment-logs/feature/src/lib/environment-pipeline/environment-pipeline.tsx b/libs/domains/environment-logs/feature/src/lib/environment-pipeline/environment-pipeline.tsx index 7f3b404710e..be15670294d 100644 --- a/libs/domains/environment-logs/feature/src/lib/environment-pipeline/environment-pipeline.tsx +++ b/libs/domains/environment-logs/feature/src/lib/environment-pipeline/environment-pipeline.tsx @@ -288,6 +288,7 @@ export function EnvironmentPipeline() { }, enabled: Boolean(organizationId) && Boolean(environment?.cluster_id) && Boolean(projectId) && Boolean(environmentId), + shouldReconnect: true, onMessage: messageHandler, }) diff --git a/libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version-modal.spec.tsx b/libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version-modal.spec.tsx new file mode 100644 index 00000000000..e76e0dcc9a2 --- /dev/null +++ b/libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version-modal.spec.tsx @@ -0,0 +1,324 @@ +import { environmentFactoryMock } from '@qovery/shared/factories' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { type DeployByVersionService } from './deploy-by-version' +import { DeployByVersionModal } from './deploy-by-version-modal' + +const mockEnvironment = environmentFactoryMock(1)[0] +const mockDeployAllServices = jest.fn() +const mockCloseModal = jest.fn() +const mockSetModalDismissible = jest.fn() +let mockDeployIsLoading = false + +const unavailableService: DeployByVersionService = { + id: 'unavailable', + name: 'Unavailable service', + iconUri: 'app://qovery-console/application', + serviceType: 'APPLICATION', + sourceType: 'git', + currentVersion: 'current-commit', + versions: [], + isSkipped: false, + hasVersionError: true, +} + +const defaultServices: DeployByVersionService[] = [ + { + id: 'terraform', + name: 'Infrastructure', + iconUri: 'app://qovery-console/terraform', + serviceType: 'TERRAFORM', + sourceType: 'git', + currentVersion: 'old-terraform-commit', + versions: [ + { value: 'new-terraform-commit', message: 'Update infrastructure' }, + { value: 'old-terraform-commit', message: 'Current infrastructure' }, + ], + isSkipped: false, + }, + { + id: 'application', + name: 'API', + iconUri: 'app://qovery-console/application', + serviceType: 'APPLICATION', + sourceType: 'git', + currentVersion: 'current-app-commit', + versions: [ + { value: 'current-app-commit', message: 'Current application' }, + { value: 'previous-app-commit', message: 'Previous application' }, + ], + isSkipped: false, + }, + unavailableService, +] +let mockServices = defaultServices + +jest.mock('@qovery/domains/services/feature', () => ({ + ServiceAvatar: () => , +})) + +jest.mock('@qovery/shared/ui', () => ({ + ...jest.requireActual('@qovery/shared/ui'), + useModal: () => ({ closeModal: mockCloseModal, setModalDismissible: mockSetModalDismissible }), +})) + +jest.mock('./use-deploy-by-version-services', () => ({ + useDeployByVersionServices: () => ({ data: mockServices, isLoading: false, isError: false }), +})) + +jest.mock('../hooks/use-deploy-all-services/use-deploy-all-services', () => ({ + useDeployAllServices: () => ({ mutate: mockDeployAllServices, isLoading: mockDeployIsLoading }), +})) + +describe('DeployByVersionModal', () => { + beforeEach(() => { + jest.useFakeTimers() + jest.clearAllMocks() + mockServices = defaultServices + mockDeployIsLoading = false + mockDeployAllServices.mockImplementation((_variables: unknown, options: { onSuccess?: () => void }) => { + options.onSuccess?.() + }) + }) + + afterEach(() => { + jest.clearAllTimers() + jest.useRealTimers() + }) + + it('preselects outdated services and submits an explicit Terraform commit', async () => { + const { userEvent } = renderWithProviders() + + expect(screen.getByRole('button', { name: 'Update 1 service' })).toBeEnabled() + expect(screen.getByRole('checkbox', { name: 'Infrastructure' })).toBeChecked() + expect(screen.getByRole('checkbox', { name: 'API' })).not.toBeChecked() + + await userEvent.click(screen.getByRole('button', { name: 'Update 1 service' })) + + expect(mockDeployAllServices).toHaveBeenCalledWith( + { + environment: mockEnvironment, + payload: { terraforms: [{ id: 'terraform', git_commit_id: 'new-terraform-commit' }] }, + }, + { onSuccess: mockCloseModal } + ) + expect(mockSetModalDismissible).toHaveBeenLastCalledWith(false) + expect(mockCloseModal).toHaveBeenCalledTimes(1) + }) + + it('allows an up-to-date service to be selected explicitly', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByRole('checkbox', { name: 'API' })) + + expect(screen.getByRole('button', { name: 'Update 2 services' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'Select a version for API' })).toBeEnabled() + }) + + it('keeps the version selector enabled and selects the service when its target changes', async () => { + const { userEvent } = renderWithProviders() + + const selector = screen.getByRole('button', { name: 'Select a version for API' }) + expect(selector).toBeEnabled() + expect(screen.getByRole('checkbox', { name: 'API' })).not.toBeChecked() + + await userEvent.click(selector) + await userEvent.click(screen.getByRole('menuitem', { name: /previou Previous application/ })) + + expect(screen.getByRole('checkbox', { name: 'API' })).toBeChecked() + expect(selector).toHaveTextContent('previou') + expect(screen.getByRole('button', { name: 'Update 2 services' })).toBeEnabled() + }) + + it('sizes version icons with their adjacent text', () => { + renderWithProviders() + + const selector = screen.getByRole('button', { name: 'Select a version for Infrastructure' }) + const row = selector.closest('[data-testid="service-version-row"]') + + expect(selector.querySelector('.fa-code-commit')).toHaveClass('text-ssm') + expect(selector.querySelector('.fa-chevron-down')).toHaveClass('text-ssm') + expect(selector.querySelector('.fa-chevron-down')).not.toHaveClass('text-neutral-subtle') + expect(selector.querySelector('.fa-chevron-down')).toHaveClass('group-data-[state=open]:rotate-180') + expect(row?.querySelector('.fa-arrow-right')).toHaveClass('text-ssm') + }) + + it('lets version controls hug their content without width constraints', () => { + renderWithProviders() + + const selector = screen.getByRole('button', { name: 'Select a version for Infrastructure' }) + const row = selector.closest('[data-testid="service-version-row"]') + const currentVersion = screen.getByText('old-ter').closest('[class~="h-6"]') + + expect(selector).not.toHaveClass('min-w-28', 'max-w-44') + expect(row?.querySelector('.max-w-36')).not.toBeInTheDocument() + expect(currentVersion).toHaveClass('px-1.5') + }) + + it('renders commit details using the Figma dropdown hierarchy', async () => { + const { userEvent } = renderWithProviders() + + const selector = screen.getByRole('button', { name: 'Select a version for Infrastructure' }) + await userEvent.click(selector) + + expect(selector).toHaveAttribute('data-state', 'open') + expect(screen.getByRole('menuitem', { name: /new-ter Update infrastructure/ })).toHaveClass( + 'hover:bg-surface-neutral-subtle' + ) + expect(screen.getByText('Update infrastructure')).toBeInTheDocument() + expect(screen.queryByText('main')).not.toBeInTheDocument() + expect(screen.getAllByText('Latest').length).toBeGreaterThan(0) + expect(screen.getByTestId('version-options')).toHaveClass('z-dropdown') + expect(screen.getByTestId('version-options')).not.toHaveClass('font-code', 'font-mono') + expect(screen.getByTestId('version-options').querySelector('.fa-code-commit')).not.toBeInTheDocument() + }) + + it('renders complete non-Git versions and prevents selecting the mutable latest tag', async () => { + mockServices = [ + { + id: 'container', + name: 'Container', + iconUri: 'app://qovery-console/container', + serviceType: 'CONTAINER', + sourceType: 'container', + currentVersion: 'release-2026-09-alpha', + containerRegistryId: 'registry', + imageName: 'image', + versions: [ + { value: 'release-2026-09-alpha' }, + { value: 'release-2026-09-beta' }, + { + value: 'latest', + isDisabled: true, + disabledReason: 'Image tag cannot be latest to ensure consistent deployment', + }, + ], + isSkipped: false, + }, + ] + const { userEvent } = renderWithProviders() + const checkbox = screen.getByRole('checkbox', { name: 'Container' }) + + await userEvent.click(screen.getByRole('button', { name: 'Select a version for Container' })) + + expect(screen.getByRole('menuitem', { name: 'release-2026-09-alpha' })).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: 'release-2026-09-beta' })).toBeInTheDocument() + const latest = screen.getByRole('menuitem', { + name: 'latest Image tag cannot be latest to ensure consistent deployment', + }) + expect(latest).toHaveAttribute('data-disabled') + + await userEvent.click(latest) + + expect(checkbox).not.toBeChecked() + }) + + it('renders every fetched version in a natively scrollable four-row menu', async () => { + mockServices = [ + { + ...defaultServices[0], + versions: Array.from({ length: 6 }, (_, index) => ({ + value: `commit-${index}`, + message: `Commit message ${index}`, + })), + }, + ] + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByRole('button', { name: 'Select a version for Infrastructure' })) + + const versionItems = screen.getAllByRole('menuitem') + expect(versionItems).toHaveLength(6) + versionItems.forEach((item) => expect(item).toHaveClass('h-[62px]', 'shrink-0')) + expect(screen.getByTestId('version-options')).toHaveClass( + 'max-h-[248px]', + 'overflow-x-hidden', + 'overflow-y-auto', + 'overscroll-contain' + ) + }) + + it('selects a different version from the dropdown', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByRole('button', { name: 'Select a version for Infrastructure' })) + await userEvent.click(screen.getByRole('menuitem', { name: /old-ter Current infrastructure/ })) + + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Select a version for Infrastructure' })).toHaveTextContent('old-ter') + + await userEvent.click(screen.getByRole('button', { name: 'Update 1 service' })) + + expect(mockDeployAllServices).toHaveBeenCalledWith( + { + environment: mockEnvironment, + payload: { terraforms: [{ id: 'terraform', git_commit_id: 'old-terraform-commit' }] }, + }, + { onSuccess: mockCloseModal } + ) + }) + + it('locks modal dismissal and prevents duplicate submission while deployment is pending', async () => { + mockDeployAllServices.mockImplementation(() => undefined) + const { rerender, userEvent } = renderWithProviders() + + await userEvent.click(screen.getByRole('button', { name: 'Update 1 service' })) + expect(mockDeployAllServices).toHaveBeenCalledTimes(1) + expect(mockSetModalDismissible).toHaveBeenLastCalledWith(false) + + mockDeployIsLoading = true + rerender() + + const submit = screen.getByRole('button', { name: 'Update 1 service' }) + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled() + expect(submit).toBeDisabled() + submit.focus() + await userEvent.keyboard('{Enter}') + expect(mockDeployAllServices).toHaveBeenCalledTimes(1) + expect(mockCloseModal).not.toHaveBeenCalled() + + mockDeployIsLoading = false + rerender() + expect(mockSetModalDismissible).toHaveBeenLastCalledWith(true) + }) + + it('does not show services without selectable versions', () => { + renderWithProviders() + + expect(screen.getAllByTestId('service-version-row')).toHaveLength(2) + expect(screen.queryByText('Unavailable service')).not.toBeInTheDocument() + }) + + it('keeps the actions visible while the modal content scrolls', () => { + renderWithProviders() + + const modalMainContent = screen.getByTestId('modal-main-content') + const form = modalMainContent.closest('form') + const actions = screen.getByRole('button', { name: 'Cancel' }).parentElement + + expect(form).toHaveClass('flex', 'max-h-[80vh]', 'flex-col') + expect(modalMainContent).toHaveClass('min-h-0', 'flex-1', 'overflow-y-auto', 'p-6') + expect(actions).toHaveClass('shrink-0', 'border-t', 'bg-background') + expect(modalMainContent).toContainElement(screen.getByRole('heading', { name: 'Deploy by version' })) + expect(modalMainContent).toContainElement(screen.getByText('1 outdated service')) + expect(modalMainContent).toContainElement(screen.getByText('1 up to date service')) + expect(screen.queryByTestId('scroll-shadow-wrapper')).not.toBeInTheDocument() + expect(screen.queryByTestId('scroll-shadow-bottom')).not.toBeInTheDocument() + }) + + it('shows an error when every version lookup failed', () => { + mockServices = [unavailableService] + + renderWithProviders() + + expect(screen.getByText('Service versions could not be loaded.')).toBeInTheDocument() + expect(screen.queryByText('Unavailable service')).not.toBeInTheDocument() + }) + + it('renders the dedicated empty state when only excluded services remain', () => { + mockServices = [] + + renderWithProviders() + + expect(screen.getByText('No services support version selection.')).toBeInTheDocument() + }) +}) diff --git a/libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version-modal.tsx b/libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version-modal.tsx new file mode 100644 index 00000000000..bca385b9712 --- /dev/null +++ b/libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version-modal.tsx @@ -0,0 +1,238 @@ +import { type Environment } from 'qovery-typescript-axios' +import { useEffect, useMemo, useState } from 'react' +import { Button, Icon, LoaderSpinner, useModal } from '@qovery/shared/ui' +import { useDeployAllServices } from '../hooks/use-deploy-all-services/use-deploy-all-services' +import { + type DeployByVersionService, + type ServiceVersionSelections, + buildDeployByVersionPayload, + countDeployByVersionServices, + createInitialSelections, + getFirstDeployableVersion, +} from './deploy-by-version' +import { ServiceVersionRow } from './service-version-row' +import { useDeployByVersionServices } from './use-deploy-by-version-services' + +export interface DeployByVersionModalProps { + environment: Environment +} + +function ServiceSection({ + title, + services, + selections, + action, + onToggle, + onVersionChange, +}: { + title: string + services: DeployByVersionService[] + selections: ServiceVersionSelections + action?: React.ReactNode + onToggle: (serviceId: string) => void + onVersionChange: (serviceId: string, version: string) => void +}) { + if (services.length === 0) return null + + return ( +
+
+

+ {services.length} {title} +

+ {action} +
+
    + {services.map((service) => ( + onToggle(service.id)} + onVersionChange={(version) => onVersionChange(service.id, version)} + /> + ))} +
+
+ ) +} + +function DeployByVersionHeader() { + return ( +
+

Deploy by version

+

+ Select the service and choose a specific version to deploy on each of those +

+
+ ) +} + +function DeployByVersionForm({ + environment, + services, +}: DeployByVersionModalProps & { services: DeployByVersionService[] }) { + const { closeModal, setModalDismissible } = useModal() + const { mutate: deployAllServices, isLoading } = useDeployAllServices() + const [selections, setSelections] = useState(() => createInitialSelections(services)) + + const outdatedServices = services.filter( + (service) => getFirstDeployableVersion(service)?.value !== service.currentVersion + ) + const upToDateServices = services.filter( + (service) => getFirstDeployableVersion(service)?.value === service.currentVersion + ) + const payload = useMemo(() => buildDeployByVersionPayload(services, selections), [selections, services]) + const selectedCount = countDeployByVersionServices(payload) + const allOutdatedSelected = outdatedServices + .filter(({ isSkipped }) => !isSkipped) + .every(({ id }) => selections[id].selected) + + useEffect(() => { + setModalDismissible(!isLoading) + }, [isLoading, setModalDismissible]) + + useEffect( + () => () => { + setModalDismissible(true) + }, + [setModalDismissible] + ) + + const toggleService = (serviceId: string) => { + setSelections((current) => ({ + ...current, + [serviceId]: { ...current[serviceId], selected: !current[serviceId].selected }, + })) + } + + const changeVersion = (serviceId: string, version: string) => { + setSelections((current) => ({ + ...current, + [serviceId]: { ...current[serviceId], selected: true, version }, + })) + } + + const toggleOutdatedServices = () => { + setSelections((current) => + Object.fromEntries( + Object.entries(current).map(([serviceId, selection]) => { + const service = outdatedServices.find(({ id }) => id === serviceId) + return [ + serviceId, + service && !service.isSkipped ? { ...selection, selected: !allOutdatedSelected } : selection, + ] + }) + ) + ) + } + + const onSubmit = (event: React.FormEvent) => { + event.preventDefault() + if (selectedCount === 0 || isLoading) return + setModalDismissible(false) + deployAllServices( + { environment, payload }, + { + onSuccess: closeModal, + } + ) + } + + return ( +
+
+ + !isSkipped) ? ( + + ) : undefined + } + onToggle={toggleService} + onVersionChange={changeVersion} + /> + +
+ +
+ + +
+
+ ) +} + +export function DeployByVersionModal({ environment }: DeployByVersionModalProps) { + const { closeModal } = useModal() + const { + data: services, + isLoading, + isError, + } = useDeployByVersionServices({ + environmentId: environment.id, + organizationId: environment.organization.id, + }) + const servicesWithVersions = services.filter((service) => getFirstDeployableVersion(service)) + const hasVersionErrors = services.some(({ hasVersionError }) => hasVersionError) + + const formKey = servicesWithVersions + .map((service) => + [service.id, service.currentVersion, getFirstDeployableVersion(service)?.value, service.isSkipped].join(':') + ) + .join('|') + + if (!isLoading && !isError && servicesWithVersions.length > 0) { + return + } + + return ( +
+ + {isLoading ? ( +
+ +
+ ) : isError || (servicesWithVersions.length === 0 && hasVersionErrors) ? ( +
+ +

Service versions could not be loaded.

+

Close the modal and try again.

+ +
+ ) : servicesWithVersions.length === 0 ? ( +
+ +

No services support version selection.

+

+ Blueprint, database, Argo CD, and agentic workflow services are not included. +

+ +
+ ) : null} +
+ ) +} + +export default DeployByVersionModal diff --git a/libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version.spec.ts b/libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version.spec.ts new file mode 100644 index 00000000000..0fb4ba493d8 --- /dev/null +++ b/libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version.spec.ts @@ -0,0 +1,206 @@ +import { type Commit } from 'qovery-typescript-axios' +import { helmFactoryMock, terraformFactoryMock } from '@qovery/shared/factories' +import { + type DeployByVersionService, + buildDeployByVersionPayload, + commitsToVersionOptions, + containerVersionsToOptions, + countDeployByVersionServices, + createInitialSelections, + sortVersions, + toVersionedService, + versionsToOptions, +} from './deploy-by-version' + +const services: DeployByVersionService[] = [ + { + id: 'application', + name: 'Application', + iconUri: 'app://qovery-console/application', + serviceType: 'APPLICATION', + sourceType: 'git', + currentVersion: 'app-old', + versions: [{ value: 'app-new' }, { value: 'app-old' }], + isSkipped: false, + }, + { + id: 'container', + name: 'Container', + iconUri: 'app://qovery-console/container', + serviceType: 'CONTAINER', + sourceType: 'container', + currentVersion: '1.0.0', + containerRegistryId: 'registry', + imageName: 'nginx', + versions: [{ value: '2.0.0' }, { value: '1.0.0' }], + isSkipped: false, + }, + { + id: 'git-job', + name: 'Git job', + iconUri: 'app://qovery-console/cron-job', + serviceType: 'JOB', + sourceType: 'git', + jobType: 'CRON', + currentVersion: 'job-old', + versions: [{ value: 'job-new' }], + isSkipped: false, + }, + { + id: 'container-job', + name: 'Container job', + iconUri: 'app://qovery-console/lifecycle-job', + serviceType: 'JOB', + sourceType: 'container', + jobType: 'LIFECYCLE', + currentVersion: '1.0.0', + containerRegistryId: 'registry', + imageName: 'job', + versions: [{ value: '2.0.0' }], + isSkipped: false, + }, + { + id: 'git-helm', + name: 'Git Helm', + iconUri: 'app://qovery-console/helm', + serviceType: 'HELM', + sourceType: 'git', + currentVersion: 'helm-old', + versions: [{ value: 'helm-new' }], + isSkipped: false, + }, + { + id: 'repository-helm', + name: 'Repository Helm', + iconUri: 'app://qovery-console/helm', + serviceType: 'HELM', + sourceType: 'helm-repository', + currentVersion: '1.0.0', + helmRepositoryId: 'helm-repository', + chartName: 'chart', + versions: [{ value: '2.0.0' }], + isSkipped: false, + }, + { + id: 'terraform', + name: 'Terraform', + iconUri: 'app://qovery-console/terraform', + serviceType: 'TERRAFORM', + sourceType: 'git', + currentVersion: 'terraform-old', + versions: [{ value: 'terraform-new' }], + isSkipped: false, + }, + { + id: 'skipped-terraform', + name: 'Skipped Terraform', + iconUri: 'app://qovery-console/terraform', + serviceType: 'TERRAFORM', + sourceType: 'git', + currentVersion: 'terraform-old', + versions: [{ value: 'terraform-new' }], + isSkipped: true, + }, +] + +describe('deploy by version', () => { + it('builds a typed payload for every supported source, including Terraform', () => { + const selections = createInitialSelections(services) + expect(selections['skipped-terraform'].selected).toBe(false) + selections['skipped-terraform'].selected = true + const payload = buildDeployByVersionPayload(services, selections) + + expect(payload).toEqual({ + applications: [{ application_id: 'application', git_commit_id: 'app-new' }], + containers: [{ id: 'container', image_tag: '2.0.0' }], + jobs: [ + { id: 'git-job', git_commit_id: 'job-new' }, + { id: 'container-job', image_tag: '2.0.0' }, + ], + helms: [ + { id: 'git-helm', git_commit_id: 'helm-new' }, + { id: 'repository-helm', chart_version: '2.0.0' }, + ], + terraforms: [{ id: 'terraform', git_commit_id: 'terraform-new' }], + }) + expect(countDeployByVersionServices(payload)).toBe(7) + expect(payload.terraforms).not.toContainEqual(expect.objectContaining({ id: 'skipped-terraform' })) + }) + + it('excludes all blueprint-backed services before source classification', () => { + const terraform = { ...terraformFactoryMock(1)[0], blueprint_id: 'blueprint-id' } + const helm = { ...helmFactoryMock(1)[0], blueprint_id: 'blueprint-id' } + + expect(toVersionedService(terraform)).toBeUndefined() + expect(toVersionedService(helm)).toBeUndefined() + }) + + it('includes a regular Terraform service and reads its deployed commit', () => { + const terraform = terraformFactoryMock(1)[0] + const gitRepository = terraform.terraform_files_source?.git?.git_repository + if (!gitRepository) throw new Error('The Terraform factory must have a Git repository') + gitRepository.deployed_commit_id = 'deployed-commit' + + expect(toVersionedService(terraform)).toMatchObject({ + id: terraform.id, + serviceType: 'TERRAFORM', + sourceType: 'git', + currentVersion: 'deployed-commit', + }) + }) + + it('keeps API version arrays immutable while sorting semantic versions first', () => { + const versions = ['latest', '1.9.0', 'v2.0.0'] + + expect(sortVersions(versions)).toEqual(['v2.0.0', '1.9.0', 'latest']) + expect(versions).toEqual(['latest', '1.9.0', 'v2.0.0']) + }) + + it('disables the mutable latest tag only for container versions', () => { + expect(containerVersionsToOptions(['latest', '1.0.0'])).toEqual([ + { value: '1.0.0' }, + { + value: 'latest', + isDisabled: true, + disabledReason: 'Image tag cannot be latest to ensure consistent deployment', + }, + ]) + expect(versionsToOptions(['latest'])).toEqual([{ value: 'latest' }]) + }) + + it('selects the first deployable version and rejects disabled selections from the payload', () => { + const container = { + ...services[1], + versions: [{ value: 'latest', isDisabled: true }, { value: '2.0.0' }], + } satisfies DeployByVersionService + const containerJob = { + ...services[3], + versions: [{ value: 'latest', isDisabled: true }], + } satisfies DeployByVersionService + const selections = createInitialSelections([container, containerJob]) + + expect(selections).toEqual({ + container: { selected: true, version: '2.0.0' }, + 'container-job': { selected: false, version: '' }, + }) + + selections.container = { selected: true, version: 'latest' } + selections['container-job'] = { selected: true, version: 'latest' } + + expect(buildDeployByVersionPayload([container, containerJob], selections)).toEqual({}) + }) + + it('preserves commit metadata for the version menu', () => { + const commit: Commit = { + git_commit_id: 'abc123', + created_at: '2026-01-01T00:00:00Z', + message: 'Add deployment support', + tag: 'v1.0.0', + author_name: 'Ada', + } + + expect(commitsToVersionOptions([commit])).toEqual([ + { value: 'abc123', message: 'Add deployment support', tag: 'v1.0.0' }, + ]) + }) +}) diff --git a/libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version.ts b/libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version.ts new file mode 100644 index 00000000000..e8c903d1002 --- /dev/null +++ b/libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version.ts @@ -0,0 +1,276 @@ +import { type Commit, type DeployAllRequest } from 'qovery-typescript-axios' +import { match } from 'ts-pattern' +import { + type AnyService, + isApplication, + isBlueprintService, + isContainer, + isHelm, + isJob, +} from '@qovery/domains/services/data-access' +import { isHelmGitSource, isHelmRepositorySource, isJobContainerSource, isJobGitSource } from '@qovery/shared/enums' + +interface VersionedServiceBase { + id: string + name: string + iconUri: string + currentVersion?: string + jobType?: 'CRON' | 'LIFECYCLE' +} + +export type GitVersionedService = VersionedServiceBase & { + sourceType: 'git' + serviceType: 'APPLICATION' | 'JOB' | 'HELM' | 'TERRAFORM' +} + +export type ContainerVersionedService = VersionedServiceBase & { + sourceType: 'container' + serviceType: 'CONTAINER' | 'JOB' + containerRegistryId: string + imageName: string +} + +export type HelmRepositoryVersionedService = VersionedServiceBase & { + sourceType: 'helm-repository' + serviceType: 'HELM' + helmRepositoryId: string + chartName: string +} + +export type VersionedService = GitVersionedService | ContainerVersionedService | HelmRepositoryVersionedService + +export interface VersionOption { + value: string + message?: string + tag?: string + isDisabled?: boolean + disabledReason?: string +} + +export type DeployByVersionService = VersionedService & { + versions: VersionOption[] + isSkipped: boolean + hasVersionError?: boolean +} + +export interface ServiceVersionSelection { + selected: boolean + version: string +} + +export type ServiceVersionSelections = Record + +function baseService(service: AnyService): VersionedServiceBase { + return { + id: service.id, + name: service.name, + iconUri: service.icon_uri, + jobType: isJob(service) ? service.job_type : undefined, + } +} + +export function toVersionedService(service: AnyService): VersionedService | undefined { + // Blueprint-backed services are deployed through their blueprint flow. + if (isBlueprintService(service)) return undefined + + if (isApplication(service)) { + if (!service.git_repository) return undefined + + return { + ...baseService(service), + sourceType: 'git', + serviceType: 'APPLICATION', + currentVersion: service.git_repository.deployed_commit_id, + } + } + + if (isContainer(service)) { + const containerRegistryId = service.registry.id + if (!containerRegistryId) return undefined + + return { + ...baseService(service), + sourceType: 'container', + serviceType: 'CONTAINER', + currentVersion: service.tag, + containerRegistryId, + imageName: service.image_name, + } + } + + if (isJob(service)) { + if (isJobGitSource(service.source) && service.source.docker?.git_repository) { + return { + ...baseService(service), + sourceType: 'git', + serviceType: 'JOB', + currentVersion: service.source.docker.git_repository.deployed_commit_id, + } + } + + if (isJobContainerSource(service.source)) { + const containerRegistryId = service.source.image.registry.id + if (!containerRegistryId) return undefined + + return { + ...baseService(service), + sourceType: 'container', + serviceType: 'JOB', + currentVersion: service.source.image.tag, + containerRegistryId, + imageName: service.source.image.image_name, + } + } + + return undefined + } + + if (isHelm(service)) { + if (isHelmGitSource(service.source) && service.source.git?.git_repository) { + return { + ...baseService(service), + sourceType: 'git', + serviceType: 'HELM', + currentVersion: service.source.git.git_repository.deployed_commit_id, + } + } + + if (isHelmRepositorySource(service.source) && service.source.repository) { + return { + ...baseService(service), + sourceType: 'helm-repository', + serviceType: 'HELM', + currentVersion: service.source.repository.chart_version, + helmRepositoryId: service.source.repository.repository.id, + chartName: service.source.repository.chart_name, + } + } + + return undefined + } + + if (service.serviceType === 'TERRAFORM') { + const gitRepository = service.terraform_files_source?.git?.git_repository + if (!gitRepository) return undefined + + return { + ...baseService(service), + sourceType: 'git', + serviceType: 'TERRAFORM', + currentVersion: gitRepository.deployed_commit_id, + } + } + + return undefined +} + +export function commitsToVersionOptions(commits: Commit[]): VersionOption[] { + return commits.map(({ git_commit_id, message, tag }) => ({ + value: git_commit_id, + message, + tag, + })) +} + +export function sortVersions(versions: string[]): string[] { + const semanticVersion = /^v?\d+\.\d+\.\d+/ + + return [...versions].sort((a, b) => { + const aIsSemantic = semanticVersion.test(a) + const bIsSemantic = semanticVersion.test(b) + + if (aIsSemantic && bIsSemantic) { + return b.replace(/^v/i, '').localeCompare(a.replace(/^v/i, ''), undefined, { numeric: true }) + } + if (aIsSemantic) return -1 + if (bIsSemantic) return 1 + return a.localeCompare(b) + }) +} + +export function versionsToOptions(versions: string[]): VersionOption[] { + return sortVersions(versions).map((value) => ({ value })) +} + +export function containerVersionsToOptions(versions: string[]): VersionOption[] { + return versionsToOptions(versions).map((option) => + option.value === 'latest' + ? { + ...option, + isDisabled: true, + disabledReason: 'Image tag cannot be latest to ensure consistent deployment', + } + : option + ) +} + +export function getFirstDeployableVersion(service: DeployByVersionService): VersionOption | undefined { + return service.versions.find(({ isDisabled }) => !isDisabled) +} + +export function createInitialSelections(services: DeployByVersionService[]): ServiceVersionSelections { + return Object.fromEntries( + services.map((service) => { + const latestVersion = getFirstDeployableVersion(service)?.value ?? '' + return [ + service.id, + { + selected: !service.isSkipped && Boolean(latestVersion) && latestVersion !== service.currentVersion, + version: latestVersion, + }, + ] + }) + ) +} + +export function buildDeployByVersionPayload( + services: DeployByVersionService[], + selections: ServiceVersionSelections +): DeployAllRequest { + return services.reduce((payload, service) => { + const selection = selections[service.id] + const selectedVersion = service.versions.find(({ value }) => value === selection?.version) + if (!selection?.selected || !selectedVersion || selectedVersion.isDisabled || service.isSkipped) return payload + + return match(service) + .with({ serviceType: 'APPLICATION', sourceType: 'git' }, ({ id }) => ({ + ...payload, + applications: [...(payload.applications ?? []), { application_id: id, git_commit_id: selection.version }], + })) + .with({ serviceType: 'CONTAINER', sourceType: 'container' }, ({ id }) => ({ + ...payload, + containers: [...(payload.containers ?? []), { id, image_tag: selection.version }], + })) + .with({ serviceType: 'JOB', sourceType: 'git' }, ({ id }) => ({ + ...payload, + jobs: [...(payload.jobs ?? []), { id, git_commit_id: selection.version }], + })) + .with({ serviceType: 'JOB', sourceType: 'container' }, ({ id }) => ({ + ...payload, + jobs: [...(payload.jobs ?? []), { id, image_tag: selection.version }], + })) + .with({ serviceType: 'HELM', sourceType: 'git' }, ({ id }) => ({ + ...payload, + helms: [...(payload.helms ?? []), { id, git_commit_id: selection.version }], + })) + .with({ serviceType: 'HELM', sourceType: 'helm-repository' }, ({ id }) => ({ + ...payload, + helms: [...(payload.helms ?? []), { id, chart_version: selection.version }], + })) + .with({ serviceType: 'TERRAFORM', sourceType: 'git' }, ({ id }) => ({ + ...payload, + terraforms: [...(payload.terraforms ?? []), { id, git_commit_id: selection.version }], + })) + .exhaustive() + }, {}) +} + +export function countDeployByVersionServices(payload: DeployAllRequest): number { + return ( + (payload.applications?.length ?? 0) + + (payload.containers?.length ?? 0) + + (payload.jobs?.length ?? 0) + + (payload.helms?.length ?? 0) + + (payload.terraforms?.length ?? 0) + ) +} diff --git a/libs/domains/environments/feature/src/lib/deploy-by-version-modal/service-version-row.tsx b/libs/domains/environments/feature/src/lib/deploy-by-version-modal/service-version-row.tsx new file mode 100644 index 00000000000..af7f1722cff --- /dev/null +++ b/libs/domains/environments/feature/src/lib/deploy-by-version-modal/service-version-row.tsx @@ -0,0 +1,230 @@ +// eslint-disable-next-line @nx/enforce-module-boundaries +import { ServiceAvatar } from '@qovery/domains/services/feature' +import { Button, Checkbox, DropdownMenu, Icon, Tooltip, Truncate } from '@qovery/shared/ui' +import { twMerge } from '@qovery/shared/util-js' +import { + type DeployByVersionService, + type ServiceVersionSelections, + type VersionOption, + getFirstDeployableVersion, +} from './deploy-by-version' + +function Version({ service, value, latest }: { service: DeployByVersionService; value?: string; latest?: boolean }) { + if (!value) return Not deployed + + return ( + + {service.sourceType === 'git' && ( + + )} + + {latest ? 'Latest' : service.sourceType === 'git' ? value.slice(0, 7) : value} + + + ) +} + +function VersionServiceAvatar({ service }: { service: DeployByVersionService }) { + const props = { + size: 'custom' as const, + radius: 'none' as const, + serviceAvatarRadius: 'sm' as const, + className: 'h-5 w-5', + } + + if (service.serviceType === 'JOB') { + if (!service.jobType) return null + return ( + + ) + } + + return +} + +function VersionOptionButton({ + option, + isGit, + selected, + onChange, +}: { + option: VersionOption + isGit: boolean + selected: boolean + onChange: (version: string) => void +}) { + return ( + onChange(option.value)} + > + + {isGit ? option.value.slice(0, 7) : option.value} + {selected && } + + {(option.message || option.disabledReason) && ( + + {option.message ?? option.disabledReason} + + )} + + ) +} + +function VersionOptions({ + service, + value, + onChange, +}: { + service: DeployByVersionService + value: string + onChange: (version: string) => void +}) { + return service.versions.map((option) => ( + + )) +} + +function VersionSelector({ + service, + value, + selected, + disabled, + onChange, +}: { + service: DeployByVersionService + value: string + selected: boolean + disabled: boolean + onChange: (version: string) => void +}) { + return ( + + + + + + + + + ) +} + +export function ServiceVersionRow({ + service, + selection, + onToggle, + onVersionChange, +}: { + service: DeployByVersionService + selection: ServiceVersionSelections[string] + onToggle: () => void + onVersionChange: (version: string) => void +}) { + const hasVersions = Boolean(getFirstDeployableVersion(service)) + const disabled = service.isSkipped || !hasVersions + const tooltip = service.isSkipped + ? 'This service is skipped and cannot be deployed at environment level.' + : service.hasVersionError + ? 'Versions could not be loaded for this service.' + : 'No versions are available for this service.' + + return ( + +
  • + + +
    + + + + + +
    +
  • +
    + ) +} diff --git a/libs/domains/environments/feature/src/lib/deploy-by-version-modal/use-deploy-by-version-services.ts b/libs/domains/environments/feature/src/lib/deploy-by-version-modal/use-deploy-by-version-services.ts new file mode 100644 index 00000000000..90c68554ee1 --- /dev/null +++ b/libs/domains/environments/feature/src/lib/deploy-by-version-modal/use-deploy-by-version-services.ts @@ -0,0 +1,150 @@ +import { useQueries, useQuery } from '@tanstack/react-query' +import { useMemo } from 'react' +import { match } from 'ts-pattern' +import { queries } from '@qovery/state/util-queries' +import { useListDeploymentStages } from '../hooks/use-list-deployment-stages/use-list-deployment-stages' +import { + type ContainerVersionedService, + type DeployByVersionService, + type GitVersionedService, + type HelmRepositoryVersionedService, + type VersionedService, + commitsToVersionOptions, + containerVersionsToOptions, + toVersionedService, + versionsToOptions, +} from './deploy-by-version' + +interface UseDeployByVersionServicesProps { + environmentId: string + organizationId: string +} + +const queryOptions = { + staleTime: 3 * 60 * 1000, + retry: false, + retryOnMount: true, + refetchOnWindowFocus: false, +} as const + +function isGitService(service: VersionedService): service is GitVersionedService { + return service.sourceType === 'git' +} + +function isContainerService(service: VersionedService): service is ContainerVersionedService { + return service.sourceType === 'container' +} + +function isHelmRepositoryService(service: VersionedService): service is HelmRepositoryVersionedService { + return service.sourceType === 'helm-repository' +} + +export function useDeployByVersionServices({ environmentId, organizationId }: UseDeployByVersionServicesProps) { + const servicesQuery = useQuery({ + ...queries.services.list(environmentId), + refetchOnWindowFocus: false, + }) + const deploymentStagesQuery = useListDeploymentStages({ environmentId }) + + const versionedServices = useMemo( + () => servicesQuery.data?.map(toVersionedService).filter((service) => service !== undefined) ?? [], + [servicesQuery.data] + ) + const gitServices = versionedServices.filter(isGitService) + const containerServices = versionedServices.filter(isContainerService) + const helmRepositoryServices = versionedServices.filter(isHelmRepositoryService) + + const gitQueries = useQueries({ + queries: gitServices.map((service) => ({ + ...match(service.serviceType) + .with('HELM', () => queries.services.listCommits({ serviceId: service.id, serviceType: 'HELM', of: 'chart' })) + .with('APPLICATION', () => queries.services.listCommits({ serviceId: service.id, serviceType: 'APPLICATION' })) + .with('JOB', () => queries.services.listCommits({ serviceId: service.id, serviceType: 'JOB' })) + .with('TERRAFORM', () => queries.services.listCommits({ serviceId: service.id, serviceType: 'TERRAFORM' })) + .exhaustive(), + ...queryOptions, + })), + }) + + const containerQueries = useQueries({ + queries: containerServices.map(({ containerRegistryId, imageName }) => ({ + ...queries.organizations.containerVersions({ organizationId, containerRegistryId, imageName }), + ...queryOptions, + })), + }) + + const helmRepositoryQueries = useQueries({ + queries: helmRepositoryServices.map(({ helmRepositoryId, chartName }) => ({ + ...queries.serviceHelm.helmCharts({ organizationId, helmRepositoryId, chartName }), + ...queryOptions, + })), + }) + + const data = useMemo(() => { + const skippedServiceIds = new Set( + deploymentStagesQuery.data?.flatMap(({ services = [] }) => + services.filter(({ is_skipped }) => is_skipped).map(({ service_id }) => service_id) + ) ?? [] + ) + + const gitResults = new Map(gitServices.map((service, index) => [service.id, gitQueries[index]])) + const containerResults = new Map(containerServices.map((service, index) => [service.id, containerQueries[index]])) + const helmResults = new Map( + helmRepositoryServices.map((service, index) => [service.id, helmRepositoryQueries[index]]) + ) + + return versionedServices.map((service): DeployByVersionService => { + const result = match(service) + .with({ sourceType: 'git' }, ({ id }) => { + const query = gitResults.get(id) + return { + versions: commitsToVersionOptions(query?.data ?? []), + hasVersionError: query?.isError, + } + }) + .with({ sourceType: 'container' }, ({ id, imageName }) => { + const query = containerResults.get(id) + const versions = query?.data?.find(({ image_name }) => image_name === imageName)?.versions ?? [] + return { + versions: containerVersionsToOptions(versions), + hasVersionError: query?.isError, + } + }) + .with({ sourceType: 'helm-repository' }, ({ id, chartName }) => { + const query = helmResults.get(id) + const versions = query?.data?.find(({ chart_name }) => chart_name === chartName)?.versions ?? [] + return { + versions: versionsToOptions(versions), + hasVersionError: query?.isError, + } + }) + .exhaustive() + + return { + ...service, + ...result, + isSkipped: skippedServiceIds.has(service.id), + } + }) + }, [ + containerQueries, + containerServices, + deploymentStagesQuery.data, + gitQueries, + gitServices, + helmRepositoryQueries, + helmRepositoryServices, + versionedServices, + ]) + + return { + data, + isLoading: + servicesQuery.isLoading || + deploymentStagesQuery.isLoading || + gitQueries.some(({ isLoading }) => isLoading) || + containerQueries.some(({ isLoading }) => isLoading) || + helmRepositoryQueries.some(({ isLoading }) => isLoading), + isError: servicesQuery.isError || deploymentStagesQuery.isError, + } +} diff --git a/libs/domains/environments/feature/src/lib/environment-action-toolbar/__snapshots__/environment-action-toolbar.spec.tsx.snap b/libs/domains/environments/feature/src/lib/environment-action-toolbar/__snapshots__/environment-action-toolbar.spec.tsx.snap index a72dfac10e4..33dadd0fb80 100644 --- a/libs/domains/environments/feature/src/lib/environment-action-toolbar/__snapshots__/environment-action-toolbar.spec.tsx.snap +++ b/libs/domains/environments/feature/src/lib/environment-action-toolbar/__snapshots__/environment-action-toolbar.spec.tsx.snap @@ -173,9 +173,9 @@ exports[`EnvironmentActionToolbar should match manage deployment snapshot 1`] = >