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 = () => { />
) 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 ( <>
- 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) }