diff --git a/apps/ocp-plugin/src/components/AppContext/AppContext.tsx b/apps/ocp-plugin/src/components/AppContext/AppContext.tsx index 4fff54f64e..cca51e5d6e 100644 --- a/apps/ocp-plugin/src/components/AppContext/AppContext.tsx +++ b/apps/ocp-plugin/src/components/AppContext/AppContext.tsx @@ -24,6 +24,7 @@ import { getUser } from '@openshift-console/dynamic-plugin-sdk/lib/app/core/redu import { useSelector } from 'react-redux'; import { useFetch } from '../../hooks/useFetch'; import { apiProxy } from '../../utils/apiCalls'; +import QuickStartHost from '@flightctl/ui-components/src/components/QuickStart/QuickStartHost'; import '@flightctl/ui-components/src/styles/global.css'; import './AppContext.css'; @@ -59,7 +60,11 @@ export const OCPPluginAppContext: React.FC { - return {children}; + return ( + + {children} + + ); }; const appRoutes = { diff --git a/apps/ocp-plugin/src/components/common/WithPageLayout.tsx b/apps/ocp-plugin/src/components/common/WithPageLayout.tsx index 0f8c96cbe5..2b07829abe 100644 --- a/apps/ocp-plugin/src/components/common/WithPageLayout.tsx +++ b/apps/ocp-plugin/src/components/common/WithPageLayout.tsx @@ -29,7 +29,7 @@ const WithPageLayoutContent = ({ children }: React.PropsWithChildren) => { return ( - + {children} ); diff --git a/apps/standalone/src/app/components/AppLayout/AppLayout.tsx b/apps/standalone/src/app/components/AppLayout/AppLayout.tsx index 9bff7f2bc2..fc80a1f16d 100644 --- a/apps/standalone/src/app/components/AppLayout/AppLayout.tsx +++ b/apps/standalone/src/app/components/AppLayout/AppLayout.tsx @@ -23,6 +23,7 @@ import PageNavigation from '@flightctl/ui-components/src/components/common/PageN import { useTranslation } from '@flightctl/ui-components/src/hooks/useTranslation'; import { SystemRestoreProvider } from '@flightctl/ui-components/src/hooks/useSystemRestoreContext'; import { PermissionsContextProvider } from '@flightctl/ui-components/src/components/common/PermissionsContext'; +import QuickStartHost from '@flightctl/ui-components/src/components/QuickStart/QuickStartHost'; import { useBrandLogo } from '../../hooks/useBrandLogo'; import AppNavigation from './AppNavigation'; @@ -118,9 +119,11 @@ const AppLayout = () => { return ( - - - + + + + + ); diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index 05d6286b63..3c98a37aa0 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -574,8 +574,10 @@ "OS mode: Unknown": "OS mode: Unknown", "Organization ID": "Organization ID", "Change Organization": "Change Organization", - "Copy login command": "Copy login command", "Settings": "Settings", + "Show quick start guide": "Show quick start guide", + "Hide quick start guide": "Hide quick start guide", + "Copy login command": "Copy login command", "Fleet not found": "Fleet not found", "We could not find the fleet with id <1>{id}": "We could not find the fleet with id <1>{id}", "Device not found": "Device not found", @@ -1669,6 +1671,39 @@ "All good!": "All good!", "Tasks": "Tasks", "View the health of your edge environment and explore details across devices, fleets, and security.": "View the health of your edge environment and explore details across devices, fleets, and security.", + "{{count}} minutes_one": "{{count}} minute", + "{{count}} minutes_other": "{{count}} minutes", + "{{count}} tasks_one": "{{count}} task", + "{{count}} tasks_other": "{{count}} tasks", + "Locked": "Locked", + "Prerequisites": "Prerequisites", + "Complete \"{{prevPhaseTitle}}\"": "Complete \"{{prevPhaseTitle}}\"", + "Prerequisites (1)": "Prerequisites (1)", + "To show it again, open Settings in the toolbar above and choose Show quick start guide.": "To show it again, open Settings in the toolbar above and choose Show quick start guide.", + "Quick start guide": "Quick start guide", + "{{completed}}/{{total}} phases complete": "{{completed}}/{{total}} phases complete", + "Expand guide": "Expand guide", + "Collapse guide": "Collapse guide", + "Follow these phases to get your edge management environment up and running.": "Follow these phases to get your edge management environment up and running.", + "Progress": "Progress", + "{{completed}} of {{total}}": "{{completed}} of {{total}}", + "About {{count}} minutes_one": "About {{count}} minute", + "About {{count}} minutes_other": "About {{count}} minutes", + "Welcome & Orientation": "Welcome & Orientation", + "Get familiar with {{ productName }} and learn how to navigate the console.": "Get familiar with {{ productName }} and learn how to navigate the console.", + "Build an OS Image": "Build an OS Image", + "Create a custom edge OS image so devices auto-register on boot.": "Create a custom edge OS image so devices auto-register on boot.", + "Enroll Your First Device": "Enroll Your First Device", + "Boot a device with your image, then approve it to bring it under management.": "Boot a device with your image, then approve it to bring it under management.", + "Create and Manage Fleets": "Create and Manage Fleets", + "Group devices that share identical or similar configuration and update policy for centralized management.": "Group devices that share identical or similar configuration and update policy for centralized management.", + "Close guide": "Close guide", + "Minimize guide": "Minimize guide", + "Explore the console": "Explore the console", + "Orientation": "Orientation", + "Some description here.": "Some description here.", + "Sections visible to you depend on your permissions.": "Sections visible to you depend on your permissions.", + "Drag guide": "Drag guide", "Failed to retrieve repository details": "Failed to retrieve repository details", "Failed to retrieve the resource syncs": "Failed to retrieve the resource syncs", "The repository cannot be modified at the moment because some of its details could not be obtained.": "The repository cannot be modified at the moment because some of its details could not be obtained.", diff --git a/libs/ui-components/src/components/OverviewPage/Overview.tsx b/libs/ui-components/src/components/OverviewPage/Overview.tsx index 9a1410e6b4..6ee088d82a 100644 --- a/libs/ui-components/src/components/OverviewPage/Overview.tsx +++ b/libs/ui-components/src/components/OverviewPage/Overview.tsx @@ -7,6 +7,7 @@ import { RESOURCE, VERB } from '../../types/rbac'; import PageWithPermissions from '../common/PageWithPermissions'; import { GlobalSystemRestoreBanners } from '../SystemRestore/SystemRestoreBanners'; +import QuickStart from '../QuickStart/QuickStart'; import AlertsCard from './Cards/Alerts/AlertsCard'; import StatusCard from './Cards/Status/StatusCard'; import TasksCard from './Cards/Tasks/TasksCard'; @@ -31,6 +32,8 @@ const Overview = () => { > + + diff --git a/libs/ui-components/src/components/QuickStart/QuickStart.css b/libs/ui-components/src/components/QuickStart/QuickStart.css new file mode 100644 index 0000000000..906bf3e124 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/QuickStart.css @@ -0,0 +1,7 @@ +.fctl-quickstart-card { + margin-bottom: var(--pf-t--global--spacer--xl); +} + +.fctl-quickstart-card__expanded { + border-top: 3px solid var(--pf-t--global--color--brand--default, #06c); +} diff --git a/libs/ui-components/src/components/QuickStart/QuickStart.tsx b/libs/ui-components/src/components/QuickStart/QuickStart.tsx new file mode 100644 index 0000000000..86df3e0481 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/QuickStart.tsx @@ -0,0 +1,297 @@ +import * as React from 'react'; +import { + Button, + Card, + CardBody, + CardHeader, + CardTitle, + Content, + ContentVariants, + Flex, + FlexItem, + Grid, + GridItem, + Icon, + Label, + LabelGroup, + Popover, + Progress, + Split, + SplitItem, + Stack, + StackItem, + Tooltip, +} from '@patternfly/react-core'; +import WindowMinimizeIcon from '@patternfly/react-icons/dist/js/icons/window-minimize-icon'; +import OutlinedWindowRestoreIcon from '@patternfly/react-icons/dist/js/icons/outlined-window-restore-icon'; +import OutlinedClockIcon from '@patternfly/react-icons/dist/js/icons/outlined-clock-icon'; +import InfoCircleIcon from '@patternfly/react-icons/dist/js/icons/info-circle-icon'; +import LockIcon from '@patternfly/react-icons/dist/js/icons/lock-icon'; +import HomeIcon from '@patternfly/react-icons/dist/js/icons/home-icon'; +import BuilderImageIcon from '@patternfly/react-icons/dist/js/icons/builder-image-icon'; +import PlusCircleIcon from '@patternfly/react-icons/dist/js/icons/plus-circle-icon'; +import CubesIcon from '@patternfly/react-icons/dist/js/icons/cubes-icon'; + +import type { QuickStartPhase } from './types'; +import { useTranslation } from '../../hooks/useTranslation'; +import { useQuickStart } from './QuickStartContext'; + +import './QuickStart.css'; + +const PHASE_ICONS: Record = { + home: HomeIcon, + image: BuilderImageIcon, + plus: PlusCircleIcon, + cubes: CubesIcon, +}; + +interface PhaseCardProps { + card: QuickStartPhase; + isLocked: boolean; + prevPhaseTitle: string | undefined; +} + +const PhaseCard = ({ card, isLocked, prevPhaseTitle }: PhaseCardProps) => { + const { t } = useTranslation(); + const { startPhase } = useQuickStart(); + const PhaseIcon = PHASE_ICONS[card.icon] || HomeIcon; + + return ( + + + + + + + + + + + {card.title} + + + + + + + {isLocked && ( + + )} + + + + + {card.description} + + + +
+ {isLocked ? ( + +
  • {t('Complete "{{prevPhaseTitle}}"', { prevPhaseTitle })}
  • + + } + triggerAction="hover" + > + +
    + ) : ( + + )} +
    +
    +
    + ); +}; + +const QuickStartToggleAction = ({ onToggle, compact = false }: { onToggle: () => void; compact?: boolean }) => { + const { t } = useTranslation(); + + return ( + + + + ); +}; + +const QuickStart = () => { + const { t } = useTranslation(); + const { panelVisibility, phases, toggleVisibility, toggleCollapsed } = useQuickStart(); + + if (panelVisibility === 'hidden') { + return null; + } + + const totalPhases = phases.length; + + if (panelVisibility === 'collapsed') { + return ( + + + + + + + + {t('Quick start guide')} + + + + + {t('{{completed}}/{{total}} phases complete', { + completed: 0, + total: totalPhases, + })} + + + + + + + + + + + + + + + + + ) : ( + + + ), + hasNoOffset: true, + }} + > + + + + {activePhase.title} + + + + + + + + + + + + + + + + + + + + + + + )} + , + document.body, + ); +}; + +export default QuickStartGuide; diff --git a/libs/ui-components/src/components/QuickStart/QuickStartHost.tsx b/libs/ui-components/src/components/QuickStart/QuickStartHost.tsx new file mode 100644 index 0000000000..88c369961b --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/QuickStartHost.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; + +import { QuickStartProvider, useQuickStartGuide } from './QuickStartContext'; + +const QuickStartGuideLoader = React.lazy(() => import('./QuickStartGuide')); + +const QuickStartGuideGate = () => { + const { activePhase } = useQuickStartGuide(); + + if (!activePhase) { + return null; + } + + return ( + + + + ); +}; + +interface QuickStartHostProps { + children: React.ReactNode; +} + +const QuickStartHost = ({ children }: QuickStartHostProps) => { + return ( + + {children} + + + ); +}; + +export default QuickStartHost; diff --git a/libs/ui-components/src/components/QuickStart/quickStartDefinitions.ts b/libs/ui-components/src/components/QuickStart/quickStartDefinitions.ts new file mode 100644 index 0000000000..a164ce7c7d --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/quickStartDefinitions.ts @@ -0,0 +1,38 @@ +import type { TFunction } from 'i18next'; + +import type { QuickStartPhase } from './types'; + +export const QUICK_START_PHASE_ORDER = ['orientation', 'image-building', 'enrollment', 'fleet-management'] as const; + +export type QuickStartPhaseId = (typeof QUICK_START_PHASE_ORDER)[number]; + +export const getQuickStartPhases = (t: TFunction, productName: string): QuickStartPhase[] => [ + { + id: 'orientation', + title: t('Welcome & Orientation'), + icon: 'home', + description: t('Get familiar with {{ productName }} and learn how to navigate the console.', { + productName, + }), + }, + { + id: 'image-building', + title: t('Build an OS Image'), + icon: 'image', + description: t('Create a custom edge OS image so devices auto-register on boot.'), + }, + { + id: 'enrollment', + title: t('Enroll Your First Device'), + icon: 'plus', + description: t('Boot a device with your image, then approve it to bring it under management.'), + }, + { + id: 'fleet-management', + title: t('Create and Manage Fleets'), + icon: 'cubes', + description: t( + 'Group devices that share identical or similar configuration and update policy for centralized management.', + ), + }, +]; diff --git a/libs/ui-components/src/components/QuickStart/quickStartStorage.ts b/libs/ui-components/src/components/QuickStart/quickStartStorage.ts new file mode 100644 index 0000000000..ebae788625 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/quickStartStorage.ts @@ -0,0 +1,35 @@ +import { DEFAULT_QUICK_START_STATE, type PanelVisibility, type QuickStartPersistedState } from './types'; + +const QUICK_START_STORAGE_KEY = 'flightctl/quickstart-state'; + +const isPanelVisibility = (value: unknown): value is PanelVisibility => + value === 'expanded' || value === 'collapsed' || value === 'hidden'; + +export const getPersistedQuickStartState = (): QuickStartPersistedState => { + try { + const stored = window.localStorage.getItem(QUICK_START_STORAGE_KEY); + if (!stored) { + return DEFAULT_QUICK_START_STATE; + } + + const parsed = JSON.parse(stored) as Partial; + + if (isPanelVisibility(parsed.panelVisibility)) { + return { + panelVisibility: parsed.panelVisibility, + }; + } + } catch { + // Fall through to defaults. + } + + return DEFAULT_QUICK_START_STATE; +}; + +export const persistQuickStartState = (state: QuickStartPersistedState): void => { + try { + window.localStorage.setItem(QUICK_START_STORAGE_KEY, JSON.stringify(state)); + } catch { + // Fall through — in-memory state still works; preference won't survive reload. + } +}; diff --git a/libs/ui-components/src/components/QuickStart/types.ts b/libs/ui-components/src/components/QuickStart/types.ts new file mode 100644 index 0000000000..d80a21c0f5 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/types.ts @@ -0,0 +1,21 @@ +import type { QuickStartPhaseId } from './quickStartDefinitions'; + +export type { QuickStartPhaseId } from './quickStartDefinitions'; +export { QUICK_START_PHASE_ORDER } from './quickStartDefinitions'; + +export type PanelVisibility = 'expanded' | 'collapsed' | 'hidden'; + +export interface QuickStartPhase { + id: QuickStartPhaseId; + title: string; + icon: string; + description: string; +} + +export interface QuickStartPersistedState { + panelVisibility: PanelVisibility; +} + +export const DEFAULT_QUICK_START_STATE: QuickStartPersistedState = { + panelVisibility: 'expanded', +}; diff --git a/libs/ui-components/src/components/QuickStart/useQuickStartGuideDrag.ts b/libs/ui-components/src/components/QuickStart/useQuickStartGuideDrag.ts new file mode 100644 index 0000000000..b203efe44e --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/useQuickStartGuideDrag.ts @@ -0,0 +1,149 @@ +import * as React from 'react'; +import type { QuickStartPhaseId } from './types'; + +const VIEWPORT_MARGIN = 8; + +const clampPosition = (x: number, y: number, width: number, height: number) => { + const maxX = Math.max(VIEWPORT_MARGIN, window.innerWidth - width - VIEWPORT_MARGIN); + const maxY = Math.max(VIEWPORT_MARGIN, window.innerHeight - height - VIEWPORT_MARGIN); + return { + x: Math.min(Math.max(VIEWPORT_MARGIN, x), maxX), + y: Math.min(Math.max(VIEWPORT_MARGIN, y), maxY), + }; +}; + +export const useQuickStartGuideDrag = (activePhaseId: QuickStartPhaseId | undefined) => { + const panelRef = React.useRef(null); + const [position, setPosition] = React.useState<{ x: number; y: number } | null>(null); + const [isDragging, setIsDragging] = React.useState(false); + const userPositionedRef = React.useRef(false); + const dragRef = React.useRef<{ + pointerId: number; + startX: number; + startY: number; + originX: number; + originY: number; + } | null>(null); + + React.useEffect(() => { + setPosition(null); + setIsDragging(false); + userPositionedRef.current = false; + dragRef.current = null; + }, [activePhaseId]); + + const getPanelPosition = React.useCallback(() => { + if (position) { + return position; + } + const panel = panelRef.current; + if (!panel) { + return { x: VIEWPORT_MARGIN, y: VIEWPORT_MARGIN }; + } + const rect = panel.getBoundingClientRect(); + return { x: rect.left, y: rect.top }; + }, [position]); + + const endDrag = React.useCallback((pointerId: number, moved: boolean) => { + dragRef.current = null; + setIsDragging(false); + if (moved) { + userPositionedRef.current = true; + } + panelRef.current?.releasePointerCapture(pointerId); + }, []); + + const handlePointerDown = React.useCallback( + (event: React.PointerEvent) => { + if (event.button !== 0) { + return; + } + + const target = event.target as HTMLElement; + if (!target.closest('.fctl-quickstart-guide__drag-handle') || target.closest('button')) { + return; + } + + const panel = panelRef.current; + if (!panel) { + return; + } + + const current = getPanelPosition(); + setPosition(current); + dragRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + originX: current.x, + originY: current.y, + }; + setIsDragging(true); + panel.setPointerCapture(event.pointerId); + event.preventDefault(); + }, + [getPanelPosition], + ); + + const handlePointerMove = React.useCallback((event: React.PointerEvent) => { + const drag = dragRef.current; + const panel = panelRef.current; + if (!drag || drag.pointerId !== event.pointerId || !panel) { + return; + } + + const next = clampPosition( + drag.originX + event.clientX - drag.startX, + drag.originY + event.clientY - drag.startY, + panel.offsetWidth, + panel.offsetHeight, + ); + setPosition(next); + }, []); + + const handlePointerUp = React.useCallback( + (event: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + const moved = Math.abs(event.clientX - drag.startX) > 3 || Math.abs(event.clientY - drag.startY) > 3; + endDrag(event.pointerId, moved); + }, + [endDrag], + ); + + const handlePointerCancel = React.useCallback( + (event: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + endDrag(event.pointerId, false); + }, + [endDrag], + ); + + const panelStyle = React.useMemo( + () => + position + ? { + top: position.y, + left: position.x, + right: 'auto', + bottom: 'auto', + } + : undefined, + [position], + ); + + return { + panelRef, + panelStyle, + isDragging, + handlePointerDown, + handlePointerMove, + handlePointerUp, + handlePointerCancel, + }; +}; diff --git a/libs/ui-components/src/components/QuickStart/useQuickStartProductName.ts b/libs/ui-components/src/components/QuickStart/useQuickStartProductName.ts new file mode 100644 index 0000000000..4539284e57 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/useQuickStartProductName.ts @@ -0,0 +1,9 @@ +import { useAppContext } from '../../hooks/useAppContext'; +import { useTranslation } from '../../hooks/useTranslation'; + +export const useQuickStartProductName = (): string => { + const { t } = useTranslation(); + const { settings } = useAppContext(); + + return settings.isRHEM ? t('Red Hat Edge Manager') : t('Flight Control'); +}; diff --git a/libs/ui-components/src/components/common/PageNavigation.tsx b/libs/ui-components/src/components/common/PageNavigation.tsx index 220ce046ea..65a4d4ad16 100644 --- a/libs/ui-components/src/components/common/PageNavigation.tsx +++ b/libs/ui-components/src/components/common/PageNavigation.tsx @@ -21,6 +21,7 @@ import { useAppContext } from '../../hooks/useAppContext'; import LoginCommandModal from '../modals/LoginCommandModal/LoginCommandModal'; import { RESOURCE, VERB } from '../../types/rbac'; import { usePermissionsContext } from './PermissionsContext'; +import { useQuickStart } from '../QuickStart/QuickStartContext'; import OrganizationSelector from './OrganizationSelector'; import { type OrganizationItem, useOrganizationGuardContext } from './OrganizationGuard'; @@ -124,14 +125,70 @@ const getRedirectPathAfterOrgSwitch = (pathname: string, appRoutes: Record { +const SettingsDropdown = () => { const { t } = useTranslation(); const navigate = useNavigate(); const { router } = useAppContext(); + const { panelVisibility, toggleVisibility } = useQuickStart(); + const { checkPermissions } = usePermissionsContext(); + const [canManageAuthProviders] = checkPermissions([{ kind: RESOURCE.AUTH_PROVIDER, verb: VERB.CREATE }]); + const [isOpen, setIsOpen] = React.useState(false); + + const authProvidersPath = router.appRoutes[ROUTE.AUTH_PROVIDERS]; + const showAuthProvidersItem = + canManageAuthProviders && authProvidersPath !== '/' && authProvidersPath !== router.appRoutes[ROUTE.ROOT]; + + const isGuideHidden = panelVisibility === 'hidden'; + + const onToggleQuickStart = () => { + toggleVisibility(); + setIsOpen(false); + }; + + const onManageAuthProviders = () => { + navigate(ROUTE.AUTH_PROVIDERS); + setIsOpen(false); + }; + + const onDropdownToggle = () => { + setIsOpen((prev) => !prev); + }; + + return ( + ) => ( + } + > + {t('Settings')} + + )} + popperProps={{ position: 'right' }} + > + + + {isGuideHidden ? t('Show quick start guide') : t('Hide quick start guide')} + + {showAuthProvidersItem && ( + {t('Manage authentication providers')} + )} + + + ); +}; + +const PageNavigation = () => { + const { t } = useTranslation(); + const { router } = useAppContext(); const location = router.useLocation(); const { currentOrganization, availableOrganizations } = useOrganizationGuardContext(); - const { checkPermissions } = usePermissionsContext(); - const [isAdmin] = checkPermissions([{ kind: RESOURCE.AUTH_PROVIDER, verb: VERB.CREATE }]); const [showOrganizationModal, setShowOrganizationModal] = React.useState(false); const [showLoginCommandModal, setShowLoginCommandModal] = React.useState(false); @@ -162,20 +219,9 @@ const PageNavigation = ({ showSettings = true }: { showSettings?: boolean }) => {t('Copy login command')} - {isAdmin && showSettings && ( - - - - - - )} + + +