Revert "Make onboarding optional, able to be ignored on desktop" (#6610)

Revert "Make onboarding optional, able to be ignored on desktop (#6564)"

This reverts commit 820082d7f2.
This commit is contained in:
Frank Noirot
2025-04-30 21:58:11 -04:00
committed by GitHub
parent 012102fe86
commit 2d77aa0d36
48 changed files with 1043 additions and 826 deletions

View File

@ -1,9 +1,5 @@
import { useCallback, useEffect } from 'react'
import {
type NavigateFunction,
type useLocation,
useNavigate,
} from 'react-router-dom'
import { useNavigate } from 'react-router-dom'
import { waitFor } from 'xstate'
import { ActionButton } from '@src/components/ActionButton'
@ -15,39 +11,30 @@ import { NetworkHealthState } from '@src/hooks/useNetworkStatus'
import { EngineConnectionStateType } from '@src/lang/std/engineConnection'
import { bracket } from '@src/lib/exampleKcl'
import makeUrlPathRelative from '@src/lib/makeUrlPathRelative'
import { joinRouterPaths, PATHS } from '@src/lib/paths'
import {
codeManager,
editorManager,
kclManager,
systemIOActor,
} from '@src/lib/singletons'
import { PATHS } from '@src/lib/paths'
import { codeManager, editorManager, kclManager } from '@src/lib/singletons'
import { reportRejection, trap } from '@src/lib/trap'
import { settingsActor } from '@src/lib/singletons'
import { isKclEmptyOrOnlySettings, parse, resultIsOk } from '@src/lang/wasm'
import { onboardingRoutes } from '@src/routes/Onboarding'
import { onboardingPaths } from '@src/routes/Onboarding/paths'
import { parse, resultIsOk } from '@src/lang/wasm'
import { updateModelingState } from '@src/lang/modelingWorkflows'
import {
DEFAULT_PROJECT_KCL_FILE,
EXECUTION_TYPE_REAL,
ONBOARDING_PROJECT_NAME,
} from '@src/lib/constants'
import toast from 'react-hot-toast'
import type CodeManager from '@src/lang/codeManager'
import type { OnboardingStatus } from '@rust/kcl-lib/bindings/OnboardingStatus'
import { isDesktop } from '@src/lib/isDesktop'
import type { KclManager } from '@src/lang/KclSingleton'
import { Logo } from '@src/components/Logo'
import { SystemIOMachineEvents } from '@src/machines/systemIO/utils'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
import { EXECUTION_TYPE_REAL } from '@src/lib/constants'
export const kbdClasses =
'py-0.5 px-1 text-sm rounded bg-chalkboard-10 dark:bg-chalkboard-100 border border-chalkboard-50 border-b-2'
// Get the 1-indexed step number of the current onboarding step
function useStepNumber(
slug?: (typeof ONBOARDING_SUBPATHS)[keyof typeof ONBOARDING_SUBPATHS]
slug?: (typeof onboardingPaths)[keyof typeof onboardingPaths]
) {
return slug ? Object.values(ONBOARDING_SUBPATHS).indexOf(slug) + 1 : -1
return slug
? slug === onboardingPaths.INDEX
? 1
: onboardingRoutes.findIndex(
(r) => r.path === makeUrlPathRelative(slug)
) + 1
: 1
}
export function useDemoCode() {
@ -93,7 +80,7 @@ export function useNextClick(newStatus: string) {
data: { level: 'user', value: newStatus },
})
navigate(filePath + PATHS.ONBOARDING.INDEX.slice(0, -1) + newStatus)
}, [filePath, newStatus, navigate])
}, [filePath, newStatus, settingsActor.send, navigate])
}
export function useDismiss() {
@ -107,17 +94,9 @@ export function useDismiss() {
data: { level: 'user', value: 'dismissed' },
})
waitFor(settingsActor, (state) => state.matches('idle'))
.then(() => {
navigate(filePath)
toast.success(
'Click the question mark in the lower-right corner if you ever want to redo the tutorial!',
{
duration: 5_000,
}
)
})
.then(() => navigate(filePath))
.catch(reportRejection)
}, [send, filePath, navigate])
}, [send])
return settingsCallback
}
@ -128,31 +107,32 @@ export function OnboardingButtons({
onNextOverride,
...props
}: {
currentSlug?: (typeof ONBOARDING_SUBPATHS)[keyof typeof ONBOARDING_SUBPATHS]
currentSlug?: (typeof onboardingPaths)[keyof typeof onboardingPaths]
className?: string
dismissClassName?: string
onNextOverride?: () => void
} & React.HTMLAttributes<HTMLDivElement>) {
const onboardingPathsArray = Object.values(ONBOARDING_SUBPATHS)
const dismiss = useDismiss()
const stepNumber = useStepNumber(currentSlug)
const previousStep =
!stepNumber || stepNumber <= 1 ? null : onboardingPathsArray[stepNumber]
const goToPrevious = useNextClick(previousStep ?? ONBOARDING_SUBPATHS.INDEX)
!stepNumber || stepNumber === 0 ? null : onboardingRoutes[stepNumber - 2]
const goToPrevious = useNextClick(
onboardingPaths.INDEX + (previousStep?.path ?? '')
)
const nextStep =
!stepNumber || stepNumber === onboardingPathsArray.length
!stepNumber || stepNumber === onboardingRoutes.length
? null
: onboardingPathsArray[stepNumber]
const goToNext = useNextClick(nextStep + ONBOARDING_SUBPATHS.INDEX)
: onboardingRoutes[stepNumber]
const goToNext = useNextClick(onboardingPaths.INDEX + (nextStep?.path ?? ''))
return (
<>
<button
type="button"
onClick={dismiss}
className={`group block !absolute left-auto right-full top-[-3px] m-2.5 p-0 border-none bg-transparent hover:bg-transparent ${
className={
'group block !absolute left-auto right-full top-[-3px] m-2.5 p-0 border-none bg-transparent hover:bg-transparent ' +
dismissClassName
}`}
}
data-testid="onboarding-dismiss"
>
<CustomIcon
@ -164,12 +144,16 @@ export function OnboardingButtons({
</Tooltip>
</button>
<div
className={`flex items-center justify-between ${className ?? ''}`}
className={'flex items-center justify-between ' + (className ?? '')}
{...props}
>
<ActionButton
Element="button"
onClick={() => (previousStep ? goToPrevious() : dismiss())}
onClick={() =>
previousStep?.path || previousStep?.index
? goToPrevious()
: dismiss()
}
iconStart={{
icon: previousStep ? 'arrowLeft' : 'close',
className: 'text-chalkboard-10',
@ -178,18 +162,18 @@ export function OnboardingButtons({
className="hover:border-destroy-40 hover:bg-destroy-10/50 dark:hover:bg-destroy-80/50"
data-testid="onboarding-prev"
>
{previousStep ? 'Back' : 'Dismiss'}
{previousStep ? `Back` : 'Dismiss'}
</ActionButton>
{stepNumber !== undefined && (
<p className="font-mono text-xs text-center m-0">
{stepNumber} / {onboardingPathsArray.length}
{stepNumber} / {onboardingRoutes.length}
</p>
)}
<ActionButton
autoFocus
Element="button"
onClick={() => {
if (nextStep) {
if (nextStep?.path) {
onNextOverride ? onNextOverride() : goToNext()
} else {
dismiss()
@ -202,221 +186,9 @@ export function OnboardingButtons({
className="dark:hover:bg-chalkboard-80/50"
data-testid="onboarding-next"
>
{nextStep ? 'Next' : 'Finish'}
{nextStep ? `Next` : 'Finish'}
</ActionButton>
</div>
</>
)
}
export interface OnboardingUtilDeps {
onboardingStatus: OnboardingStatus
codeManager: CodeManager
kclManager: KclManager
navigate: NavigateFunction
}
export const ERROR_MUST_WARN = 'Must warn user before overwrite'
/**
* Accept to begin the onboarding tutorial,
* depending on the platform and the state of the user's code.
*/
export async function acceptOnboarding(deps: OnboardingUtilDeps) {
if (isDesktop()) {
/** TODO: rename this event to be more generic, like `createKCLFileAndNavigate` */
systemIOActor.send({
type: SystemIOMachineEvents.importFileFromURL,
data: {
requestedProjectName: ONBOARDING_PROJECT_NAME,
requestedFileName: DEFAULT_PROJECT_KCL_FILE,
requestedCode: bracket,
requestedSubRoute: joinRouterPaths(
PATHS.ONBOARDING.INDEX,
deps.onboardingStatus
),
},
})
return Promise.resolve()
}
const isCodeResettable = hasResetReadyCode(deps.codeManager)
if (isCodeResettable) {
return resetCodeAndAdvanceOnboarding(deps)
}
return Promise.reject(new Error(ERROR_MUST_WARN))
}
/**
* Given that the user has accepted overwriting their web editor,
* advance to the next step and clear their editor.
*/
export async function resetCodeAndAdvanceOnboarding({
onboardingStatus,
codeManager,
kclManager,
navigate,
}: OnboardingUtilDeps) {
// We do want to update both the state and editor here.
codeManager.updateCodeStateEditor(bracket)
codeManager.writeToFile().catch(reportRejection)
kclManager.executeCode().catch(reportRejection)
// TODO: this is not navigating to the correct `/onboarding/blah` path yet
navigate(
makeUrlPathRelative(
`${PATHS.ONBOARDING.INDEX}${makeUrlPathRelative(onboardingStatus)}`
)
)
}
function hasResetReadyCode(codeManager: CodeManager) {
return (
isKclEmptyOrOnlySettings(codeManager.code) || codeManager.code === bracket
)
}
export function needsToOnboard(
location: ReturnType<typeof useLocation>,
onboardingStatus: OnboardingStatus
) {
return (
!location.pathname.includes(PATHS.ONBOARDING.INDEX) &&
(onboardingStatus.length === 0 ||
!(onboardingStatus === 'completed' || onboardingStatus === 'dismissed'))
)
}
export const ONBOARDING_TOAST_ID = 'onboarding-toast'
export function onDismissOnboardingInvite() {
settingsActor.send({
type: 'set.app.onboardingStatus',
data: { level: 'user', value: 'dismissed' },
})
toast.dismiss(ONBOARDING_TOAST_ID)
toast.success(
'Click the question mark in the lower-right corner if you ever want to do the tutorial!',
{
duration: 5_000,
}
)
}
export function TutorialRequestToast(props: OnboardingUtilDeps) {
function onAccept() {
acceptOnboarding(props)
.then(() => {
toast.dismiss(ONBOARDING_TOAST_ID)
})
.catch((reason) => catchOnboardingWarnError(reason, props))
}
return (
<div
data-testid="onboarding-toast"
className="flex items-center gap-6 min-w-80"
>
<Logo className="w-auto h-8 flex-none" />
<div className="flex flex-col justify-between gap-6">
<section>
<h2>Welcome to Zoo Design Studio</h2>
<p className="text-sm text-chalkboard-70 dark:text-chalkboard-30">
Would you like a tutorial to show you around the app?
</p>
</section>
<div className="flex justify-between gap-8">
<ActionButton
Element="button"
iconStart={{
icon: 'close',
}}
data-negative-button="dismiss"
name="dismiss"
onClick={onDismissOnboardingInvite}
>
Not right now
</ActionButton>
<ActionButton
Element="button"
iconStart={{
icon: 'checkmark',
}}
name="accept"
onClick={onAccept}
>
Get started
</ActionButton>
</div>
</div>
</div>
)
}
/**
* Helper function to catch the `ERROR_MUST_WARN` error from
* `acceptOnboarding` and show a warning toast.
*/
export async function catchOnboardingWarnError(
err: Error,
props: OnboardingUtilDeps
) {
if (err instanceof Error && err.message === ERROR_MUST_WARN) {
toast.success(TutorialWebConfirmationToast(props), {
id: ONBOARDING_TOAST_ID,
duration: Number.POSITIVE_INFINITY,
icon: null,
})
} else {
toast.dismiss(ONBOARDING_TOAST_ID)
return reportRejection(err)
}
}
export function TutorialWebConfirmationToast(props: OnboardingUtilDeps) {
function onAccept() {
toast.dismiss(ONBOARDING_TOAST_ID)
resetCodeAndAdvanceOnboarding(props).catch(reportRejection)
}
return (
<div
data-testid="onboarding-toast-confirmation"
className="flex items-center gap-6 min-w-80"
>
<Logo className="w-auto h-8 flex-none" />
<div className="flex flex-col justify-between gap-6">
<section>
<h2>The welcome tutorial resets your code in the browser</h2>
<p className="text-sm text-chalkboard-70 dark:text-chalkboard-30">
We see you have some of your own code written in this project.
Please save it somewhere else before continuing the onboarding.
</p>
</section>
<div className="flex justify-between gap-8">
<ActionButton
Element="button"
iconStart={{
icon: 'close',
}}
data-negative-button="dismiss"
name="dismiss"
onClick={onDismissOnboardingInvite}
>
I'll save it
</ActionButton>
<ActionButton
Element="button"
iconStart={{
icon: 'checkmark',
}}
name="accept"
onClick={onAccept}
>
Overwrite and begin
</ActionButton>
</div>
</div>
</div>
)
}