Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useParams, useRouter } from '@tanstack/react-router'
import { useMemo } from 'react'
import { ClusterAvatar, useClusters } from '@qovery/domains/clusters/feature'
import { EnvironmentMode, useEnvironments } from '@qovery/domains/environments/feature'
import { EnvironmentMode, isFakeArgoCdService, useEnvironments } from '@qovery/domains/environments/feature'
import { useOrganization, useOrganizations } from '@qovery/domains/organizations/feature'
import { useProjects } from '@qovery/domains/projects/feature'
import { ServiceAvatar, ServiceStateChip, useServices } from '@qovery/domains/services/feature'
Expand Down Expand Up @@ -84,18 +84,29 @@ export function Breadcrumbs() {

const serviceItems: BreadcrumbItemData[] = services
.sort((a, b) => a.name.trim().localeCompare(b.name.trim()))
.map((service) => ({
id: service.id,
label: service.name,
path: buildLocation({
to: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/overview',
params: { organizationId, projectId, environmentId, serviceId: service.id },
}).href,
prefix: (
<ServiceAvatar service={service} size="custom" className="h-5 w-5" serviceAvatarRadius="sm" radius="none" />
),
suffix: <ServiceStateChip mode="running" environmentId={service.environment?.id} serviceId={service.id} />,
}))
.map((service) => {
const serviceEnvironmentId = service.environment?.id ?? environmentId
const isArgoCdService =
Boolean(serviceEnvironmentId) &&
isFakeArgoCdService({
environmentId: serviceEnvironmentId,
serviceId: service.id,
})
const serviceAvatar = isArgoCdService ? { ...service, icon_uri: 'app://qovery-console/argocd' } : service

return {
id: service.id,
label: service.name,
path: buildLocation({
to: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/overview',
params: { organizationId, projectId, environmentId, serviceId: service.id },
}).href,
prefix: (
<ServiceAvatar service={service} size="custom" className="h-5 w-5" serviceAvatarRadius="sm" radius="none" />
),
suffix: <ServiceStateChip mode="running" environmentId={service.environment?.id} serviceId={service.id} />,
}
})

const currentCluster = useMemo(
() => clusterItems.find((cluster) => cluster.id === clusterId),
Expand Down
122 changes: 122 additions & 0 deletions apps/console-v5/src/app/components/use-cases/use-case-bottom-bar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { useLocation, useMatches } from '@tanstack/react-router'
import { Icon, InputSelect, Tooltip } from '@qovery/shared/ui'
import { GIT_BRANCH, GIT_SHA } from '@qovery/shared/util-node-env'
import { useUseCases } from './use-case-context'

export function UseCaseBottomBar() {
const location = useLocation()
const matches = useMatches()
const routeId = matches[matches.length - 1]?.routeId
const scopeLabel = resolveScopeLabel(routeId)
const pageName = resolvePageName(routeId, location.pathname)
const pageLabel = `${scopeLabel} - ${pageName}`

const { activePageId, optionsByPageId, selectionsByPageId, setSelection } = useUseCases()
const useCaseOptions = activePageId ? optionsByPageId[activePageId] ?? [] : []
const selectedFromState = activePageId ? selectionsByPageId[activePageId] : undefined
const resolvedSelection =
selectedFromState && useCaseOptions.some((option) => option.id === selectedFromState)
? selectedFromState
: useCaseOptions[0]?.id

if (useCaseOptions.length === 0) {
return null
}

const branchLabel = GIT_BRANCH || 'unknown'
const commitLabel = GIT_SHA ? GIT_SHA.slice(0, 7) : undefined

return (
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-[calc(var(--modal-zindex)+1)]">
<div className="pointer-events-auto border-t border-neutral bg-background">
<div className="flex h-10 w-full items-center px-4 text-xs text-neutral">
<div className="flex h-10 min-w-0 flex-1 items-center gap-2 border-r border-neutral pr-4">
<Tooltip content="Git branch">
<span className="inline-flex h-5 w-5 items-center justify-center text-neutral-subtle">
<Icon iconName="code-branch" iconStyle="regular" />
</span>
</Tooltip>
<span className="text-xs font-semibold uppercase text-neutral-subtle">Branch</span>
<span className="min-w-0 truncate font-mono text-xs text-neutral">
{branchLabel}
{commitLabel ? ` (${commitLabel})` : ''}
</span>
</div>

<div className="flex h-10 min-w-0 flex-1 items-center gap-2 border-r border-neutral px-4">
<span className="text-xs font-semibold uppercase text-neutral-subtle">Page</span>
<span title={routeId ?? pageLabel} className="min-w-0 truncate font-mono text-xs text-neutral">
{pageLabel}
</span>
</div>

<div className="flex h-10 min-w-0 flex-1 items-center gap-2 pl-4">
<span className="text-xs font-semibold uppercase text-neutral-subtle">Use case</span>
<InputSelect
options={useCaseOptions.map((option) => ({
label: option.label,
value: option.id,
}))}
value={resolvedSelection}
onChange={(next) => {
if (activePageId && typeof next === 'string') {
setSelection(activePageId, next)
}
}}
className="min-w-0 flex-1 [&_.input-select__control]:!h-10 [&_.input-select__value-container]:!top-0 [&_.input-select__value-container]:!mt-0 [&_.input-select__value-container]:!h-10 [&_.input-select__value-container]:!items-center"
inputClassName="input--inline !min-h-0 !h-10 !border-0 !bg-transparent !px-0 !py-0 !hover:bg-transparent !outline-none focus-within:!outline-none !shadow-none"
valueClassName="text-xs font-mono text-neutral"
iconClassName="right-0"
/>
</div>
</div>
</div>
</div>
)
}

export default UseCaseBottomBar

function resolveScopeLabel(routeId?: string) {
if (!routeId) {
return 'Org'
}

if (routeId.includes('/service/$serviceId')) {
return 'Service'
}

if (routeId.includes('/environment/$environmentId')) {
return 'Env'
}

if (routeId.includes('/project/$projectId')) {
return 'Project'
}

if (routeId.includes('/organization/$organizationId')) {
return 'Org'
}

return 'Org'
}

function resolvePageName(routeId: string | undefined, pathname: string) {
if (routeId) {
const segments = routeId.split('/').filter(Boolean)
let lastSegment = segments[segments.length - 1] ?? 'index'

if (lastSegment.startsWith('$')) {
lastSegment = segments[segments.length - 2] ?? lastSegment
}

if (lastSegment === '_index' || lastSegment === 'index') {
return 'index'
}

return lastSegment
}

const pathSegments = pathname.split('/').filter(Boolean)
return pathSegments[pathSegments.length - 1] ?? 'index'
}
159 changes: 159 additions & 0 deletions apps/console-v5/src/app/components/use-cases/use-case-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import {
type ReactNode,
type SetStateAction,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'

export type UseCaseOption = {
id: string
label: string
}

type UseCaseContextValue = {
activePageId: string | null
optionsByPageId: Record<string, UseCaseOption[]>
selectionsByPageId: Record<string, string>
registerUseCases: (pageId: string, options: UseCaseOption[]) => void
setActivePageId: (pageId: SetStateAction<string | null>) => void
setSelection: (pageId: string, selectionId: string) => void
}

type UseCaseProviderProps = {
children: ReactNode
}

type UseCasePageConfig = {
pageId: string
options: UseCaseOption[]
defaultCaseId?: string
}

const STORAGE_KEY = 'qovery:use-cases'

const UseCaseContext = createContext<UseCaseContextValue | undefined>(undefined)

const areOptionsEqual = (next: UseCaseOption[], prev: UseCaseOption[]) =>
next.length === prev.length &&
next.every((option, index) => option.id === prev[index]?.id && option.label === prev[index]?.label)

const readSelections = () => {
if (typeof window === 'undefined') {
return {}
}

try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? (JSON.parse(raw) as Record<string, string>) : {}
} catch {
return {}
}
}

export function UseCaseProvider({ children }: UseCaseProviderProps) {
const [activePageId, setActivePageId] = useState<string | null>(null)
const [optionsByPageId, setOptionsByPageId] = useState<Record<string, UseCaseOption[]>>({})
const [selectionsByPageId, setSelectionsByPageId] = useState<Record<string, string>>(readSelections)

const registerUseCases = useCallback((pageId: string, options: UseCaseOption[]) => {
setOptionsByPageId((prev) => {
const existing = prev[pageId]
if (existing && areOptionsEqual(options, existing)) {
return prev
}

return {
...prev,
[pageId]: options,
}
})
}, [])

const setSelection = useCallback((pageId: string, selectionId: string) => {
setSelectionsByPageId((prev) => ({
...prev,
[pageId]: selectionId,
}))
}, [])

useEffect(() => {
if (typeof window === 'undefined') {
return
}

try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(selectionsByPageId))
} catch {
// Ignore localStorage failures (private mode, quota, etc.)
}
}, [selectionsByPageId])

const value = useMemo<UseCaseContextValue>(
() => ({
activePageId,
optionsByPageId,
selectionsByPageId,
registerUseCases,
setActivePageId,
setSelection,
}),
[activePageId, optionsByPageId, registerUseCases, selectionsByPageId, setSelection]
)

return <UseCaseContext.Provider value={value}>{children}</UseCaseContext.Provider>
}

