Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d6b315b
feat(use-cases): add reusable bottom bar baseline
TheoGrandin74 Mar 23, 2026
4be1790
refactor(root-layout): remove TanStackRouterDevtools for cleaner stru…
TheoGrandin74 Mar 23, 2026
b4c2564
fix(use-cases): resolve branch metadata from git in vite
TheoGrandin74 Mar 23, 2026
ade91e4
fix(use-cases): port select styles for bottom bar use-case input
TheoGrandin74 Mar 23, 2026
88e51d2
fix(use-cases): handle empty use case options in bottom bar
TheoGrandin74 Mar 24, 2026
907b25d
fix(use-cases): update styles for bottom bar input components
TheoGrandin74 Mar 24, 2026
ba03452
feat(use-cases): implement UseCaseBottomBar and context for managing …
TheoGrandin74 Mar 9, 2026
fc3e94c
feat(tailwind): add negativeInvert color tokens and update styles for…
TheoGrandin74 Mar 9, 2026
52cfc3e
refactor(vite.config): remove Git branch resolution logic to simplify…
TheoGrandin74 Mar 9, 2026
1a4a472
feat(service-variables): enhance service variables management with ne…
TheoGrandin74 Mar 9, 2026
0de5755
feat(secret-managers): enhance secret manager options with type label…
TheoGrandin74 Mar 9, 2026
df1eeb9
feat(environment-toolbar): add variant prop to MenuOtherActions and i…
TheoGrandin74 Mar 19, 2026
3355725
feat(secret-managers): integrate secret manager associated services a…
TheoGrandin74 Mar 19, 2026
d8853e6
feat(step-addons): import SecretManagerOption type and enhance variab…
TheoGrandin74 Mar 20, 2026
c72177e
Rebase conflicts
TheoGrandin74 Mar 24, 2026
ba5a1fb
Fix bugs
TheoGrandin74 Mar 23, 2026
50a5c7f
feat(variables-action-toolbar): add importEnvFileAccess prop to contr…
TheoGrandin74 Mar 23, 2026
d931317
fix(vite.config): update process.env assignment to use clientEnv for …
TheoGrandin74 Mar 24, 2026
9c66258
feat(secret-manager): correct automatic/assume roles behavior, add in…
TheoGrandin74 Mar 30, 2026
1ecab4c
refactor: clean up code formatting and improve readability in various…
TheoGrandin74 Mar 30, 2026
4a2f3d1
fix(create-update-variable-modal): update warning message and icon fo…
TheoGrandin74 Mar 30, 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
Loading