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

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

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

View File

@ -24,7 +24,6 @@ export class HomePageFixture {
projectTextName!: Locator projectTextName!: Locator
sortByDateBtn!: Locator sortByDateBtn!: Locator
sortByNameBtn!: Locator sortByNameBtn!: Locator
tutorialBtn!: Locator
constructor(page: Page) { constructor(page: Page) {
this.page = page this.page = page
@ -44,7 +43,6 @@ export class HomePageFixture {
this.sortByDateBtn = this.page.getByTestId('home-sort-by-modified') this.sortByDateBtn = this.page.getByTestId('home-sort-by-modified')
this.sortByNameBtn = this.page.getByTestId('home-sort-by-name') this.sortByNameBtn = this.page.getByTestId('home-sort-by-name')
this.tutorialBtn = this.page.getByTestId('home-tutorial-button')
} }
private _serialiseSortBy = async (): Promise< private _serialiseSortBy = async (): Promise<

View File

@ -17,8 +17,6 @@ type LengthUnitLabel = (typeof baseUnitLabels)[keyof typeof baseUnitLabels]
export class ToolbarFixture { export class ToolbarFixture {
public page: Page public page: Page
projectName!: Locator
fileName!: Locator
extrudeButton!: Locator extrudeButton!: Locator
loftButton!: Locator loftButton!: Locator
sweepButton!: Locator sweepButton!: Locator
@ -55,8 +53,6 @@ export class ToolbarFixture {
constructor(page: Page) { constructor(page: Page) {
this.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.extrudeButton = page.getByTestId('extrude')
this.loftButton = page.getByTestId('loft') this.loftButton = page.getByTestId('loft')
this.sweepButton = page.getByTestId('sweep') this.sweepButton = page.getByTestId('sweep')

View File

@ -450,7 +450,7 @@ test.describe(
) )
await expect(actual).toBeVisible() await expect(actual).toBeVisible()
}) })
test('Home.Help.Replay onboarding tutorial', async ({ test('Home.Help.Reset onboarding', async ({
tronApp, tronApp,
cmdBar, cmdBar,
page, page,
@ -464,7 +464,7 @@ test.describe(
await tronApp.electron.evaluate(async ({ app }) => { await tronApp.electron.evaluate(async ({ app }) => {
if (!app || !app.applicationMenu) return false if (!app || !app.applicationMenu) return false
const menu = app.applicationMenu.getMenuItemById( const menu = app.applicationMenu.getMenuItemById(
'Help.Replay onboarding tutorial' 'Help.Reset onboarding'
) )
if (!menu) { if (!menu) {
return false return false
@ -2339,7 +2339,7 @@ test.describe(
await scene.connectionEstablished() await scene.connectionEstablished()
await expect(toolbar.startSketchBtn).toBeVisible() await expect(toolbar.startSketchBtn).toBeVisible()
}) })
test('Modeling.Help.Replay onboarding tutorial', async ({ test('Modeling.Help.Reset onboarding', async ({
tronApp, tronApp,
cmdBar, cmdBar,
page, page,
@ -2358,7 +2358,7 @@ test.describe(
await tronApp.electron.evaluate(async ({ app }) => { await tronApp.electron.evaluate(async ({ app }) => {
if (!app || !app.applicationMenu) fail() if (!app || !app.applicationMenu) fail()
const menu = app.applicationMenu.getMenuItemById( const menu = app.applicationMenu.getMenuItemById(
'Help.Replay onboarding tutorial' 'Help.Reset onboarding'
) )
if (!menu) fail() if (!menu) fail()
menu.click() menu.click()

View File

@ -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' 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.describe('Onboarding tests', () => {
test('Desktop onboarding flow works', async ({ test('Onboarding code is shown in the editor', async ({
page, page,
homePage, homePage,
toolbar,
editor,
scene,
tronApp, tronApp,
}) => { }) => {
if (!tronApp) { if (!tronApp) {
fail() 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({ await tronApp.cleanProjectDir({
app: { app: {
onboarding_status: '', onboarding_status: '',
}, },
}) })
const bracketComment = '// Shelf Bracket' const u = await getUtils(page)
const tutorialWelcomHeading = page.getByText( await page.setBodyDimensions({ width: 1200, height: 500 })
'Welcome to Design Studio! This' await homePage.goToModelingScene()
)
const nextButton = page.getByTestId('onboarding-next') // Test that the onboarding pane loaded
const prevButton = page.getByTestId('onboarding-prev') await expect(page.getByText('Welcome to Design Studio! This')).toBeVisible()
const userMenuButton = toolbar.userSidebarButton
const userMenuSettingsButton = page.getByRole('button', { // Test that the onboarding pane loaded
name: 'User settings', await expect(page.getByText('Welcome to Design Studio! This')).toBeVisible()
})
const settingsHeading = page.getByRole('heading', { // *and* that the code is shown in the editor
name: 'Settings', await expect(page.locator('.cm-content')).toContainText('// Shelf Bracket')
exact: true,
}) // Make sure the model loaded
const restartOnboardingSettingsButton = page.getByRole('button', { 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', name: 'Replay onboarding',
}) })
const helpMenuButton = page.getByRole('button', { await expect(replayButton).toBeVisible()
name: 'Help and resources', 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: '',
},
}) })
const helpMenuRestartOnboardingButton = page.getByRole('button', { // Override beforeEach test setup
name: 'Replay onboarding tutorial', await context.addInitScript(
}) async ({ settingsKey, settings }) => {
const postDismissToast = page.getByText( // Give no initial code, so that the onboarding start is shown immediately
'Click the question mark in the lower-right corner if you ever want to redo the tutorial!' localStorage.setItem('persistCode', '')
localStorage.setItem(settingsKey, settings)
},
{
settingsKey: TEST_SETTINGS_KEY,
settings: settingsToToml({
settings: TEST_SETTINGS_ONBOARDING_START,
}),
}
) )
await test.step('Test initial home page view, showing a tutorial button', async () => { await page.setBodyDimensions({ width: 1200, height: 1080 })
await expect(homePage.tutorialBtn).toBeVisible() await homePage.goToModelingScene()
await homePage.expectState({
projectCards: [], // Test that the onboarding pane loaded
sortBy: 'last-modified-desc', await expect(page.getByText('Welcome to Design Studio! This')).toBeVisible()
})
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',
},
}) })
await test.step('Create a blank project and verify no onboarding chrome is shown', async () => { const originalCode = 'sigmaAllow = 15000'
await homePage.goToModelingScene()
await expect(toolbar.projectName).toContainText('testDefault') // Override beforeEach test setup
await expect(tutorialWelcomHeading).not.toBeVisible() await context.addInitScript(
await editor.expectEditor.toContain('@settings(defaultLengthUnit = in)', { async ({ settingsKey, settings, code }) => {
shouldNormalise: true, // Give some initial code, so we can test that it's cleared
}) localStorage.setItem('persistCode', code)
await scene.connectionEstablished() localStorage.setItem(settingsKey, settings)
await expect(toolbar.startSketchBtn).toBeEnabled({ timeout: 15_000 }) },
{
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',
},
}) })
await test.step('Go home and verify we still see the tutorial button, then begin it.', async () => { const badCode = `// This is bad code we shouldn't see`
await toolbar.logoLink.click()
await expect(homePage.tutorialBtn).toBeVisible() await page.setBodyDimensions({ width: 1200, height: 1080 })
await homePage.expectState({ await homePage.goToModelingScene()
projectCards: [
{ await expect
title: 'testDefault', .poll(() => page.url())
fileCount: 1, .toContain(onboardingPaths.PARAMETRIC_MODELING)
},
], // Check the code got reset on load
sortBy: 'last-modified-desc', await toolbar.openPane('code')
}) await editor.expectEditor.toContain(bracket, {
await homePage.tutorialBtn.click() shouldNormalise: true,
timeout: 10_000,
}) })
// This is web-only. // Mess with the code again
// TODO: write a new test just for the onboarding in browser await editor.replaceCode('', badCode)
// await test.step('Ensure the onboarding request toast appears', async () => { await editor.expectEditor.toContain(badCode, {
// await expect(page.getByTestId('onboarding-toast')).toBeVisible() shouldNormalise: true,
// await page.getByTestId('onboarding-next').click() timeout: 10_000,
// })
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 test.step('Test the clicking through the onboarding flow', async () => { // Click to the next step
await test.step('Going forward', async () => { await page.locator('[data-testid="onboarding-next"]').hover()
while ((await nextButton.innerText()) !== 'Finish') { await page.locator('[data-testid="onboarding-next"]').click()
await nextButton.hover() await page.waitForURL('**' + onboardingPaths.INTERACTIVE_NUMBERS, {
await nextButton.click() waitUntil: 'domcontentloaded',
}
})
await test.step('Going backward', async () => {
while ((await prevButton.innerText()) !== 'Dismiss') {
await prevButton.hover()
await prevButton.click()
}
})
// 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('Resetting onboarding from inside project should always make a new one', async () => { // Check that the code has been reset
await test.step('Reset onboarding from settings', async () => { await editor.expectEditor.toContain(bracket, {
await userMenuButton.click() shouldNormalise: true,
await userMenuSettingsButton.click() timeout: 10_000,
await expect(settingsHeading).toBeVisible()
await expect(restartOnboardingSettingsButton).toBeVisible()
await restartOnboardingSettingsButton.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('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 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 helpMenuButton.click()
await helpMenuRestartOnboardingButton.click()
})
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 })
})
}) })
}) })
// (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: '',
},
})
// 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 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!',
})
})
// 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 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: '',
},
})
// 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')
)
})
// 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()
})
}) })

