Skip to content

Commit 2916cf0

Browse files
committed
feat(use-cases): add reusable bottom bar baseline
1 parent e65e956 commit 2916cf0

File tree

4 files changed

+290
-2
lines changed

4 files changed

+290
-2
lines changed
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { useLocation, useMatches } from '@tanstack/react-router'
2+
import { Icon, InputSelect, Tooltip } from '@qovery/shared/ui'
3+
import { GIT_BRANCH, GIT_SHA } from '@qovery/shared/util-node-env'
4+
import { useUseCases } from './use-case-context'
5+
6+
export function UseCaseBottomBar() {
7+
const location = useLocation()
8+
const matches = useMatches()
9+
const routeId = matches[matches.length - 1]?.routeId
10+
const scopeLabel = resolveScopeLabel(routeId)
11+
const pageName = resolvePageName(routeId, location.pathname)
12+
const pageLabel = `${scopeLabel} - ${pageName}`
13+
14+
const { activePageId, optionsByPageId, selectionsByPageId, setSelection } = useUseCases()
15+
const useCaseOptions = activePageId ? optionsByPageId[activePageId] ?? [] : []
16+
const selectedFromState = activePageId ? selectionsByPageId[activePageId] : undefined
17+
const resolvedSelection =
18+
selectedFromState && useCaseOptions.some((option) => option.id === selectedFromState)
19+
? selectedFromState
20+
: useCaseOptions[0]?.id
21+
22+
const branchLabel = GIT_BRANCH || 'unknown'
23+
const commitLabel = GIT_SHA ? GIT_SHA.slice(0, 7) : undefined
24+
25+
return (
26+
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-[calc(var(--modal-zindex)+1)]">
27+
<div className="pointer-events-auto border-t border-neutral bg-background">
28+
<div className="flex h-10 w-full items-center px-4 text-xs text-neutral">
29+
<div className="flex h-10 min-w-0 flex-1 items-center gap-2 border-r border-neutral pr-4">
30+
<Tooltip content="Git branch">
31+
<span className="inline-flex h-5 w-5 items-center justify-center text-neutral-subtle">
32+
<Icon iconName="code-branch" iconStyle="regular" />
33+
</span>
34+
</Tooltip>
35+
<span className="text-xs font-semibold uppercase text-neutral-subtle">Branch</span>
36+
<span className="min-w-0 truncate font-mono text-xs text-neutral">
37+
{branchLabel}
38+
{commitLabel ? ` (${commitLabel})` : ''}
39+
</span>
40+
</div>
41+
42+
<div className="flex h-10 min-w-0 flex-1 items-center gap-2 border-r border-neutral px-4">
43+
<span className="text-xs font-semibold uppercase text-neutral-subtle">Page</span>
44+
<span title={routeId ?? pageLabel} className="min-w-0 truncate font-mono text-xs text-neutral">
45+
{pageLabel}
46+
</span>
47+
</div>
48+
49+
<div className="flex h-10 min-w-0 flex-1 items-center gap-2 pl-4">
50+
{useCaseOptions.length > 0 && resolvedSelection ? (
51+
<>
52+
<span className="text-xs font-semibold uppercase text-neutral-subtle">Use case</span>
53+
<InputSelect
54+
options={useCaseOptions.map((option) => ({
55+
label: option.label,
56+
value: option.id,
57+
}))}
58+
value={resolvedSelection}
59+
onChange={(next) => {
60+
if (activePageId && typeof next === 'string') {
61+
setSelection(activePageId, next)
62+
}
63+
}}
64+
className="min-w-0 flex-1 [&_.input-select__control]:!h-10 [&_.input-select__single-value]:!font-mono [&_.input-select__single-value]:!text-xs [&_.input-select__single-value]:!text-neutral [&_.react-select__dropdown-indicator]:!right-0"
65+
/>
66+
</>
67+
) : (
68+
<>
69+
<span className="text-xs font-semibold uppercase text-neutral-subtle">Use case</span>
70+
<span className="min-w-0 truncate font-mono text-xs text-neutral-subtle">No use case detected</span>
71+
</>
72+
)}
73+
</div>
74+
</div>
75+
</div>
76+
</div>
77+
)
78+
}
79+
80+
export default UseCaseBottomBar
81+
82+
function resolveScopeLabel(routeId?: string) {
83+
if (!routeId) {
84+
return 'Org'
85+
}
86+
87+
if (routeId.includes('/service/$serviceId')) {
88+
return 'Service'
89+
}
90+
91+
if (routeId.includes('/environment/$environmentId')) {
92+
return 'Env'
93+
}
94+
95+
if (routeId.includes('/project/$projectId')) {
96+
return 'Project'
97+
}
98+
99+
if (routeId.includes('/organization/$organizationId')) {
100+
return 'Org'
101+
}
102+
103+
return 'Org'
104+
}
105+
106+
function resolvePageName(routeId: string | undefined, pathname: string) {
107+
if (routeId) {
108+
const segments = routeId.split('/').filter(Boolean)
109+
let lastSegment = segments[segments.length - 1] ?? 'index'
110+
111+
if (lastSegment.startsWith('$')) {
112+
lastSegment = segments[segments.length - 2] ?? lastSegment
113+
}
114+
115+
if (lastSegment === '_index' || lastSegment === 'index') {
116+
return 'index'
117+
}
118+
119+
return lastSegment
120+
}
121+
122+
const pathSegments = pathname.split('/').filter(Boolean)
123+
return pathSegments[pathSegments.length - 1] ?? 'index'
124+
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import {
2+
type ReactNode,
3+
type SetStateAction,
4+
createContext,
5+
useCallback,
6+
useContext,
7+
useEffect,
8+
useMemo,
9+
useState,
10+
} from 'react'
11+
12+
export type UseCaseOption = {
13+
id: string
14+
label: string
15+
}
16+
17+
type UseCaseContextValue = {
18+
activePageId: string | null
19+
optionsByPageId: Record<string, UseCaseOption[]>
20+
selectionsByPageId: Record<string, string>
21+
registerUseCases: (pageId: string, options: UseCaseOption[]) => void
22+
setActivePageId: (pageId: SetStateAction<string | null>) => void
23+
setSelection: (pageId: string, selectionId: string) => void
24+
}
25+
26+
type UseCaseProviderProps = {
27+
children: ReactNode
28+
}
29+
30+
type UseCasePageConfig = {
31+
pageId: string
32+
options: UseCaseOption[]
33+
defaultCaseId?: string
34+
}
35+
36+
const STORAGE_KEY = 'qovery:use-cases'
37+
38+
const UseCaseContext = createContext<UseCaseContextValue | undefined>(undefined)
39+
40+
const areOptionsEqual = (next: UseCaseOption[], prev: UseCaseOption[]) =>
41+
next.length === prev.length &&
42+
next.every((option, index) => option.id === prev[index]?.id && option.label === prev[index]?.label)
43+
44+
const readSelections = () => {
45+
if (typeof window === 'undefined') {
46+
return {}
47+
}
48+
49+
try {
50+
const raw = localStorage.getItem(STORAGE_KEY)
51+
return raw ? (JSON.parse(raw) as Record<string, string>) : {}
52+
} catch {
53+
return {}
54+
}
55+
}
56+
57+
export function UseCaseProvider({ children }: UseCaseProviderProps) {
58+
const [activePageId, setActivePageId] = useState<string | null>(null)
59+
const [optionsByPageId, setOptionsByPageId] = useState<Record<string, UseCaseOption[]>>({})
60+
const [selectionsByPageId, setSelectionsByPageId] = useState<Record<string, string>>(readSelections)
61+
62+
const registerUseCases = useCallback((pageId: string, options: UseCaseOption[]) => {
63+
setOptionsByPageId((prev) => {
64+
const existing = prev[pageId]
65+
if (existing && areOptionsEqual(options, existing)) {
66+
return prev
67+
}
68+
69+
return {
70+
...prev,
71+
[pageId]: options,
72+
}
73+
})
74+
}, [])
75+
76+
const setSelection = useCallback((pageId: string, selectionId: string) => {
77+
setSelectionsByPageId((prev) => ({
78+
...prev,
79+
[pageId]: selectionId,
80+
}))
81+
}, [])
82+
83+
useEffect(() => {
84+
if (typeof window === 'undefined') {
85+
return
86+
}
87+
88+
try {
89+
localStorage.setItem(STORAGE_KEY, JSON.stringify(selectionsByPageId))
90+
} catch {
91+
// Ignore localStorage failures (private mode, quota, etc.)
92+
}
93+
}, [selectionsByPageId])
94+
95+
const value = useMemo<UseCaseContextValue>(
96+
() => ({
97+
activePageId,
98+
optionsByPageId,
99+
selectionsByPageId,
100+
registerUseCases,
101+
setActivePageId,
102+
setSelection,
103+
}),
104+
[activePageId, optionsByPageId, registerUseCases, selectionsByPageId, setSelection]
105+
)
106+
107+
return <UseCaseContext.Provider value={value}>{children}</UseCaseContext.Provider>
108+
}
109+
110+
export function useUseCases() {
111+
const context = useContext(UseCaseContext)
112+
113+
if (!context) {
114+
throw new Error('useUseCases must be used within a UseCaseProvider')
115+
}
116+
117+
return context
118+
}
119+
120+
export function useUseCasePage({ pageId, options, defaultCaseId }: UseCasePageConfig) {
121+
const { registerUseCases, setActivePageId, selectionsByPageId, setSelection } = useUseCases()
122+
123+
useEffect(() => {
124+
registerUseCases(pageId, options)
125+
setActivePageId(pageId)
126+
127+
return () => {
128+
setActivePageId((current) => (current === pageId ? null : current))
129+
}
130+
}, [options, pageId, registerUseCases, setActivePageId])
131+
132+
const selectedCaseId = useMemo(() => {
133+
const selected = selectionsByPageId[pageId]
134+
if (selected && options.some((option) => option.id === selected)) {
135+
return selected
136+
}
137+
138+
if (defaultCaseId && options.some((option) => option.id === defaultCaseId)) {
139+
return defaultCaseId
140+
}
141+
142+
return options[0]?.id ?? ''
143+
}, [defaultCaseId, options, pageId, selectionsByPageId])
144+
145+
useEffect(() => {
146+
if (!selectedCaseId) {
147+
return
148+
}
149+
150+
if (selectionsByPageId[pageId] !== selectedCaseId) {
151+
setSelection(pageId, selectedCaseId)
152+
}
153+
}, [pageId, selectedCaseId, selectionsByPageId, setSelection])
154+
155+
return {
156+
selectedCaseId,
157+
setSelectedCaseId: (nextId: string) => setSelection(pageId, nextId),
158+
}
159+
}

apps/console-v5/src/routes/__root.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { type QueryClient } from '@tanstack/react-query'
22
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
33
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
44
import { ModalProvider, ToastBehavior } from '@qovery/shared/ui'
5+
import { UseCaseBottomBar } from '../app/components/use-cases/use-case-bottom-bar'
6+
import { UseCaseProvider } from '../app/components/use-cases/use-case-context'
57
import { type Auth0ContextType } from '../auth/auth0'
68

79
interface RouterContext {
@@ -11,13 +13,14 @@ interface RouterContext {
1113

1214
const RootLayout = () => {
1315
return (
14-
<>
16+
<UseCaseProvider>
1517
<ModalProvider>
1618
<Outlet />
1719
<ToastBehavior />
20+
<UseCaseBottomBar />
1821
</ModalProvider>
1922
<TanStackRouterDevtools />
20-
</>
23+
</UseCaseProvider>
2124
)
2225
}
2326

libs/shared/util-node-env/src/lib/shared-util-node-env.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ declare global {
44
interface ProcessEnv {
55
NODE_ENV: string
66
NX_PUBLIC_GIT_SHA: string
7+
NX_PUBLIC_GIT_BRANCH: string
78
NX_PUBLIC_QOVERY_API: string
89
NX_PUBLIC_QOVERY_WS: string
910
NX_PUBLIC_OAUTH_DOMAIN: string
@@ -25,6 +26,7 @@ declare global {
2526

2627
export const NODE_ENV = process.env.NODE_ENV,
2728
GIT_SHA = process.env.NX_PUBLIC_GIT_SHA,
29+
GIT_BRANCH = process.env.NX_PUBLIC_GIT_BRANCH,
2830
QOVERY_API = process.env.NX_PUBLIC_QOVERY_API,
2931
QOVERY_WS = process.env.NX_PUBLIC_QOVERY_WS,
3032
OAUTH_DOMAIN = process.env.NX_PUBLIC_OAUTH_DOMAIN,

0 commit comments

Comments
 (0)