From 487a79ca2b776956dd3b3d21cd2599ecfc1d84a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Thu, 26 Mar 2026 15:27:57 +0100 Subject: [PATCH 1/4] feat(service-avatar): enhance ServiceAvatar component with customizable radius and update related components --- .../header/breadcrumbs/breadcrumbs.tsx | 4 ++- .../service-avatar-switcher.tsx | 19 ++++++++----- .../src/lib/service-avatar/service-avatar.tsx | 9 ++++-- .../src/lib/service-icon/service-icon.tsx | 28 +++++++++++++++---- .../src/lib/service-new/service-new.tsx | 27 ++++++++---------- .../service-header/service-header.tsx | 3 +- .../components/status-chip/status-chip.tsx | 2 +- 7 files changed, 60 insertions(+), 32 deletions(-) diff --git a/apps/console-v5/src/app/components/header/breadcrumbs/breadcrumbs.tsx b/apps/console-v5/src/app/components/header/breadcrumbs/breadcrumbs.tsx index b93a415ee6d..457cc4dcc07 100644 --- a/apps/console-v5/src/app/components/header/breadcrumbs/breadcrumbs.tsx +++ b/apps/console-v5/src/app/components/header/breadcrumbs/breadcrumbs.tsx @@ -91,7 +91,9 @@ export function Breadcrumbs() { to: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/overview', params: { organizationId, projectId, environmentId, serviceId: service.id }, }).href, - prefix: , + prefix: ( + + ), suffix: , })) diff --git a/libs/domains/services/feature/src/lib/service-avatar-switcher/service-avatar-switcher.tsx b/libs/domains/services/feature/src/lib/service-avatar-switcher/service-avatar-switcher.tsx index bfc78bd7985..72a2ada58b3 100644 --- a/libs/domains/services/feature/src/lib/service-avatar-switcher/service-avatar-switcher.tsx +++ b/libs/domains/services/feature/src/lib/service-avatar-switcher/service-avatar-switcher.tsx @@ -43,7 +43,7 @@ export function ServiceAvatarSwitcher({ onChange, service }: ServiceAvatarSwitch setSearchTerm(value)} autofocus /> {filteredIcons.length > 0 ? (
- {filteredIcons.map(({ icon, title, uri }) => { + {filteredIcons.map(({ icon, title, uri, className }) => { const isSelected = uri === service.icon_uri // XXX: corner case as application and container have the same icon, we want to hide one of them. if (uri === 'app://qovery-console/container') { @@ -52,18 +52,23 @@ export function ServiceAvatarSwitcher({ onChange, service }: ServiceAvatarSwitch return ( - {title} handleClick(uri)} - /> + > + {title} + ) diff --git a/libs/domains/services/feature/src/lib/service-avatar/service-avatar.tsx b/libs/domains/services/feature/src/lib/service-avatar/service-avatar.tsx index 21fdf73f223..873d443493b 100644 --- a/libs/domains/services/feature/src/lib/service-avatar/service-avatar.tsx +++ b/libs/domains/services/feature/src/lib/service-avatar/service-avatar.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx' import { type ComponentPropsWithoutRef, type ElementRef, forwardRef } from 'react' import { match } from 'ts-pattern' import { type AnyService } from '@qovery/domains/services/data-access' @@ -6,8 +7,11 @@ import { type IconURI, ServiceIcons } from '../service-icon/service-icon' // XXX: Todo remove `job_type` // https://qovery.atlassian.net/jira/software/projects/FRT/boards/23?selectedIssue=FRT-1427 +type ServiceAvatarRadius = 'none' | 'sm' | 'md' | 'full' + export interface ServiceAvatarProps extends Omit, 'fallback' | 'size'> { size?: ComponentPropsWithoutRef['size'] | 'custom' + serviceAvatarRadius?: ServiceAvatarRadius service: | { icon_uri: string @@ -22,7 +26,7 @@ export interface ServiceAvatarProps extends Omit, ServiceAvatarProps>(function ServiceAvatar( - { service, size, className, ...props }, + { service, size, className, radius, serviceAvatarRadius = 'full', ...props }, ref ) { const iconName = match(service) @@ -40,6 +44,7 @@ export const ServiceAvatar = forwardRef, ServiceAvatar , ServiceAvatar alt={service.serviceType} height="100%" width="100%" - className="max-h-full max-w-full rounded-full object-contain" + className={clsx('max-h-full max-w-full object-contain', `rounded-${serviceAvatarRadius}`, serviceAvatar.className)} /> ) : ( diff --git a/libs/domains/services/feature/src/lib/service-icon/service-icon.tsx b/libs/domains/services/feature/src/lib/service-icon/service-icon.tsx index 95daacd4861..3bd9fbad2df 100644 --- a/libs/domains/services/feature/src/lib/service-icon/service-icon.tsx +++ b/libs/domains/services/feature/src/lib/service-icon/service-icon.tsx @@ -39,6 +39,12 @@ import Svelte from 'devicon/icons/svelte/svelte-original.svg' import Terraform from 'devicon/icons/terraform/terraform-original.svg' import Vue from 'devicon/icons/vuejs/vuejs-original.svg' +type ServiceIcon = { + icon: string + title: string + className?: string +} + const Qovery = '/assets/logos/logo-icon.svg' const Datadog = '/assets/devicon/datadog.svg' const Crossplane = '/assets/devicon/crossplane.svg' @@ -67,7 +73,7 @@ export const ServiceIcons = { // Devicons 'app://qovery-console/apache': { icon: Apache, title: 'Apache' }, 'app://qovery-console/apacheairflow': { icon: ApacheAirflow, title: 'Apache Airflow' }, - 'app://qovery-console/apachekafka': { icon: ApacheKafka, title: 'Apache Kafka' }, + 'app://qovery-console/apachekafka': { icon: ApacheKafka, title: 'Apache Kafka', className: 'dark:invert' }, 'app://qovery-console/angular': { icon: Angular, title: 'Angular' }, 'app://qovery-console/aws': { icon: AWS, title: 'AWS' }, 'app://qovery-console/azure': { icon: Azure, title: 'Azure' }, @@ -77,7 +83,7 @@ export const ServiceIcons = { 'app://qovery-console/docker': { icon: Docker, title: 'Docker' }, 'app://qovery-console/elasticsearch': { icon: Elasticsearch, title: 'Elasticsearch' }, 'app://qovery-console/fastapi': { icon: FastAPI, title: 'FastAPI' }, - 'app://qovery-console/flask': { icon: Flask, title: 'Flask' }, + 'app://qovery-console/flask': { icon: Flask, title: 'Flask', className: 'dark:invert' }, 'app://qovery-console/gcp': { icon: GCP, title: 'GCP' }, 'app://qovery-console/golang': { icon: Golang, title: 'Golang' }, 'app://qovery-console/grafana': { icon: Grafana, title: 'Grafana' }, @@ -100,7 +106,7 @@ export const ServiceIcons = { 'app://qovery-console/react': { icon: React, title: 'React' }, 'app://qovery-console/redis': { icon: Redis, title: 'Redis' }, 'app://qovery-console/ruby': { icon: Ruby, title: 'Ruby' }, - 'app://qovery-console/rust': { icon: Rust, title: 'Rust' }, + 'app://qovery-console/rust': { icon: Rust, title: 'Rust', className: 'dark:invert' }, 'app://qovery-console/spring': { icon: Spring, title: 'Spring' }, 'app://qovery-console/svelte': { icon: Svelte, title: 'Svelte' }, 'app://qovery-console/terraform': { icon: Terraform, title: 'Terraform' }, @@ -116,10 +122,22 @@ export const ServiceIcons = { 'app://qovery-console/kubecost': { icon: Kubecost, title: 'Kubecost' }, 'app://qovery-console/qovery': { icon: Qovery, title: 'Qovery' }, 'app://qovery-console/scaleway': { icon: Scaleway, title: 'Scaleway' }, - 'app://qovery-console/temporal': { icon: Temporal, title: 'Temporal' }, + 'app://qovery-console/temporal': { icon: Temporal, title: 'Temporal', className: 'dark:invert' }, 'app://qovery-console/windmill': { icon: Windmill, title: 'Windmill' }, 'app://qovery-console/lambda': { icon: Lambda, title: 'Lambda' }, 'app://qovery-console/s3': { icon: S3, title: 'S3' }, -} as const +} as const satisfies Record export type IconURI = keyof typeof ServiceIcons + +export function isServiceIconURI(iconUri: string): iconUri is IconURI { + return iconUri in ServiceIcons +} + +export function getServiceIconClassName(iconUri?: string): string | undefined { + if (!iconUri || !isServiceIconURI(iconUri)) { + return undefined + } + + return ServiceIcons[iconUri].className +} diff --git a/libs/domains/services/feature/src/lib/service-new/service-new.tsx b/libs/domains/services/feature/src/lib/service-new/service-new.tsx index 224cf05a898..8fe891aa5e4 100644 --- a/libs/domains/services/feature/src/lib/service-new/service-new.tsx +++ b/libs/domains/services/feature/src/lib/service-new/service-new.tsx @@ -23,11 +23,10 @@ import { type TagsEnum, serviceTemplates, } from './service-templates' +import { getServiceIconClassName } from '../service-icon/service-icon' const CloudFormationIcon = '/assets/devicon/cloudformation.svg' -const SERVICE_TEMPLATE_ICON_INVERT_DARK_TITLES = new Set(['Apache Kafka', 'Replibyte', 'Rust', 'Flask', 'Temporal']) - const getEnvironmentBasePath = (organizationId: string, projectId: string, environmentId: string) => `/organization/${organizationId}/project/${projectId}/environment/${environmentId}` @@ -196,12 +195,14 @@ function CardOption({ recommended, badge, template_id, + icon_uri, organizationId, projectId, environmentId, isTerraformFeatureFlag, onUpgradePlanClick, }: CardOptionProps) { + const iconClassName = getServiceIconClassName(icon_uri) const pathSuffix = servicePathSuffix(type, parentSlug, slug) const to = pathSuffix ? getServicesPath(organizationId, projectId, environmentId, pathSuffix) : undefined @@ -216,7 +217,7 @@ function CardOption({ onKeyDown={(e) => e.key === 'Enter' && onUpgradePlanClick()} className="flex cursor-pointer items-start gap-3 rounded-sm border border-neutral bg-surface-neutral-subtle p-3 transition hover:bg-surface-neutral-componentHover" > - {title} + {title} {title} @@ -256,7 +257,7 @@ function CardOption({ }) } > - {title} + {title} {title} @@ -299,6 +300,7 @@ function CardService({ projectId, environmentId, cloudProvider, + icon_uri, isTerraformFeatureFlag, onUpgradePlanClick, }: Omit & { @@ -312,6 +314,7 @@ function CardService({ onUpgradePlanClick?: () => void }) { const [expanded, setExpanded] = useState(false) + const iconClassName = getServiceIconClassName(icon_uri) if (options && !slug) { return null @@ -331,7 +334,7 @@ function CardService({
{typeof icon === 'string' ? ( {title} ) : ( cloneElement(icon as ReactElement, { - className: twMerge('w-10', SERVICE_TEMPLATE_ICON_INVERT_DARK_TITLES.has(title) && 'dark:invert'), + className: twMerge('w-10', iconClassName), }) )} @@ -440,16 +440,13 @@ function CardService({
{typeof icon === 'string' ? ( {title} ) : ( cloneElement(icon as ReactElement, { - className: twMerge('w-10', SERVICE_TEMPLATE_ICON_INVERT_DARK_TITLES.has(title) && 'dark:invert'), + className: twMerge('w-10', iconClassName), }) )}
diff --git a/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx b/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx index 83f0dfdc992..be422ebe947 100644 --- a/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx +++ b/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx @@ -108,8 +108,9 @@ function ServiceHeaderContent({ environment, serviceId, service }: ServiceHeader
) - .with('UNAVAILABLE', () => ) + .with('UNAVAILABLE', () => ) .exhaustive() return ( From 8adf29ee316a198fa90286f8b72faaab7ee4239e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Thu, 26 Mar 2026 16:52:36 +0100 Subject: [PATCH 2/4] refactor(service-icon): remove unused functions and simplify icon class name retrieval in service-new component --- .../src/lib/service-avatar/service-avatar.tsx | 6 +++++- .../src/lib/service-icon/service-icon.tsx | 12 ------------ .../src/lib/service-new/service-new.tsx | 18 +++++------------- 3 files changed, 10 insertions(+), 26 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-avatar/service-avatar.tsx b/libs/domains/services/feature/src/lib/service-avatar/service-avatar.tsx index 873d443493b..961256a8ad9 100644 --- a/libs/domains/services/feature/src/lib/service-avatar/service-avatar.tsx +++ b/libs/domains/services/feature/src/lib/service-avatar/service-avatar.tsx @@ -53,7 +53,11 @@ export const ServiceAvatar = forwardRef, ServiceAvatar alt={service.serviceType} height="100%" width="100%" - className={clsx('max-h-full max-w-full object-contain', `rounded-${serviceAvatarRadius}`, serviceAvatar.className)} + className={clsx( + 'max-h-full max-w-full object-contain', + `rounded-${serviceAvatarRadius}`, + serviceAvatar.className + )} /> ) : ( diff --git a/libs/domains/services/feature/src/lib/service-icon/service-icon.tsx b/libs/domains/services/feature/src/lib/service-icon/service-icon.tsx index 3bd9fbad2df..31670f60ef9 100644 --- a/libs/domains/services/feature/src/lib/service-icon/service-icon.tsx +++ b/libs/domains/services/feature/src/lib/service-icon/service-icon.tsx @@ -129,15 +129,3 @@ export const ServiceIcons = { } as const satisfies Record export type IconURI = keyof typeof ServiceIcons - -export function isServiceIconURI(iconUri: string): iconUri is IconURI { - return iconUri in ServiceIcons -} - -export function getServiceIconClassName(iconUri?: string): string | undefined { - if (!iconUri || !isServiceIconURI(iconUri)) { - return undefined - } - - return ServiceIcons[iconUri].className -} diff --git a/libs/domains/services/feature/src/lib/service-new/service-new.tsx b/libs/domains/services/feature/src/lib/service-new/service-new.tsx index 8fe891aa5e4..84e9305ac11 100644 --- a/libs/domains/services/feature/src/lib/service-new/service-new.tsx +++ b/libs/domains/services/feature/src/lib/service-new/service-new.tsx @@ -17,13 +17,13 @@ import { Badge, Button, ExternalLink, Heading, Icon, InputSearch, Link, Section import { useSupportChat } from '@qovery/shared/util-hooks' import { twMerge } from '@qovery/shared/util-js' import { TemplateIds } from '@qovery/shared/util-services' +import { ServiceIcons } from '../service-icon/service-icon' import { type ServiceTemplateOptionType, type ServiceTemplateType, type TagsEnum, serviceTemplates, } from './service-templates' -import { getServiceIconClassName } from '../service-icon/service-icon' const CloudFormationIcon = '/assets/devicon/cloudformation.svg' @@ -202,7 +202,7 @@ function CardOption({ isTerraformFeatureFlag, onUpgradePlanClick, }: CardOptionProps) { - const iconClassName = getServiceIconClassName(icon_uri) + const iconClassName = ServiceIcons[icon_uri].className const pathSuffix = servicePathSuffix(type, parentSlug, slug) const to = pathSuffix ? getServicesPath(organizationId, projectId, environmentId, pathSuffix) : undefined @@ -314,7 +314,7 @@ function CardService({ onUpgradePlanClick?: () => void }) { const [expanded, setExpanded] = useState(false) - const iconClassName = getServiceIconClassName(icon_uri) + const iconClassName = icon_uri ? ServiceIcons[icon_uri].className : undefined if (options && !slug) { return null @@ -399,11 +399,7 @@ function CardService({
{typeof icon === 'string' ? ( - {title} + {title} ) : ( cloneElement(icon as ReactElement, { className: twMerge('w-10', iconClassName), @@ -439,11 +435,7 @@ function CardService({
{typeof icon === 'string' ? ( - {title} + {title} ) : ( cloneElement(icon as ReactElement, { className: twMerge('w-10', iconClassName), From 97e00015893101453867afbac6dcfca85e4105d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Thu, 26 Mar 2026 17:14:41 +0100 Subject: [PATCH 3/4] refactor(service-icon): rename and restructure ServiceIcons for improved clarity and type safety --- .../feature/src/lib/service-icon/service-icon.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-icon/service-icon.tsx b/libs/domains/services/feature/src/lib/service-icon/service-icon.tsx index 31670f60ef9..d7b6c84abcf 100644 --- a/libs/domains/services/feature/src/lib/service-icon/service-icon.tsx +++ b/libs/domains/services/feature/src/lib/service-icon/service-icon.tsx @@ -59,7 +59,7 @@ const EC2 = '/assets/devicon/ec2.svg' const Lambda = '/assets/devicon/lambda.svg' const S3 = '/assets/devicon/s3.svg' -export const ServiceIcons = { +const serviceIcons = { 'app://qovery-console/lifecycle-job': { icon: '/assets/services/lifecycle-job.svg', title: 'LifecycleJob' }, 'app://qovery-console/cron-job': { icon: '/assets/services/cron-job.svg', title: 'CronJob' }, 'app://qovery-console/container': { icon: '/assets/services/application.svg', title: 'Container' }, @@ -126,6 +126,8 @@ export const ServiceIcons = { 'app://qovery-console/windmill': { icon: Windmill, title: 'Windmill' }, 'app://qovery-console/lambda': { icon: Lambda, title: 'Lambda' }, 'app://qovery-console/s3': { icon: S3, title: 'S3' }, -} as const satisfies Record +} as const -export type IconURI = keyof typeof ServiceIcons +export type IconURI = keyof typeof serviceIcons + +export const ServiceIcons: Readonly> = serviceIcons From 3001abf84ad2503787f163ea9c3dc9258a5492dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Fri, 27 Mar 2026 14:58:58 +0100 Subject: [PATCH 4/4] fix(service-icon): add className for Bash icon to support dark mode styling --- .../services/feature/src/lib/service-icon/service-icon.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/domains/services/feature/src/lib/service-icon/service-icon.tsx b/libs/domains/services/feature/src/lib/service-icon/service-icon.tsx index d7b6c84abcf..06b39298323 100644 --- a/libs/domains/services/feature/src/lib/service-icon/service-icon.tsx +++ b/libs/domains/services/feature/src/lib/service-icon/service-icon.tsx @@ -77,7 +77,7 @@ const serviceIcons = { 'app://qovery-console/angular': { icon: Angular, title: 'Angular' }, 'app://qovery-console/aws': { icon: AWS, title: 'AWS' }, 'app://qovery-console/azure': { icon: Azure, title: 'Azure' }, - 'app://qovery-console/bash': { icon: Bash, title: 'Bash' }, + 'app://qovery-console/bash': { icon: Bash, title: 'Bash', className: 'dark:invert' }, 'app://qovery-console/cloudflare': { icon: Cloudflare, title: 'Cloudflare' }, 'app://qovery-console/couchbase': { icon: Couchbase, title: 'Couchbase' }, 'app://qovery-console/docker': { icon: Docker, title: 'Docker' },