View File

@ -1,10 +1,12 @@
import type { SaveSettingsPayload } from '@src/lib/settings/settingsTypes' import type { SaveSettingsPayload } from '@src/lib/settings/settingsTypes'
import { Themes } from '@src/lib/theme' import { Themes } from '@src/lib/theme'
import type { DeepPartial } from '@src/lib/types' 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' 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_KEY = '/settings.toml'
export const TEST_SETTINGS: DeepPartial<Settings> = { export const TEST_SETTINGS: DeepPartial<Settings> = {
app: { app: {
@ -31,15 +33,12 @@ export const TEST_SETTINGS: DeepPartial<Settings> = {
export const TEST_SETTINGS_ONBOARDING_USER_MENU: DeepPartial<Settings> = { export const TEST_SETTINGS_ONBOARDING_USER_MENU: DeepPartial<Settings> = {
...TEST_SETTINGS, ...TEST_SETTINGS,
app: { app: { ...TEST_SETTINGS.app, onboarding_status: onboardingPaths.USER_MENU },
...TEST_SETTINGS.app,
onboarding_status: ONBOARDING_SUBPATHS.USER_MENU,
},
} }
export const TEST_SETTINGS_ONBOARDING_EXPORT: DeepPartial<Settings> = { export const TEST_SETTINGS_ONBOARDING_EXPORT: DeepPartial<Settings> = {
...TEST_SETTINGS, ...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<Settings> = export const TEST_SETTINGS_ONBOARDING_PARAMETRIC_MODELING: DeepPartial<Settings> =
@ -47,7 +46,7 @@ export const TEST_SETTINGS_ONBOARDING_PARAMETRIC_MODELING: DeepPartial<Settings>
...TEST_SETTINGS, ...TEST_SETTINGS,
app: { app: {
...TEST_SETTINGS.app, ...TEST_SETTINGS.app,
onboarding_status: ONBOARDING_SUBPATHS.PARAMETRIC_MODELING, onboarding_status: onboardingPaths.PARAMETRIC_MODELING,
}, },
} }

View File

@ -5,7 +5,7 @@ import type { BrowserContext, Locator, Page, TestInfo } from '@playwright/test'
import { expect } from '@playwright/test' import { expect } from '@playwright/test'
import type { EngineCommand } from '@src/lang/std/artifactGraph' import type { EngineCommand } from '@src/lang/std/artifactGraph'
import type { Configuration } from '@src/lang/wasm' 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 { reportRejection } from '@src/lib/trap'
import type { DeepPartial } from '@src/lib/types' import type { DeepPartial } from '@src/lib/types'
import { isArray } from '@src/lib/utils' 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 { isErrorWhitelisted } from '@e2e/playwright/lib/console-error-whitelist'
import { secrets } from '@e2e/playwright/secrets' 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' import { test } from '@e2e/playwright/zoo-test'
const toNormalizedCode = (text: string) => { const toNormalizedCode = (text: string) => {

View File

@ -11,3 +11,4 @@
6) src/lib/singletons.ts -> src/clientSideScene/sceneEntities.ts -> src/clientSideScene/segments.ts -> src/components/Toolbar/angleLengthInfo.ts 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 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 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

View File

@ -527,6 +527,9 @@ pub enum OnboardingStatus {
#[serde(rename = "/export")] #[serde(rename = "/export")]
#[display("/export")] #[display("/export")]
Export, Export,
#[serde(rename = "/move")]
#[display("/move")]
Move,
#[serde(rename = "/sketching")] #[serde(rename = "/sketching")]
#[display("/sketching")] #[display("/sketching")]
Sketching, Sketching,

View File

@ -4,7 +4,6 @@ import { useHotkeys } from 'react-hotkeys-hook'
import ModalContainer from 'react-modal-promise' import ModalContainer from 'react-modal-promise'
import { import {
useLoaderData, useLoaderData,
useLocation,
useNavigate, useNavigate,
useRouteLoaderData, useRouteLoaderData,
useSearchParams, useSearchParams,
@ -27,20 +26,15 @@ import useHotkeyWrapper from '@src/lib/hotkeyWrapper'
import { isDesktop } from '@src/lib/isDesktop' import { isDesktop } from '@src/lib/isDesktop'
import { PATHS } from '@src/lib/paths' import { PATHS } from '@src/lib/paths'
import { takeScreenshotOfVideoStreamCanvas } from '@src/lib/screenshot' 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 { 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 { engineStreamActor, useSettings, useToken } from '@src/lib/singletons'
import { commandBarActor } from '@src/lib/singletons' import { commandBarActor } from '@src/lib/singletons'
import { EngineStreamTransition } from '@src/machines/engineStreamMachine' import { EngineStreamTransition } from '@src/machines/engineStreamMachine'
import { onboardingPaths } from '@src/routes/Onboarding/paths'
import { CommandBarOpenButton } from '@src/components/CommandBarOpenButton' import { CommandBarOpenButton } from '@src/components/CommandBarOpenButton'
import { ShareButton } from '@src/components/ShareButton' 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 // CYCLIC REF
sceneInfra.camControls.engineStreamActor = engineStreamActor sceneInfra.camControls.engineStreamActor = engineStreamActor
@ -64,7 +58,6 @@ export function App() {
}) })
}) })
const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const filePath = useAbsoluteFilePath() const filePath = useAbsoluteFilePath()
const { onProjectOpen } = useLspContext() const { onProjectOpen } = useLspContext()
@ -73,7 +66,7 @@ export function App() {
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
// Stream related refs and data // Stream related refs and data
const [searchParams] = useSearchParams() let [searchParams] = useSearchParams()
const pool = searchParams.get('pool') const pool = searchParams.get('pool')
const projectName = project?.name || null const projectName = project?.name || null
@ -83,10 +76,9 @@ export function App() {
const loaderData = useRouteLoaderData(PATHS.FILE) as IndexLoaderData const loaderData = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
const lastCommandType = commands[commands.length - 1]?.type const lastCommandType = commands[commands.length - 1]?.type
// Run LSP file open hook when navigating between projects or files
useEffect(() => { useEffect(() => {
onProjectOpen({ name: projectName, path: projectPath }, file || null) onProjectOpen({ name: projectName, path: projectPath }, file || null)
}, [onProjectOpen, projectName, projectPath, file]) }, [projectName, projectPath])
useHotKeyListener() useHotKeyListener()
@ -112,10 +104,9 @@ export function App() {
toast.success('Your work is auto-saved in real-time') toast.success('Your work is auto-saved in real-time')
}) })
const paneOpacity = [ const paneOpacity = [onboardingPaths.CAMERA, onboardingPaths.STREAMING].some(
ONBOARDING_SUBPATHS.CAMERA, (p) => p === onboardingStatus.current
ONBOARDING_SUBPATHS.STREAMING, )
].some((p) => p === onboardingStatus.current)
? 'opacity-20' ? 'opacity-20'
: '' : ''
@ -141,7 +132,7 @@ export function App() {
}) })
}, 500) }, 500)
} }
}, [lastCommandType, loaderData?.project?.path]) }, [lastCommandType])
useEffect(() => { useEffect(() => {
// When leaving the modeling scene, cut the engine stream. // 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 ( return (
<div className="relative h-full flex flex-col" ref={ref}> <div className="relative h-full flex flex-col" ref={ref}>
<AppHeader <AppHeader
@ -190,7 +155,7 @@ export function App() {
<ModelingSidebar paneOpacity={paneOpacity} /> <ModelingSidebar paneOpacity={paneOpacity} />
<EngineStream pool={pool} authToken={authToken} /> <EngineStream pool={pool} authToken={authToken} />
{/* <CamToggle /> */} {/* <CamToggle /> */}
<LowerRightControls navigate={navigate}> <LowerRightControls>
<UnitsMenu /> <UnitsMenu />
<Gizmo /> <Gizmo />
</LowerRightControls> </LowerRightControls>

View File

@ -28,7 +28,7 @@ import useHotkeyWrapper from '@src/lib/hotkeyWrapper'
import { isDesktop } from '@src/lib/isDesktop' import { isDesktop } from '@src/lib/isDesktop'
import makeUrlPathRelative from '@src/lib/makeUrlPathRelative' import makeUrlPathRelative from '@src/lib/makeUrlPathRelative'
import { PATHS } from '@src/lib/paths' import { PATHS } from '@src/lib/paths'
import { fileLoader, homeLoader } from '@src/lib/routeLoaders' import { fileLoader, homeLoader, telemetryLoader } from '@src/lib/routeLoaders'
import { import {
codeManager, codeManager,
engineCommandManager, engineCommandManager,
@ -110,6 +110,7 @@ const router = createRouter([
}, },
{ {
id: PATHS.FILE + 'TELEMETRY', id: PATHS.FILE + 'TELEMETRY',
loader: telemetryLoader,
children: [ children: [
{ {
path: makeUrlPathRelative(PATHS.TELEMETRY), path: makeUrlPathRelative(PATHS.TELEMETRY),
@ -143,6 +144,7 @@ const router = createRouter([
}, },
{ {
path: makeUrlPathRelative(PATHS.TELEMETRY), path: makeUrlPathRelative(PATHS.TELEMETRY),
loader: telemetryLoader,
element: <Telemetry />, element: <Telemetry />,
}, },
], ],

View File

@ -854,14 +854,6 @@ const CustomIconMap = {
/> />
</svg> </svg>
), ),
play: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M14.2842 9.83887L14.2617 10.6758L7.76172 14.6758L7 14.25V5.75L7.78418 5.33887L14.2842 9.83887ZM8 13.3555L13.0859 10.2246L8 6.70312V13.3555Z"
fill="currentColor"
/>
</svg>
),
rotate: ( rotate: (
<svg <svg
viewBox="0 0 20 20" viewBox="0 0 20 20"

View File

@ -1,46 +1,49 @@
import { Popover } from '@headlessui/react' import { Popover } from '@headlessui/react'
import { type NavigateFunction, useLocation } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { CustomIcon } from '@src/components/CustomIcon' import { CustomIcon } from '@src/components/CustomIcon'
import { useLspContext } from '@src/components/LspProvider'
import Tooltip from '@src/components/Tooltip' import Tooltip from '@src/components/Tooltip'
import { useAbsoluteFilePath } from '@src/hooks/useAbsoluteFilePath' import { useAbsoluteFilePath } from '@src/hooks/useAbsoluteFilePath'
import { useMenuListener } from '@src/hooks/useMenu' import { useMenuListener } from '@src/hooks/useMenu'
import { createAndOpenNewTutorialProject } from '@src/lib/desktopFS'
import { openExternalBrowserIfDesktop } from '@src/lib/openWindow' import { openExternalBrowserIfDesktop } from '@src/lib/openWindow'
import { PATHS } from '@src/lib/paths' import { PATHS } from '@src/lib/paths'
import { codeManager, kclManager } from '@src/lib/singletons' import { reportRejection } from '@src/lib/trap'
import { settingsActor } from '@src/lib/singletons'
import type { WebContentSendPayload } from '@src/menu/channels' import type { WebContentSendPayload } from '@src/menu/channels'
import {
acceptOnboarding,
catchOnboardingWarnError,
} from '@src/routes/Onboarding/utils'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
const HelpMenuDivider = () => ( const HelpMenuDivider = () => (
<div className="h-[1px] bg-chalkboard-110 dark:bg-chalkboard-80" /> <div className="h-[1px] bg-chalkboard-110 dark:bg-chalkboard-80" />
) )
export function HelpMenu({ export function HelpMenu(props: React.PropsWithChildren) {
navigate = () => {},
}: {
navigate?: NavigateFunction
}) {
const location = useLocation() const location = useLocation()
const { onProjectOpen } = useLspContext()
const filePath = useAbsoluteFilePath() const filePath = useAbsoluteFilePath()
const isInProject = location.pathname.includes(PATHS.FILE)
const navigate = useNavigate()
const resetOnboardingWorkflow = () => { const resetOnboardingWorkflow = () => {
const props = { settingsActor.send({
onboardingStatus: ONBOARDING_SUBPATHS.INDEX, type: 'set.app.onboardingStatus',
navigate, data: {
codeManager, value: '',
kclManager, 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) => { const cb = (data: WebContentSendPayload) => {
if (data.menuLabel === 'Help.Replay onboarding tutorial') { if (data.menuLabel === 'Help.Reset onboarding') {
resetOnboardingWorkflow() resetOnboardingWorkflow()
} }
} }
@ -65,81 +68,71 @@ export function HelpMenu({
as="ul" 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" 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 }) => ( <HelpMenuItem
<> as="a"
<HelpMenuItem href="https://github.com/KittyCAD/modeling-app/issues/new/choose"
as="a" target="_blank"
href="https://github.com/KittyCAD/modeling-app/issues/new/choose" rel="noopener noreferrer"
target="_blank" >
rel="noopener noreferrer" Report a bug
> </HelpMenuItem>
Report a bug <HelpMenuItem
</HelpMenuItem> as="a"
<HelpMenuItem href="https://github.com/KittyCAD/modeling-app/discussions"
as="a" target="_blank"
href="https://github.com/KittyCAD/modeling-app/discussions" rel="noopener noreferrer"
target="_blank" >
rel="noopener noreferrer" Request a feature
> </HelpMenuItem>
Request a feature <HelpMenuItem
</HelpMenuItem> as="a"
<HelpMenuItem href="https://discord.gg/JQEpHR7Nt2"
as="a" target="_blank"
href="https://discord.gg/JQEpHR7Nt2" rel="noopener noreferrer"
target="_blank" >
rel="noopener noreferrer" Ask the community
> </HelpMenuItem>
Ask the community <HelpMenuDivider />
</HelpMenuItem> <HelpMenuItem
<HelpMenuDivider /> as="a"
<HelpMenuItem href="https://zoo.dev/docs/kcl-samples"
as="a" target="_blank"
href="https://zoo.dev/docs/kcl-samples" rel="noopener noreferrer"
target="_blank" >
rel="noopener noreferrer" KCL code samples
> </HelpMenuItem>
KCL code samples <HelpMenuItem
</HelpMenuItem> as="a"
<HelpMenuItem href="https://zoo.dev/docs/kcl"
as="a" target="_blank"
href="https://zoo.dev/docs/kcl" rel="noopener noreferrer"
target="_blank" >
rel="noopener noreferrer" KCL docs
> </HelpMenuItem>
KCL docs <HelpMenuDivider />
</HelpMenuItem> <HelpMenuItem
<HelpMenuDivider /> as="a"
<HelpMenuItem href="https://github.com/KittyCAD/modeling-app/releases"
as="a" target="_blank"
href="https://github.com/KittyCAD/modeling-app/releases" rel="noopener noreferrer"
target="_blank" >
rel="noopener noreferrer" Release notes
> </HelpMenuItem>
Release notes <HelpMenuItem
</HelpMenuItem> as="button"
<HelpMenuItem onClick={() => {
as="button" const targetPath = location.pathname.includes(PATHS.FILE)
onClick={() => { ? filePath + PATHS.SETTINGS_KEYBINDINGS
const targetPath = location.pathname.includes(PATHS.FILE) : PATHS.HOME + PATHS.SETTINGS_KEYBINDINGS
? filePath + PATHS.SETTINGS_KEYBINDINGS navigate(targetPath)
: PATHS.HOME + PATHS.SETTINGS_KEYBINDINGS }}
navigate(targetPath) data-testid="keybindings-button"
}} >
data-testid="keybindings-button" Keyboard shortcuts
> </HelpMenuItem>
Keyboard shortcuts <HelpMenuItem as="button" onClick={resetOnboardingWorkflow}>
</HelpMenuItem> Reset onboarding
<HelpMenuItem </HelpMenuItem>
as="button"
onClick={() => {
close()
resetOnboardingWorkflow()
}}
>
Replay onboarding tutorial
</HelpMenuItem>
</>
)}
</Popover.Panel> </Popover.Panel>
</Popover> </Popover>
) )

View File

@ -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 { CustomIcon } from '@src/components/CustomIcon'
import { HelpMenu } from '@src/components/HelpMenu' import { HelpMenu } from '@src/components/HelpMenu'
import { NetworkHealthIndicator } from '@src/components/NetworkHealthIndicator' import { NetworkHealthIndicator } from '@src/components/NetworkHealthIndicator'
@ -11,10 +12,8 @@ import { APP_VERSION, getReleaseUrl } from '@src/routes/utils'
export function LowerRightControls({ export function LowerRightControls({
children, children,
navigate = () => {},
}: { }: {
children?: React.ReactNode children?: React.ReactNode
navigate?: NavigateFunction
}) { }) {
const location = useLocation() const location = useLocation()
const filePath = useAbsoluteFilePath() const filePath = useAbsoluteFilePath()
@ -73,7 +72,7 @@ export function LowerRightControls({
{!location.pathname.startsWith(PATHS.HOME) && ( {!location.pathname.startsWith(PATHS.HOME) && (
<NetworkHealthIndicator /> <NetworkHealthIndicator />
)} )}
<HelpMenu navigate={navigate} /> <HelpMenu />
</menu> </menu>
</section> </section>
) )

View File

@ -6,7 +6,7 @@ import { ActionIcon } from '@src/components/ActionIcon'
import type { CustomIconName } from '@src/components/CustomIcon' import type { CustomIconName } from '@src/components/CustomIcon'
import Tooltip from '@src/components/Tooltip' import Tooltip from '@src/components/Tooltip'
import { useSettings } from '@src/lib/singletons' 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' import styles from './ModelingPane.module.css'
@ -71,7 +71,7 @@ export const ModelingPane = ({
const settings = useSettings() const settings = useSettings()
const onboardingStatus = settings.app.onboardingStatus const onboardingStatus = settings.app.onboardingStatus
const pointerEventsCssClass = const pointerEventsCssClass =
onboardingStatus.current === ONBOARDING_SUBPATHS.CAMERA onboardingStatus.current === onboardingPaths.CAMERA
? 'pointer-events-none ' ? 'pointer-events-none '
: 'pointer-events-auto ' : 'pointer-events-auto '
return ( return (

View File

@ -24,7 +24,7 @@ import { SIDEBAR_BUTTON_SUFFIX } from '@src/lib/constants'
import { isDesktop } from '@src/lib/isDesktop' import { isDesktop } from '@src/lib/isDesktop'
import { useSettings } from '@src/lib/singletons' import { useSettings } from '@src/lib/singletons'
import { commandBarActor } 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 { reportRejection } from '@src/lib/trap'
import { refreshPage } from '@src/lib/utils' import { refreshPage } from '@src/lib/utils'
import { hotkeyDisplay } from '@src/lib/hotkeyWrapper' import { hotkeyDisplay } from '@src/lib/hotkeyWrapper'
@ -53,7 +53,7 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
const onboardingStatus = settings.app.onboardingStatus const onboardingStatus = settings.app.onboardingStatus
const { send, context } = useModelingContext() const { send, context } = useModelingContext()
const pointerEventsCssClass = const pointerEventsCssClass =
onboardingStatus.current === ONBOARDING_SUBPATHS.CAMERA || onboardingStatus.current === onboardingPaths.CAMERA ||
context.store?.openPanes.length === 0 context.store?.openPanes.length === 0
? 'pointer-events-none ' ? 'pointer-events-none '
: 'pointer-events-auto ' : 'pointer-events-auto '

View File

@ -1,10 +1,5 @@
import { useFileSystemWatcher } from '@src/hooks/useFileSystemWatcher' import { useFileSystemWatcher } from '@src/hooks/useFileSystemWatcher'
import { import { PATHS } from '@src/lib/paths'
PATHS,
joinRouterPaths,
joinOSPaths,
safeEncodeForRouterPaths,
} from '@src/lib/paths'
import { systemIOActor, useSettings, useToken } from '@src/lib/singletons' import { systemIOActor, useSettings, useToken } from '@src/lib/singletons'
import { import {
useHasListedProjects, useHasListedProjects,
@ -40,14 +35,14 @@ export function SystemIOMachineLogicListenerDesktop() {
if (!requestedProjectName.name) { if (!requestedProjectName.name) {
return return
} }
const projectPathWithoutSpecificKCLFile = joinOSPaths( let projectPathWithoutSpecificKCLFile =
projectDirectoryPath, projectDirectoryPath +
window.electron.path.sep +
requestedProjectName.name requestedProjectName.name
)
const requestedPath = joinRouterPaths( const requestedPath = `${PATHS.FILE}/${encodeURIComponent(
PATHS.FILE, projectPathWithoutSpecificKCLFile
safeEncodeForRouterPaths(projectPathWithoutSpecificKCLFile) )}`
)
navigate(requestedPath) navigate(requestedPath)
}, [requestedProjectName]) }, [requestedProjectName])
} }
@ -57,16 +52,12 @@ export function SystemIOMachineLogicListenerDesktop() {
if (!requestedFileName.file || !requestedFileName.project) { if (!requestedFileName.file || !requestedFileName.project) {
return return
} }
const filePath = joinOSPaths( const projectPath = window.electron.join(
projectDirectoryPath, 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) navigate(requestedPath)
}, [requestedFileName]) }, [requestedFileName])
} }

View File

@ -7,6 +7,8 @@ import {
useRouteLoaderData, useRouteLoaderData,
} from 'react-router-dom' } from 'react-router-dom'
import type { OnboardingStatus } from '@rust/kcl-lib/bindings/OnboardingStatus'
import { useAuthNavigation } from '@src/hooks/useAuthNavigation' import { useAuthNavigation } from '@src/hooks/useAuthNavigation'
import { useFileSystemWatcher } from '@src/hooks/useFileSystemWatcher' import { useFileSystemWatcher } from '@src/hooks/useFileSystemWatcher'
import { getAppSettingsFilePath } from '@src/lib/desktop' import { getAppSettingsFilePath } from '@src/lib/desktop'
@ -16,7 +18,7 @@ import { markOnce } from '@src/lib/performance'
import { loadAndValidateSettings } from '@src/lib/settings/settingsUtils' import { loadAndValidateSettings } from '@src/lib/settings/settingsUtils'
import { trap } from '@src/lib/trap' import { trap } from '@src/lib/trap'
import type { IndexLoaderData } from '@src/lib/types' import type { IndexLoaderData } from '@src/lib/types'
import { settingsActor } from '@src/lib/singletons' import { settingsActor, useSettings } from '@src/lib/singletons'
export const RouteProviderContext = createContext({}) export const RouteProviderContext = createContext({})
@ -30,6 +32,7 @@ export function RouteProvider({ children }: { children: ReactNode }) {
const navigation = useNavigation() const navigation = useNavigation()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const settings = useSettings()
useEffect(() => { useEffect(() => {
// On initialization, the react-router-dom does not send a 'loading' state event. // 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') markOnce('code/willLoadHome')
} else if (isFile) { } else if (isFile) {
markOnce('code/willLoadFile') 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) setFirstState(false)
}, [first, navigation, location.pathname]) }, [navigation])
useEffect(() => { useEffect(() => {
if (!isDesktop()) return if (!isDesktop()) return

View File

@ -6,12 +6,16 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { Fragment } from 'react/jsx-runtime' import { Fragment } from 'react/jsx-runtime'
import { ActionButton } from '@src/components/ActionButton' import { ActionButton } from '@src/components/ActionButton'
import { useLspContext } from '@src/components/LspProvider'
import { SettingsFieldInput } from '@src/components/Settings/SettingsFieldInput' import { SettingsFieldInput } from '@src/components/Settings/SettingsFieldInput'
import { SettingsSection } from '@src/components/Settings/SettingsSection' 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 { isDesktop } from '@src/lib/isDesktop'
import { openExternalBrowserIfDesktop } from '@src/lib/openWindow' import { openExternalBrowserIfDesktop } from '@src/lib/openWindow'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
import { PATHS } from '@src/lib/paths' import { PATHS } from '@src/lib/paths'
import type { Setting } from '@src/lib/settings/initialSettings' import type { Setting } from '@src/lib/settings/initialSettings'
import type { import type {
@ -24,17 +28,9 @@ import {
} from '@src/lib/settings/settingsUtils' } from '@src/lib/settings/settingsUtils'
import { reportRejection } from '@src/lib/trap' import { reportRejection } from '@src/lib/trap'
import { toSync } from '@src/lib/utils' import { toSync } from '@src/lib/utils'
import { import { settingsActor, useSettings } from '@src/lib/singletons'
codeManager,
kclManager,
settingsActor,
useSettings,
} from '@src/lib/singletons'
import { APP_VERSION, IS_NIGHTLY, getReleaseUrl } from '@src/routes/utils' import { APP_VERSION, IS_NIGHTLY, getReleaseUrl } from '@src/routes/utils'
import { import { waitFor } from 'xstate'
acceptOnboarding,
catchOnboardingWarnError,
} from '@src/routes/Onboarding/utils'
interface AllSettingsFieldsProps { interface AllSettingsFieldsProps {
searchParamTab: SettingsLevel searchParamTab: SettingsLevel
@ -48,6 +44,8 @@ export const AllSettingsFields = forwardRef(
) => { ) => {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const { onProjectOpen } = useLspContext()
const dotDotSlash = useDotDotSlash()
const context = useSettings() const context = useSettings()
const projectPath = useMemo(() => { const projectPath = useMemo(() => {
@ -65,18 +63,26 @@ export const AllSettingsFields = forwardRef(
: undefined : undefined
return projectPath return projectPath
}, [location.pathname, isFileSettings]) }, [location.pathname])
async function restartOnboarding() { async function restartOnboarding() {
const props = { settingsActor.send({
onboardingStatus: ONBOARDING_SUBPATHS.INDEX, type: `set.app.onboardingStatus`,
navigate, data: { level: 'user', value: '' },
codeManager, })
kclManager, 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 ( return (

View File

@ -2,7 +2,6 @@ import type { Models } from '@kittycad/lib/dist/types/src'
import type { UnitAngle, UnitLength } from '@rust/kcl-lib/bindings/ModelingCmd' import type { UnitAngle, UnitLength } from '@rust/kcl-lib/bindings/ModelingCmd'
export const IS_PLAYWRIGHT_KEY = 'playwright'
export const APP_NAME = 'Design Studio' export const APP_NAME = 'Design Studio'
/** Search string in new project names to increment as an index */ /** Search string in new project names to increment as an index */
export const INDEX_IDENTIFIER = '$n' export const INDEX_IDENTIFIER = '$n'

View File

@ -1,6 +1,18 @@
import { relevantFileExtensions } from '@src/lang/wasmUtils' 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 { isDesktop } from '@src/lib/isDesktop'
import { PATHS } from '@src/lib/paths'
import type { FileEntry } from '@src/lib/project' import type { FileEntry } from '@src/lib/project'
export const isHidden = (fileOrDir: FileEntry) => 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 * Get the next available file name by appending a hyphen and number to the end of the name
*/ */

View File

@ -2,7 +2,7 @@ import type { PlatformPath } from 'path'
import type { Configuration } from '@rust/kcl-lib/bindings/Configuration' 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 { import {
BROWSER_FILE_NAME, BROWSER_FILE_NAME,
@ -14,7 +14,7 @@ import { isDesktop } from '@src/lib/isDesktop'
import { readLocalStorageAppSettingsFile } from '@src/lib/settings/settingsUtils' import { readLocalStorageAppSettingsFile } from '@src/lib/settings/settingsUtils'
import { err } from '@src/lib/trap' import { err } from '@src/lib/trap'
import type { DeepPartial } from '@src/lib/types' import type { DeepPartial } from '@src/lib/types'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths' import { onboardingPaths } from '@src/routes/Onboarding/paths'
const prependRoutes = const prependRoutes =
(routesObject: Record<string, string>) => (prepend: string) => { (routesObject: Record<string, string>) => (prepend: string) => {
@ -27,7 +27,7 @@ const prependRoutes =
} }
type OnboardingPaths = { 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' const SETTINGS = '/settings'
@ -48,9 +48,7 @@ export const PATHS = {
SETTINGS_PROJECT: `${SETTINGS}?tab=project` as const, SETTINGS_PROJECT: `${SETTINGS}?tab=project` as const,
SETTINGS_KEYBINDINGS: `${SETTINGS}?tab=keybindings` as const, SETTINGS_KEYBINDINGS: `${SETTINGS}?tab=keybindings` as const,
SIGN_IN: '/signin', SIGN_IN: '/signin',
ONBOARDING: prependRoutes(ONBOARDING_SUBPATHS)( ONBOARDING: prependRoutes(onboardingPaths)('/onboarding') as OnboardingPaths,
'/onboarding'
) as OnboardingPaths,
TELEMETRY: '/telemetry', TELEMETRY: '/telemetry',
} as const } as const
export const BROWSER_PATH = `%2F${BROWSER_PROJECT_NAME}%2F${BROWSER_FILE_NAME}${FILE_EXT}` export const BROWSER_PATH = `%2F${BROWSER_PROJECT_NAME}%2F${BROWSER_FILE_NAME}${FILE_EXT}`
@ -138,56 +136,3 @@ export function parseProjectRoute(
currentFilePath: currentFilePath, 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() || ''
}

View File

@ -22,6 +22,12 @@ import type {
} from '@src/lib/types' } from '@src/lib/types'
import { settingsActor } from '@src/lib/singletons' import { settingsActor } from '@src/lib/singletons'
export const telemetryLoader: LoaderFunction = async ({
params,
}): Promise<null> => {
return null
}
export const fileLoader: LoaderFunction = async ( export const fileLoader: LoaderFunction = async (
routerData routerData
): Promise<FileLoaderData | Response> => { ): Promise<FileLoaderData | Response> => {

View File

@ -43,11 +43,7 @@ export const systemIOMachine = setup({
} }
| { | {
type: SystemIOMachineEvents.navigateToFile type: SystemIOMachineEvents.navigateToFile
data: { data: { requestedProjectName: string; requestedFileName: string }
requestedProjectName: string
requestedFileName: string
requestedSubRoute?: string
}
} }
| { | {
type: SystemIOMachineEvents.createProject type: SystemIOMachineEvents.createProject
@ -79,7 +75,6 @@ export const systemIOMachine = setup({
requestedProjectName: string requestedProjectName: string
requestedFileName: string requestedFileName: string
requestedCode: string requestedCode: string
requestedSubRoute?: string
} }
} }
| { | {
@ -122,9 +117,7 @@ export const systemIOMachine = setup({
[SystemIOMachineActions.setRequestedProjectName]: assign({ [SystemIOMachineActions.setRequestedProjectName]: assign({
requestedProjectName: ({ event }) => { requestedProjectName: ({ event }) => {
assertEvent(event, SystemIOMachineEvents.navigateToProject) assertEvent(event, SystemIOMachineEvents.navigateToProject)
return { return { name: event.data.requestedProjectName }
name: event.data.requestedProjectName,
}
}, },
}), }),
[SystemIOMachineActions.setRequestedFileName]: assign({ [SystemIOMachineActions.setRequestedFileName]: assign({
@ -133,7 +126,6 @@ export const systemIOMachine = setup({
return { return {
project: event.data.requestedProjectName, project: event.data.requestedProjectName,
file: event.data.requestedFileName, file: event.data.requestedFileName,
subRoute: event.data.requestedSubRoute,
} }
}, },
}), }),
@ -232,15 +224,13 @@ export const systemIOMachine = setup({
requestedFileName: string requestedFileName: string
requestedCode: string requestedCode: string
rootContext: AppMachineContext rootContext: AppMachineContext
requestedSubRoute?: string
} }
}): Promise<{ }): Promise<{
message: string message: string
fileName: string fileName: string
projectName: string projectName: string
subRoute: string
}> => { }> => {
return { message: '', fileName: '', projectName: '', subRoute: '' } return { message: '', fileName: '', projectName: '' }
} }
), ),
[SystemIOMachineActors.checkReadWrite]: fromPromise( [SystemIOMachineActors.checkReadWrite]: fromPromise(
@ -468,7 +458,6 @@ export const systemIOMachine = setup({
context, context,
requestedProjectName: event.data.requestedProjectName, requestedProjectName: event.data.requestedProjectName,
requestedFileName: event.data.requestedFileName, requestedFileName: event.data.requestedFileName,
requestedSubRoute: event.data.requestedSubRoute,
requestedCode: event.data.requestedCode, requestedCode: event.data.requestedCode,
rootContext: self.system.get('root').getSnapshot().context, rootContext: self.system.get('root').getSnapshot().context,
} }
@ -487,7 +476,6 @@ export const systemIOMachine = setup({
return { return {
project: event.output.projectName, project: event.output.projectName,
file, file,
subRoute: event.output.subRoute,
} }
}, },
}), }),

View File

@ -158,7 +158,6 @@ export const systemIOMachineDesktop = systemIOMachine.provide({
requestedFileName: string requestedFileName: string
requestedCode: string requestedCode: string
rootContext: AppMachineContext rootContext: AppMachineContext
requestedSubRoute?: string
} }
}) => { }) => {
const requestedProjectName = input.requestedProjectName const requestedProjectName = input.requestedProjectName
@ -207,7 +206,6 @@ export const systemIOMachineDesktop = systemIOMachine.provide({
message: 'File created successfully', message: 'File created successfully',
fileName: newFileName, fileName: newFileName,
projectName: newProjectName, projectName: newProjectName,
subRoute: input.requestedSubRoute || '',
} }
} }
), ),

View File

@ -20,7 +20,6 @@ export const systemIOMachineWeb = systemIOMachine.provide({
requestedFileName: string requestedFileName: string
requestedCode: string requestedCode: string
rootContext: AppMachineContext rootContext: AppMachineContext
requestedSubRoute?: string
} }
}) => { }) => {
// Browser version doesn't navigate, just overwrites the current file // Browser version doesn't navigate, just overwrites the current file
@ -44,7 +43,6 @@ export const systemIOMachineWeb = systemIOMachine.provide({
message: 'File overwritten successfully', message: 'File overwritten successfully',
fileName: input.requestedFileName, fileName: input.requestedFileName,
projectName: '', projectName: '',
subRoute: input.requestedSubRoute || '',
} }
} }
), ),

View File

@ -8,7 +8,6 @@ export enum SystemIOMachineActors {
deleteProject = 'delete project', deleteProject = 'delete project',
createKCLFile = 'create kcl file', createKCLFile = 'create kcl file',
checkReadWrite = 'check read write', checkReadWrite = 'check read write',
/** TODO: rename this event to be more generic, like `createKCLFileAndNavigate` */
importFileFromURL = 'import file from URL', importFileFromURL = 'import file from URL',
deleteKCLFile = 'delete kcl delete', deleteKCLFile = 'delete kcl delete',
} }
@ -22,7 +21,6 @@ export enum SystemIOMachineStates {
deletingProject = 'deletingProject', deletingProject = 'deletingProject',
creatingKCLFile = 'creatingKCLFile', creatingKCLFile = 'creatingKCLFile',
checkingReadWrite = 'checkingReadWrite', checkingReadWrite = 'checkingReadWrite',
/** TODO: rename this event to be more generic, like `createKCLFileAndNavigate` */
importFileFromURL = 'importFileFromURL', importFileFromURL = 'importFileFromURL',
deletingKCLFile = 'deletingKCLFile', deletingKCLFile = 'deletingKCLFile',
} }
@ -43,7 +41,6 @@ export enum SystemIOMachineEvents {
createKCLFile = 'create kcl file', createKCLFile = 'create kcl file',
setDefaultProjectFolderName = 'set default project folder name', setDefaultProjectFolderName = 'set default project folder name',
done_checkReadWrite = donePrefix + 'check read write', done_checkReadWrite = donePrefix + 'check read write',
/** TODO: rename this event to be more generic, like `createKCLFileAndNavigate` */
importFileFromURL = 'import file from URL', importFileFromURL = 'import file from URL',
done_importFileFromURL = donePrefix + 'import file from URL', done_importFileFromURL = donePrefix + 'import file from URL',
generateTextToCAD = 'generate text to CAD', generateTextToCAD = 'generate text to CAD',
@ -77,7 +74,7 @@ export type SystemIOContext = {
* this is required to prevent chokidar from spamming invalid events during initialization. */ * this is required to prevent chokidar from spamming invalid events during initialization. */
hasListedProjects: boolean hasListedProjects: boolean
requestedProjectName: { name: string } requestedProjectName: { name: string }
requestedFileName: { project: string; file: string; subRoute?: string } requestedFileName: { project: string; file: string }
canReadWriteProjectDirectory: { value: boolean; error: unknown } canReadWriteProjectDirectory: { value: boolean; error: unknown }
clearURLParams: { value: boolean } clearURLParams: { value: boolean }
requestedTextToCadGeneration: { requestedTextToCadGeneration: {

View File

@ -6,7 +6,7 @@ import type { Channel } from '@src/channels'
export type MenuLabels = export type MenuLabels =
| 'Help.Command Palette...' | 'Help.Command Palette...'
| 'Help.Report a bug' | 'Help.Report a bug'
| 'Help.Replay onboarding tutorial' | 'Help.Reset onboarding'
| 'Edit.Rename project' | 'Edit.Rename project'
| 'Edit.Delete project' | 'Edit.Delete project'
| 'Edit.Change project directory' | 'Edit.Change project directory'

View File

@ -84,11 +84,11 @@ export const helpRole = (
}, },
{ type: 'separator' }, { type: 'separator' },
{ {
id: 'Help.Replay onboarding tutorial', id: 'Help.Reset onboarding',
label: 'Replay onboarding tutorial', label: 'Reset onboarding',
click: () => { click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', { typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'Help.Replay onboarding tutorial', menuLabel: 'Help.Reset onboarding',
}) })
}, },
}, },

View File

@ -45,7 +45,7 @@ type HelpRoleLabel =
| 'Ask the community discourse' | 'Ask the community discourse'
| 'KCL code samples' | 'KCL code samples'
| 'KCL docs' | 'KCL docs'
| 'Replay onboarding tutorial' | 'Reset onboarding'
| 'Show release notes' | 'Show release notes'
| 'Manage account' | 'Manage account'
| 'Get started with Text-to-CAD' | 'Get started with Text-to-CAD'

View File

@ -2,12 +2,7 @@ import type { FormEvent, HTMLProps } from 'react'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { import { Link, useNavigate, useSearchParams } from 'react-router-dom'
Link,
useLocation,
useNavigate,
useSearchParams,
} from 'react-router-dom'
import { ActionButton } from '@src/components/ActionButton' import { ActionButton } from '@src/components/ActionButton'
import { AppHeader } from '@src/components/AppHeader' import { AppHeader } from '@src/components/AppHeader'
@ -24,7 +19,7 @@ import { isDesktop } from '@src/lib/isDesktop'
import { PATHS } from '@src/lib/paths' import { PATHS } from '@src/lib/paths'
import { markOnce } from '@src/lib/performance' import { markOnce } from '@src/lib/performance'
import type { Project } from '@src/lib/project' import type { Project } from '@src/lib/project'
import { codeManager, kclManager } from '@src/lib/singletons' import { kclManager } from '@src/lib/singletons'
import { import {
getNextSearchParams, getNextSearchParams,
getSortFunction, getSortFunction,
@ -44,12 +39,6 @@ import {
} from '@src/machines/systemIO/utils' } from '@src/machines/systemIO/utils'
import type { WebContentSendPayload } from '@src/menu/channels' import type { WebContentSendPayload } from '@src/menu/channels'
import { openExternalBrowserIfDesktop } from '@src/lib/openWindow' import { openExternalBrowserIfDesktop } from '@src/lib/openWindow'
import {
acceptOnboarding,
needsToOnboard,
onDismissOnboardingInvite,
} from '@src/routes/Onboarding/utils'
import Tooltip from '@src/components/Tooltip'
type ReadWriteProjectState = { type ReadWriteProjectState = {
value: boolean value: boolean
@ -80,10 +69,8 @@ const Home = () => {
}) })
}) })
const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const settings = useSettings() const settings = useSettings()
const onboardingStatus = settings.app.onboardingStatus.current
// Menu listeners // Menu listeners
const cb = (data: WebContentSendPayload) => { const cb = (data: WebContentSendPayload) => {
@ -206,7 +193,7 @@ const Home = () => {
return ( return (
<div className="relative flex flex-col h-screen overflow-hidden" ref={ref}> <div className="relative flex flex-col h-screen overflow-hidden" ref={ref}>
<AppHeader showToolbar={false} /> <AppHeader showToolbar={false} />
<div className="overflow-hidden flex-1 home-layout max-w-4xl xl:max-w-7xl mb-12 px-4 mx-auto mt-24 lg:px-0"> <div className="overflow-hidden home-layout max-w-4xl xl:max-w-7xl mb-12 px-4 mx-auto mt-24 lg:px-0">
<HomeHeader <HomeHeader
setQuery={setQuery} setQuery={setQuery}
sort={sort} sort={sort}
@ -215,44 +202,8 @@ const Home = () => {
readWriteProjectDir={readWriteProjectDir} readWriteProjectDir={readWriteProjectDir}
className="col-start-2 -col-end-1" className="col-start-2 -col-end-1"
/> />
<aside className="lg:row-start-1 -row-end-1 flex flex-col justify-between"> <aside className="row-start-1 -row-end-1 flex flex-col justify-between">
<ul className="flex flex-col"> <ul className="flex flex-col">
{needsToOnboard(location, onboardingStatus) && (
<li className="flex group">
<ActionButton
Element="button"
onClick={() => {
acceptOnboarding({
onboardingStatus,
navigate,
codeManager,
kclManager,
}).catch(reportRejection)
}}
className={`${sidebarButtonClasses} !text-primary flex-1`}
iconStart={{
icon: 'play',
bgClassName: '!bg-primary rounded-sm',
iconClassName: '!text-white',
}}
data-testid="home-tutorial-button"
>
{onboardingStatus === '' ? 'Start' : 'Continue'} tutorial
</ActionButton>
<ActionButton
Element="button"
onClick={onDismissOnboardingInvite}
className={`${sidebarButtonClasses} hidden group-hover:flex flex-none ml-auto`}
iconStart={{
icon: 'close',
bgClassName: '!bg-transparent rounded-sm',
}}
data-testid="onboarding-dismiss"
>
<Tooltip>Dismiss tutorial</Tooltip>
</ActionButton>
</li>
)}
<li className="contents"> <li className="contents">
<ActionButton <ActionButton
Element="button" Element="button"
@ -373,7 +324,7 @@ const Home = () => {
sort={sort} sort={sort}
className="flex-1 col-start-2 -col-end-1 overflow-y-auto pr-2 pb-24" className="flex-1 col-start-2 -col-end-1 overflow-y-auto pr-2 pb-24"
/> />
<LowerRightControls navigate={navigate} /> <LowerRightControls />
</div> </div>
</div> </div>
) )

View File

@ -1,11 +1,18 @@
import { SettingsSection } from '@src/components/Settings/SettingsSection' import { SettingsSection } from '@src/components/Settings/SettingsSection'
import type { CameraSystem } from '@src/lib/cameraControls' import type { CameraSystem } from '@src/lib/cameraControls'
import { cameraMouseDragGuards, cameraSystems } 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 { 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() { export default function Units() {
useDismiss()
useNextClick(onboardingPaths.STREAMING)
const { const {
modeling: { mouseControls }, modeling: { mouseControls },
} = useSettings() } = useSettings()
@ -59,7 +66,7 @@ export default function Units() {
</ul> </ul>
</SettingsSection> </SettingsSection>
<OnboardingButtons <OnboardingButtons
currentSlug={ONBOARDING_SUBPATHS.CAMERA} currentSlug={onboardingPaths.CAMERA}
dismissClassName="right-auto left-full" dismissClassName="right-auto left-full"
/> />
</div> </div>

View File

@ -1,7 +1,8 @@
import { COMMAND_PALETTE_HOTKEY } from '@src/components/CommandBar/CommandBar' import { COMMAND_PALETTE_HOTKEY } from '@src/components/CommandBar/CommandBar'
import usePlatform from '@src/hooks/usePlatform' import usePlatform from '@src/hooks/usePlatform'
import { hotkeyDisplay } from '@src/lib/hotkeyWrapper' 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' import { OnboardingButtons, kbdClasses } from '@src/routes/Onboarding/utils'
export default function CmdK() { export default function CmdK() {
@ -36,7 +37,7 @@ export default function CmdK() {
. You can control settings, authentication, and file management from . You can control settings, authentication, and file management from
the command bar, as well as a growing number of modeling commands. the command bar, as well as a growing number of modeling commands.
</p> </p>
<OnboardingButtons currentSlug={ONBOARDING_SUBPATHS.COMMAND_K} /> <OnboardingButtons currentSlug={onboardingPaths.COMMAND_K} />
</div> </div>
</div> </div>
) )

View File

@ -1,4 +1,5 @@
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths' import { onboardingPaths } from '@src/routes/Onboarding/paths'
import { import {
OnboardingButtons, OnboardingButtons,
kbdClasses, kbdClasses,
@ -69,7 +70,7 @@ export default function OnboardingCodeEditor() {
pressing <kbd className={kbdClasses}>Shift + C</kbd>. pressing <kbd className={kbdClasses}>Shift + C</kbd>.
</p> </p>
</section> </section>
<OnboardingButtons currentSlug={ONBOARDING_SUBPATHS.EDITOR} /> <OnboardingButtons currentSlug={onboardingPaths.EDITOR} />
</div> </div>
</div> </div>
) )

View File

@ -1,5 +1,6 @@
import { APP_NAME } from '@src/lib/constants' 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' import { OnboardingButtons } from '@src/routes/Onboarding/utils'
export default function Export() { export default function Export() {
@ -49,7 +50,7 @@ export default function Export() {
! !
</p> </p>
</section> </section>
<OnboardingButtons currentSlug={ONBOARDING_SUBPATHS.EXPORT} /> <OnboardingButtons currentSlug={onboardingPaths.EXPORT} />
</div> </div>
</div> </div>
) )

View File

@ -1,9 +1,11 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useModelingContext } from '@src/hooks/useModelingContext' import { useModelingContext } from '@src/hooks/useModelingContext'
import { APP_NAME } from '@src/lib/constants' import { APP_NAME } from '@src/lib/constants'
import { sceneInfra } from '@src/lib/singletons' import { sceneInfra } from '@src/lib/singletons'
import { onboardingPaths } from '@src/routes/Onboarding/paths'
import { OnboardingButtons, useDemoCode } from '@src/routes/Onboarding/utils' import { OnboardingButtons, useDemoCode } from '@src/routes/Onboarding/utils'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
export default function FutureWork() { export default function FutureWork() {
const { send } = useModelingContext() const { send } = useModelingContext()
@ -56,7 +58,7 @@ export default function FutureWork() {
</p> </p>
<p className="my-4">💚 The Zoo Team</p> <p className="my-4">💚 The Zoo Team</p>
<OnboardingButtons <OnboardingButtons
currentSlug={ONBOARDING_SUBPATHS.FUTURE_WORK} currentSlug={onboardingPaths.FUTURE_WORK}
className="mt-6" className="mt-6"
/> />
</div> </div>

View File

@ -1,5 +1,6 @@
import { bracketWidthConstantLine } from '@src/lib/exampleKcl' import { bracketWidthConstantLine } from '@src/lib/exampleKcl'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths' import { onboardingPaths } from '@src/routes/Onboarding/paths'
import { import {
OnboardingButtons, OnboardingButtons,
kbdClasses, kbdClasses,
@ -84,9 +85,7 @@ export default function OnboardingInteractiveNumbers() {
your ideas for how to make it better. your ideas for how to make it better.
</p> </p>
</section> </section>
<OnboardingButtons <OnboardingButtons currentSlug={onboardingPaths.INTERACTIVE_NUMBERS} />
currentSlug={ONBOARDING_SUBPATHS.INTERACTIVE_NUMBERS}
/>
</div> </div>
</div> </div>
) )

View File

@ -1,11 +1,124 @@
import { APP_NAME } from '@src/lib/constants' import { useEffect, useState } from 'react'
import { isDesktop } from '@src/lib/isDesktop' import { useNavigate, useRouteLoaderData } from 'react-router-dom'
import { Themes, getSystemTheme } from '@src/lib/theme'
import { useSettings } from '@src/lib/singletons'
import { OnboardingButtons, useDemoCode } from '@src/routes/Onboarding/utils'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
export default function Introduction() { 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'
/**
* 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 ? (
<OnboardingResetWarning setShouldShowWarning={setShouldShowWarning} />
) : (
<OnboardingIntroductionInner />
)
}
interface OnboardingResetWarningProps {
setShouldShowWarning: (arg: boolean) => void
}
function OnboardingResetWarning(props: OnboardingResetWarningProps) {
return (
<div className="fixed inset-0 z-50 grid place-content-center bg-chalkboard-110/50">
<div className="relative max-w-3xl p-8 rounded bg-chalkboard-10 dark:bg-chalkboard-90">
{!isDesktop() ? (
<OnboardingWarningWeb {...props} />
) : (
<OnboardingWarningDesktop {...props} />
)}
</div>
</div>
)
}
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 (
<>
<h1 className="flex flex-wrap items-center gap-4 text-3xl font-bold">
Would you like to create a new project?
</h1>
<section className="my-12">
<p className="my-4">
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.
</p>
</section>
<OnboardingButtons
className="mt-6"
onNextOverride={() => {
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 (
<>
<h1 className="text-3xl font-bold text-warn-80 dark:text-warn-10">
Replaying onboarding resets your code
</h1>
<p className="my-4">
We see you have some of your own code written in this project. Please
save it somewhere else before continuing the onboarding.
</p>
<OnboardingButtons className="mt-6" />
</>
)
}
function OnboardingIntroductionInner() {
// Reset the code to the bracket code // Reset the code to the bracket code
useDemoCode() useDemoCode()
@ -69,7 +182,7 @@ export default function Introduction() {
</p> </p>
</section> </section>
<OnboardingButtons <OnboardingButtons
currentSlug={ONBOARDING_SUBPATHS.INDEX} currentSlug={onboardingPaths.INDEX}
className="mt-6" className="mt-6"
/> />
</div> </div>

View File

@ -2,8 +2,9 @@ import { bracketThicknessCalculationLine } from '@src/lib/exampleKcl'
import { isDesktop } from '@src/lib/isDesktop' import { isDesktop } from '@src/lib/isDesktop'
import { Themes, getSystemTheme } from '@src/lib/theme' import { Themes, getSystemTheme } from '@src/lib/theme'
import { useSettings } from '@src/lib/singletons' import { useSettings } from '@src/lib/singletons'
import { onboardingPaths } from '@src/routes/Onboarding/paths'
import { OnboardingButtons, useDemoCode } from '@src/routes/Onboarding/utils' import { OnboardingButtons, useDemoCode } from '@src/routes/Onboarding/utils'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
export default function OnboardingParametricModeling() { export default function OnboardingParametricModeling() {
useDemoCode() useDemoCode()
@ -71,9 +72,7 @@ export default function OnboardingParametricModeling() {
</figcaption> </figcaption>
</figure> </figure>
</section> </section>
<OnboardingButtons <OnboardingButtons currentSlug={onboardingPaths.PARAMETRIC_MODELING} />
currentSlug={ONBOARDING_SUBPATHS.PARAMETRIC_MODELING}
/>
</div> </div>
</div> </div>
) )

View File

@ -1,5 +1,6 @@
import { isDesktop } from '@src/lib/isDesktop' 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' import { OnboardingButtons } from '@src/routes/Onboarding/utils'
export default function ProjectMenu() { export default function ProjectMenu() {
@ -55,7 +56,7 @@ export default function ProjectMenu() {
</> </>
)} )}
</section> </section>
<OnboardingButtons currentSlug={ONBOARDING_SUBPATHS.PROJECT_MENU} /> <OnboardingButtons currentSlug={onboardingPaths.PROJECT_MENU} />
</div> </div>
</div> </div>
) )

View File

@ -1,7 +1,9 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { codeManager, kclManager } from '@src/lib/singletons' import { codeManager, kclManager } from '@src/lib/singletons'
import { onboardingPaths } from '@src/routes/Onboarding/paths'
import { OnboardingButtons } from '@src/routes/Onboarding/utils' import { OnboardingButtons } from '@src/routes/Onboarding/utils'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
export default function Sketching() { export default function Sketching() {
useEffect(() => { useEffect(() => {
@ -40,7 +42,7 @@ export default function Sketching() {
always just modifying and generating code in Zoo Design Studio. always just modifying and generating code in Zoo Design Studio.
</p> </p>
<OnboardingButtons <OnboardingButtons
currentSlug={ONBOARDING_SUBPATHS.SKETCHING} currentSlug={onboardingPaths.SKETCHING}
className="mt-6" className="mt-6"
/> />
</div> </div>

View File

@ -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' import { OnboardingButtons } from '@src/routes/Onboarding/utils'
export default function Streaming() { export default function Streaming() {
@ -40,7 +41,7 @@ export default function Streaming() {
</p> </p>
</section> </section>
<OnboardingButtons <OnboardingButtons
currentSlug={ONBOARDING_SUBPATHS.STREAMING} currentSlug={onboardingPaths.STREAMING}
dismissClassName="right-auto left-full" dismissClassName="right-auto left-full"
/> />
</div> </div>

View File

@ -4,12 +4,13 @@ import { ActionButton } from '@src/components/ActionButton'
import { SettingsSection } from '@src/components/Settings/SettingsSection' import { SettingsSection } from '@src/components/Settings/SettingsSection'
import { type BaseUnit, baseUnitsUnion } from '@src/lib/settings/settingsTypes' import { type BaseUnit, baseUnitsUnion } from '@src/lib/settings/settingsTypes'
import { settingsActor, useSettings } from '@src/lib/singletons' 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' import { useDismiss, useNextClick } from '@src/routes/Onboarding/utils'
export default function Units() { export default function Units() {
const dismiss = useDismiss() const dismiss = useDismiss()
const next = useNextClick(ONBOARDING_SUBPATHS.CAMERA) const next = useNextClick(onboardingPaths.CAMERA)
const { const {
modeling: { defaultUnit }, modeling: { defaultUnit },
} = useSettings() } = useSettings()

View File

@ -1,7 +1,9 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useUser } from '@src/lib/singletons' import { useUser } from '@src/lib/singletons'
import { onboardingPaths } from '@src/routes/Onboarding/paths'
import { OnboardingButtons } from '@src/routes/Onboarding/utils' import { OnboardingButtons } from '@src/routes/Onboarding/utils'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
export default function UserMenu() { export default function UserMenu() {
const user = useUser() const user = useUser()
@ -46,7 +48,7 @@ export default function UserMenu() {
only apply to the current project. only apply to the current project.
</p> </p>
</section> </section>
<OnboardingButtons currentSlug={ONBOARDING_SUBPATHS.USER_MENU} /> <OnboardingButtons currentSlug={onboardingPaths.USER_MENU} />
</div> </div>
</div> </div>
) )

View File

@ -14,8 +14,8 @@ import ProjectMenu from '@src/routes/Onboarding/ProjectMenu'
import Sketching from '@src/routes/Onboarding/Sketching' import Sketching from '@src/routes/Onboarding/Sketching'
import Streaming from '@src/routes/Onboarding/Streaming' import Streaming from '@src/routes/Onboarding/Streaming'
import UserMenu from '@src/routes/Onboarding/UserMenu' import UserMenu from '@src/routes/Onboarding/UserMenu'
import { onboardingPaths } from '@src/routes/Onboarding/paths'
import { useDismiss } from '@src/routes/Onboarding/utils' import { useDismiss } from '@src/routes/Onboarding/utils'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
export const onboardingRoutes = [ export const onboardingRoutes = [
{ {
@ -23,48 +23,48 @@ export const onboardingRoutes = [
element: <Introduction />, element: <Introduction />,
}, },
{ {
path: makeUrlPathRelative(ONBOARDING_SUBPATHS.CAMERA), path: makeUrlPathRelative(onboardingPaths.CAMERA),
element: <Camera />, element: <Camera />,
}, },
{ {
path: makeUrlPathRelative(ONBOARDING_SUBPATHS.STREAMING), path: makeUrlPathRelative(onboardingPaths.STREAMING),
element: <Streaming />, element: <Streaming />,
}, },
{ {
path: makeUrlPathRelative(ONBOARDING_SUBPATHS.EDITOR), path: makeUrlPathRelative(onboardingPaths.EDITOR),
element: <CodeEditor />, element: <CodeEditor />,
}, },
{ {
path: makeUrlPathRelative(ONBOARDING_SUBPATHS.PARAMETRIC_MODELING), path: makeUrlPathRelative(onboardingPaths.PARAMETRIC_MODELING),
element: <ParametricModeling />, element: <ParametricModeling />,
}, },
{ {
path: makeUrlPathRelative(ONBOARDING_SUBPATHS.INTERACTIVE_NUMBERS), path: makeUrlPathRelative(onboardingPaths.INTERACTIVE_NUMBERS),
element: <InteractiveNumbers />, element: <InteractiveNumbers />,
}, },
{ {
path: makeUrlPathRelative(ONBOARDING_SUBPATHS.COMMAND_K), path: makeUrlPathRelative(onboardingPaths.COMMAND_K),
element: <CmdK />, element: <CmdK />,
}, },
{ {
path: makeUrlPathRelative(ONBOARDING_SUBPATHS.USER_MENU), path: makeUrlPathRelative(onboardingPaths.USER_MENU),
element: <UserMenu />, element: <UserMenu />,
}, },
{ {
path: makeUrlPathRelative(ONBOARDING_SUBPATHS.PROJECT_MENU), path: makeUrlPathRelative(onboardingPaths.PROJECT_MENU),
element: <ProjectMenu />, element: <ProjectMenu />,
}, },
{ {
path: makeUrlPathRelative(ONBOARDING_SUBPATHS.EXPORT), path: makeUrlPathRelative(onboardingPaths.EXPORT),
element: <Export />, element: <Export />,
}, },
// Export / conversion API // Export / conversion API
{ {
path: makeUrlPathRelative(ONBOARDING_SUBPATHS.SKETCHING), path: makeUrlPathRelative(onboardingPaths.SKETCHING),
element: <Sketching />, element: <Sketching />,
}, },
{ {
path: makeUrlPathRelative(ONBOARDING_SUBPATHS.FUTURE_WORK), path: makeUrlPathRelative(onboardingPaths.FUTURE_WORK),
element: <FutureWork />, element: <FutureWork />,
}, },
] ]

View File

@ -1,6 +1,6 @@
import type { OnboardingStatus } from '@rust/kcl-lib/bindings/OnboardingStatus' import type { OnboardingStatus } from '@rust/kcl-lib/bindings/OnboardingStatus'
export const ONBOARDING_SUBPATHS: Record<string, OnboardingStatus> = { export const onboardingPaths: Record<string, OnboardingStatus> = {
INDEX: '/', INDEX: '/',
CAMERA: '/camera', CAMERA: '/camera',
STREAMING: '/streaming', STREAMING: '/streaming',
@ -11,6 +11,7 @@ export const ONBOARDING_SUBPATHS: Record<string, OnboardingStatus> = {
USER_MENU: '/user-menu', USER_MENU: '/user-menu',
PROJECT_MENU: '/project-menu', PROJECT_MENU: '/project-menu',
EXPORT: '/export', EXPORT: '/export',
MOVE: '/move',
SKETCHING: '/sketching', SKETCHING: '/sketching',
FUTURE_WORK: '/future-work', FUTURE_WORK: '/future-work',
} as const } as const

View File

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

View File

@ -26,9 +26,7 @@ const SignIn = () => {
if (isDesktop()) { if (isDesktop()) {
window.electron.createFallbackMenu().catch(reportRejection) window.electron.createFallbackMenu().catch(reportRejection)
// Disable these since they cannot be accessed within the sign in page. // Disable these since they cannot be accessed within the sign in page.
window.electron window.electron.disableMenu('Help.Reset onboarding').catch(reportRejection)
.disableMenu('Help.Replay onboarding tutorial')
.catch(reportRejection)
window.electron.disableMenu('Help.Show all commands').catch(reportRejection) window.electron.disableMenu('Help.Show all commands').catch(reportRejection)
} }

View File

@ -1,7 +1,7 @@
import { NODE_ENV } from '@src/env' import { NODE_ENV } from '@src/env'
import { isDesktop } from '@src/lib/isDesktop' 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' const isTestEnv = window?.localStorage.getItem(IS_PLAYWRIGHT_KEY) === 'true'