diff --git a/e2e/playwright/fixtures/homePageFixture.ts b/e2e/playwright/fixtures/homePageFixture.ts index 43765161682..4878e5fbb93 100644 --- a/e2e/playwright/fixtures/homePageFixture.ts +++ b/e2e/playwright/fixtures/homePageFixture.ts @@ -24,7 +24,6 @@ export class HomePageFixture { projectTextName!: Locator sortByDateBtn!: Locator sortByNameBtn!: Locator - tutorialBtn!: Locator constructor(page: Page) { this.page = page @@ -44,7 +43,6 @@ 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 ae51d659acd..ed7bffe79c9 100644 --- a/e2e/playwright/fixtures/toolbarFixture.ts +++ b/e2e/playwright/fixtures/toolbarFixture.ts @@ -17,8 +17,6 @@ type LengthUnitLabel = (typeof baseUnitLabels)[keyof typeof baseUnitLabels] export class ToolbarFixture { public page: Page - projectName!: Locator - fileName!: Locator extrudeButton!: Locator loftButton!: Locator sweepButton!: Locator @@ -55,8 +53,6 @@ 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 5c6dc4d725d..d9967f06efa 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.Replay onboarding tutorial', async ({ + test('Home.Help.Reset onboarding', 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.Replay onboarding tutorial' + 'Help.Reset onboarding' ) if (!menu) { return false @@ -2339,7 +2339,7 @@ test.describe( await scene.connectionEstablished() await expect(toolbar.startSketchBtn).toBeVisible() }) - test('Modeling.Help.Replay onboarding tutorial', async ({ + test('Modeling.Help.Reset onboarding', 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.Replay onboarding tutorial' + 'Help.Reset onboarding' ) if (!menu) fail() menu.click() diff --git a/e2e/playwright/onboarding-tests.spec.ts b/e2e/playwright/onboarding-tests.spec.ts index 6febaffc8e1..a2e776c5617 100644 --- a/e2e/playwright/onboarding-tests.spec.ts +++ b/e2e/playwright/onboarding-tests.spec.ts @@ -1,175 +1,560 @@ +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('Desktop onboarding flow works', async ({ + test('Onboarding code is shown in the editor', async ({ page, homePage, - toolbar, - editor, - scene, 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: '', }, }) - const bracketComment = '// Shelf Bracket' - const tutorialWelcomHeading = page.getByText( - 'Welcome to Design Studio! This' + 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 ({ + page, + homePage, + tronApp, + 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() + } + 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 nextButton = page.getByTestId('onboarding-next') const prevButton = page.getByTestId('onboarding-prev') - 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', + + 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 helpMenuButton = page.getByRole('button', { - name: 'Help and resources', + + 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 helpMenuRestartOnboardingButton = page.getByRole('button', { - name: 'Replay onboarding tutorial', + + 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 postDismissToast = page.getByText( - 'Click the question mark in the lower-right corner if you ever want to redo the tutorial!' - ) - 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', - }) + // Mess with the code again + await editor.replaceCode('', badCode) + await editor.expectEditor.toContain(badCode, { + shouldNormalise: true, + timeout: 10_000, }) - 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 }) + // 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', }) - 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() + // Check that the code has been reset + await editor.expectEditor.toContain(bracket, { + shouldNormalise: true, + timeout: 10_000, }) + }) + + // (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() + } - // 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 }) + await tronApp.cleanProjectDir({ + app: { + onboarding_status: '', + }, }) - 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() - } - }) + // 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, + }), + } + ) - await test.step('Going backward', async () => { - while ((await prevButton.innerText()) !== 'Dismiss') { - await prevButton.hover() - await prevButton.click() - } - }) + await page.setBodyDimensions({ width: 1200, height: 500 }) + await homePage.goToModelingScene() - // 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') + // 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('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() + // 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('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 page.reload({ waitUntil: 'domcontentloaded' }) - 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') - }) + // 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: '', + }, }) + // 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 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 page.setBodyDimensions({ width: 1200, height: 500 }) + await homePage.goToModelingScene() - await helpMenuButton.click() - await helpMenuRestartOnboardingButton.click() - }) + // 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) - 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 }) - }) + // 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') + ) + }) + + // 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') + + 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('Navigate into project', async () => { + await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible() + await expect(projectCard).toBeVisible() + await projectCard.click() + await u.waitForPageLoad() + }) + + await test.step('Restart the onboarding from help menu', async () => { + await helpMenuButton.click() + await restartOnboardingButton.click() + + await nextButton.hover() + await nextButton.click() + }) + + 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 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('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 test.step('Navigate to settings', async () => { + await userMenuButton.click() + await userMenuSettingsButton.click() + await expect(settingsHeading).toBeVisible() + await expect(restartOnboardingSettingsButton).toBeVisible() }) + + 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 48d9fc5e55d..79d7f4af1ad 100644 --- a/e2e/playwright/storageStates.ts +++ b/e2e/playwright/storageStates.ts @@ -1,10 +1,12 @@ import type { SaveSettingsPayload } from '@src/lib/settings/settingsTypes' import { Themes } from '@src/lib/theme' import type { DeepPartial } from '@src/lib/types' -import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths' +import { onboardingPaths } from '@src/routes/Onboarding/paths' import type { Settings } from '@rust/kcl-lib/bindings/Settings' +export const IS_PLAYWRIGHT_KEY = 'playwright' + export const TEST_SETTINGS_KEY = '/settings.toml' export const TEST_SETTINGS: DeepPartial = { app: { @@ -31,15 +33,12 @@ export const TEST_SETTINGS: DeepPartial = { export const TEST_SETTINGS_ONBOARDING_USER_MENU: DeepPartial = { ...TEST_SETTINGS, - app: { - ...TEST_SETTINGS.app, - onboarding_status: ONBOARDING_SUBPATHS.USER_MENU, - }, + app: { ...TEST_SETTINGS.app, onboarding_status: onboardingPaths.USER_MENU }, } export const TEST_SETTINGS_ONBOARDING_EXPORT: DeepPartial = { ...TEST_SETTINGS, - app: { ...TEST_SETTINGS.app, onboarding_status: ONBOARDING_SUBPATHS.EXPORT }, + app: { ...TEST_SETTINGS.app, onboarding_status: onboardingPaths.EXPORT }, } export const TEST_SETTINGS_ONBOARDING_PARAMETRIC_MODELING: DeepPartial = @@ -47,7 +46,7 @@ export const TEST_SETTINGS_ONBOARDING_PARAMETRIC_MODELING: DeepPartial ...TEST_SETTINGS, app: { ...TEST_SETTINGS.app, - onboarding_status: ONBOARDING_SUBPATHS.PARAMETRIC_MODELING, + onboarding_status: onboardingPaths.PARAMETRIC_MODELING, }, } diff --git a/e2e/playwright/test-utils.ts b/e2e/playwright/test-utils.ts index 9f4201e09b0..55dcf9eff43 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, IS_PLAYWRIGHT_KEY } from '@src/lib/constants' +import { COOKIE_NAME } from '@src/lib/constants' import { reportRejection } from '@src/lib/trap' import type { DeepPartial } from '@src/lib/types' import { isArray } from '@src/lib/utils' @@ -18,7 +18,11 @@ 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, + TEST_SETTINGS, + TEST_SETTINGS_KEY, +} from '@e2e/playwright/storageStates' import { test } from '@e2e/playwright/zoo-test' const toNormalizedCode = (text: string) => { diff --git a/known-circular.txt b/known-circular.txt index fbdd980df08..a36dee1ef7f 100644 --- a/known-circular.txt +++ b/known-circular.txt @@ -11,3 +11,4 @@ 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 babf539965f..0437d4b0241 100644 --- a/rust/kcl-lib/src/settings/types/mod.rs +++ b/rust/kcl-lib/src/settings/types/mod.rs @@ -527,6 +527,9 @@ 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 e087d074e60..4e501a20e1b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,7 +4,6 @@ import { useHotkeys } from 'react-hotkeys-hook' import ModalContainer from 'react-modal-promise' import { useLoaderData, - useLocation, useNavigate, useRouteLoaderData, useSearchParams, @@ -27,20 +26,15 @@ 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, codeManager, kclManager } from '@src/lib/singletons' +import { sceneInfra } 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 @@ -64,7 +58,6 @@ export function App() { }) }) - const location = useLocation() const navigate = useNavigate() const filePath = useAbsoluteFilePath() const { onProjectOpen } = useLspContext() @@ -73,7 +66,7 @@ export function App() { const ref = useRef(null) // Stream related refs and data - const [searchParams] = useSearchParams() + let [searchParams] = useSearchParams() const pool = searchParams.get('pool') const projectName = project?.name || null @@ -83,10 +76,9 @@ 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) - }, [onProjectOpen, projectName, projectPath, file]) + }, [projectName, projectPath]) useHotKeyListener() @@ -112,10 +104,9 @@ export function App() { toast.success('Your work is auto-saved in real-time') }) - const paneOpacity = [ - ONBOARDING_SUBPATHS.CAMERA, - ONBOARDING_SUBPATHS.STREAMING, - ].some((p) => p === onboardingStatus.current) + const paneOpacity = [onboardingPaths.CAMERA, onboardingPaths.STREAMING].some( + (p) => p === onboardingStatus.current + ) ? 'opacity-20' : '' @@ -141,7 +132,7 @@ export function App() { }) }, 500) } - }, [lastCommandType, loaderData?.project?.path]) + }, [lastCommandType]) useEffect(() => { // When leaving the modeling scene, cut the engine stream. @@ -150,32 +141,6 @@ 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 e457870f2a8..f592783deff 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 } from '@src/lib/routeLoaders' +import { fileLoader, homeLoader, telemetryLoader } from '@src/lib/routeLoaders' import { codeManager, engineCommandManager, @@ -110,6 +110,7 @@ const router = createRouter([ }, { id: PATHS.FILE + 'TELEMETRY', + loader: telemetryLoader, children: [ { path: makeUrlPathRelative(PATHS.TELEMETRY), @@ -143,6 +144,7 @@ const router = createRouter([ }, { path: makeUrlPathRelative(PATHS.TELEMETRY), + loader: telemetryLoader, element: , }, ], diff --git a/src/components/CustomIcon.tsx b/src/components/CustomIcon.tsx index 0d918aef306..c69f7fe4c4a 100644 --- a/src/components/CustomIcon.tsx +++ b/src/components/CustomIcon.tsx @@ -854,14 +854,6 @@ const CustomIconMap = { /> ), - play: ( - - - - ), rotate: ( (
) -export function HelpMenu({ - navigate = () => {}, -}: { - navigate?: NavigateFunction -}) { +export function HelpMenu(props: React.PropsWithChildren) { const location = useLocation() + const { onProjectOpen } = useLspContext() const filePath = useAbsoluteFilePath() + const isInProject = location.pathname.includes(PATHS.FILE) + const navigate = useNavigate() const resetOnboardingWorkflow = () => { - const props = { - onboardingStatus: ONBOARDING_SUBPATHS.INDEX, - navigate, - codeManager, - kclManager, + settingsActor.send({ + type: 'set.app.onboardingStatus', + data: { + value: '', + level: 'user', + }, + }) + if (isInProject) { + navigate(filePath + PATHS.ONBOARDING.INDEX) + } else { + createAndOpenNewTutorialProject({ + onProjectOpen, + navigate, + }).catch(reportRejection) } - acceptOnboarding(props).catch((reason) => - catchOnboardingWarnError(reason, props) - ) } const cb = (data: WebContentSendPayload) => { - if (data.menuLabel === 'Help.Replay onboarding tutorial') { + if (data.menuLabel === 'Help.Reset onboarding') { resetOnboardingWorkflow() } } @@ -65,81 +68,71 @@ export function HelpMenu({ 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" > - {({ 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 - - - )} + + 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 + ) diff --git a/src/components/LowerRightControls.tsx b/src/components/LowerRightControls.tsx index 219ae2334f8..42d539e8ddc 100644 --- a/src/components/LowerRightControls.tsx +++ b/src/components/LowerRightControls.tsx @@ -1,4 +1,5 @@ -import { Link, type NavigateFunction, useLocation } from 'react-router-dom' +import { Link, useLocation } from 'react-router-dom' + import { CustomIcon } from '@src/components/CustomIcon' import { HelpMenu } from '@src/components/HelpMenu' import { NetworkHealthIndicator } from '@src/components/NetworkHealthIndicator' @@ -11,10 +12,8 @@ import { APP_VERSION, getReleaseUrl } from '@src/routes/utils' export function LowerRightControls({ children, - navigate = () => {}, }: { children?: React.ReactNode - navigate?: NavigateFunction }) { const location = useLocation() const filePath = useAbsoluteFilePath() @@ -73,7 +72,7 @@ export function LowerRightControls({ {!location.pathname.startsWith(PATHS.HOME) && ( )} - + ) diff --git a/src/components/ModelingSidebar/ModelingPane.tsx b/src/components/ModelingSidebar/ModelingPane.tsx index 264da072a0f..b919b8ff079 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 { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths' +import { onboardingPaths } from '@src/routes/Onboarding/paths' 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 === ONBOARDING_SUBPATHS.CAMERA + onboardingStatus.current === onboardingPaths.CAMERA ? 'pointer-events-none ' : 'pointer-events-auto ' return ( diff --git a/src/components/ModelingSidebar/ModelingSidebar.tsx b/src/components/ModelingSidebar/ModelingSidebar.tsx index 7e4d18450cf..6e5eb2f6861 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 { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths' +import { onboardingPaths } from '@src/routes/Onboarding/paths' 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 === ONBOARDING_SUBPATHS.CAMERA || + onboardingStatus.current === onboardingPaths.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 5dc4a8261c0..a20b6a25ef9 100644 --- a/src/components/Providers/SystemIOProviderDesktop.tsx +++ b/src/components/Providers/SystemIOProviderDesktop.tsx @@ -1,10 +1,5 @@ import { useFileSystemWatcher } from '@src/hooks/useFileSystemWatcher' -import { - PATHS, - joinRouterPaths, - joinOSPaths, - safeEncodeForRouterPaths, -} from '@src/lib/paths' +import { PATHS } from '@src/lib/paths' import { systemIOActor, useSettings, useToken } from '@src/lib/singletons' import { useHasListedProjects, @@ -40,14 +35,14 @@ export function SystemIOMachineLogicListenerDesktop() { if (!requestedProjectName.name) { return } - const projectPathWithoutSpecificKCLFile = joinOSPaths( - projectDirectoryPath, + let projectPathWithoutSpecificKCLFile = + projectDirectoryPath + + window.electron.path.sep + requestedProjectName.name - ) - const requestedPath = joinRouterPaths( - PATHS.FILE, - safeEncodeForRouterPaths(projectPathWithoutSpecificKCLFile) - ) + + const requestedPath = `${PATHS.FILE}/${encodeURIComponent( + projectPathWithoutSpecificKCLFile + )}` navigate(requestedPath) }, [requestedProjectName]) } @@ -57,16 +52,12 @@ export function SystemIOMachineLogicListenerDesktop() { if (!requestedFileName.file || !requestedFileName.project) { return } - const filePath = joinOSPaths( + const projectPath = window.electron.join( projectDirectoryPath, - requestedFileName.project, - requestedFileName.file - ) - const requestedPath = joinRouterPaths( - PATHS.FILE, - safeEncodeForRouterPaths(filePath), - requestedFileName.subRoute || '' + requestedFileName.project ) + 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 3cb61cfc420..b18f55743a5 100644 --- a/src/components/RouteProvider.tsx +++ b/src/components/RouteProvider.tsx @@ -7,6 +7,8 @@ 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' @@ -16,7 +18,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 } from '@src/lib/singletons' +import { settingsActor, useSettings } from '@src/lib/singletons' export const RouteProviderContext = createContext({}) @@ -30,6 +32,7 @@ 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. @@ -43,9 +46,35 @@ 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) - }, [first, navigation, location.pathname]) + }, [navigation]) useEffect(() => { if (!isDesktop()) return diff --git a/src/components/Settings/AllSettingsFields.tsx b/src/components/Settings/AllSettingsFields.tsx index ec39dc2204a..9e50f2e8e2d 100644 --- a/src/components/Settings/AllSettingsFields.tsx +++ b/src/components/Settings/AllSettingsFields.tsx @@ -6,12 +6,16 @@ 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 { getSettingsFolderPaths } from '@src/lib/desktopFS' +import { useDotDotSlash } from '@src/hooks/useDotDotSlash' +import { + createAndOpenNewTutorialProject, + 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 { @@ -24,17 +28,9 @@ import { } from '@src/lib/settings/settingsUtils' import { reportRejection } from '@src/lib/trap' import { toSync } from '@src/lib/utils' -import { - codeManager, - kclManager, - settingsActor, - useSettings, -} from '@src/lib/singletons' +import { settingsActor, useSettings } from '@src/lib/singletons' import { APP_VERSION, IS_NIGHTLY, getReleaseUrl } from '@src/routes/utils' -import { - acceptOnboarding, - catchOnboardingWarnError, -} from '@src/routes/Onboarding/utils' +import { waitFor } from 'xstate' interface AllSettingsFieldsProps { searchParamTab: SettingsLevel @@ -48,6 +44,8 @@ export const AllSettingsFields = forwardRef( ) => { const location = useLocation() const navigate = useNavigate() + const { onProjectOpen } = useLspContext() + const dotDotSlash = useDotDotSlash() const context = useSettings() const projectPath = useMemo(() => { @@ -65,18 +63,26 @@ export const AllSettingsFields = forwardRef( : undefined return projectPath - }, [location.pathname, isFileSettings]) + }, [location.pathname]) async function restartOnboarding() { - const props = { - onboardingStatus: ONBOARDING_SUBPATHS.INDEX, - navigate, - codeManager, - kclManager, + 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 }) } - acceptOnboarding(props).catch((reason) => - catchOnboardingWarnError(reason, props) - ) } return ( diff --git a/src/lib/constants.ts b/src/lib/constants.ts index adbdb695090..4a527381d4c 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -2,7 +2,6 @@ import type { Models } from '@kittycad/lib/dist/types/src' import type { UnitAngle, UnitLength } from '@rust/kcl-lib/bindings/ModelingCmd' -export const IS_PLAYWRIGHT_KEY = 'playwright' export const APP_NAME = 'Design Studio' /** Search string in new project names to increment as an index */ export const INDEX_IDENTIFIER = '$n' diff --git a/src/lib/desktopFS.ts b/src/lib/desktopFS.ts index e12225129ca..29bb9d8ef3b 100644 --- a/src/lib/desktopFS.ts +++ b/src/lib/desktopFS.ts @@ -1,6 +1,18 @@ import { relevantFileExtensions } from '@src/lang/wasmUtils' -import { FILE_EXT, INDEX_IDENTIFIER, MAX_PADDING } from '@src/lib/constants' +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 { isDesktop } from '@src/lib/isDesktop' +import { PATHS } from '@src/lib/paths' import type { FileEntry } from '@src/lib/project' export const isHidden = (fileOrDir: FileEntry) => @@ -120,6 +132,65 @@ 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/lib/paths.ts b/src/lib/paths.ts index 7d921d6a4db..4ad514f197e 100644 --- a/src/lib/paths.ts +++ b/src/lib/paths.ts @@ -2,7 +2,7 @@ import type { PlatformPath } from 'path' import type { Configuration } from '@rust/kcl-lib/bindings/Configuration' -import { IS_PLAYWRIGHT_KEY } from '@src/lib/constants' +import { IS_PLAYWRIGHT_KEY } from '@e2e/playwright/storageStates' import { BROWSER_FILE_NAME, @@ -14,7 +14,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 { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths' +import { onboardingPaths } from '@src/routes/Onboarding/paths' const prependRoutes = (routesObject: Record) => (prepend: string) => { @@ -27,7 +27,7 @@ const prependRoutes = } type OnboardingPaths = { - [K in keyof typeof ONBOARDING_SUBPATHS]: `/onboarding${(typeof ONBOARDING_SUBPATHS)[K]}` + [K in keyof typeof onboardingPaths]: `/onboarding${(typeof onboardingPaths)[K]}` } const SETTINGS = '/settings' @@ -48,9 +48,7 @@ export const PATHS = { SETTINGS_PROJECT: `${SETTINGS}?tab=project` as const, SETTINGS_KEYBINDINGS: `${SETTINGS}?tab=keybindings` as const, SIGN_IN: '/signin', - ONBOARDING: prependRoutes(ONBOARDING_SUBPATHS)( - '/onboarding' - ) as OnboardingPaths, + ONBOARDING: prependRoutes(onboardingPaths)('/onboarding') as OnboardingPaths, TELEMETRY: '/telemetry', } as const export const BROWSER_PATH = `%2F${BROWSER_PROJECT_NAME}%2F${BROWSER_FILE_NAME}${FILE_EXT}` @@ -138,56 +136,3 @@ 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 020efedfbfd..de46b4c4a5c 100644 --- a/src/lib/routeLoaders.ts +++ b/src/lib/routeLoaders.ts @@ -22,6 +22,12 @@ 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 93984de0331..57e1750827b 100644 --- a/src/machines/systemIO/systemIOMachine.ts +++ b/src/machines/systemIO/systemIOMachine.ts @@ -43,11 +43,7 @@ export const systemIOMachine = setup({ } | { type: SystemIOMachineEvents.navigateToFile - data: { - requestedProjectName: string - requestedFileName: string - requestedSubRoute?: string - } + data: { requestedProjectName: string; requestedFileName: string } } | { type: SystemIOMachineEvents.createProject @@ -79,7 +75,6 @@ export const systemIOMachine = setup({ requestedProjectName: string requestedFileName: string requestedCode: string - requestedSubRoute?: string } } | { @@ -122,9 +117,7 @@ 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({ @@ -133,7 +126,6 @@ export const systemIOMachine = setup({ return { project: event.data.requestedProjectName, file: event.data.requestedFileName, - subRoute: event.data.requestedSubRoute, } }, }), @@ -232,15 +224,13 @@ 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: '', subRoute: '' } + return { message: '', fileName: '', projectName: '' } } ), [SystemIOMachineActors.checkReadWrite]: fromPromise( @@ -468,7 +458,6 @@ 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, } @@ -487,7 +476,6 @@ 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 4a382477b62..bc42af2e55c 100644 --- a/src/machines/systemIO/systemIOMachineDesktop.ts +++ b/src/machines/systemIO/systemIOMachineDesktop.ts @@ -158,7 +158,6 @@ export const systemIOMachineDesktop = systemIOMachine.provide({ requestedFileName: string requestedCode: string rootContext: AppMachineContext - requestedSubRoute?: string } }) => { const requestedProjectName = input.requestedProjectName @@ -207,7 +206,6 @@ 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 67034695eb9..cac7ef9adcf 100644 --- a/src/machines/systemIO/systemIOMachineWeb.ts +++ b/src/machines/systemIO/systemIOMachineWeb.ts @@ -20,7 +20,6 @@ export const systemIOMachineWeb = systemIOMachine.provide({ requestedFileName: string requestedCode: string rootContext: AppMachineContext - requestedSubRoute?: string } }) => { // Browser version doesn't navigate, just overwrites the current file @@ -44,7 +43,6 @@ 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 a07605d6e6b..5691fe2e005 100644 --- a/src/machines/systemIO/utils.ts +++ b/src/machines/systemIO/utils.ts @@ -8,7 +8,6 @@ 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', } @@ -22,7 +21,6 @@ export enum SystemIOMachineStates { deletingProject = 'deletingProject', creatingKCLFile = 'creatingKCLFile', checkingReadWrite = 'checkingReadWrite', - /** TODO: rename this event to be more generic, like `createKCLFileAndNavigate` */ importFileFromURL = 'importFileFromURL', deletingKCLFile = 'deletingKCLFile', } @@ -43,7 +41,6 @@ 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', @@ -77,7 +74,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; subRoute?: string } + requestedFileName: { project: string; file: string } canReadWriteProjectDirectory: { value: boolean; error: unknown } clearURLParams: { value: boolean } requestedTextToCadGeneration: { diff --git a/src/menu/channels.ts b/src/menu/channels.ts index 6312fa8db8b..f9ba9f5d8a4 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.Replay onboarding tutorial' + | 'Help.Reset onboarding' | 'Edit.Rename project' | 'Edit.Delete project' | 'Edit.Change project directory' diff --git a/src/menu/helpRole.ts b/src/menu/helpRole.ts index b6941994a77..de7a1c1af87 100644 --- a/src/menu/helpRole.ts +++ b/src/menu/helpRole.ts @@ -84,11 +84,11 @@ export const helpRole = ( }, { type: 'separator' }, { - id: 'Help.Replay onboarding tutorial', - label: 'Replay onboarding tutorial', + id: 'Help.Reset onboarding', + label: 'Reset onboarding', click: () => { typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', { - menuLabel: 'Help.Replay onboarding tutorial', + menuLabel: 'Help.Reset onboarding', }) }, }, diff --git a/src/menu/roles.ts b/src/menu/roles.ts index 5ead00a0f14..8b51ad1dafa 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' - | 'Replay onboarding tutorial' + | 'Reset onboarding' | 'Show release notes' | 'Manage account' | 'Get started with Text-to-CAD' diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index aed35b0b846..5e85e597fc4 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -2,12 +2,7 @@ import type { FormEvent, HTMLProps } from 'react' import { useEffect, useRef } from 'react' import { toast } from 'react-hot-toast' import { useHotkeys } from 'react-hotkeys-hook' -import { - Link, - useLocation, - useNavigate, - useSearchParams, -} from 'react-router-dom' +import { Link, useNavigate, useSearchParams } from 'react-router-dom' import { ActionButton } from '@src/components/ActionButton' import { AppHeader } from '@src/components/AppHeader' @@ -24,7 +19,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 { codeManager, kclManager } from '@src/lib/singletons' +import { kclManager } from '@src/lib/singletons' import { getNextSearchParams, getSortFunction, @@ -44,12 +39,6 @@ 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 @@ -80,10 +69,8 @@ const Home = () => { }) }) - const location = useLocation() const navigate = useNavigate() const settings = useSettings() - const onboardingStatus = settings.app.onboardingStatus.current // Menu listeners const cb = (data: WebContentSendPayload) => { @@ -206,7 +193,7 @@ const Home = () => { return (
-
+
{ readWriteProjectDir={readWriteProjectDir} className="col-start-2 -col-end-1" /> -
) diff --git a/src/routes/Onboarding/Camera.tsx b/src/routes/Onboarding/Camera.tsx index 199e26bed5d..211cd8e7bed 100644 --- a/src/routes/Onboarding/Camera.tsx +++ b/src/routes/Onboarding/Camera.tsx @@ -1,11 +1,18 @@ 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 { OnboardingButtons } from '@src/routes/Onboarding/utils' +import { onboardingPaths } from '@src/routes/Onboarding/paths' + +import { + OnboardingButtons, + useDismiss, + useNextClick, +} from '@src/routes/Onboarding/utils' export default function Units() { + useDismiss() + useNextClick(onboardingPaths.STREAMING) const { modeling: { mouseControls }, } = useSettings() @@ -59,7 +66,7 @@ export default function Units() {
diff --git a/src/routes/Onboarding/CmdK.tsx b/src/routes/Onboarding/CmdK.tsx index 161f2974283..7dc2e4e2ca6 100644 --- a/src/routes/Onboarding/CmdK.tsx +++ b/src/routes/Onboarding/CmdK.tsx @@ -1,7 +1,8 @@ import { COMMAND_PALETTE_HOTKEY } from '@src/components/CommandBar/CommandBar' import usePlatform from '@src/hooks/usePlatform' import { hotkeyDisplay } from '@src/lib/hotkeyWrapper' -import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths' +import { onboardingPaths } from '@src/routes/Onboarding/paths' + import { OnboardingButtons, kbdClasses } from '@src/routes/Onboarding/utils' export default function CmdK() { @@ -36,7 +37,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 597885016d7..e0e4e47fba5 100644 --- a/src/routes/Onboarding/CodeEditor.tsx +++ b/src/routes/Onboarding/CodeEditor.tsx @@ -1,4 +1,5 @@ -import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths' +import { onboardingPaths } from '@src/routes/Onboarding/paths' + import { OnboardingButtons, kbdClasses, @@ -69,7 +70,7 @@ export default function OnboardingCodeEditor() { pressing Shift + C.

- + ) diff --git a/src/routes/Onboarding/Export.tsx b/src/routes/Onboarding/Export.tsx index a7274c06f74..5d14b4d876e 100644 --- a/src/routes/Onboarding/Export.tsx +++ b/src/routes/Onboarding/Export.tsx @@ -1,5 +1,6 @@ import { APP_NAME } from '@src/lib/constants' -import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths' +import { onboardingPaths } from '@src/routes/Onboarding/paths' + import { OnboardingButtons } from '@src/routes/Onboarding/utils' export default function Export() { @@ -49,7 +50,7 @@ export default function Export() { !

- + ) diff --git a/src/routes/Onboarding/FutureWork.tsx b/src/routes/Onboarding/FutureWork.tsx index 0fbd7585584..fa2756adfee 100644 --- a/src/routes/Onboarding/FutureWork.tsx +++ b/src/routes/Onboarding/FutureWork.tsx @@ -1,9 +1,11 @@ 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() @@ -56,7 +58,7 @@ export default function FutureWork() {

💚 The Zoo Team

diff --git a/src/routes/Onboarding/InteractiveNumbers.tsx b/src/routes/Onboarding/InteractiveNumbers.tsx index a6b336ac2c8..d5217f08576 100644 --- a/src/routes/Onboarding/InteractiveNumbers.tsx +++ b/src/routes/Onboarding/InteractiveNumbers.tsx @@ -1,5 +1,6 @@ import { bracketWidthConstantLine } from '@src/lib/exampleKcl' -import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths' +import { onboardingPaths } from '@src/routes/Onboarding/paths' + import { OnboardingButtons, kbdClasses, @@ -84,9 +85,7 @@ 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 e89d7bf4360..ceb0eeb1730 100644 --- a/src/routes/Onboarding/Introduction.tsx +++ b/src/routes/Onboarding/Introduction.tsx @@ -1,11 +1,124 @@ +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' -export default function Introduction() { +/** + * 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() { // Reset the code to the bracket code useDemoCode() @@ -69,7 +182,7 @@ export default function Introduction() {

diff --git a/src/routes/Onboarding/ParametricModeling.tsx b/src/routes/Onboarding/ParametricModeling.tsx index 189d0044930..a3fb6fa2468 100644 --- a/src/routes/Onboarding/ParametricModeling.tsx +++ b/src/routes/Onboarding/ParametricModeling.tsx @@ -2,8 +2,9 @@ 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() @@ -71,9 +72,7 @@ export default function OnboardingParametricModeling() { - + ) diff --git a/src/routes/Onboarding/ProjectMenu.tsx b/src/routes/Onboarding/ProjectMenu.tsx index 6e73d9a791d..b8f3ee3d488 100644 --- a/src/routes/Onboarding/ProjectMenu.tsx +++ b/src/routes/Onboarding/ProjectMenu.tsx @@ -1,5 +1,6 @@ import { isDesktop } from '@src/lib/isDesktop' -import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths' +import { onboardingPaths } from '@src/routes/Onboarding/paths' + import { OnboardingButtons } from '@src/routes/Onboarding/utils' export default function ProjectMenu() { @@ -55,7 +56,7 @@ export default function ProjectMenu() { )} - + ) diff --git a/src/routes/Onboarding/Sketching.tsx b/src/routes/Onboarding/Sketching.tsx index 652557048f5..e24c76c1a10 100644 --- a/src/routes/Onboarding/Sketching.tsx +++ b/src/routes/Onboarding/Sketching.tsx @@ -1,7 +1,9 @@ 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(() => { @@ -40,7 +42,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 1583ad92247..ef052be5968 100644 --- a/src/routes/Onboarding/Streaming.tsx +++ b/src/routes/Onboarding/Streaming.tsx @@ -1,4 +1,5 @@ -import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths' +import { onboardingPaths } from '@src/routes/Onboarding/paths' + import { OnboardingButtons } from '@src/routes/Onboarding/utils' export default function Streaming() { @@ -40,7 +41,7 @@ export default function Streaming() {

diff --git a/src/routes/Onboarding/Units.tsx b/src/routes/Onboarding/Units.tsx index b56a1cde69c..cbccf8e17f8 100644 --- a/src/routes/Onboarding/Units.tsx +++ b/src/routes/Onboarding/Units.tsx @@ -4,12 +4,13 @@ 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 { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths' +import { onboardingPaths } from '@src/routes/Onboarding/paths' + import { useDismiss, useNextClick } from '@src/routes/Onboarding/utils' export default function Units() { const dismiss = useDismiss() - const next = useNextClick(ONBOARDING_SUBPATHS.CAMERA) + const next = useNextClick(onboardingPaths.CAMERA) const { modeling: { defaultUnit }, } = useSettings() diff --git a/src/routes/Onboarding/UserMenu.tsx b/src/routes/Onboarding/UserMenu.tsx index 53347934937..e3e03fee924 100644 --- a/src/routes/Onboarding/UserMenu.tsx +++ b/src/routes/Onboarding/UserMenu.tsx @@ -1,7 +1,9 @@ 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() @@ -46,7 +48,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 dbf7be27824..1652d70ac96 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,48 +23,48 @@ export const onboardingRoutes = [ element: , }, { - path: makeUrlPathRelative(ONBOARDING_SUBPATHS.CAMERA), + path: makeUrlPathRelative(onboardingPaths.CAMERA), element: , }, { - path: makeUrlPathRelative(ONBOARDING_SUBPATHS.STREAMING), + path: makeUrlPathRelative(onboardingPaths.STREAMING), element: , }, { - path: makeUrlPathRelative(ONBOARDING_SUBPATHS.EDITOR), + path: makeUrlPathRelative(onboardingPaths.EDITOR), element: , }, { - path: makeUrlPathRelative(ONBOARDING_SUBPATHS.PARAMETRIC_MODELING), + path: makeUrlPathRelative(onboardingPaths.PARAMETRIC_MODELING), element: , }, { - path: makeUrlPathRelative(ONBOARDING_SUBPATHS.INTERACTIVE_NUMBERS), + path: makeUrlPathRelative(onboardingPaths.INTERACTIVE_NUMBERS), element: , }, { - path: makeUrlPathRelative(ONBOARDING_SUBPATHS.COMMAND_K), + path: makeUrlPathRelative(onboardingPaths.COMMAND_K), element: , }, { - path: makeUrlPathRelative(ONBOARDING_SUBPATHS.USER_MENU), + path: makeUrlPathRelative(onboardingPaths.USER_MENU), element: , }, { - path: makeUrlPathRelative(ONBOARDING_SUBPATHS.PROJECT_MENU), + path: makeUrlPathRelative(onboardingPaths.PROJECT_MENU), element: , }, { - path: makeUrlPathRelative(ONBOARDING_SUBPATHS.EXPORT), + path: makeUrlPathRelative(onboardingPaths.EXPORT), element: , }, // Export / conversion API { - path: makeUrlPathRelative(ONBOARDING_SUBPATHS.SKETCHING), + path: makeUrlPathRelative(onboardingPaths.SKETCHING), element: , }, { - path: makeUrlPathRelative(ONBOARDING_SUBPATHS.FUTURE_WORK), + path: makeUrlPathRelative(onboardingPaths.FUTURE_WORK), element: , }, ] diff --git a/src/lib/onboardingPaths.ts b/src/routes/Onboarding/paths.ts similarity index 83% rename from src/lib/onboardingPaths.ts rename to src/routes/Onboarding/paths.ts index 95abdb1cae9..f8a069331ac 100644 --- a/src/lib/onboardingPaths.ts +++ b/src/routes/Onboarding/paths.ts @@ -1,6 +1,6 @@ import type { OnboardingStatus } from '@rust/kcl-lib/bindings/OnboardingStatus' -export const ONBOARDING_SUBPATHS: Record = { +export const onboardingPaths: Record = { INDEX: '/', CAMERA: '/camera', STREAMING: '/streaming', @@ -11,6 +11,7 @@ export const ONBOARDING_SUBPATHS: Record = { USER_MENU: '/user-menu', PROJECT_MENU: '/project-menu', EXPORT: '/export', + MOVE: '/move', SKETCHING: '/sketching', FUTURE_WORK: '/future-work', } as const diff --git a/src/routes/Onboarding/utils.tsx b/src/routes/Onboarding/utils.tsx index 5a72b4716d9..30309ad87a8 100644 --- a/src/routes/Onboarding/utils.tsx +++ b/src/routes/Onboarding/utils.tsx @@ -1,9 +1,5 @@ import { useCallback, useEffect } from 'react' -import { - type NavigateFunction, - type useLocation, - useNavigate, -} from 'react-router-dom' +import { useNavigate } from 'react-router-dom' import { waitFor } from 'xstate' import { ActionButton } from '@src/components/ActionButton' @@ -15,39 +11,30 @@ import { NetworkHealthState } from '@src/hooks/useNetworkStatus' import { EngineConnectionStateType } from '@src/lang/std/engineConnection' import { bracket } from '@src/lib/exampleKcl' import makeUrlPathRelative from '@src/lib/makeUrlPathRelative' -import { joinRouterPaths, PATHS } from '@src/lib/paths' -import { - codeManager, - editorManager, - kclManager, - systemIOActor, -} from '@src/lib/singletons' +import { PATHS } from '@src/lib/paths' +import { codeManager, editorManager, kclManager } from '@src/lib/singletons' import { reportRejection, trap } from '@src/lib/trap' import { settingsActor } from '@src/lib/singletons' -import { isKclEmptyOrOnlySettings, parse, resultIsOk } from '@src/lang/wasm' +import { onboardingRoutes } from '@src/routes/Onboarding' +import { onboardingPaths } from '@src/routes/Onboarding/paths' +import { parse, resultIsOk } from '@src/lang/wasm' import { updateModelingState } from '@src/lang/modelingWorkflows' -import { - DEFAULT_PROJECT_KCL_FILE, - EXECUTION_TYPE_REAL, - ONBOARDING_PROJECT_NAME, -} from '@src/lib/constants' -import toast from 'react-hot-toast' -import type CodeManager from '@src/lang/codeManager' -import type { OnboardingStatus } from '@rust/kcl-lib/bindings/OnboardingStatus' -import { isDesktop } from '@src/lib/isDesktop' -import type { KclManager } from '@src/lang/KclSingleton' -import { Logo } from '@src/components/Logo' -import { SystemIOMachineEvents } from '@src/machines/systemIO/utils' -import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths' +import { EXECUTION_TYPE_REAL } from '@src/lib/constants' export const kbdClasses = 'py-0.5 px-1 text-sm rounded bg-chalkboard-10 dark:bg-chalkboard-100 border border-chalkboard-50 border-b-2' // Get the 1-indexed step number of the current onboarding step function useStepNumber( - slug?: (typeof ONBOARDING_SUBPATHS)[keyof typeof ONBOARDING_SUBPATHS] + slug?: (typeof onboardingPaths)[keyof typeof onboardingPaths] ) { - return slug ? Object.values(ONBOARDING_SUBPATHS).indexOf(slug) + 1 : -1 + return slug + ? slug === onboardingPaths.INDEX + ? 1 + : onboardingRoutes.findIndex( + (r) => r.path === makeUrlPathRelative(slug) + ) + 1 + : 1 } export function useDemoCode() { @@ -93,7 +80,7 @@ export function useNextClick(newStatus: string) { data: { level: 'user', value: newStatus }, }) navigate(filePath + PATHS.ONBOARDING.INDEX.slice(0, -1) + newStatus) - }, [filePath, newStatus, navigate]) + }, [filePath, newStatus, settingsActor.send, navigate]) } export function useDismiss() { @@ -107,17 +94,9 @@ export function useDismiss() { data: { level: 'user', value: 'dismissed' }, }) waitFor(settingsActor, (state) => state.matches('idle')) - .then(() => { - navigate(filePath) - toast.success( - 'Click the question mark in the lower-right corner if you ever want to redo the tutorial!', - { - duration: 5_000, - } - ) - }) + .then(() => navigate(filePath)) .catch(reportRejection) - }, [send, filePath, navigate]) + }, [send]) return settingsCallback } @@ -128,31 +107,32 @@ export function OnboardingButtons({ onNextOverride, ...props }: { - currentSlug?: (typeof ONBOARDING_SUBPATHS)[keyof typeof ONBOARDING_SUBPATHS] + currentSlug?: (typeof onboardingPaths)[keyof typeof onboardingPaths] className?: string dismissClassName?: string onNextOverride?: () => void } & React.HTMLAttributes) { - const onboardingPathsArray = Object.values(ONBOARDING_SUBPATHS) const dismiss = useDismiss() const stepNumber = useStepNumber(currentSlug) const previousStep = - !stepNumber || stepNumber <= 1 ? null : onboardingPathsArray[stepNumber] - const goToPrevious = useNextClick(previousStep ?? ONBOARDING_SUBPATHS.INDEX) + !stepNumber || stepNumber === 0 ? null : onboardingRoutes[stepNumber - 2] + const goToPrevious = useNextClick( + onboardingPaths.INDEX + (previousStep?.path ?? '') + ) const nextStep = - !stepNumber || stepNumber === onboardingPathsArray.length + !stepNumber || stepNumber === onboardingRoutes.length ? null - : onboardingPathsArray[stepNumber] - const goToNext = useNextClick(nextStep + ONBOARDING_SUBPATHS.INDEX) + : onboardingRoutes[stepNumber] + const goToNext = useNextClick(onboardingPaths.INDEX + (nextStep?.path ?? '')) return ( <>
(previousStep ? goToPrevious() : dismiss())} + onClick={() => + previousStep?.path || previousStep?.index + ? goToPrevious() + : dismiss() + } iconStart={{ icon: previousStep ? 'arrowLeft' : 'close', className: 'text-chalkboard-10', @@ -178,18 +162,18 @@ export function OnboardingButtons({ className="hover:border-destroy-40 hover:bg-destroy-10/50 dark:hover:bg-destroy-80/50" data-testid="onboarding-prev" > - {previousStep ? 'Back' : 'Dismiss'} + {previousStep ? `Back` : 'Dismiss'} {stepNumber !== undefined && (

- {stepNumber} / {onboardingPathsArray.length} + {stepNumber} / {onboardingRoutes.length}

)} { - if (nextStep) { + if (nextStep?.path) { onNextOverride ? onNextOverride() : goToNext() } else { dismiss() @@ -202,221 +186,9 @@ export function OnboardingButtons({ className="dark:hover:bg-chalkboard-80/50" data-testid="onboarding-next" > - {nextStep ? 'Next' : 'Finish'} + {nextStep ? `Next` : 'Finish'}
) } - -export interface OnboardingUtilDeps { - onboardingStatus: OnboardingStatus - codeManager: CodeManager - kclManager: KclManager - navigate: NavigateFunction -} - -export const ERROR_MUST_WARN = 'Must warn user before overwrite' - -/** - * Accept to begin the onboarding tutorial, - * depending on the platform and the state of the user's code. - */ -export async function acceptOnboarding(deps: OnboardingUtilDeps) { - if (isDesktop()) { - /** TODO: rename this event to be more generic, like `createKCLFileAndNavigate` */ - systemIOActor.send({ - type: SystemIOMachineEvents.importFileFromURL, - data: { - requestedProjectName: ONBOARDING_PROJECT_NAME, - requestedFileName: DEFAULT_PROJECT_KCL_FILE, - requestedCode: bracket, - requestedSubRoute: joinRouterPaths( - PATHS.ONBOARDING.INDEX, - deps.onboardingStatus - ), - }, - }) - return Promise.resolve() - } - - const isCodeResettable = hasResetReadyCode(deps.codeManager) - if (isCodeResettable) { - return resetCodeAndAdvanceOnboarding(deps) - } - - return Promise.reject(new Error(ERROR_MUST_WARN)) -} - -/** - * Given that the user has accepted overwriting their web editor, - * advance to the next step and clear their editor. - */ -export async function resetCodeAndAdvanceOnboarding({ - onboardingStatus, - codeManager, - kclManager, - navigate, -}: OnboardingUtilDeps) { - // We do want to update both the state and editor here. - codeManager.updateCodeStateEditor(bracket) - codeManager.writeToFile().catch(reportRejection) - kclManager.executeCode().catch(reportRejection) - // TODO: this is not navigating to the correct `/onboarding/blah` path yet - navigate( - makeUrlPathRelative( - `${PATHS.ONBOARDING.INDEX}${makeUrlPathRelative(onboardingStatus)}` - ) - ) -} - -function hasResetReadyCode(codeManager: CodeManager) { - return ( - isKclEmptyOrOnlySettings(codeManager.code) || codeManager.code === bracket - ) -} - -export function needsToOnboard( - location: ReturnType, - 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 fd14429ef31..67e48f30f98 100644 --- a/src/routes/SignIn.tsx +++ b/src/routes/SignIn.tsx @@ -26,9 +26,7 @@ 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.Replay onboarding tutorial') - .catch(reportRejection) + window.electron.disableMenu('Help.Reset onboarding').catch(reportRejection) window.electron.disableMenu('Help.Show all commands').catch(reportRejection) } diff --git a/src/routes/utils.ts b/src/routes/utils.ts index bd8bcbd9aa4..5cadccea19e 100644 --- a/src/routes/utils.ts +++ b/src/routes/utils.ts @@ -1,7 +1,7 @@ import { NODE_ENV } from '@src/env' import { isDesktop } from '@src/lib/isDesktop' -import { IS_PLAYWRIGHT_KEY } from '@src/lib/constants' +import { IS_PLAYWRIGHT_KEY } from '@e2e/playwright/storageStates' const isTestEnv = window?.localStorage.getItem(IS_PLAYWRIGHT_KEY) === 'true'