diff --git a/client/shared/src/settings/temporary/TemporarySettings.ts b/client/shared/src/settings/temporary/TemporarySettings.ts index e0939414e384..37cf0e946ae1 100644 --- a/client/shared/src/settings/temporary/TemporarySettings.ts +++ b/client/shared/src/settings/temporary/TemporarySettings.ts @@ -58,6 +58,7 @@ export interface TemporarySettingsSchema { 'batches.minSavedPerChangeset': number 'search.notebooks.minSavedPerView': number 'repo.commitPage.diffMode': DiffMode + 'setup.activeStepId': string } /** diff --git a/client/web/src/Layout.tsx b/client/web/src/Layout.tsx index a0ef5b4e10d0..2a0f51334b0b 100644 --- a/client/web/src/Layout.tsx +++ b/client/web/src/Layout.tsx @@ -51,8 +51,10 @@ import type { LayoutRouteComponentProps, LayoutRouteProps } from './routes' import { EnterprisePageRoutes, PageRoutes } from './routes.constants' import { parseSearchURLQuery, SearchAggregationProps, SearchStreamingProps } from './search' import { NotepadContainer } from './search/Notepad' +import { SetupWizard } from './setup-wizard' import type { SiteAdminAreaRoute } from './site-admin/SiteAdminArea' import type { SiteAdminSideBarGroups } from './site-admin/SiteAdminSidebar' +import { useExperimentalFeatures } from './stores' import { useTheme, useThemeProps } from './theme' import type { UserAreaRoute } from './user/area/UserArea' import type { UserAreaHeaderNavItem } from './user/area/UserAreaHeader' @@ -132,6 +134,9 @@ export const Layout: React.FunctionComponent + if (isSetupWizardPage) { + return + } + return (
this.setState({ authenticatedUser: null }) @@ -337,7 +331,7 @@ export class SourcegraphWebApp extends React.Component } - const { authenticatedUser, graphqlClient, temporarySettingsStorage, isSetupWizardEnabled } = this.state + const { authenticatedUser, graphqlClient, temporarySettingsStorage } = this.state if (authenticatedUser === undefined || graphqlClient === undefined || temporarySettingsStorage === undefined) { return null @@ -363,7 +357,6 @@ export class SourcegraphWebApp extends React.Component - {isSetupWizardEnabled ? } /> : null}

Hello local repositories step

, + }, + { + id: '002', + name: 'Add remote repositories', + path: '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/setup/remote-repositories', + render: () => ( + +

Hello remote repositories step

+ +
+ ), + }, + { + id: '003', + name: 'Sync repositories', + path: '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/setup/sync-repositories', + render: () =>

Hello sync repositories step

, + }, +] + export const SetupWizard: FC = props => { - const [step, setStep] = useState(0) + const [activeStepId, setStepId, status] = useTemporarySetting('setup.activeStepId') + + if (status !== 'loaded') { + return null + } + + const handleStepChange = (step: StepConfiguration): void => { + setStepId(step.id) + } return (
@@ -17,16 +52,11 @@ export const SetupWizard: FC = props => {

- Welcome to Sourcegraph! Let's get your instance ready. + Welcome to Sourcegraph! Let's get started.

- - - Add code hosts - Sync repositories - - +
) } diff --git a/client/web/src/setup-wizard/components/SetupTabs.module.scss b/client/web/src/setup-wizard/components/SetupTabs.module.scss deleted file mode 100644 index 02a7d2df2f2a..000000000000 --- a/client/web/src/setup-wizard/components/SetupTabs.module.scss +++ /dev/null @@ -1,50 +0,0 @@ -.tabs { - background-color: unset; -} - -.header-list { - display: flex; - gap: 1rem !important; -} - -.tab { - &[data-selected] { - color: var(--black) !important; - border-bottom: 2px solid var(--primary) !important; - } - - &--completed { - color: var(--black) !important; - border-bottom: 2px solid var(--primary) !important; - } - - &[data-reach-tab] { - color: var(--black) !important; - - &:hover { - border-bottom: none !important; - } - } -} - -.panels { - margin-top: 0; - padding: 1rem; - border-top-left-radius: 0 !important; - border-bottom-right-radius: 0 !important; - border-top: none !important; -} - -.actions { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.5rem; - padding: 1rem; - margin: 1rem -1rem -1rem -1rem; - border-top: 1px solid var(--border-color); - - &-skip { - margin-right: auto; - } -} diff --git a/client/web/src/setup-wizard/components/SetupTabs.tsx b/client/web/src/setup-wizard/components/SetupTabs.tsx deleted file mode 100644 index 0f8cfd8d0861..000000000000 --- a/client/web/src/setup-wizard/components/SetupTabs.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { createContext, FC, PropsWithChildren, useContext } from 'react' - -import classNames from 'classnames' -import { noop } from 'lodash' - -import { Button, Tab, TabList, TabPanel, Tabs, TabListProps, useTabsContext } from '@sourcegraph/wildcard' - -import styles from './SetupTabs.module.scss' - -interface SetupTabsContextData { - onTabChange: (index: number) => void -} - -const SetupTabsContext = createContext({ - onTabChange: noop, -}) - -interface SetupTabsProps { - activeTabIndex: number - defaultActiveIndex: number - onTabChange: (activeIndex: number) => void -} - -/** - * The root visual element for the setup Tabs UI wizard layout. Enforces - * the right layout and internal state for completed, current and further setup steps - */ -export const SetupTabs: FC> = props => { - const { activeTabIndex, defaultActiveIndex, children, onTabChange } = props - - return ( - - - {children} - - - ) -} - -/** UI component to declare list of steps headers (tabs) UI */ -export const SetupList: FC> = props => ( - -) - -interface SetupTabProps { - index: number -} - -export const SetupTab: FC> = props => { - const { index, children } = props - const { selectedIndex } = useTabsContext() - - return ( - index })} - > - {children} - - ) -} - -export { TabPanel as SetupStep } - -interface SetupStepActions { - nextAvailable: boolean - finish?: boolean - onSkip?: () => void - onComplete?: () => void -} - -export const SetupStepActions: FC = props => { - const { nextAvailable, finish, onSkip, onComplete } = props - - const { selectedIndex } = useTabsContext() - const { onTabChange } = useContext(SetupTabsContext) - - const isFirstStep = selectedIndex === 0 - - return ( -
- {!finish && ( - <> - - {!isFirstStep && ( - - )} - - - )} - - {finish && ( - - )} -
- ) -} diff --git a/client/web/src/setup-wizard/components/setup-steps/SetupSteps.module.scss b/client/web/src/setup-wizard/components/setup-steps/SetupSteps.module.scss new file mode 100644 index 000000000000..f9f3079b6ea2 --- /dev/null +++ b/client/web/src/setup-wizard/components/setup-steps/SetupSteps.module.scss @@ -0,0 +1,105 @@ +.root { + display: flex; + flex-direction: column; + gap: 2rem; + flex-grow: 1; + justify-content: center; +} + +.header { + display: flex; + justify-content: space-between; + gap: 2rem; + margin: -1.5rem auto 0; + + &-step { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + } + + &-step-number { + width: 3rem; + height: 3rem; + display: flex; + justify-content: center; + align-items: center; + font-size: 1.5rem; + border-radius: 50%; + + :global(.theme-light) & { + color: var(--white); + border: 2px solid var(--body-bg); + background-color: var(--purple); + + &--completed { + color: #b99dcc; + background-color: #dccde7; + } + + &--disabled { + color: var(--text-muted); + background-color: #d9d9d9; + } + } + + :global(.theme-dark) & { + color: var(--gray-01); + border: 2px solid var(--gray-01); + background-color: var(--purple); + + &--completed { + color: #b99dcc; + background-color: #dccde7; + } + + &--disabled { + color: var(--text-muted); + background-color: var(--gray-03); + } + } + } + + &-step-label { + font-weight: normal; + } +} + +.content { + flex-grow: 1; + margin: auto; +} + +.actions { + width: 100%; +} + +.navigation { + display: flex; + padding: 0.5rem; + background-color: var(--gray-02); + border-top: 1px solid var(--border-color); + + :global(.theme-dark) & { + background-color: var(--color-bg-2); + } + + &-inner { + display: flex; + flex-grow: 1; + gap: 0.5rem; + max-width: 40rem; + margin: auto; + } + + // Hide default next button if the custom button is rendered + &-next-portal:not(:empty) + &-next { + display: none; + } + + &-next-portal, + &-next { + margin-left: auto; + } +} diff --git a/client/web/src/setup-wizard/components/setup-steps/SetupSteps.tsx b/client/web/src/setup-wizard/components/setup-steps/SetupSteps.tsx new file mode 100644 index 000000000000..3063e0202cd4 --- /dev/null +++ b/client/web/src/setup-wizard/components/setup-steps/SetupSteps.tsx @@ -0,0 +1,223 @@ +import { + useRef, + Ref, + createContext, + FC, + ReactNode, + HTMLAttributes, + useMemo, + useContext, + useCallback, + useEffect, +} from 'react' + +import { mdiChevronLeft, mdiChevronRight } from '@mdi/js' +import classNames from 'classnames' +import { createPortal } from 'react-dom' +import { useLocation, useNavigate, Routes, Route, Navigate, matchPath } from 'react-router-dom-v5-compat' + +import { Button, Icon } from '@sourcegraph/wildcard' + +import styles from './SetupSteps.module.scss' + +export interface StepConfiguration { + id: string + path: string + name: string + render: () => ReactNode +} + +interface SetupStepsContextData { + steps: StepConfiguration[] + nextButtonPortalElement: HTMLDivElement | null + onNextStep: () => void +} + +const SetupStepsContext = createContext({ + steps: [], + nextButtonPortalElement: null, + onNextStep: () => {}, +}) + +interface SetupStepsProps { + initialStepId: string | undefined + steps: StepConfiguration[] + onStepChange: (nextStep: StepConfiguration) => void +} + +interface SetupStepURLContext { + currentStep: StepConfiguration + activeStepIndex: number +} + +export const SetupStepsRoot: FC = props => { + const { initialStepId, steps, onStepChange } = props + + const navigate = useNavigate() + const location = useLocation() + const nextButtonPortalRef = useRef(null) + + // Resolve current setup step and its index by URL matches + const { currentStep, activeStepIndex } = useMemo(() => { + // Try to find step by URL based on available steps + const urlStepIndex = steps.findIndex(step => matchPath(location.pathname, step.path) !== null) + + if (urlStepIndex !== -1) { + return { + activeStepIndex: urlStepIndex, + currentStep: steps[urlStepIndex], + } + } + + // Try to find step by pre-saved settings if URL doesn't resolve any step + const savedStepIndex = steps.findIndex(step => step.id === initialStepId) + + if (savedStepIndex !== -1) { + return { + activeStepIndex: savedStepIndex, + currentStep: steps[savedStepIndex], + } + } + + // Fallback on the first available step if URL doesn't match any step, and we + // don't have any pre-saved step + return { + activeStepIndex: 0, + currentStep: steps[0], + } + }, [location, initialStepId, steps]) + + useEffect(() => { + onStepChange(currentStep) + }, [currentStep, onStepChange]) + + const handleGoToNextStep = useCallback(() => { + const nextStepIndex = activeStepIndex + 1 + + if (nextStepIndex < steps.length) { + const nextStep = steps[nextStepIndex] + + navigate(nextStep.path) + } + }, [activeStepIndex, steps, navigate]) + + const handleGoToPrevStep = useCallback(() => { + const prevStepIndex = activeStepIndex - 1 + + if (prevStepIndex >= 0) { + const prevStep = steps[prevStepIndex] + + navigate(prevStep.path) + } + }, [activeStepIndex, steps, navigate]) + + const cachedContext = useMemo( + () => ({ + steps, + nextButtonPortalElement: nextButtonPortalRef.current, + onNextStep: handleGoToNextStep, + }), + [handleGoToNextStep, steps] + ) + + return ( + +
+ +
+ + {steps.map(step => ( + + ))} + } /> + +
+ +
+
+ ) +} + +interface SetupStepsHeaderProps extends HTMLAttributes { + steps: StepConfiguration[] + activeStepIndex: number +} + +export const SetupStepsHeader: FC = props => { + const { steps, activeStepIndex, className, ...attributes } = props + + return ( +
+ {steps.map((step, index) => ( +
+ activeStepIndex, + })} + > + {index + 1} + + {step.name} +
+ ))} +
+ ) +} + +interface SetupStepsFooterProps { + steps: StepConfiguration[] + activeStepIndex: number + nextButtonPortalRef: Ref + onPrevStep: () => void + onNextStep: () => void +} + +export const SetupStepsFooter: FC = props => { + const { steps, activeStepIndex, nextButtonPortalRef, onPrevStep, onNextStep } = props + + return ( +
+
+ {activeStepIndex > 0 && ( + + )} + +
+ +
+
+ ) +} + +interface CustomNextButtonProps { + label: string + disabled: boolean +} + +export const CustomNextButton: FC = props => { + const { label, disabled } = props + const { nextButtonPortalElement, onNextStep } = useContext(SetupStepsContext) + + if (!nextButtonPortalElement) { + return null + } + + return createPortal( + , + nextButtonPortalElement + ) +} diff --git a/client/web/src/setup-wizard/components/setup-steps/index.ts b/client/web/src/setup-wizard/components/setup-steps/index.ts new file mode 100644 index 000000000000..628ccae0f510 --- /dev/null +++ b/client/web/src/setup-wizard/components/setup-steps/index.ts @@ -0,0 +1,2 @@ +export { SetupStepsRoot, CustomNextButton } from './SetupSteps' +export type { StepConfiguration } from './SetupSteps'