diff --git a/e2e/playwright/fixtures/homePageFixture.ts b/e2e/playwright/fixtures/homePageFixture.ts
index 4878e5fbb93..43765161682 100644
--- a/e2e/playwright/fixtures/homePageFixture.ts
+++ b/e2e/playwright/fixtures/homePageFixture.ts
@@ -24,6 +24,7 @@ export class HomePageFixture {
projectTextName!: Locator
sortByDateBtn!: Locator
sortByNameBtn!: Locator
+ tutorialBtn!: Locator
constructor(page: Page) {
this.page = page
@@ -43,6 +44,7 @@ export class HomePageFixture {
this.sortByDateBtn = this.page.getByTestId('home-sort-by-modified')
this.sortByNameBtn = this.page.getByTestId('home-sort-by-name')
+ this.tutorialBtn = this.page.getByTestId('home-tutorial-button')
}
private _serialiseSortBy = async (): Promise<
diff --git a/e2e/playwright/fixtures/toolbarFixture.ts b/e2e/playwright/fixtures/toolbarFixture.ts
index ed7bffe79c9..ae51d659acd 100644
--- a/e2e/playwright/fixtures/toolbarFixture.ts
+++ b/e2e/playwright/fixtures/toolbarFixture.ts
@@ -17,6 +17,8 @@ type LengthUnitLabel = (typeof baseUnitLabels)[keyof typeof baseUnitLabels]
export class ToolbarFixture {
public page: Page
+ projectName!: Locator
+ fileName!: Locator
extrudeButton!: Locator
loftButton!: Locator
sweepButton!: Locator
@@ -53,6 +55,8 @@ export class ToolbarFixture {
constructor(page: Page) {
this.page = page
+ this.projectName = page.getByTestId('app-header-project-name')
+ this.fileName = page.getByTestId('app-header-file-name')
this.extrudeButton = page.getByTestId('extrude')
this.loftButton = page.getByTestId('loft')
this.sweepButton = page.getByTestId('sweep')
diff --git a/e2e/playwright/native-file-menu.spec.ts b/e2e/playwright/native-file-menu.spec.ts
index 1097f8ecf9a..d6fff4b0201 100644
--- a/e2e/playwright/native-file-menu.spec.ts
+++ b/e2e/playwright/native-file-menu.spec.ts
@@ -450,7 +450,7 @@ test.describe(
)
await expect(actual).toBeVisible()
})
- test('Home.Help.Reset onboarding', async ({
+ test('Home.Help.Replay onboarding tutorial', async ({
tronApp,
cmdBar,
page,
@@ -464,7 +464,7 @@ test.describe(
await tronApp.electron.evaluate(async ({ app }) => {
if (!app || !app.applicationMenu) return false
const menu = app.applicationMenu.getMenuItemById(
- 'Help.Reset onboarding'
+ 'Help.Replay onboarding tutorial'
)
if (!menu) {
return false
@@ -2339,7 +2339,7 @@ test.describe(
await scene.connectionEstablished()
await expect(toolbar.startSketchBtn).toBeVisible()
})
- test('Modeling.Help.Reset onboarding', async ({
+ test('Modeling.Help.Replay onboarding tutorial', async ({
tronApp,
cmdBar,
page,
@@ -2358,7 +2358,7 @@ test.describe(
await tronApp.electron.evaluate(async ({ app }) => {
if (!app || !app.applicationMenu) fail()
const menu = app.applicationMenu.getMenuItemById(
- 'Help.Reset onboarding'
+ 'Help.Replay onboarding tutorial'
)
if (!menu) fail()
menu.click()
diff --git a/e2e/playwright/onboarding-tests.spec.ts b/e2e/playwright/onboarding-tests.spec.ts
index a2e776c5617..6febaffc8e1 100644
--- a/e2e/playwright/onboarding-tests.spec.ts
+++ b/e2e/playwright/onboarding-tests.spec.ts
@@ -1,560 +1,175 @@
-import { join } from 'path'
-import { bracket } from '@e2e/playwright/fixtures/bracket'
-import { onboardingPaths } from '@src/routes/Onboarding/paths'
-import fsp from 'fs/promises'
-
-import { expectPixelColor } from '@e2e/playwright/fixtures/sceneFixture'
-import {
- TEST_SETTINGS_KEY,
- TEST_SETTINGS_ONBOARDING_EXPORT,
- TEST_SETTINGS_ONBOARDING_START,
- TEST_SETTINGS_ONBOARDING_USER_MENU,
-} from '@e2e/playwright/storageStates'
-import {
- createProject,
- executorInputPath,
- getUtils,
- settingsToToml,
-} from '@e2e/playwright/test-utils'
import { expect, test } from '@e2e/playwright/zoo-test'
-// Because our default test settings have the onboardingStatus set to 'dismissed',
-// we must set it to empty for the tests where we want to see the onboarding immediately.
-
test.describe('Onboarding tests', () => {
- test('Onboarding code is shown in the editor', async ({
- page,
- homePage,
- tronApp,
- }) => {
- if (!tronApp) {
- fail()
- }
- await tronApp.cleanProjectDir({
- app: {
- onboarding_status: '',
- },
- })
-
- const u = await getUtils(page)
- await page.setBodyDimensions({ width: 1200, height: 500 })
- await homePage.goToModelingScene()
-
- // Test that the onboarding pane loaded
- await expect(page.getByText('Welcome to Design Studio! This')).toBeVisible()
-
- // Test that the onboarding pane loaded
- await expect(page.getByText('Welcome to Design Studio! This')).toBeVisible()
-
- // *and* that the code is shown in the editor
- await expect(page.locator('.cm-content')).toContainText('// Shelf Bracket')
-
- // Make sure the model loaded
- const XYPlanePoint = { x: 774, y: 116 } as const
- const modelColor: [number, number, number] = [45, 45, 45]
- await page.mouse.move(XYPlanePoint.x, XYPlanePoint.y)
- expect(await u.getGreatestPixDiff(XYPlanePoint, modelColor)).toBeLessThan(8)
- })
-
- test(
- 'Desktop: fresh onboarding executes and loads',
- {
- tag: '@electron',
- },
- async ({ page, tronApp, scene }) => {
- if (!tronApp) {
- fail()
- }
- await tronApp.cleanProjectDir({
- app: {
- onboarding_status: '',
- },
- })
-
- const viewportSize = { width: 1200, height: 500 }
- await page.setBodyDimensions(viewportSize)
-
- await test.step(`Create a project and open to the onboarding`, async () => {
- await createProject({ name: 'project-link', page })
- await test.step(`Ensure the engine connection works by testing the sketch button`, async () => {
- await scene.connectionEstablished()
- })
- })
-
- await test.step(`Ensure we see the onboarding stuff`, async () => {
- // Test that the onboarding pane loaded
- await expect(
- page.getByText('Welcome to Design Studio! This')
- ).toBeVisible()
-
- // *and* that the code is shown in the editor
- await expect(page.locator('.cm-content')).toContainText(
- '// Shelf Bracket'
- )
-
- // TODO: jess make less shit
- // Make sure the model loaded
- //const XYPlanePoint = { x: 986, y: 522 } as const
- //const modelColor: [number, number, number] = [76, 76, 76]
- //await page.mouse.move(XYPlanePoint.x, XYPlanePoint.y)
-
- //await expectPixelColor(page, modelColor, XYPlanePoint, 8)
- })
- }
- )
-
- test('Code resets after confirmation', async ({
+ test('Desktop onboarding flow works', async ({
page,
homePage,
- tronApp,
+ toolbar,
+ editor,
scene,
- }) => {
- if (!tronApp) {
- fail()
- }
- await tronApp.cleanProjectDir()
-
- const initialCode = `sketch001 = startSketchOn(XZ)`
-
- // Load the page up with some code so we see the confirmation warning
- // when we go to replay onboarding
- await page.addInitScript((code) => {
- localStorage.setItem('persistCode', code)
- }, initialCode)
-
- await page.setBodyDimensions({ width: 1200, height: 500 })
- await homePage.goToModelingScene()
- await scene.connectionEstablished()
-
- // Replay the onboarding
- await page.getByRole('link', { name: 'Settings' }).last().click()
- const replayButton = page.getByRole('button', {
- name: 'Replay onboarding',
- })
- await expect(replayButton).toBeVisible()
- await replayButton.click()
-
- // Ensure we see the warning, and that the code has not yet updated
- await expect(page.getByText('Would you like to create')).toBeVisible()
- await expect(page.locator('.cm-content')).toHaveText(initialCode)
-
- const nextButton = page.getByTestId('onboarding-next')
- await nextButton.hover()
- await nextButton.click()
-
- // Ensure we see the introduction and that the code has been reset
- await expect(page.getByText('Welcome to Design Studio!')).toBeVisible()
- await expect(page.locator('.cm-content')).toContainText('// Shelf Bracket')
-
- // There used to be old code here that checked if we stored the reset
- // code into localStorage but that isn't the case on desktop. It gets
- // saved to the file system, which we have other tests for.
- })
-
- test('Click through each onboarding step and back', async ({
- context,
- page,
- homePage,
tronApp,
}) => {
if (!tronApp) {
fail()
}
+
+ // Because our default test settings have the onboardingStatus set to 'dismissed',
+ // we must set it to empty for the tests where we want to see the onboarding UI.
await tronApp.cleanProjectDir({
app: {
onboarding_status: '',
},
})
- // Override beforeEach test setup
- await context.addInitScript(
- async ({ settingsKey, settings }) => {
- // Give no initial code, so that the onboarding start is shown immediately
- localStorage.setItem('persistCode', '')
- localStorage.setItem(settingsKey, settings)
- },
- {
- settingsKey: TEST_SETTINGS_KEY,
- settings: settingsToToml({
- settings: TEST_SETTINGS_ONBOARDING_START,
- }),
- }
- )
-
- await page.setBodyDimensions({ width: 1200, height: 1080 })
- await homePage.goToModelingScene()
-
- // Test that the onboarding pane loaded
- await expect(page.getByText('Welcome to Design Studio! This')).toBeVisible()
+ const bracketComment = '// Shelf Bracket'
+ const tutorialWelcomHeading = page.getByText(
+ 'Welcome to Design Studio! This'
+ )
const nextButton = page.getByTestId('onboarding-next')
const prevButton = page.getByTestId('onboarding-prev')
-
- while ((await nextButton.innerText()) !== 'Finish') {
- await nextButton.hover()
- await nextButton.click()
- }
-
- while ((await prevButton.innerText()) !== 'Dismiss') {
- await prevButton.hover()
- await prevButton.click()
- }
-
- // Dismiss the onboarding
- await prevButton.hover()
- await prevButton.click()
-
- // Test that the onboarding pane is gone
- await expect(page.getByTestId('onboarding-content')).not.toBeVisible()
- await expect.poll(() => page.url()).not.toContain('/onboarding')
- })
-
- test('Onboarding redirects and code updating', async ({
- context,
- page,
- homePage,
- tronApp,
- }) => {
- if (!tronApp) {
- fail()
- }
- await tronApp.cleanProjectDir({
- app: {
- onboarding_status: '/export',
- },
- })
-
- const originalCode = 'sigmaAllow = 15000'
-
- // Override beforeEach test setup
- await context.addInitScript(
- async ({ settingsKey, settings, code }) => {
- // Give some initial code, so we can test that it's cleared
- localStorage.setItem('persistCode', code)
- localStorage.setItem(settingsKey, settings)
- },
- {
- settingsKey: TEST_SETTINGS_KEY,
- settings: settingsToToml({
- settings: TEST_SETTINGS_ONBOARDING_EXPORT,
- }),
- code: originalCode,
- }
- )
-
- await page.setBodyDimensions({ width: 1200, height: 500 })
- await homePage.goToModelingScene()
-
- // Test that the redirect happened
- await expect.poll(() => page.url()).toContain('/onboarding/export')
-
- // Test that you come back to this page when you refresh
- await page.reload()
- await expect.poll(() => page.url()).toContain('/onboarding/export')
-
- // Test that the code changes when you advance to the next step
- await page.getByTestId('onboarding-next').hover()
- await page.getByTestId('onboarding-next').click()
-
- // Test that the onboarding pane loaded
- const title = page.locator('[data-testid="onboarding-content"]')
- await expect(title).toBeAttached()
-
- await expect(page.locator('.cm-content')).not.toHaveText(originalCode)
-
- // Test that the code is not empty when you click on the next step
- await page.locator('[data-testid="onboarding-next"]').hover()
- await page.locator('[data-testid="onboarding-next"]').click()
- await expect(page.locator('.cm-content')).toHaveText(/.+/)
- })
-
- test('Onboarding code gets reset to demo on Interactive Numbers step', async ({
- page,
- homePage,
- tronApp,
- editor,
- toolbar,
- }) => {
- if (!tronApp) {
- fail()
- }
- await tronApp.cleanProjectDir({
- app: {
- onboarding_status: '/parametric-modeling',
- },
- })
-
- const badCode = `// This is bad code we shouldn't see`
-
- await page.setBodyDimensions({ width: 1200, height: 1080 })
- await homePage.goToModelingScene()
-
- await expect
- .poll(() => page.url())
- .toContain(onboardingPaths.PARAMETRIC_MODELING)
-
- // Check the code got reset on load
- await toolbar.openPane('code')
- await editor.expectEditor.toContain(bracket, {
- shouldNormalise: true,
- timeout: 10_000,
+ const userMenuButton = toolbar.userSidebarButton
+ const userMenuSettingsButton = page.getByRole('button', {
+ name: 'User settings',
})
-
- // Mess with the code again
- await editor.replaceCode('', badCode)
- await editor.expectEditor.toContain(badCode, {
- shouldNormalise: true,
- timeout: 10_000,
+ const settingsHeading = page.getByRole('heading', {
+ name: 'Settings',
+ exact: true,
})
-
- // Click to the next step
- await page.locator('[data-testid="onboarding-next"]').hover()
- await page.locator('[data-testid="onboarding-next"]').click()
- await page.waitForURL('**' + onboardingPaths.INTERACTIVE_NUMBERS, {
- waitUntil: 'domcontentloaded',
+ const restartOnboardingSettingsButton = page.getByRole('button', {
+ name: 'Replay onboarding',
})
-
- // Check that the code has been reset
- await editor.expectEditor.toContain(bracket, {
- shouldNormalise: true,
- timeout: 10_000,
+ const helpMenuButton = page.getByRole('button', {
+ name: 'Help and resources',
})
- })
-
- // (lee) The two avatar tests are weird because even on main, we don't have
- // anything to do with the avatar inside the onboarding test. Due to the
- // low impact of an avatar not showing I'm changing this to fixme.
- test('Avatar text updates depending on image load success', async ({
- context,
- page,
- toolbar,
- homePage,
- tronApp,
- }) => {
- if (!tronApp) {
- fail()
- }
-
- await tronApp.cleanProjectDir({
- app: {
- onboarding_status: '',
- },
+ const helpMenuRestartOnboardingButton = page.getByRole('button', {
+ name: 'Replay onboarding tutorial',
})
-
- // Override beforeEach test setup
- await context.addInitScript(
- async ({ settingsKey, settings }) => {
- localStorage.setItem(settingsKey, settings)
- },
- {
- settingsKey: TEST_SETTINGS_KEY,
- settings: settingsToToml({
- settings: TEST_SETTINGS_ONBOARDING_USER_MENU,
- }),
- }
+ const postDismissToast = page.getByText(
+ 'Click the question mark in the lower-right corner if you ever want to redo the tutorial!'
)
- await page.setBodyDimensions({ width: 1200, height: 500 })
- await homePage.goToModelingScene()
-
- // Test that the text in this step is correct
- const avatarLocator = toolbar.userSidebarButton.locator('img')
- const onboardingOverlayLocator = page
- .getByTestId('onboarding-content')
- .locator('div')
- .nth(1)
-
- // Expect the avatar to be visible and for the text to reference it
- await expect(avatarLocator).toBeVisible()
- await expect(onboardingOverlayLocator).toBeVisible()
- await expect(onboardingOverlayLocator).toContainText('your avatar')
-
- // This is to force the avatar to 404.
- // For our test image (only triggers locally. on CI, it's Kurt's /
- // gravatar image )
- await page.route('/cat.jpg', async (route) => {
- await route.fulfill({
- status: 404,
- contentType: 'text/plain',
- body: 'Not Found!',
+ await test.step('Test initial home page view, showing a tutorial button', async () => {
+ await expect(homePage.tutorialBtn).toBeVisible()
+ await homePage.expectState({
+ projectCards: [],
+ sortBy: 'last-modified-desc',
})
})
- // 404 the CI avatar image
- await page.route('https://lh3.googleusercontent.com/**', async (route) => {
- await route.fulfill({
- status: 404,
- contentType: 'text/plain',
- body: 'Not Found!',
+ await test.step('Create a blank project and verify no onboarding chrome is shown', async () => {
+ await homePage.goToModelingScene()
+ await expect(toolbar.projectName).toContainText('testDefault')
+ await expect(tutorialWelcomHeading).not.toBeVisible()
+ await editor.expectEditor.toContain('@settings(defaultLengthUnit = in)', {
+ shouldNormalise: true,
})
+ await scene.connectionEstablished()
+ await expect(toolbar.startSketchBtn).toBeEnabled({ timeout: 15_000 })
})
- await page.reload({ waitUntil: 'domcontentloaded' })
-
- // Now expect the text to be different
- await expect(avatarLocator).not.toBeVisible()
- await expect(onboardingOverlayLocator).toBeVisible()
- await expect(onboardingOverlayLocator).toContainText('the menu button')
- })
-
- test("Avatar text doesn't mention avatar when no avatar", async ({
- context,
- page,
- toolbar,
- homePage,
- tronApp,
- }) => {
- if (!tronApp) {
- fail()
- }
-
- await tronApp.cleanProjectDir({
- app: {
- onboarding_status: '',
- },
+ await test.step('Go home and verify we still see the tutorial button, then begin it.', async () => {
+ await toolbar.logoLink.click()
+ await expect(homePage.tutorialBtn).toBeVisible()
+ await homePage.expectState({
+ projectCards: [
+ {
+ title: 'testDefault',
+ fileCount: 1,
+ },
+ ],
+ sortBy: 'last-modified-desc',
+ })
+ await homePage.tutorialBtn.click()
})
- // Override beforeEach test setup
- await context.addInitScript(
- async ({ settingsKey, settings }) => {
- localStorage.setItem(settingsKey, settings)
- localStorage.setItem('FORCE_NO_IMAGE', 'FORCE_NO_IMAGE')
- },
- {
- settingsKey: TEST_SETTINGS_KEY,
- settings: settingsToToml({
- settings: TEST_SETTINGS_ONBOARDING_USER_MENU,
- }),
- }
- )
-
- await page.setBodyDimensions({ width: 1200, height: 500 })
- await homePage.goToModelingScene()
- // Test that the text in this step is correct
- const sidebar = toolbar.userSidebarButton
- const avatar = sidebar.locator('img')
- const onboardingOverlayLocator = page
- .getByTestId('onboarding-content')
- .locator('div')
- .nth(1)
-
- // Expect the avatar to be visible and for the text to reference it
- await expect(avatar).not.toBeVisible()
- await expect(onboardingOverlayLocator).toBeVisible()
- await expect(onboardingOverlayLocator).toContainText('the menu button')
-
- // Test we mention what else is in this menu for https://github.com/KittyCAD/modeling-app/issues/2939
- // which doesn't deserver its own full test spun up
- const userMenuFeatures = [
- 'manage your account',
- 'report a bug',
- 'request a feature',
- 'sign out',
- ]
- for (const feature of userMenuFeatures) {
- await expect(onboardingOverlayLocator).toContainText(feature)
- }
- })
-})
-
-test('Restarting onboarding on desktop takes one attempt', async ({
- context,
- page,
- toolbar,
- tronApp,
-}) => {
- if (!tronApp) {
- fail()
- }
-
- await tronApp.cleanProjectDir({
- app: {
- onboarding_status: 'dismissed',
- },
- })
-
- await context.folderSetupFn(async (dir) => {
- const routerTemplateDir = join(dir, 'router-template-slate')
- await fsp.mkdir(routerTemplateDir, { recursive: true })
- await fsp.copyFile(
- executorInputPath('router-template-slate.kcl'),
- join(routerTemplateDir, 'main.kcl')
- )
- })
+ // This is web-only.
+ // TODO: write a new test just for the onboarding in browser
+ // await test.step('Ensure the onboarding request toast appears', async () => {
+ // await expect(page.getByTestId('onboarding-toast')).toBeVisible()
+ // await page.getByTestId('onboarding-next').click()
+ // })
+
+ await test.step('Ensure we see the welcome screen in a new project', async () => {
+ await expect(toolbar.projectName).toContainText('Tutorial Project 00')
+ await expect(tutorialWelcomHeading).toBeVisible()
+ await editor.expectEditor.toContain(bracketComment)
+ await scene.connectionEstablished()
+ await expect(toolbar.startSketchBtn).toBeEnabled({ timeout: 15_000 })
+ })
- // Our constants
- const u = await getUtils(page)
- const projectCard = page.getByText('router-template-slate')
- const helpMenuButton = page.getByRole('button', {
- name: 'Help and resources',
- })
- const restartOnboardingButton = page.getByRole('button', {
- name: 'Reset onboarding',
- })
- const nextButton = page.getByTestId('onboarding-next')
+ await test.step('Test the clicking through the onboarding flow', async () => {
+ await test.step('Going forward', async () => {
+ while ((await nextButton.innerText()) !== 'Finish') {
+ await nextButton.hover()
+ await nextButton.click()
+ }
+ })
- const tutorialProjectIndicator = page
- .getByTestId('project-sidebar-toggle')
- .filter({ hasText: 'Tutorial Project 00' })
- const tutorialModalText = page.getByText('Welcome to Design Studio!')
- const tutorialDismissButton = page.getByRole('button', { name: 'Dismiss' })
- const userMenuButton = toolbar.userSidebarButton
- const userMenuSettingsButton = page.getByRole('button', {
- name: 'User settings',
- })
- const settingsHeading = page.getByRole('heading', {
- name: 'Settings',
- exact: true,
- })
- const restartOnboardingSettingsButton = page.getByRole('button', {
- name: 'Replay onboarding',
- })
+ await test.step('Going backward', async () => {
+ while ((await prevButton.innerText()) !== 'Dismiss') {
+ await prevButton.hover()
+ await prevButton.click()
+ }
+ })
- await test.step('Navigate into project', async () => {
- await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible()
- await expect(projectCard).toBeVisible()
- await projectCard.click()
- await u.waitForPageLoad()
- })
+ // Dismiss the onboarding
+ await test.step('Dismiss the onboarding', async () => {
+ await prevButton.hover()
+ await prevButton.click()
+ await expect(page.getByTestId('onboarding-content')).not.toBeVisible()
+ await expect(postDismissToast).toBeVisible()
+ await expect.poll(() => page.url()).not.toContain('/onboarding')
+ })
+ })
- await test.step('Restart the onboarding from help menu', async () => {
- await helpMenuButton.click()
- await restartOnboardingButton.click()
+ await test.step('Resetting onboarding from inside project should always make a new one', async () => {
+ await test.step('Reset onboarding from settings', async () => {
+ await userMenuButton.click()
+ await userMenuSettingsButton.click()
+ await expect(settingsHeading).toBeVisible()
+ await expect(restartOnboardingSettingsButton).toBeVisible()
+ await restartOnboardingSettingsButton.click()
+ })
- await nextButton.hover()
- await nextButton.click()
- })
+ await test.step('Makes a new project', async () => {
+ await expect(toolbar.projectName).toContainText('Tutorial Project 01')
+ await expect(tutorialWelcomHeading).toBeVisible()
+ await editor.expectEditor.toContain(bracketComment)
+ await scene.connectionEstablished()
+ await expect(toolbar.startSketchBtn).toBeEnabled({ timeout: 15_000 })
+ })
- await test.step('Confirm that the onboarding has restarted', async () => {
- await expect(tutorialProjectIndicator).toBeVisible()
- await expect(tutorialModalText).toBeVisible()
- // Make sure the model loaded
- const XYPlanePoint = { x: 988, y: 523 } as const
- const modelColor: [number, number, number] = [76, 76, 76]
+ await test.step('Dismiss the onboarding', async () => {
+ await postDismissToast.waitFor({ state: 'detached' })
+ await page.keyboard.press('Escape')
+ await expect(postDismissToast).toBeVisible()
+ await expect(page.getByTestId('onboarding-content')).not.toBeVisible()
+ await expect.poll(() => page.url()).not.toContain('/onboarding')
+ })
+ })
- await page.mouse.move(XYPlanePoint.x, XYPlanePoint.y)
- await expectPixelColor(page, modelColor, XYPlanePoint, 8)
- await tutorialDismissButton.click()
- // Make sure model still there.
- await expectPixelColor(page, modelColor, XYPlanePoint, 8)
- })
+ await test.step('Resetting onboarding from home help menu makes a new project', async () => {
+ await test.step('Go home and reset onboarding from lower-right help menu', async () => {
+ await toolbar.logoLink.click()
+ await expect(homePage.tutorialBtn).not.toBeVisible()
+ await expect(
+ homePage.projectCard.getByText('Tutorial Project 00')
+ ).toBeVisible()
+ await expect(
+ homePage.projectCard.getByText('Tutorial Project 01')
+ ).toBeVisible()
- await test.step('Clear code and restart onboarding from settings', async () => {
- await u.openKclCodePanel()
- await expect(u.codeLocator).toContainText('// Shelf Bracket')
- await u.codeLocator.selectText()
- await u.codeLocator.fill('')
+ await helpMenuButton.click()
+ await helpMenuRestartOnboardingButton.click()
+ })
- await test.step('Navigate to settings', async () => {
- await userMenuButton.click()
- await userMenuSettingsButton.click()
- await expect(settingsHeading).toBeVisible()
- await expect(restartOnboardingSettingsButton).toBeVisible()
+ await test.step('Makes a new project', async () => {
+ await expect(toolbar.projectName).toContainText('Tutorial Project 02')
+ await expect(tutorialWelcomHeading).toBeVisible()
+ await editor.expectEditor.toContain(bracketComment)
+ await scene.connectionEstablished()
+ await expect(toolbar.startSketchBtn).toBeEnabled({ timeout: 15_000 })
+ })
})
-
- await restartOnboardingSettingsButton.click()
- // Since the code is empty, we should not see the confirmation dialog
- await expect(nextButton).not.toBeVisible()
- await expect(tutorialProjectIndicator).toBeVisible()
- await expect(tutorialModalText).toBeVisible()
})
})
diff --git a/e2e/playwright/storageStates.ts b/e2e/playwright/storageStates.ts
index 345bdfdb109..48d9fc5e55d 100644
--- a/e2e/playwright/storageStates.ts
+++ b/e2e/playwright/storageStates.ts
@@ -1,7 +1,7 @@
import type { SaveSettingsPayload } from '@src/lib/settings/settingsTypes'
import { Themes } from '@src/lib/theme'
import type { DeepPartial } from '@src/lib/types'
-import { onboardingPaths } from '@src/routes/Onboarding/paths'
+import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
import type { Settings } from '@rust/kcl-lib/bindings/Settings'
@@ -31,12 +31,15 @@ export const TEST_SETTINGS: DeepPartial = {
export const TEST_SETTINGS_ONBOARDING_USER_MENU: DeepPartial = {
...TEST_SETTINGS,
- app: { ...TEST_SETTINGS.app, onboarding_status: onboardingPaths.USER_MENU },
+ app: {
+ ...TEST_SETTINGS.app,
+ onboarding_status: ONBOARDING_SUBPATHS.USER_MENU,
+ },
}
export const TEST_SETTINGS_ONBOARDING_EXPORT: DeepPartial = {
...TEST_SETTINGS,
- app: { ...TEST_SETTINGS.app, onboarding_status: onboardingPaths.EXPORT },
+ app: { ...TEST_SETTINGS.app, onboarding_status: ONBOARDING_SUBPATHS.EXPORT },
}
export const TEST_SETTINGS_ONBOARDING_PARAMETRIC_MODELING: DeepPartial =
@@ -44,7 +47,7 @@ export const TEST_SETTINGS_ONBOARDING_PARAMETRIC_MODELING: DeepPartial
...TEST_SETTINGS,
app: {
...TEST_SETTINGS.app,
- onboarding_status: onboardingPaths.PARAMETRIC_MODELING,
+ onboarding_status: ONBOARDING_SUBPATHS.PARAMETRIC_MODELING,
},
}
diff --git a/e2e/playwright/test-utils.ts b/e2e/playwright/test-utils.ts
index 9ef93263ff4..9f4201e09b0 100644
--- a/e2e/playwright/test-utils.ts
+++ b/e2e/playwright/test-utils.ts
@@ -5,7 +5,7 @@ import type { BrowserContext, Locator, Page, TestInfo } from '@playwright/test'
import { expect } from '@playwright/test'
import type { EngineCommand } from '@src/lang/std/artifactGraph'
import type { Configuration } from '@src/lang/wasm'
-import { COOKIE_NAME } from '@src/lib/constants'
+import { COOKIE_NAME, IS_PLAYWRIGHT_KEY } from '@src/lib/constants'
import { reportRejection } from '@src/lib/trap'
import type { DeepPartial } from '@src/lib/types'
import { isArray } from '@src/lib/utils'
@@ -19,7 +19,6 @@ import type { ProjectConfiguration } from '@rust/kcl-lib/bindings/ProjectConfigu
import { isErrorWhitelisted } from '@e2e/playwright/lib/console-error-whitelist'
import { secrets } from '@e2e/playwright/secrets'
import { TEST_SETTINGS, TEST_SETTINGS_KEY } from '@e2e/playwright/storageStates'
-import { IS_PLAYWRIGHT_KEY } from '@src/lib/constants'
import { test } from '@e2e/playwright/zoo-test'
const toNormalizedCode = (text: string) => {
diff --git a/known-circular.txt b/known-circular.txt
index a36dee1ef7f..fbdd980df08 100644
--- a/known-circular.txt
+++ b/known-circular.txt
@@ -11,4 +11,3 @@
6) src/lib/singletons.ts -> src/clientSideScene/sceneEntities.ts -> src/clientSideScene/segments.ts -> src/components/Toolbar/angleLengthInfo.ts
7) src/lib/singletons.ts -> src/clientSideScene/sceneEntities.ts -> src/clientSideScene/segments.ts
8) src/hooks/useModelingContext.ts -> src/components/ModelingMachineProvider.tsx -> src/components/Toolbar/Intersect.tsx -> src/components/SetHorVertDistanceModal.tsx -> src/lib/useCalculateKclExpression.ts
- 9) src/routes/Onboarding/index.tsx -> src/routes/Onboarding/Camera.tsx -> src/routes/Onboarding/utils.tsx
diff --git a/rust/kcl-lib/src/settings/types/mod.rs b/rust/kcl-lib/src/settings/types/mod.rs
index 0437d4b0241..babf539965f 100644
--- a/rust/kcl-lib/src/settings/types/mod.rs
+++ b/rust/kcl-lib/src/settings/types/mod.rs
@@ -527,9 +527,6 @@ pub enum OnboardingStatus {
#[serde(rename = "/export")]
#[display("/export")]
Export,
- #[serde(rename = "/move")]
- #[display("/move")]
- Move,
#[serde(rename = "/sketching")]
#[display("/sketching")]
Sketching,
diff --git a/src/App.tsx b/src/App.tsx
index 4e501a20e1b..e087d074e60 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -4,6 +4,7 @@ import { useHotkeys } from 'react-hotkeys-hook'
import ModalContainer from 'react-modal-promise'
import {
useLoaderData,
+ useLocation,
useNavigate,
useRouteLoaderData,
useSearchParams,
@@ -26,15 +27,20 @@ import useHotkeyWrapper from '@src/lib/hotkeyWrapper'
import { isDesktop } from '@src/lib/isDesktop'
import { PATHS } from '@src/lib/paths'
import { takeScreenshotOfVideoStreamCanvas } from '@src/lib/screenshot'
-import { sceneInfra } from '@src/lib/singletons'
+import { sceneInfra, codeManager, kclManager } from '@src/lib/singletons'
import { maybeWriteToDisk } from '@src/lib/telemetry'
-import { type IndexLoaderData } from '@src/lib/types'
+import type { IndexLoaderData } from '@src/lib/types'
import { engineStreamActor, useSettings, useToken } from '@src/lib/singletons'
import { commandBarActor } from '@src/lib/singletons'
import { EngineStreamTransition } from '@src/machines/engineStreamMachine'
-import { onboardingPaths } from '@src/routes/Onboarding/paths'
import { CommandBarOpenButton } from '@src/components/CommandBarOpenButton'
import { ShareButton } from '@src/components/ShareButton'
+import {
+ needsToOnboard,
+ ONBOARDING_TOAST_ID,
+ TutorialRequestToast,
+} from '@src/routes/Onboarding/utils'
+import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
// CYCLIC REF
sceneInfra.camControls.engineStreamActor = engineStreamActor
@@ -58,6 +64,7 @@ export function App() {
})
})
+ const location = useLocation()
const navigate = useNavigate()
const filePath = useAbsoluteFilePath()
const { onProjectOpen } = useLspContext()
@@ -66,7 +73,7 @@ export function App() {
const ref = useRef(null)
// Stream related refs and data
- let [searchParams] = useSearchParams()
+ const [searchParams] = useSearchParams()
const pool = searchParams.get('pool')
const projectName = project?.name || null
@@ -76,9 +83,10 @@ export function App() {
const loaderData = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
const lastCommandType = commands[commands.length - 1]?.type
+ // Run LSP file open hook when navigating between projects or files
useEffect(() => {
onProjectOpen({ name: projectName, path: projectPath }, file || null)
- }, [projectName, projectPath])
+ }, [onProjectOpen, projectName, projectPath, file])
useHotKeyListener()
@@ -104,9 +112,10 @@ export function App() {
toast.success('Your work is auto-saved in real-time')
})
- const paneOpacity = [onboardingPaths.CAMERA, onboardingPaths.STREAMING].some(
- (p) => p === onboardingStatus.current
- )
+ const paneOpacity = [
+ ONBOARDING_SUBPATHS.CAMERA,
+ ONBOARDING_SUBPATHS.STREAMING,
+ ].some((p) => p === onboardingStatus.current)
? 'opacity-20'
: ''
@@ -132,7 +141,7 @@ export function App() {
})
}, 500)
}
- }, [lastCommandType])
+ }, [lastCommandType, loaderData?.project?.path])
useEffect(() => {
// When leaving the modeling scene, cut the engine stream.
@@ -141,6 +150,32 @@ export function App() {
}
}, [])
+ // Show a custom toast to users if they haven't done the onboarding
+ // and they're on the web
+ useEffect(() => {
+ const onboardingStatus =
+ settings.app.onboardingStatus.current ||
+ settings.app.onboardingStatus.default
+ const needsOnboarded = needsToOnboard(location, onboardingStatus)
+
+ if (!isDesktop() && needsOnboarded) {
+ toast.success(
+ () =>
+ TutorialRequestToast({
+ onboardingStatus: settings.app.onboardingStatus.current,
+ navigate,
+ codeManager,
+ kclManager,
+ }),
+ {
+ id: ONBOARDING_TOAST_ID,
+ duration: Number.POSITIVE_INFINITY,
+ icon: null,
+ }
+ )
+ }
+ }, [location, settings.app.onboardingStatus, navigate])
+
return (
{/*
*/}
-
+
diff --git a/src/Router.tsx b/src/Router.tsx
index f592783deff..e457870f2a8 100644
--- a/src/Router.tsx
+++ b/src/Router.tsx
@@ -28,7 +28,7 @@ import useHotkeyWrapper from '@src/lib/hotkeyWrapper'
import { isDesktop } from '@src/lib/isDesktop'
import makeUrlPathRelative from '@src/lib/makeUrlPathRelative'
import { PATHS } from '@src/lib/paths'
-import { fileLoader, homeLoader, telemetryLoader } from '@src/lib/routeLoaders'
+import { fileLoader, homeLoader } from '@src/lib/routeLoaders'
import {
codeManager,
engineCommandManager,
@@ -110,7 +110,6 @@ const router = createRouter([
},
{
id: PATHS.FILE + 'TELEMETRY',
- loader: telemetryLoader,
children: [
{
path: makeUrlPathRelative(PATHS.TELEMETRY),
@@ -144,7 +143,6 @@ const router = createRouter([
},
{
path: makeUrlPathRelative(PATHS.TELEMETRY),
- loader: telemetryLoader,
element: ,
},
],
diff --git a/src/components/CustomIcon.tsx b/src/components/CustomIcon.tsx
index c69f7fe4c4a..0d918aef306 100644
--- a/src/components/CustomIcon.tsx
+++ b/src/components/CustomIcon.tsx
@@ -854,6 +854,14 @@ const CustomIconMap = {
/>
),
+ play: (
+
+
+
+ ),
rotate: (
(
)
-export function HelpMenu(props: React.PropsWithChildren) {
+export function HelpMenu({
+ navigate = () => {},
+}: {
+ navigate?: NavigateFunction
+}) {
const location = useLocation()
- const { onProjectOpen } = useLspContext()
const filePath = useAbsoluteFilePath()
- const isInProject = location.pathname.includes(PATHS.FILE)
- const navigate = useNavigate()
const resetOnboardingWorkflow = () => {
- settingsActor.send({
- type: 'set.app.onboardingStatus',
- data: {
- value: '',
- level: 'user',
- },
- })
- if (isInProject) {
- navigate(filePath + PATHS.ONBOARDING.INDEX)
- } else {
- createAndOpenNewTutorialProject({
- onProjectOpen,
- navigate,
- }).catch(reportRejection)
+ const props = {
+ onboardingStatus: ONBOARDING_SUBPATHS.INDEX,
+ navigate,
+ codeManager,
+ kclManager,
}
+ acceptOnboarding(props).catch((reason) =>
+ catchOnboardingWarnError(reason, props)
+ )
}
const cb = (data: WebContentSendPayload) => {
- if (data.menuLabel === 'Help.Reset onboarding') {
+ if (data.menuLabel === 'Help.Replay onboarding tutorial') {
resetOnboardingWorkflow()
}
}
@@ -68,71 +65,81 @@ export function HelpMenu(props: React.PropsWithChildren) {
as="ul"
className="absolute right-0 left-auto flex flex-col w-64 gap-1 p-0 py-2 m-0 mb-1 text-sm border border-solid rounded shadow-lg bottom-full align-stretch text-chalkboard-10 dark:text-inherit bg-chalkboard-110 dark:bg-chalkboard-100 border-chalkboard-110 dark:border-chalkboard-80"
>
-
- Report a bug
-
-
- Request a feature
-
-
- Ask the community
-
-
-
- KCL code samples
-
-
- KCL docs
-
-
-
- Release notes
-
- {
- const targetPath = location.pathname.includes(PATHS.FILE)
- ? filePath + PATHS.SETTINGS_KEYBINDINGS
- : PATHS.HOME + PATHS.SETTINGS_KEYBINDINGS
- navigate(targetPath)
- }}
- data-testid="keybindings-button"
- >
- Keyboard shortcuts
-
-
- Reset onboarding
-
+ {({ close }) => (
+ <>
+
+ Report a bug
+
+
+ Request a feature
+
+
+ Ask the community
+
+
+
+ KCL code samples
+
+
+ KCL docs
+
+
+
+ Release notes
+
+ {
+ const targetPath = location.pathname.includes(PATHS.FILE)
+ ? filePath + PATHS.SETTINGS_KEYBINDINGS
+ : PATHS.HOME + PATHS.SETTINGS_KEYBINDINGS
+ navigate(targetPath)
+ }}
+ data-testid="keybindings-button"
+ >
+ Keyboard shortcuts
+
+ {
+ close()
+ resetOnboardingWorkflow()
+ }}
+ >
+ Replay onboarding tutorial
+
+ >
+ )}
)
diff --git a/src/components/LowerRightControls.tsx b/src/components/LowerRightControls.tsx
index 42d539e8ddc..219ae2334f8 100644
--- a/src/components/LowerRightControls.tsx
+++ b/src/components/LowerRightControls.tsx
@@ -1,5 +1,4 @@
-import { Link, useLocation } from 'react-router-dom'
-
+import { Link, type NavigateFunction, useLocation } from 'react-router-dom'
import { CustomIcon } from '@src/components/CustomIcon'
import { HelpMenu } from '@src/components/HelpMenu'
import { NetworkHealthIndicator } from '@src/components/NetworkHealthIndicator'
@@ -12,8 +11,10 @@ import { APP_VERSION, getReleaseUrl } from '@src/routes/utils'
export function LowerRightControls({
children,
+ navigate = () => {},
}: {
children?: React.ReactNode
+ navigate?: NavigateFunction
}) {
const location = useLocation()
const filePath = useAbsoluteFilePath()
@@ -72,7 +73,7 @@ export function LowerRightControls({
{!location.pathname.startsWith(PATHS.HOME) && (
)}
-
+
)
diff --git a/src/components/ModelingSidebar/ModelingPane.tsx b/src/components/ModelingSidebar/ModelingPane.tsx
index b919b8ff079..264da072a0f 100644
--- a/src/components/ModelingSidebar/ModelingPane.tsx
+++ b/src/components/ModelingSidebar/ModelingPane.tsx
@@ -6,7 +6,7 @@ import { ActionIcon } from '@src/components/ActionIcon'
import type { CustomIconName } from '@src/components/CustomIcon'
import Tooltip from '@src/components/Tooltip'
import { useSettings } from '@src/lib/singletons'
-import { onboardingPaths } from '@src/routes/Onboarding/paths'
+import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
import styles from './ModelingPane.module.css'
@@ -71,7 +71,7 @@ export const ModelingPane = ({
const settings = useSettings()
const onboardingStatus = settings.app.onboardingStatus
const pointerEventsCssClass =
- onboardingStatus.current === onboardingPaths.CAMERA
+ onboardingStatus.current === ONBOARDING_SUBPATHS.CAMERA
? 'pointer-events-none '
: 'pointer-events-auto '
return (
diff --git a/src/components/ModelingSidebar/ModelingSidebar.tsx b/src/components/ModelingSidebar/ModelingSidebar.tsx
index 6e5eb2f6861..7e4d18450cf 100644
--- a/src/components/ModelingSidebar/ModelingSidebar.tsx
+++ b/src/components/ModelingSidebar/ModelingSidebar.tsx
@@ -24,7 +24,7 @@ import { SIDEBAR_BUTTON_SUFFIX } from '@src/lib/constants'
import { isDesktop } from '@src/lib/isDesktop'
import { useSettings } from '@src/lib/singletons'
import { commandBarActor } from '@src/lib/singletons'
-import { onboardingPaths } from '@src/routes/Onboarding/paths'
+import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
import { reportRejection } from '@src/lib/trap'
import { refreshPage } from '@src/lib/utils'
import { hotkeyDisplay } from '@src/lib/hotkeyWrapper'
@@ -53,7 +53,7 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
const onboardingStatus = settings.app.onboardingStatus
const { send, context } = useModelingContext()
const pointerEventsCssClass =
- onboardingStatus.current === onboardingPaths.CAMERA ||
+ onboardingStatus.current === ONBOARDING_SUBPATHS.CAMERA ||
context.store?.openPanes.length === 0
? 'pointer-events-none '
: 'pointer-events-auto '
diff --git a/src/components/Providers/SystemIOProviderDesktop.tsx b/src/components/Providers/SystemIOProviderDesktop.tsx
index a20b6a25ef9..5dc4a8261c0 100644
--- a/src/components/Providers/SystemIOProviderDesktop.tsx
+++ b/src/components/Providers/SystemIOProviderDesktop.tsx
@@ -1,5 +1,10 @@
import { useFileSystemWatcher } from '@src/hooks/useFileSystemWatcher'
-import { PATHS } from '@src/lib/paths'
+import {
+ PATHS,
+ joinRouterPaths,
+ joinOSPaths,
+ safeEncodeForRouterPaths,
+} from '@src/lib/paths'
import { systemIOActor, useSettings, useToken } from '@src/lib/singletons'
import {
useHasListedProjects,
@@ -35,14 +40,14 @@ export function SystemIOMachineLogicListenerDesktop() {
if (!requestedProjectName.name) {
return
}
- let projectPathWithoutSpecificKCLFile =
- projectDirectoryPath +
- window.electron.path.sep +
+ const projectPathWithoutSpecificKCLFile = joinOSPaths(
+ projectDirectoryPath,
requestedProjectName.name
-
- const requestedPath = `${PATHS.FILE}/${encodeURIComponent(
- projectPathWithoutSpecificKCLFile
- )}`
+ )
+ const requestedPath = joinRouterPaths(
+ PATHS.FILE,
+ safeEncodeForRouterPaths(projectPathWithoutSpecificKCLFile)
+ )
navigate(requestedPath)
}, [requestedProjectName])
}
@@ -52,12 +57,16 @@ export function SystemIOMachineLogicListenerDesktop() {
if (!requestedFileName.file || !requestedFileName.project) {
return
}
- const projectPath = window.electron.join(
+ const filePath = joinOSPaths(
projectDirectoryPath,
- requestedFileName.project
+ requestedFileName.project,
+ requestedFileName.file
+ )
+ const requestedPath = joinRouterPaths(
+ PATHS.FILE,
+ safeEncodeForRouterPaths(filePath),
+ requestedFileName.subRoute || ''
)
- const filePath = window.electron.join(projectPath, requestedFileName.file)
- const requestedPath = `${PATHS.FILE}/${encodeURIComponent(filePath)}`
navigate(requestedPath)
}, [requestedFileName])
}
diff --git a/src/components/RouteProvider.tsx b/src/components/RouteProvider.tsx
index b18f55743a5..3cb61cfc420 100644
--- a/src/components/RouteProvider.tsx
+++ b/src/components/RouteProvider.tsx
@@ -7,8 +7,6 @@ import {
useRouteLoaderData,
} from 'react-router-dom'
-import type { OnboardingStatus } from '@rust/kcl-lib/bindings/OnboardingStatus'
-
import { useAuthNavigation } from '@src/hooks/useAuthNavigation'
import { useFileSystemWatcher } from '@src/hooks/useFileSystemWatcher'
import { getAppSettingsFilePath } from '@src/lib/desktop'
@@ -18,7 +16,7 @@ import { markOnce } from '@src/lib/performance'
import { loadAndValidateSettings } from '@src/lib/settings/settingsUtils'
import { trap } from '@src/lib/trap'
import type { IndexLoaderData } from '@src/lib/types'
-import { settingsActor, useSettings } from '@src/lib/singletons'
+import { settingsActor } from '@src/lib/singletons'
export const RouteProviderContext = createContext({})
@@ -32,7 +30,6 @@ export function RouteProvider({ children }: { children: ReactNode }) {
const navigation = useNavigation()
const navigate = useNavigate()
const location = useLocation()
- const settings = useSettings()
useEffect(() => {
// On initialization, the react-router-dom does not send a 'loading' state event.
@@ -46,35 +43,9 @@ export function RouteProvider({ children }: { children: ReactNode }) {
markOnce('code/willLoadHome')
} else if (isFile) {
markOnce('code/willLoadFile')
-
- /**
- * TODO: Move to XState. This block has been moved from routerLoaders
- * and is borrowing the `isFile` logic from the rest of this
- * telemetry-focused `useEffect`. Once `appMachine` knows about
- * the current route and navigation, this can be moved into settingsMachine
- * to fire as soon as the user settings have been read.
- */
- const onboardingStatus: OnboardingStatus =
- settings.app.onboardingStatus.current || ''
- // '' is the initial state, 'completed' and 'dismissed' are the final states
- const needsToOnboard =
- onboardingStatus.length === 0 ||
- !(onboardingStatus === 'completed' || onboardingStatus === 'dismissed')
- const shouldRedirectToOnboarding = isFile && needsToOnboard
-
- if (
- shouldRedirectToOnboarding &&
- settingsActor.getSnapshot().matches('idle')
- ) {
- navigate(
- (first ? location.pathname : navigation.location?.pathname) +
- PATHS.ONBOARDING.INDEX +
- onboardingStatus.slice(1)
- )
- }
}
setFirstState(false)
- }, [navigation])
+ }, [first, navigation, location.pathname])
useEffect(() => {
if (!isDesktop()) return
diff --git a/src/components/Settings/AllSettingsFields.tsx b/src/components/Settings/AllSettingsFields.tsx
index 9e50f2e8e2d..ec39dc2204a 100644
--- a/src/components/Settings/AllSettingsFields.tsx
+++ b/src/components/Settings/AllSettingsFields.tsx
@@ -6,16 +6,12 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { Fragment } from 'react/jsx-runtime'
import { ActionButton } from '@src/components/ActionButton'
-import { useLspContext } from '@src/components/LspProvider'
import { SettingsFieldInput } from '@src/components/Settings/SettingsFieldInput'
import { SettingsSection } from '@src/components/Settings/SettingsSection'
-import { useDotDotSlash } from '@src/hooks/useDotDotSlash'
-import {
- createAndOpenNewTutorialProject,
- getSettingsFolderPaths,
-} from '@src/lib/desktopFS'
+import { getSettingsFolderPaths } from '@src/lib/desktopFS'
import { isDesktop } from '@src/lib/isDesktop'
import { openExternalBrowserIfDesktop } from '@src/lib/openWindow'
+import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
import { PATHS } from '@src/lib/paths'
import type { Setting } from '@src/lib/settings/initialSettings'
import type {
@@ -28,9 +24,17 @@ import {
} from '@src/lib/settings/settingsUtils'
import { reportRejection } from '@src/lib/trap'
import { toSync } from '@src/lib/utils'
-import { settingsActor, useSettings } from '@src/lib/singletons'
+import {
+ codeManager,
+ kclManager,
+ settingsActor,
+ useSettings,
+} from '@src/lib/singletons'
import { APP_VERSION, IS_NIGHTLY, getReleaseUrl } from '@src/routes/utils'
-import { waitFor } from 'xstate'
+import {
+ acceptOnboarding,
+ catchOnboardingWarnError,
+} from '@src/routes/Onboarding/utils'
interface AllSettingsFieldsProps {
searchParamTab: SettingsLevel
@@ -44,8 +48,6 @@ export const AllSettingsFields = forwardRef(
) => {
const location = useLocation()
const navigate = useNavigate()
- const { onProjectOpen } = useLspContext()
- const dotDotSlash = useDotDotSlash()
const context = useSettings()
const projectPath = useMemo(() => {
@@ -63,26 +65,18 @@ export const AllSettingsFields = forwardRef(
: undefined
return projectPath
- }, [location.pathname])
+ }, [location.pathname, isFileSettings])
async function restartOnboarding() {
- settingsActor.send({
- type: `set.app.onboardingStatus`,
- data: { level: 'user', value: '' },
- })
- await waitFor(settingsActor, (s) => s.matches('idle'), {
- timeout: 10_000,
- }).catch(reportRejection)
-
- if (isFileSettings) {
- // If we're in a project, first navigate to the onboarding start here
- // so we can trigger the warning screen if necessary
- navigate(dotDotSlash(1) + PATHS.ONBOARDING.INDEX)
- } else {
- // If we're in the global settings, create a new project and navigate
- // to the onboarding start in that project
- await createAndOpenNewTutorialProject({ onProjectOpen, navigate })
+ const props = {
+ onboardingStatus: ONBOARDING_SUBPATHS.INDEX,
+ navigate,
+ codeManager,
+ kclManager,
}
+ acceptOnboarding(props).catch((reason) =>
+ catchOnboardingWarnError(reason, props)
+ )
}
return (
diff --git a/src/lib/desktopFS.ts b/src/lib/desktopFS.ts
index 29bb9d8ef3b..e12225129ca 100644
--- a/src/lib/desktopFS.ts
+++ b/src/lib/desktopFS.ts
@@ -1,18 +1,6 @@
import { relevantFileExtensions } from '@src/lang/wasmUtils'
-import {
- FILE_EXT,
- INDEX_IDENTIFIER,
- MAX_PADDING,
- ONBOARDING_PROJECT_NAME,
-} from '@src/lib/constants'
-import {
- createNewProjectDirectory,
- listProjects,
- readAppSettingsFile,
-} from '@src/lib/desktop'
-import { bracket } from '@src/lib/exampleKcl'
+import { FILE_EXT, INDEX_IDENTIFIER, MAX_PADDING } from '@src/lib/constants'
import { isDesktop } from '@src/lib/isDesktop'
-import { PATHS } from '@src/lib/paths'
import type { FileEntry } from '@src/lib/project'
export const isHidden = (fileOrDir: FileEntry) =>
@@ -132,65 +120,6 @@ export async function getSettingsFolderPaths(projectPath?: string) {
}
}
-export async function createAndOpenNewTutorialProject({
- onProjectOpen,
- navigate,
-}: {
- onProjectOpen: (
- project: {
- name: string | null
- path: string | null
- } | null,
- file: FileEntry | null
- ) => void
- navigate: (path: string) => void
-}) {
- // Create a new project with the onboarding project name
- const configuration = await readAppSettingsFile()
- const projects = await listProjects(configuration)
- const nextIndex = getNextProjectIndex(ONBOARDING_PROJECT_NAME, projects)
- const name = interpolateProjectNameWithIndex(
- ONBOARDING_PROJECT_NAME,
- nextIndex
- )
-
- // Delete the tutorial project if it already exists.
- if (isDesktop()) {
- if (configuration.settings?.project?.directory === undefined) {
- return Promise.reject(new Error('configuration settings are undefined'))
- }
-
- const fullPath = window.electron.join(
- configuration.settings.project.directory,
- name
- )
- if (window.electron.exists(fullPath)) {
- await window.electron.rm(fullPath)
- }
- }
-
- const newProject = await createNewProjectDirectory(
- name,
- bracket,
- configuration
- )
-
- // Prep the LSP and navigate to the onboarding start
- onProjectOpen(
- {
- name: newProject.name,
- path: newProject.path,
- },
- null
- )
- navigate(
- `${PATHS.FILE}/${encodeURIComponent(newProject.default_file)}${
- PATHS.ONBOARDING.INDEX
- }`
- )
- return newProject
-}
-
/**
* Get the next available file name by appending a hyphen and number to the end of the name
*/
diff --git a/src/routes/Onboarding/paths.ts b/src/lib/onboardingPaths.ts
similarity index 64%
rename from src/routes/Onboarding/paths.ts
rename to src/lib/onboardingPaths.ts
index f8a069331ac..1fe15de7527 100644
--- a/src/routes/Onboarding/paths.ts
+++ b/src/lib/onboardingPaths.ts
@@ -1,6 +1,6 @@
import type { OnboardingStatus } from '@rust/kcl-lib/bindings/OnboardingStatus'
-export const onboardingPaths: Record = {
+export const ONBOARDING_SUBPATHS: Record = {
INDEX: '/',
CAMERA: '/camera',
STREAMING: '/streaming',
@@ -11,7 +11,12 @@ export const onboardingPaths: Record = {
USER_MENU: '/user-menu',
PROJECT_MENU: '/project-menu',
EXPORT: '/export',
- MOVE: '/move',
SKETCHING: '/sketching',
FUTURE_WORK: '/future-work',
} as const
+
+export const isOnboardingSubPath = (
+ input: string
+): input is OnboardingStatus => {
+ return Object.values(ONBOARDING_SUBPATHS).includes(input as OnboardingStatus)
+}
diff --git a/src/lib/paths.ts b/src/lib/paths.ts
index 45bb8d09626..6396fb445c4 100644
--- a/src/lib/paths.ts
+++ b/src/lib/paths.ts
@@ -12,7 +12,7 @@ import { isDesktop } from '@src/lib/isDesktop'
import { readLocalStorageAppSettingsFile } from '@src/lib/settings/settingsUtils'
import { err } from '@src/lib/trap'
import type { DeepPartial } from '@src/lib/types'
-import { onboardingPaths } from '@src/routes/Onboarding/paths'
+import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
const prependRoutes =
(routesObject: Record) => (prepend: string) => {
@@ -25,7 +25,7 @@ const prependRoutes =
}
type OnboardingPaths = {
- [K in keyof typeof onboardingPaths]: `/onboarding${(typeof onboardingPaths)[K]}`
+ [K in keyof typeof ONBOARDING_SUBPATHS]: `/onboarding${(typeof ONBOARDING_SUBPATHS)[K]}`
}
const SETTINGS = '/settings'
@@ -46,7 +46,9 @@ export const PATHS = {
SETTINGS_PROJECT: `${SETTINGS}?tab=project` as const,
SETTINGS_KEYBINDINGS: `${SETTINGS}?tab=keybindings` as const,
SIGN_IN: '/signin',
- ONBOARDING: prependRoutes(onboardingPaths)('/onboarding') as OnboardingPaths,
+ ONBOARDING: prependRoutes(ONBOARDING_SUBPATHS)(
+ '/onboarding'
+ ) as OnboardingPaths,
TELEMETRY: '/telemetry',
} as const
export const BROWSER_PATH = `%2F${BROWSER_PROJECT_NAME}%2F${BROWSER_FILE_NAME}${FILE_EXT}`
@@ -134,3 +136,53 @@ export function parseProjectRoute(
currentFilePath: currentFilePath,
}
}
+
+/**
+ * Joins any number of arguments of strings to create a Router level path that is safe
+ * A path will be created of the format /value/value1/value2
+ * Filters out '/', ''
+ * Removes all leading and ending slashes, this allows you to pass '//dog//','//cat//' it will resolve to
+ * /dog/cat
+ */
+export function joinRouterPaths(...parts: string[]): string {
+ return `/${parts
+ .map((part) => part.replace(/^\/+|\/+$/g, '')) // Remove leading/trailing slashes
+ .filter((part) => part.length > 0) // Remove empty segments
+ .join('/')}`
+}
+
+/**
+ * Joins any number of arguments of strings to create a OS level path that is safe
+ * A path will be created of the format /value/value1/value2
+ * or \value\value1\value2 for POSIX OSes like Windows
+ * Filters out the separator slashes
+ * Removes all leading and ending slashes, this allows you to pass '//dog//','//cat//' it will resolve to
+ * /dog/cat
+ * or \dog\cat on POSIX
+ */
+export function joinOSPaths(...parts: string[]): string {
+ const sep = window.electron?.sep || '/'
+ const regexSep = sep === '/' ? '/' : '\\'
+ return (
+ (sep === '\\' ? '' : sep) + // Windows absolute paths should not be prepended with a separator, they start with the drive name
+ parts
+ .map((part) =>
+ part.replace(new RegExp(`^${regexSep}+|${regexSep}+$`, 'g'), '')
+ ) // Remove leading/trailing slashes
+ .filter((part) => part.length > 0) // Remove empty segments
+ .join(sep)
+ )
+}
+
+export function safeEncodeForRouterPaths(dynamicValue: string): string {
+ return `${encodeURIComponent(dynamicValue)}`
+}
+
+/**
+ * /dog/cat/house.kcl gives you house.kcl
+ * \dog\cat\house.kcl gives you house.kcl
+ * Works on all OS!
+ */
+export function getStringAfterLastSeparator(path: string): string {
+ return path.split(window.electron.sep).pop() || ''
+}
diff --git a/src/lib/routeLoaders.ts b/src/lib/routeLoaders.ts
index de46b4c4a5c..020efedfbfd 100644
--- a/src/lib/routeLoaders.ts
+++ b/src/lib/routeLoaders.ts
@@ -22,12 +22,6 @@ import type {
} from '@src/lib/types'
import { settingsActor } from '@src/lib/singletons'
-export const telemetryLoader: LoaderFunction = async ({
- params,
-}): Promise => {
- return null
-}
-
export const fileLoader: LoaderFunction = async (
routerData
): Promise => {
diff --git a/src/machines/systemIO/systemIOMachine.ts b/src/machines/systemIO/systemIOMachine.ts
index 57e1750827b..93984de0331 100644
--- a/src/machines/systemIO/systemIOMachine.ts
+++ b/src/machines/systemIO/systemIOMachine.ts
@@ -43,7 +43,11 @@ export const systemIOMachine = setup({
}
| {
type: SystemIOMachineEvents.navigateToFile
- data: { requestedProjectName: string; requestedFileName: string }
+ data: {
+ requestedProjectName: string
+ requestedFileName: string
+ requestedSubRoute?: string
+ }
}
| {
type: SystemIOMachineEvents.createProject
@@ -75,6 +79,7 @@ export const systemIOMachine = setup({
requestedProjectName: string
requestedFileName: string
requestedCode: string
+ requestedSubRoute?: string
}
}
| {
@@ -117,7 +122,9 @@ export const systemIOMachine = setup({
[SystemIOMachineActions.setRequestedProjectName]: assign({
requestedProjectName: ({ event }) => {
assertEvent(event, SystemIOMachineEvents.navigateToProject)
- return { name: event.data.requestedProjectName }
+ return {
+ name: event.data.requestedProjectName,
+ }
},
}),
[SystemIOMachineActions.setRequestedFileName]: assign({
@@ -126,6 +133,7 @@ export const systemIOMachine = setup({
return {
project: event.data.requestedProjectName,
file: event.data.requestedFileName,
+ subRoute: event.data.requestedSubRoute,
}
},
}),
@@ -224,13 +232,15 @@ export const systemIOMachine = setup({
requestedFileName: string
requestedCode: string
rootContext: AppMachineContext
+ requestedSubRoute?: string
}
}): Promise<{
message: string
fileName: string
projectName: string
+ subRoute: string
}> => {
- return { message: '', fileName: '', projectName: '' }
+ return { message: '', fileName: '', projectName: '', subRoute: '' }
}
),
[SystemIOMachineActors.checkReadWrite]: fromPromise(
@@ -458,6 +468,7 @@ export const systemIOMachine = setup({
context,
requestedProjectName: event.data.requestedProjectName,
requestedFileName: event.data.requestedFileName,
+ requestedSubRoute: event.data.requestedSubRoute,
requestedCode: event.data.requestedCode,
rootContext: self.system.get('root').getSnapshot().context,
}
@@ -476,6 +487,7 @@ export const systemIOMachine = setup({
return {
project: event.output.projectName,
file,
+ subRoute: event.output.subRoute,
}
},
}),
diff --git a/src/machines/systemIO/systemIOMachineDesktop.ts b/src/machines/systemIO/systemIOMachineDesktop.ts
index bc42af2e55c..4a382477b62 100644
--- a/src/machines/systemIO/systemIOMachineDesktop.ts
+++ b/src/machines/systemIO/systemIOMachineDesktop.ts
@@ -158,6 +158,7 @@ export const systemIOMachineDesktop = systemIOMachine.provide({
requestedFileName: string
requestedCode: string
rootContext: AppMachineContext
+ requestedSubRoute?: string
}
}) => {
const requestedProjectName = input.requestedProjectName
@@ -206,6 +207,7 @@ export const systemIOMachineDesktop = systemIOMachine.provide({
message: 'File created successfully',
fileName: newFileName,
projectName: newProjectName,
+ subRoute: input.requestedSubRoute || '',
}
}
),
diff --git a/src/machines/systemIO/systemIOMachineWeb.ts b/src/machines/systemIO/systemIOMachineWeb.ts
index cac7ef9adcf..67034695eb9 100644
--- a/src/machines/systemIO/systemIOMachineWeb.ts
+++ b/src/machines/systemIO/systemIOMachineWeb.ts
@@ -20,6 +20,7 @@ export const systemIOMachineWeb = systemIOMachine.provide({
requestedFileName: string
requestedCode: string
rootContext: AppMachineContext
+ requestedSubRoute?: string
}
}) => {
// Browser version doesn't navigate, just overwrites the current file
@@ -43,6 +44,7 @@ export const systemIOMachineWeb = systemIOMachine.provide({
message: 'File overwritten successfully',
fileName: input.requestedFileName,
projectName: '',
+ subRoute: input.requestedSubRoute || '',
}
}
),
diff --git a/src/machines/systemIO/utils.ts b/src/machines/systemIO/utils.ts
index 5691fe2e005..a07605d6e6b 100644
--- a/src/machines/systemIO/utils.ts
+++ b/src/machines/systemIO/utils.ts
@@ -8,6 +8,7 @@ export enum SystemIOMachineActors {
deleteProject = 'delete project',
createKCLFile = 'create kcl file',
checkReadWrite = 'check read write',
+ /** TODO: rename this event to be more generic, like `createKCLFileAndNavigate` */
importFileFromURL = 'import file from URL',
deleteKCLFile = 'delete kcl delete',
}
@@ -21,6 +22,7 @@ export enum SystemIOMachineStates {
deletingProject = 'deletingProject',
creatingKCLFile = 'creatingKCLFile',
checkingReadWrite = 'checkingReadWrite',
+ /** TODO: rename this event to be more generic, like `createKCLFileAndNavigate` */
importFileFromURL = 'importFileFromURL',
deletingKCLFile = 'deletingKCLFile',
}
@@ -41,6 +43,7 @@ export enum SystemIOMachineEvents {
createKCLFile = 'create kcl file',
setDefaultProjectFolderName = 'set default project folder name',
done_checkReadWrite = donePrefix + 'check read write',
+ /** TODO: rename this event to be more generic, like `createKCLFileAndNavigate` */
importFileFromURL = 'import file from URL',
done_importFileFromURL = donePrefix + 'import file from URL',
generateTextToCAD = 'generate text to CAD',
@@ -74,7 +77,7 @@ export type SystemIOContext = {
* this is required to prevent chokidar from spamming invalid events during initialization. */
hasListedProjects: boolean
requestedProjectName: { name: string }
- requestedFileName: { project: string; file: string }
+ requestedFileName: { project: string; file: string; subRoute?: string }
canReadWriteProjectDirectory: { value: boolean; error: unknown }
clearURLParams: { value: boolean }
requestedTextToCadGeneration: {
diff --git a/src/menu/channels.ts b/src/menu/channels.ts
index f9ba9f5d8a4..6312fa8db8b 100644
--- a/src/menu/channels.ts
+++ b/src/menu/channels.ts
@@ -6,7 +6,7 @@ import type { Channel } from '@src/channels'
export type MenuLabels =
| 'Help.Command Palette...'
| 'Help.Report a bug'
- | 'Help.Reset onboarding'
+ | 'Help.Replay onboarding tutorial'
| 'Edit.Rename project'
| 'Edit.Delete project'
| 'Edit.Change project directory'
diff --git a/src/menu/helpRole.ts b/src/menu/helpRole.ts
index de7a1c1af87..b6941994a77 100644
--- a/src/menu/helpRole.ts
+++ b/src/menu/helpRole.ts
@@ -84,11 +84,11 @@ export const helpRole = (
},
{ type: 'separator' },
{
- id: 'Help.Reset onboarding',
- label: 'Reset onboarding',
+ id: 'Help.Replay onboarding tutorial',
+ label: 'Replay onboarding tutorial',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
- menuLabel: 'Help.Reset onboarding',
+ menuLabel: 'Help.Replay onboarding tutorial',
})
},
},
diff --git a/src/menu/roles.ts b/src/menu/roles.ts
index 8b51ad1dafa..5ead00a0f14 100644
--- a/src/menu/roles.ts
+++ b/src/menu/roles.ts
@@ -45,7 +45,7 @@ type HelpRoleLabel =
| 'Ask the community discourse'
| 'KCL code samples'
| 'KCL docs'
- | 'Reset onboarding'
+ | 'Replay onboarding tutorial'
| 'Show release notes'
| 'Manage account'
| 'Get started with Text-to-CAD'
diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx
index 475c05ed0d1..691dfc04b2c 100644
--- a/src/routes/Home.tsx
+++ b/src/routes/Home.tsx
@@ -2,7 +2,12 @@ import type { FormEvent, HTMLProps } from 'react'
import { useEffect } from 'react'
import { toast } from 'react-hot-toast'
import { useHotkeys } from 'react-hotkeys-hook'
-import { Link, useNavigate, useSearchParams } from 'react-router-dom'
+import {
+ Link,
+ useLocation,
+ useNavigate,
+ useSearchParams,
+} from 'react-router-dom'
import { ActionButton } from '@src/components/ActionButton'
import { AppHeader } from '@src/components/AppHeader'
@@ -19,7 +24,7 @@ import { isDesktop } from '@src/lib/isDesktop'
import { PATHS } from '@src/lib/paths'
import { markOnce } from '@src/lib/performance'
import type { Project } from '@src/lib/project'
-import { kclManager } from '@src/lib/singletons'
+import { codeManager, kclManager } from '@src/lib/singletons'
import {
getNextSearchParams,
getSortFunction,
@@ -39,6 +44,12 @@ import {
} from '@src/machines/systemIO/utils'
import type { WebContentSendPayload } from '@src/menu/channels'
import { openExternalBrowserIfDesktop } from '@src/lib/openWindow'
+import {
+ acceptOnboarding,
+ needsToOnboard,
+ onDismissOnboardingInvite,
+} from '@src/routes/Onboarding/utils'
+import Tooltip from '@src/components/Tooltip'
type ReadWriteProjectState = {
value: boolean
@@ -69,8 +80,10 @@ const Home = () => {
})
})
+ const location = useLocation()
const navigate = useNavigate()
const settings = useSettings()
+ const onboardingStatus = settings.app.onboardingStatus.current
// Menu listeners
const cb = (data: WebContentSendPayload) => {
@@ -203,6 +216,42 @@ const Home = () => {
/>
+ {needsToOnboard(location, onboardingStatus) && (
+
+ {
+ acceptOnboarding({
+ onboardingStatus,
+ navigate,
+ codeManager,
+ kclManager,
+ }).catch(reportRejection)
+ }}
+ className={`${sidebarButtonClasses} !text-primary flex-1`}
+ iconStart={{
+ icon: 'play',
+ bgClassName: '!bg-primary rounded-sm',
+ iconClassName: '!text-white',
+ }}
+ data-testid="home-tutorial-button"
+ >
+ {onboardingStatus === '' ? 'Start' : 'Continue'} tutorial
+
+
+ Dismiss tutorial
+
+
+ )}
{
sort={sort}
className="flex-1 col-start-2 -col-end-1 overflow-y-auto pr-2 pb-24"
/>
-
+
)
diff --git a/src/routes/Onboarding/Camera.tsx b/src/routes/Onboarding/Camera.tsx
index 211cd8e7bed..199e26bed5d 100644
--- a/src/routes/Onboarding/Camera.tsx
+++ b/src/routes/Onboarding/Camera.tsx
@@ -1,18 +1,11 @@
import { SettingsSection } from '@src/components/Settings/SettingsSection'
import type { CameraSystem } from '@src/lib/cameraControls'
import { cameraMouseDragGuards, cameraSystems } from '@src/lib/cameraControls'
+import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
import { settingsActor, useSettings } from '@src/lib/singletons'
-import { onboardingPaths } from '@src/routes/Onboarding/paths'
-
-import {
- OnboardingButtons,
- useDismiss,
- useNextClick,
-} from '@src/routes/Onboarding/utils'
+import { OnboardingButtons } from '@src/routes/Onboarding/utils'
export default function Units() {
- useDismiss()
- useNextClick(onboardingPaths.STREAMING)
const {
modeling: { mouseControls },
} = useSettings()
@@ -66,7 +59,7 @@ export default function Units() {
diff --git a/src/routes/Onboarding/CmdK.tsx b/src/routes/Onboarding/CmdK.tsx
index 7dc2e4e2ca6..161f2974283 100644
--- a/src/routes/Onboarding/CmdK.tsx
+++ b/src/routes/Onboarding/CmdK.tsx
@@ -1,8 +1,7 @@
import { COMMAND_PALETTE_HOTKEY } from '@src/components/CommandBar/CommandBar'
import usePlatform from '@src/hooks/usePlatform'
import { hotkeyDisplay } from '@src/lib/hotkeyWrapper'
-import { onboardingPaths } from '@src/routes/Onboarding/paths'
-
+import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
import { OnboardingButtons, kbdClasses } from '@src/routes/Onboarding/utils'
export default function CmdK() {
@@ -37,7 +36,7 @@ export default function CmdK() {
. You can control settings, authentication, and file management from
the command bar, as well as a growing number of modeling commands.
-
+
)
diff --git a/src/routes/Onboarding/CodeEditor.tsx b/src/routes/Onboarding/CodeEditor.tsx
index e0e4e47fba5..597885016d7 100644
--- a/src/routes/Onboarding/CodeEditor.tsx
+++ b/src/routes/Onboarding/CodeEditor.tsx
@@ -1,5 +1,4 @@
-import { onboardingPaths } from '@src/routes/Onboarding/paths'
-
+import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
import {
OnboardingButtons,
kbdClasses,
@@ -70,7 +69,7 @@ export default function OnboardingCodeEditor() {
pressing Shift + C .
-
+
)
diff --git a/src/routes/Onboarding/Export.tsx b/src/routes/Onboarding/Export.tsx
index 5d14b4d876e..a7274c06f74 100644
--- a/src/routes/Onboarding/Export.tsx
+++ b/src/routes/Onboarding/Export.tsx
@@ -1,6 +1,5 @@
import { APP_NAME } from '@src/lib/constants'
-import { onboardingPaths } from '@src/routes/Onboarding/paths'
-
+import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
import { OnboardingButtons } from '@src/routes/Onboarding/utils'
export default function Export() {
@@ -50,7 +49,7 @@ export default function Export() {
!
-
+
)
diff --git a/src/routes/Onboarding/FutureWork.tsx b/src/routes/Onboarding/FutureWork.tsx
index fa2756adfee..0fbd7585584 100644
--- a/src/routes/Onboarding/FutureWork.tsx
+++ b/src/routes/Onboarding/FutureWork.tsx
@@ -1,11 +1,9 @@
import { useEffect } from 'react'
-
import { useModelingContext } from '@src/hooks/useModelingContext'
import { APP_NAME } from '@src/lib/constants'
import { sceneInfra } from '@src/lib/singletons'
-import { onboardingPaths } from '@src/routes/Onboarding/paths'
-
import { OnboardingButtons, useDemoCode } from '@src/routes/Onboarding/utils'
+import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
export default function FutureWork() {
const { send } = useModelingContext()
@@ -58,7 +56,7 @@ export default function FutureWork() {
💚 The Zoo Team
diff --git a/src/routes/Onboarding/InteractiveNumbers.tsx b/src/routes/Onboarding/InteractiveNumbers.tsx
index d5217f08576..a6b336ac2c8 100644
--- a/src/routes/Onboarding/InteractiveNumbers.tsx
+++ b/src/routes/Onboarding/InteractiveNumbers.tsx
@@ -1,6 +1,5 @@
import { bracketWidthConstantLine } from '@src/lib/exampleKcl'
-import { onboardingPaths } from '@src/routes/Onboarding/paths'
-
+import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
import {
OnboardingButtons,
kbdClasses,
@@ -85,7 +84,9 @@ export default function OnboardingInteractiveNumbers() {
your ideas for how to make it better.
-
+
)
diff --git a/src/routes/Onboarding/Introduction.tsx b/src/routes/Onboarding/Introduction.tsx
index ceb0eeb1730..e89d7bf4360 100644
--- a/src/routes/Onboarding/Introduction.tsx
+++ b/src/routes/Onboarding/Introduction.tsx
@@ -1,124 +1,11 @@
-import { useEffect, useState } from 'react'
-import { useNavigate, useRouteLoaderData } from 'react-router-dom'
-
-import { useLspContext } from '@src/components/LspProvider'
-import { useFileContext } from '@src/hooks/useFileContext'
-import { isKclEmptyOrOnlySettings } from '@src/lang/wasm'
import { APP_NAME } from '@src/lib/constants'
-import { createAndOpenNewTutorialProject } from '@src/lib/desktopFS'
-import { bracket } from '@src/lib/exampleKcl'
import { isDesktop } from '@src/lib/isDesktop'
-import { PATHS } from '@src/lib/paths'
-import { codeManager, kclManager } from '@src/lib/singletons'
import { Themes, getSystemTheme } from '@src/lib/theme'
-import { reportRejection } from '@src/lib/trap'
-import type { IndexLoaderData } from '@src/lib/types'
import { useSettings } from '@src/lib/singletons'
-import { onboardingPaths } from '@src/routes/Onboarding/paths'
-
import { OnboardingButtons, useDemoCode } from '@src/routes/Onboarding/utils'
+import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
-/**
- * Show either a welcome screen or a warning screen
- * depending on if the user has code in the editor.
- */
-export default function OnboardingIntroduction() {
- const [shouldShowWarning, setShouldShowWarning] = useState(
- !isKclEmptyOrOnlySettings(codeManager.code) && codeManager.code !== bracket
- )
-
- return shouldShowWarning ? (
-
- ) : (
-
- )
-}
-
-interface OnboardingResetWarningProps {
- setShouldShowWarning: (arg: boolean) => void
-}
-
-function OnboardingResetWarning(props: OnboardingResetWarningProps) {
- return (
-
-
- {!isDesktop() ? (
-
- ) : (
-
- )}
-
-
- )
-}
-
-function OnboardingWarningDesktop(props: OnboardingResetWarningProps) {
- const navigate = useNavigate()
- const loaderData = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
- const { context: fileContext } = useFileContext()
- const { onProjectClose, onProjectOpen } = useLspContext()
-
- async function onAccept() {
- onProjectClose(
- loaderData.file || null,
- fileContext.project.path || null,
- false
- )
- await createAndOpenNewTutorialProject({ onProjectOpen, navigate })
- props.setShouldShowWarning(false)
- }
-
- return (
- <>
-
- Would you like to create a new project?
-
-
-
- You have some content in this project that we don't want to overwrite.
- If you would like to create a new project, please click the button
- below.
-
-
- {
- onAccept().catch(reportRejection)
- }}
- />
- >
- )
-}
-
-function OnboardingWarningWeb(props: OnboardingResetWarningProps) {
- useEffect(() => {
- async function beforeNavigate() {
- // We do want to update both the state and editor here.
- codeManager.updateCodeStateEditor(bracket)
- await codeManager.writeToFile()
-
- await kclManager.executeCode()
- props.setShouldShowWarning(false)
- }
- return () => {
- beforeNavigate().catch(reportRejection)
- }
- }, [])
- return (
- <>
-
- Replaying onboarding resets your code
-
-
- We see you have some of your own code written in this project. Please
- save it somewhere else before continuing the onboarding.
-
-
- >
- )
-}
-
-function OnboardingIntroductionInner() {
+export default function Introduction() {
// Reset the code to the bracket code
useDemoCode()
@@ -182,7 +69,7 @@ function OnboardingIntroductionInner() {
diff --git a/src/routes/Onboarding/ParametricModeling.tsx b/src/routes/Onboarding/ParametricModeling.tsx
index a3fb6fa2468..189d0044930 100644
--- a/src/routes/Onboarding/ParametricModeling.tsx
+++ b/src/routes/Onboarding/ParametricModeling.tsx
@@ -2,9 +2,8 @@ import { bracketThicknessCalculationLine } from '@src/lib/exampleKcl'
import { isDesktop } from '@src/lib/isDesktop'
import { Themes, getSystemTheme } from '@src/lib/theme'
import { useSettings } from '@src/lib/singletons'
-import { onboardingPaths } from '@src/routes/Onboarding/paths'
-
import { OnboardingButtons, useDemoCode } from '@src/routes/Onboarding/utils'
+import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
export default function OnboardingParametricModeling() {
useDemoCode()
@@ -72,7 +71,9 @@ export default function OnboardingParametricModeling() {
-
+
)
diff --git a/src/routes/Onboarding/ProjectMenu.tsx b/src/routes/Onboarding/ProjectMenu.tsx
index b8f3ee3d488..6e73d9a791d 100644
--- a/src/routes/Onboarding/ProjectMenu.tsx
+++ b/src/routes/Onboarding/ProjectMenu.tsx
@@ -1,6 +1,5 @@
import { isDesktop } from '@src/lib/isDesktop'
-import { onboardingPaths } from '@src/routes/Onboarding/paths'
-
+import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
import { OnboardingButtons } from '@src/routes/Onboarding/utils'
export default function ProjectMenu() {
@@ -56,7 +55,7 @@ export default function ProjectMenu() {
>
)}
-
+
)
diff --git a/src/routes/Onboarding/Sketching.tsx b/src/routes/Onboarding/Sketching.tsx
index e24c76c1a10..652557048f5 100644
--- a/src/routes/Onboarding/Sketching.tsx
+++ b/src/routes/Onboarding/Sketching.tsx
@@ -1,9 +1,7 @@
import { useEffect } from 'react'
-
import { codeManager, kclManager } from '@src/lib/singletons'
-import { onboardingPaths } from '@src/routes/Onboarding/paths'
-
import { OnboardingButtons } from '@src/routes/Onboarding/utils'
+import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
export default function Sketching() {
useEffect(() => {
@@ -42,7 +40,7 @@ export default function Sketching() {
always just modifying and generating code in Zoo Design Studio.
diff --git a/src/routes/Onboarding/Streaming.tsx b/src/routes/Onboarding/Streaming.tsx
index ef052be5968..1583ad92247 100644
--- a/src/routes/Onboarding/Streaming.tsx
+++ b/src/routes/Onboarding/Streaming.tsx
@@ -1,5 +1,4 @@
-import { onboardingPaths } from '@src/routes/Onboarding/paths'
-
+import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
import { OnboardingButtons } from '@src/routes/Onboarding/utils'
export default function Streaming() {
@@ -41,7 +40,7 @@ export default function Streaming() {
diff --git a/src/routes/Onboarding/Units.tsx b/src/routes/Onboarding/Units.tsx
index cbccf8e17f8..e29541d932e 100644
--- a/src/routes/Onboarding/Units.tsx
+++ b/src/routes/Onboarding/Units.tsx
@@ -1,16 +1,10 @@
-import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
-
-import { ActionButton } from '@src/components/ActionButton'
import { SettingsSection } from '@src/components/Settings/SettingsSection'
import { type BaseUnit, baseUnitsUnion } from '@src/lib/settings/settingsTypes'
import { settingsActor, useSettings } from '@src/lib/singletons'
-import { onboardingPaths } from '@src/routes/Onboarding/paths'
-
-import { useDismiss, useNextClick } from '@src/routes/Onboarding/utils'
+import { OnboardingButtons } from '@src/routes/Onboarding/utils'
+import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
export default function Units() {
- const dismiss = useDismiss()
- const next = useNextClick(onboardingPaths.CAMERA)
const {
modeling: { defaultUnit },
} = useSettings()
@@ -44,28 +38,10 @@ export default function Units() {
))}
-
-
- Dismiss
-
-
- Next: Camera
-
-
+
)
diff --git a/src/routes/Onboarding/UserMenu.tsx b/src/routes/Onboarding/UserMenu.tsx
index e3e03fee924..53347934937 100644
--- a/src/routes/Onboarding/UserMenu.tsx
+++ b/src/routes/Onboarding/UserMenu.tsx
@@ -1,9 +1,7 @@
import { useEffect, useState } from 'react'
-
import { useUser } from '@src/lib/singletons'
-import { onboardingPaths } from '@src/routes/Onboarding/paths'
-
import { OnboardingButtons } from '@src/routes/Onboarding/utils'
+import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
export default function UserMenu() {
const user = useUser()
@@ -48,7 +46,7 @@ export default function UserMenu() {
only apply to the current project.
-
+
)
diff --git a/src/routes/Onboarding/index.tsx b/src/routes/Onboarding/index.tsx
index 1652d70ac96..eab895c6f84 100644
--- a/src/routes/Onboarding/index.tsx
+++ b/src/routes/Onboarding/index.tsx
@@ -14,8 +14,8 @@ import ProjectMenu from '@src/routes/Onboarding/ProjectMenu'
import Sketching from '@src/routes/Onboarding/Sketching'
import Streaming from '@src/routes/Onboarding/Streaming'
import UserMenu from '@src/routes/Onboarding/UserMenu'
-import { onboardingPaths } from '@src/routes/Onboarding/paths'
import { useDismiss } from '@src/routes/Onboarding/utils'
+import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
export const onboardingRoutes = [
{
@@ -23,55 +23,55 @@ export const onboardingRoutes = [
element: ,
},
{
- path: makeUrlPathRelative(onboardingPaths.CAMERA),
+ path: makeUrlPathRelative(ONBOARDING_SUBPATHS.CAMERA),
element: ,
},
{
- path: makeUrlPathRelative(onboardingPaths.STREAMING),
+ path: makeUrlPathRelative(ONBOARDING_SUBPATHS.STREAMING),
element: ,
},
{
- path: makeUrlPathRelative(onboardingPaths.EDITOR),
+ path: makeUrlPathRelative(ONBOARDING_SUBPATHS.EDITOR),
element: ,
},
{
- path: makeUrlPathRelative(onboardingPaths.PARAMETRIC_MODELING),
+ path: makeUrlPathRelative(ONBOARDING_SUBPATHS.PARAMETRIC_MODELING),
element: ,
},
{
- path: makeUrlPathRelative(onboardingPaths.INTERACTIVE_NUMBERS),
+ path: makeUrlPathRelative(ONBOARDING_SUBPATHS.INTERACTIVE_NUMBERS),
element: ,
},
{
- path: makeUrlPathRelative(onboardingPaths.COMMAND_K),
+ path: makeUrlPathRelative(ONBOARDING_SUBPATHS.COMMAND_K),
element: ,
},
{
- path: makeUrlPathRelative(onboardingPaths.USER_MENU),
+ path: makeUrlPathRelative(ONBOARDING_SUBPATHS.USER_MENU),
element: ,
},
{
- path: makeUrlPathRelative(onboardingPaths.PROJECT_MENU),
+ path: makeUrlPathRelative(ONBOARDING_SUBPATHS.PROJECT_MENU),
element: ,
},
{
- path: makeUrlPathRelative(onboardingPaths.EXPORT),
+ path: makeUrlPathRelative(ONBOARDING_SUBPATHS.EXPORT),
element: ,
},
// Export / conversion API
{
- path: makeUrlPathRelative(onboardingPaths.SKETCHING),
+ path: makeUrlPathRelative(ONBOARDING_SUBPATHS.SKETCHING),
element: ,
},
{
- path: makeUrlPathRelative(onboardingPaths.FUTURE_WORK),
+ path: makeUrlPathRelative(ONBOARDING_SUBPATHS.FUTURE_WORK),
element: ,
},
]
const Onboarding = () => {
const dismiss = useDismiss()
- useHotkeys('esc', dismiss)
+ useHotkeys('esc', () => dismiss())
return (
diff --git a/src/routes/Onboarding/utils.tsx b/src/routes/Onboarding/utils.tsx
index 30309ad87a8..87361049c00 100644
--- a/src/routes/Onboarding/utils.tsx
+++ b/src/routes/Onboarding/utils.tsx
@@ -1,5 +1,9 @@
import { useCallback, useEffect } from 'react'
-import { useNavigate } from 'react-router-dom'
+import {
+ type NavigateFunction,
+ type useLocation,
+ useNavigate,
+} from 'react-router-dom'
import { waitFor } from 'xstate'
import { ActionButton } from '@src/components/ActionButton'
@@ -11,30 +15,42 @@ 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 { PATHS } from '@src/lib/paths'
-import { codeManager, editorManager, kclManager } from '@src/lib/singletons'
-import { reportRejection, trap } from '@src/lib/trap'
+import { joinRouterPaths, PATHS } from '@src/lib/paths'
+import {
+ codeManager,
+ editorManager,
+ kclManager,
+ systemIOActor,
+} from '@src/lib/singletons'
+import { err, reportRejection, trap } from '@src/lib/trap'
import { settingsActor } from '@src/lib/singletons'
-import { onboardingRoutes } from '@src/routes/Onboarding'
-import { onboardingPaths } from '@src/routes/Onboarding/paths'
-import { parse, resultIsOk } from '@src/lang/wasm'
+import { isKclEmptyOrOnlySettings, parse, resultIsOk } from '@src/lang/wasm'
import { updateModelingState } from '@src/lang/modelingWorkflows'
-import { EXECUTION_TYPE_REAL } from '@src/lib/constants'
+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 {
+ isOnboardingSubPath,
+ ONBOARDING_SUBPATHS,
+} from '@src/lib/onboardingPaths'
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 onboardingPaths)[keyof typeof onboardingPaths]
+ slug?: (typeof ONBOARDING_SUBPATHS)[keyof typeof ONBOARDING_SUBPATHS]
) {
- return slug
- ? slug === onboardingPaths.INDEX
- ? 1
- : onboardingRoutes.findIndex(
- (r) => r.path === makeUrlPathRelative(slug)
- ) + 1
- : 1
+ return slug ? Object.values(ONBOARDING_SUBPATHS).indexOf(slug) + 1 : -1
}
export function useDemoCode() {
@@ -70,17 +86,22 @@ export function useDemoCode() {
}, [editorManager.editorView, immediateState.type, overallState])
}
-export function useNextClick(newStatus: string) {
+export function useNextClick(newStatus: OnboardingStatus) {
const filePath = useAbsoluteFilePath()
const navigate = useNavigate()
return useCallback(() => {
+ if (!isOnboardingSubPath(newStatus)) {
+ return new Error(
+ `Failed to navigate to invalid onboarding status ${newStatus}`
+ )
+ }
settingsActor.send({
type: 'set.app.onboardingStatus',
data: { level: 'user', value: newStatus },
})
- navigate(filePath + PATHS.ONBOARDING.INDEX.slice(0, -1) + newStatus)
- }, [filePath, newStatus, settingsActor.send, navigate])
+ navigate(joinRouterPaths(filePath, PATHS.ONBOARDING.INDEX, newStatus))
+ }, [filePath, newStatus, navigate])
}
export function useDismiss() {
@@ -88,18 +109,34 @@ export function useDismiss() {
const send = settingsActor.send
const navigate = useNavigate()
- const settingsCallback = useCallback(() => {
- send({
- type: 'set.app.onboardingStatus',
- data: { level: 'user', value: 'dismissed' },
- })
- waitFor(settingsActor, (state) => state.matches('idle'))
- .then(() => navigate(filePath))
- .catch(reportRejection)
- }, [send])
+ const settingsCallback = useCallback(
+ (
+ dismissalType:
+ | Extract
+ | undefined = 'dismissed'
+ ) => {
+ send({
+ type: 'set.app.onboardingStatus',
+ data: { level: 'user', value: dismissalType },
+ })
+ 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,
+ }
+ )
+ })
+ .catch(reportRejection)
+ },
+ [send, filePath, navigate]
+ )
return settingsCallback
}
+
export function OnboardingButtons({
currentSlug,
className,
@@ -107,32 +144,36 @@ export function OnboardingButtons({
onNextOverride,
...props
}: {
- currentSlug?: (typeof onboardingPaths)[keyof typeof onboardingPaths]
+ currentSlug?: (typeof ONBOARDING_SUBPATHS)[keyof typeof ONBOARDING_SUBPATHS]
className?: string
dismissClassName?: string
onNextOverride?: () => void
} & React.HTMLAttributes) {
+ const onboardingPathsArray = Object.values(ONBOARDING_SUBPATHS)
const dismiss = useDismiss()
const stepNumber = useStepNumber(currentSlug)
const previousStep =
- !stepNumber || stepNumber === 0 ? null : onboardingRoutes[stepNumber - 2]
- const goToPrevious = useNextClick(
- onboardingPaths.INDEX + (previousStep?.path ?? '')
- )
+ !stepNumber || stepNumber <= 1 ? null : onboardingPathsArray[stepNumber]
const nextStep =
- !stepNumber || stepNumber === onboardingRoutes.length
+ !stepNumber || stepNumber === onboardingPathsArray.length
? null
- : onboardingRoutes[stepNumber]
- const goToNext = useNextClick(onboardingPaths.INDEX + (nextStep?.path ?? ''))
+ : onboardingPathsArray[stepNumber]
+
+ const previousOnboardingStatus: OnboardingStatus =
+ previousStep ?? ONBOARDING_SUBPATHS.INDEX
+ const nextOnboardingStatus: OnboardingStatus = nextStep ?? 'completed'
+
+ const goToPrevious = useNextClick(previousOnboardingStatus)
+ const goToNext = useNextClick(nextOnboardingStatus)
return (
<>
dismiss()}
+ 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"
>
- previousStep?.path || previousStep?.index
- ? goToPrevious()
- : dismiss()
- }
+ onClick={() => (previousStep ? goToPrevious() : dismiss())}
iconStart={{
icon: previousStep ? 'arrowLeft' : 'close',
className: 'text-chalkboard-10',
@@ -162,21 +199,24 @@ 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'}
{stepNumber !== undefined && (
- {stepNumber} / {onboardingRoutes.length}
+ {stepNumber} / {onboardingPathsArray.length}
)}
{
- if (nextStep?.path) {
- onNextOverride ? onNextOverride() : goToNext()
+ if (nextStep) {
+ const result = onNextOverride ? onNextOverride() : goToNext()
+ if (err(result)) {
+ reportRejection(result)
+ }
} else {
- dismiss()
+ dismiss('completed')
}
}}
iconStart={{
@@ -186,9 +226,220 @@ export function OnboardingButtons({
className="dark:hover:bg-chalkboard-80/50"
data-testid="onboarding-next"
>
- {nextStep ? `Next` : 'Finish'}
+ {nextStep ? 'Next' : 'Finish'}
>
)
}
+
+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)
+ navigate(
+ makeUrlPathRelative(
+ joinRouterPaths(PATHS.ONBOARDING.INDEX, onboardingStatus)
+ )
+ )
+}
+
+function hasResetReadyCode(codeManager: CodeManager) {
+ return (
+ isKclEmptyOrOnlySettings(codeManager.code) || codeManager.code === bracket
+ )
+}
+
+export function needsToOnboard(
+ location: ReturnType,
+ 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 (
+
+
+
+
+ Welcome to Zoo Design Studio
+
+ Would you like a tutorial to show you around the app?
+
+
+
+
+ Not right now
+
+
+ Get started
+
+
+
+
+ )
+}
+
+/**
+ * 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 (
+
+
+
+
+ The welcome tutorial resets your code in the browser
+
+ We see you have some of your own code written in this project.
+ Please save it somewhere else before continuing the onboarding.
+
+
+
+
+ I'll save it
+
+
+ Overwrite and begin
+
+
+
+
+ )
+}
diff --git a/src/routes/SignIn.tsx b/src/routes/SignIn.tsx
index 67e48f30f98..fd14429ef31 100644
--- a/src/routes/SignIn.tsx
+++ b/src/routes/SignIn.tsx
@@ -26,7 +26,9 @@ const SignIn = () => {
if (isDesktop()) {
window.electron.createFallbackMenu().catch(reportRejection)
// Disable these since they cannot be accessed within the sign in page.
- window.electron.disableMenu('Help.Reset onboarding').catch(reportRejection)
+ window.electron
+ .disableMenu('Help.Replay onboarding tutorial')
+ .catch(reportRejection)
window.electron.disableMenu('Help.Show all commands').catch(reportRejection)
}