export function useUseCases() {
const context = useContext(UseCaseContext)

if (!context) {
throw new Error('useUseCases must be used within a UseCaseProvider')
}

return context
}

export function useUseCasePage({ pageId, options, defaultCaseId }: UseCasePageConfig) {
const { registerUseCases, setActivePageId, selectionsByPageId, setSelection } = useUseCases()

useEffect(() => {
registerUseCases(pageId, options)
setActivePageId(pageId)

return () => {
setActivePageId((current) => (current === pageId ? null : current))
}
}, [options, pageId, registerUseCases, setActivePageId])

const selectedCaseId = useMemo(() => {
const selected = selectionsByPageId[pageId]
if (selected && options.some((option) => option.id === selected)) {
return selected
}

if (defaultCaseId && options.some((option) => option.id === defaultCaseId)) {
return defaultCaseId
}

return options[0]?.id ?? ''
}, [defaultCaseId, options, pageId, selectionsByPageId])

useEffect(() => {
if (!selectedCaseId) {
return
}

if (selectionsByPageId[pageId] !== selectedCaseId) {
setSelection(pageId, selectedCaseId)
}
}, [pageId, selectedCaseId, selectionsByPageId, setSelection])

return {
selectedCaseId,
setSelectedCaseId: (nextId: string) => setSelection(pageId, nextId),
}
}
Loading
Loading