Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0a5afc6
feat(use-cases): add reusable bottom bar baseline
TheoGrandin74 Mar 23, 2026
91c3c4d
refactor(root-layout): remove TanStackRouterDevtools for cleaner stru…
TheoGrandin74 Mar 23, 2026
160849d
fix(use-cases): resolve branch metadata from git in vite
TheoGrandin74 Mar 23, 2026
99cac87
fix(use-cases): port select styles for bottom bar use-case input
TheoGrandin74 Mar 23, 2026
638e8ab
fix(use-cases): handle empty use case options in bottom bar
TheoGrandin74 Mar 24, 2026
57cc5c7
fix(use-cases): update styles for bottom bar input components
TheoGrandin74 Mar 24, 2026
c5fc832
feat(use-cases): implement UseCaseBottomBar and context for managing …
TheoGrandin74 Mar 9, 2026
e6ebb4d
feat(tailwind): add negativeInvert color tokens and update styles for…
TheoGrandin74 Mar 9, 2026
f7e9141
refactor(vite.config): remove Git branch resolution logic to simplify…
TheoGrandin74 Mar 9, 2026
b84845e
feat(service-variables): enhance service variables management with ne…
TheoGrandin74 Mar 9, 2026
9352d47
feat(secret-managers): enhance secret manager options with type label…
TheoGrandin74 Mar 9, 2026
ac332f8
feat(environment-toolbar): add variant prop to MenuOtherActions and i…
TheoGrandin74 Mar 19, 2026
594e4e5
feat(secret-managers): integrate secret manager associated services a…
TheoGrandin74 Mar 19, 2026
f65ae6f
feat(step-addons): import SecretManagerOption type and enhance variab…
TheoGrandin74 Mar 20, 2026
66691a2
Rebase conflicts
TheoGrandin74 Mar 24, 2026
f9fc28b
Fix bugs
TheoGrandin74 Mar 23, 2026
f1f788f
feat(variables-action-toolbar): add importEnvFileAccess prop to contr…
TheoGrandin74 Mar 23, 2026
f191571
fix(vite.config): update process.env assignment to use clientEnv for …
TheoGrandin74 Mar 24, 2026
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
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