diff --git a/app/gui2/e2e/componentBrowser.spec.ts b/app/gui2/e2e/componentBrowser.spec.ts index ae7093605599..5e7239fcd34d 100644 --- a/app/gui2/e2e/componentBrowser.spec.ts +++ b/app/gui2/e2e/componentBrowser.spec.ts @@ -5,7 +5,8 @@ import * as actions from './actions' import * as customExpect from './customExpect' import * as locate from './locate' -const ACCEPT_SUGGESTION_SHORTCUT = os.platform() === 'darwin' ? 'Meta+Enter' : 'Control+Enter' +const CONTROL_KEY = os.platform() === 'darwin' ? 'Meta' : 'Control' +const ACCEPT_SUGGESTION_SHORTCUT = `${CONTROL_KEY}+Enter` async function deselectAllNodes(page: Page) { await page.keyboard.press('Escape') @@ -155,7 +156,7 @@ test('Editing existing nodes', async ({ page }) => { const ADDED_PATH = '"/home/enso/Input.txt"' // Start node editing - await locate.graphNodeIcon(node).click({ modifiers: ['Control'] }) + await locate.graphNodeIcon(node).click({ modifiers: [CONTROL_KEY] }) await expect(locate.componentBrowser(page)).toBeVisible() const input = locate.componentBrowserInput(page).locator('input') await expect(input).toHaveValue('Data.read') diff --git a/app/gui2/scripts/generateIconMetadata.js b/app/gui2/scripts/generateIconMetadata.js index 06e5f056e679..ab755ef1c2cb 100644 --- a/app/gui2/scripts/generateIconMetadata.js +++ b/app/gui2/scripts/generateIconMetadata.js @@ -11,8 +11,8 @@ console.info('Writing icon name type to "./src/util/iconName.ts"...') await fs.writeFile( './src/util/iconName.ts', `\ -// Generated by \`scripts/generateIcons.js\`. -// Please run \`npm run generate\` to regenerate this file whenever \`icons.svg\` is changed. +// Generated by \`scripts/generateIconMetadata.js\`. +// Please run \`npm run generate-metadata\` to regenerate this file whenever \`icons.svg\` is changed. import iconNames from '@/util/iconList.json' export type Icon = diff --git a/app/gui2/src/assets/icons.svg b/app/gui2/src/assets/icons.svg index 03a7486e5ab9..848cee9ef98b 100644 --- a/app/gui2/src/assets/icons.svg +++ b/app/gui2/src/assets/icons.svg @@ -676,6 +676,14 @@ + + + + + + + + diff --git a/app/gui2/src/components/ExecutionModeSelector.vue b/app/gui2/src/components/ExecutionModeSelector.vue deleted file mode 100644 index 9573bb73adc6..000000000000 --- a/app/gui2/src/components/ExecutionModeSelector.vue +++ /dev/null @@ -1,143 +0,0 @@ - - - - - diff --git a/app/gui2/src/components/GraphEditor.vue b/app/gui2/src/components/GraphEditor.vue index 3e0b86815258..789926de7f96 100644 --- a/app/gui2/src/components/GraphEditor.vue +++ b/app/gui2/src/components/GraphEditor.vue @@ -38,7 +38,6 @@ import { computed, onMounted, onScopeDispose, onUnmounted, ref, watch } from 'vu import { ProjectManagerEvents } from '../../../ide-desktop/lib/dashboard/src/utilities/ProjectManager' import { type Usage } from './ComponentBrowser/input' -const EXECUTION_MODES = ['design', 'live'] // Assumed size of a newly created node. This is used to place the component browser. const DEFAULT_NODE_SIZE = new Vec2(0, 24) const gapBetweenNodes = 48.0 @@ -334,8 +333,8 @@ const codeEditorHandler = codeEditorBindings.handler({ }, }) -/** Track play button presses. */ -function onPlayButtonPress() { +/** Handle record-once button presses. */ +function onRecordOnceButtonPress() { projectStore.lsRpcConnection.then(async () => { const modeValue = projectStore.executionMode if (modeValue == undefined) { @@ -657,8 +656,7 @@ function handleEdgeDrop(source: AstId, position: Vec2) { @canceled="onComponentBrowserCancel" /> +import SvgIcon from '@/components/SvgIcon.vue' +import ToggleIcon from '@/components/ToggleIcon.vue' + +const props = defineProps<{ recordMode: boolean }>() +const emit = defineEmits<{ recordOnce: []; 'update:recordMode': [enabled: boolean] }>() + + + + + diff --git a/app/gui2/src/components/TopBar.vue b/app/gui2/src/components/TopBar.vue index 70ae9973a696..772f6dd090f0 100644 --- a/app/gui2/src/components/TopBar.vue +++ b/app/gui2/src/components/TopBar.vue @@ -1,25 +1,24 @@ @@ -15,21 +14,19 @@ const breadcrumbs = ref(['main', 'ad_analytics'])
diff --git a/app/ide-desktop/README.md b/app/ide-desktop/README.md new file mode 100644 index 000000000000..d6018e582aa3 --- /dev/null +++ b/app/ide-desktop/README.md @@ -0,0 +1,26 @@ +# Desktop app + +This folder contains projects related to the desktop app. + +## Folder structure + +Refer to the `README.md` in each individual module (if it exists) for the +internal folder structure of the module. + +- `assets/`: Icons and images used by other modules. Currently these are only + used by `dashboard/`. +- `client/`: The code for the Electron desktop app. +- `common/`: Utility functions required by multiple other modules. +- `content/`: The entry point for the GUI1 web app. This is the main page for + the desktop app. +- `content-config/`: The statically-typed configuration object for `content/`. +- [`dashboard/`](./lib/dashboard/README.md): The dashboard, used to manage + projects. It launches the GUI (located in `content/` for GUI1, or `/app/gui2/` + for GUI2) when a project is opened. +- `esbuild-plugin-copy-directories/`: An ESBuild plugin for continuously copying + directories from the a given location to a given subdirectory of the build + output directory. +- `icons/`: Generates the logo for the app. +- `ts-plugin-namespace-auto-import/`: (WIP) A TypeScript plugin to change + auto-import to use `import * as moduleName` rather than `import {}`. +- `types/`: Miscellaneous types used by multiple modules. diff --git a/app/ide-desktop/eslint.config.js b/app/ide-desktop/eslint.config.js index a0229e078dc1..38435eb3671b 100644 --- a/app/ide-desktop/eslint.config.js +++ b/app/ide-desktop/eslint.config.js @@ -228,6 +228,11 @@ const RESTRICTED_SYNTAXES = [ selector: 'CallExpression[callee.name=toastAndLog][arguments.0.value=/\\.$/]', message: '`toastAndLog` already includes a trailing `.`', }, + { + selector: + 'JSXElement[closingElement!=null]:not(:has(.children:matches(JSXText[raw=/\\S/], :not(JSXText))))', + message: 'Use self-closing tags (``) for tags without children.', + }, ] // ============================ diff --git a/app/ide-desktop/lib/assets/change_password.svg b/app/ide-desktop/lib/assets/change_password.svg deleted file mode 100644 index 0127a1bf209f..000000000000 --- a/app/ide-desktop/lib/assets/change_password.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/ide-desktop/lib/assets/folder_arrow.svg b/app/ide-desktop/lib/assets/folder_arrow.svg new file mode 100644 index 000000000000..48327d24d7d6 --- /dev/null +++ b/app/ide-desktop/lib/assets/folder_arrow.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/app/ide-desktop/lib/dashboard/README.md b/app/ide-desktop/lib/dashboard/README.md index dcff285adb6c..f7bea14c70a2 100644 --- a/app/ide-desktop/lib/dashboard/README.md +++ b/app/ide-desktop/lib/dashboard/README.md @@ -6,10 +6,9 @@ management, project sharing, and user accounts and authentication. ## Folder structure - `mock/`: Overrides for specific files in `src/` when running Playwright tests. -- `test-component/`: Contains component tests. -- `test-e2e/`: Contains end-to-end tests. +- `e2e/`: Contains end-to-end tests. - `**/__tests__/`: Contains all unit tests. Unit tests MUST be in a `__tests__/` - subfolder, not beside the module they are testing. + subfolder, not beside (and not inside) the module they are testing. - `src/`: The dashboard application. - `index.html`: The sole HTML file used by this SPA. It imports the TS entry point. @@ -27,3 +26,8 @@ management, project sharing, and user accounts and authentication. - `components/`: Contains all components used by the authentication flow. - `providers/`: Contains React `Context`s required for authentication, and used by the main app. +- `index.html`: The entrypoint, in the format required by Vite. +- `404.html`: A copy of the entrypoint. This is served on unknown routes by + certain static hosting providers. +- `esbuild-config.ts`: Configuration for ESBuild based on the environment + variables. This is a dependency of `esbuild-config.ts` in sibling modules. diff --git a/app/ide-desktop/lib/dashboard/e2e/actions.ts b/app/ide-desktop/lib/dashboard/e2e/actions.ts index 0893baf11c78..de99299df3de 100644 --- a/app/ide-desktop/lib/dashboard/e2e/actions.ts +++ b/app/ide-desktop/lib/dashboard/e2e/actions.ts @@ -35,9 +35,9 @@ export function locateConfirmPasswordInput(page: test.Locator | test.Page) { return page.getByLabel('Confirm password') } -/** Find an "old password" input (if any) on the current page. */ -export function locateOldPasswordInput(page: test.Locator | test.Page) { - return page.getByLabel('Old password') +/** Find a "current password" input (if any) on the current page. */ +export function locateCurrentPasswordInput(page: test.Locator | test.Page) { + return page.getByPlaceholder('Enter your current password') } /** Find a "new password" input (if any) on the current page. */ @@ -83,7 +83,7 @@ export function locateSecretValueInput(page: test.Locator | test.Page) { /** Find a search bar input (if any) on the current page. */ export function locateSearchBarInput(page: test.Locator | test.Page) { return locateSearchBar(page).getByPlaceholder( - 'Type to search for projects, data connectors, users, and more.' + 'Type to search for projects, Data Links, users, and more.' ) } @@ -94,41 +94,34 @@ export function locateAssetRowName(locator: test.Locator) { // === Button locators === -/** Find a toast close button (if any) on the current page. */ +/** Find a toast close button (if any) on the current locator. */ export function locateToastCloseButton(page: test.Locator | test.Page) { // There is no other simple way to uniquely identify this element. // eslint-disable-next-line no-restricted-properties return page.locator('.Toastify__close-button') } -/** Find a login button (if any) on the current page. */ +/** Find a "login" button (if any) on the current locator. */ export function locateLoginButton(page: test.Locator | test.Page) { return page.getByRole('button', { name: 'Login', exact: true }).getByText('Login') } -/** Find a register button (if any) on the current page. */ +/** Find a "register" button (if any) on the current locator. */ export function locateRegisterButton(page: test.Locator | test.Page) { return page.getByRole('button', { name: 'Register' }).getByText('Register') } -/** Find a reset button (if any) on the current page. */ -export function locateResetButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'Reset' }).getByText('Reset') +/** Find a "change" button (if any) on the current locator. */ +export function locateChangeButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Change' }).getByText('Change') } -/** Find a user menu button (if any) on the current page. */ +/** Find a user menu button (if any) on the current locator. */ export function locateUserMenuButton(page: test.Locator | test.Page) { return page.getByAltText('Open user menu') } -/** Find a change password button (if any) on the current page. */ -export function locateChangePasswordButton(page: test.Locator | test.Page) { - return page - .getByRole('button', { name: 'Change your password' }) - .getByText('Change your password') -} - -/** Find a "sign out" button (if any) on the current page. */ +/** Find a "sign out" button (if any) on the current locator. */ export function locateLogoutButton(page: test.Locator | test.Page) { return page.getByRole('button', { name: 'Logout' }).getByText('Logout') } @@ -498,12 +491,6 @@ export function locateCollapsibleDirectories(page: test.Page) { return locateAssetRows(page).filter({ has: page.getByAltText('Collapse') }) } -/** Find a "change password" modal (if any) on the current page. */ -export function locateChangePasswordModal(page: test.Locator | test.Page) { - // This has no identifying features. - return page.getByTestId('change-password-modal') -} - /** Find a "confirm delete" modal (if any) on the current page. */ export function locateConfirmDeleteModal(page: test.Locator | test.Page) { // This has no identifying features. @@ -645,12 +632,11 @@ export async function press(page: test.Page, keyOrShortcut: string) { await test.test.step('Detect browser OS', async () => { userAgent = await page.evaluate(() => navigator.userAgent) }) - // This should be `Meta` (`Cmd`) on macOS, and `Control` on all other systems - const ctrlKey = /\bMac OS\b/i.test(userAgent) ? 'Meta' : 'Control' - const deleteKey = /\bMac OS\b/i.test(userAgent) ? 'Backspace' : 'Delete' - await page.keyboard.press( - keyOrShortcut.replace(/\bMod\b/g, ctrlKey).replace(/\bDelete\b/, deleteKey) - ) + const isMacOS = /\bMac OS\b/i.test(userAgent) + const ctrlKey = isMacOS ? 'Meta' : 'Control' + const deleteKey = isMacOS ? 'Backspace' : 'Delete' + const shortcut = keyOrShortcut.replace(/\bMod\b/, ctrlKey).replace(/\bDelete\b/, deleteKey) + await test.test.step(`Press '${shortcut}'`, () => page.keyboard.press(shortcut)) } else { await page.keyboard.press(keyOrShortcut) } diff --git a/app/ide-desktop/lib/dashboard/e2e/api.ts b/app/ide-desktop/lib/dashboard/e2e/api.ts index 616394e0650f..cdaae4663c2e 100644 --- a/app/ide-desktop/lib/dashboard/e2e/api.ts +++ b/app/ide-desktop/lib/dashboard/e2e/api.ts @@ -59,6 +59,7 @@ export async function mockApi({ page }: MockParams) { rootDirectoryId: defaultDirectoryId, } let currentUser: backend.User | null = defaultUser + let currentOrganization: backend.OrganizationInfo | null = null const assetMap = new Map() const deletedAssets = new Set() const assets: backend.AnyAsset[] = [] @@ -578,8 +579,13 @@ export async function mockApi({ page }: MockParams) { } ) await page.route(BASE_URL + remoteBackendPaths.USERS_ME_PATH + '*', async route => { + await route.fulfill({ json: currentUser }) + }) + await page.route(BASE_URL + remoteBackendPaths.GET_ORGANIZATION_PATH + '*', async route => { await route.fulfill({ - json: currentUser, + json: currentOrganization, + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + status: currentOrganization == null ? 404 : 200, }) }) await page.route(BASE_URL + remoteBackendPaths.CREATE_TAG_PATH + '*', async route => { @@ -696,6 +702,14 @@ export async function mockApi({ page }: MockParams) { setCurrentUser: (user: backend.User | null) => { currentUser = user }, + /** Returns the current value of `currentUser`. This is a getter, so its return value + * SHOULD NOT be cached. */ + get currentOrganization() { + return currentOrganization + }, + setCurrentOrganization: (user: backend.OrganizationInfo | null) => { + currentOrganization = user + }, addAsset, deleteAsset, undeleteAsset, diff --git a/app/ide-desktop/lib/dashboard/e2e/changePassword.spec.ts b/app/ide-desktop/lib/dashboard/e2e/changePassword.spec.ts new file mode 100644 index 000000000000..f8a6e0f5cf1d --- /dev/null +++ b/app/ide-desktop/lib/dashboard/e2e/changePassword.spec.ts @@ -0,0 +1,58 @@ +/** @file Test the "change password" modal. */ +import * as test from '@playwright/test' + +import * as actions from './actions' + +test.test.beforeEach(actions.mockAllAndLogin) + +test.test('change password', async ({ page }) => { + await actions.press(page, 'Mod+,') + + await actions.locateCurrentPasswordInput(page).fill(actions.VALID_PASSWORD) + await actions.locateNewPasswordInput(page).fill(actions.INVALID_PASSWORD) + await test + .expect(actions.locateChangeButton(page), 'incomplete form should be rejected') + .toBeDisabled() + + // Invalid new password + await actions.locateCurrentPasswordInput(page).fill(actions.VALID_PASSWORD) + await actions.locateNewPasswordInput(page).fill(actions.INVALID_PASSWORD) + await actions.locateConfirmNewPasswordInput(page).fill(actions.INVALID_PASSWORD) + test + .expect( + await actions + .locateNewPasswordInput(page) + .evaluate((element: HTMLInputElement) => element.validity.valid), + 'invalid new password should be rejected' + ) + .toBe(false) + await test + .expect(actions.locateChangeButton(page), 'invalid new password should be rejected') + .toBeDisabled() + + // Invalid new password confirmation + await actions.locateCurrentPasswordInput(page).fill(actions.VALID_PASSWORD) + await actions.locateNewPasswordInput(page).fill(actions.VALID_PASSWORD) + await actions.locateConfirmNewPasswordInput(page).fill(actions.VALID_PASSWORD + 'a') + test + .expect( + await actions + .locateConfirmNewPasswordInput(page) + .evaluate((element: HTMLInputElement) => element.validity.valid), + 'invalid new password confirmation should be rejected' + ) + .toBe(false) + await test + .expect( + actions.locateChangeButton(page), + 'invalid new password confirmation should be rejected' + ) + .toBeDisabled() + + // After form submission + await actions.locateConfirmNewPasswordInput(page).fill(actions.VALID_PASSWORD) + await actions.locateChangeButton(page).click() + await test.expect(actions.locateCurrentPasswordInput(page)).toHaveText('') + await test.expect(actions.locateNewPasswordInput(page)).toHaveText('') + await test.expect(actions.locateConfirmNewPasswordInput(page)).toHaveText('') +}) diff --git a/app/ide-desktop/lib/dashboard/e2e/changePasswordModal.spec.ts b/app/ide-desktop/lib/dashboard/e2e/changePasswordModal.spec.ts deleted file mode 100644 index 3b753bf6be03..000000000000 --- a/app/ide-desktop/lib/dashboard/e2e/changePasswordModal.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** @file Test the "change password" modal. */ -import * as test from '@playwright/test' - -import * as actions from './actions' - -test.test.beforeEach(actions.mockAllAndLogin) - -test.test('change password modal', async ({ page }) => { - // Change password modal - await actions.locateUserMenuButton(page).click() - await actions.locateChangePasswordButton(page).click() - await test.expect(actions.locateChangePasswordModal(page)).toBeVisible() - - // Invalid old password - await actions.locateOldPasswordInput(page).fill(actions.INVALID_PASSWORD) - test - .expect( - await page.evaluate(() => document.querySelector('form')?.checkValidity()), - 'form should reject invalid old password' - ) - .toBe(false) - await actions.locateResetButton(page).click() - - // Invalid new password - await actions.locateOldPasswordInput(page).fill(actions.VALID_PASSWORD) - await actions.locateNewPasswordInput(page).fill(actions.INVALID_PASSWORD) - test - .expect( - await page.evaluate(() => document.querySelector('form')?.checkValidity()), - 'form should reject invalid new password' - ) - .toBe(false) - await actions.locateResetButton(page).click() - - // Invalid new password confirmation - await actions.locateNewPasswordInput(page).fill(actions.VALID_PASSWORD) - await actions.locateConfirmNewPasswordInput(page).fill(actions.INVALID_PASSWORD) - test - .expect( - await page.evaluate(() => document.querySelector('form')?.checkValidity()), - 'form should reject invalid new password confirmation' - ) - .toBe(false) - await actions.locateResetButton(page).click() - - // After form submission - await actions.locateConfirmNewPasswordInput(page).fill(actions.VALID_PASSWORD) - await actions.locateResetButton(page).click() - await test.expect(actions.locateChangePasswordModal(page)).not.toBeAttached() -}) diff --git a/app/ide-desktop/lib/dashboard/e2e/sort.spec.ts b/app/ide-desktop/lib/dashboard/e2e/sort.spec.ts index 6bf6d9493990..967941acf8d4 100644 --- a/app/ide-desktop/lib/dashboard/e2e/sort.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/sort.spec.ts @@ -63,25 +63,25 @@ test.test('sort', async ({ page }) => { await nameHeading.click() await test.expect(actions.locateSortAscendingIcon(nameHeading)).toBeVisible() await test.expect(assetRows.nth(0)).toHaveText(/^a directory/) - await test.expect(assetRows.nth(1)).toHaveText(/^G directory/) - await test.expect(assetRows.nth(2)).toHaveText(/^b project/) - await test.expect(assetRows.nth(3)).toHaveText(/^C project/) - await test.expect(assetRows.nth(4)).toHaveText(/^d file/) - await test.expect(assetRows.nth(5)).toHaveText(/^e file/) - await test.expect(assetRows.nth(6)).toHaveText(/^f secret/) + await test.expect(assetRows.nth(1)).toHaveText(/^b project/) + await test.expect(assetRows.nth(2)).toHaveText(/^C project/) + await test.expect(assetRows.nth(3)).toHaveText(/^d file/) + await test.expect(assetRows.nth(4)).toHaveText(/^e file/) + await test.expect(assetRows.nth(5)).toHaveText(/^f secret/) + await test.expect(assetRows.nth(6)).toHaveText(/^G directory/) await test.expect(assetRows.nth(7)).toHaveText(/^H secret/) // Sort by name descending. await nameHeading.click() await test.expect(actions.locateSortDescendingIcon(nameHeading)).toBeVisible() - await test.expect(assetRows.nth(0)).toHaveText(/^G directory/) - await test.expect(assetRows.nth(1)).toHaveText(/^a directory/) - await test.expect(assetRows.nth(2)).toHaveText(/^C project/) - await test.expect(assetRows.nth(3)).toHaveText(/^b project/) - await test.expect(assetRows.nth(4)).toHaveText(/^e file/) - await test.expect(assetRows.nth(5)).toHaveText(/^d file/) - await test.expect(assetRows.nth(6)).toHaveText(/^H secret/) - await test.expect(assetRows.nth(7)).toHaveText(/^f secret/) + await test.expect(assetRows.nth(0)).toHaveText(/^H secret/) + await test.expect(assetRows.nth(1)).toHaveText(/^G directory/) + await test.expect(assetRows.nth(2)).toHaveText(/^f secret/) + await test.expect(assetRows.nth(3)).toHaveText(/^e file/) + await test.expect(assetRows.nth(4)).toHaveText(/^d file/) + await test.expect(assetRows.nth(5)).toHaveText(/^C project/) + await test.expect(assetRows.nth(6)).toHaveText(/^b project/) + await test.expect(assetRows.nth(7)).toHaveText(/^a directory/) // Sorting should be unset. await nameHeading.click() @@ -100,26 +100,26 @@ test.test('sort', async ({ page }) => { // Sort by date ascending. await modifiedHeading.click() await test.expect(actions.locateSortAscendingIcon(modifiedHeading)).toBeVisible() - await test.expect(assetRows.nth(0)).toHaveText(/^a directory/) - await test.expect(assetRows.nth(1)).toHaveText(/^G directory/) - await test.expect(assetRows.nth(2)).toHaveText(/^b project/) - await test.expect(assetRows.nth(3)).toHaveText(/^C project/) + await test.expect(assetRows.nth(0)).toHaveText(/^b project/) + await test.expect(assetRows.nth(1)).toHaveText(/^H secret/) + await test.expect(assetRows.nth(2)).toHaveText(/^f secret/) + await test.expect(assetRows.nth(3)).toHaveText(/^a directory/) await test.expect(assetRows.nth(4)).toHaveText(/^e file/) - await test.expect(assetRows.nth(5)).toHaveText(/^d file/) - await test.expect(assetRows.nth(6)).toHaveText(/^H secret/) - await test.expect(assetRows.nth(7)).toHaveText(/^f secret/) + await test.expect(assetRows.nth(5)).toHaveText(/^G directory/) + await test.expect(assetRows.nth(6)).toHaveText(/^C project/) + await test.expect(assetRows.nth(7)).toHaveText(/^d file/) // Sort by date descending. await modifiedHeading.click() await test.expect(actions.locateSortDescendingIcon(modifiedHeading)).toBeVisible() - await test.expect(assetRows.nth(0)).toHaveText(/^G directory/) - await test.expect(assetRows.nth(1)).toHaveText(/^a directory/) - await test.expect(assetRows.nth(2)).toHaveText(/^C project/) - await test.expect(assetRows.nth(3)).toHaveText(/^b project/) - await test.expect(assetRows.nth(4)).toHaveText(/^d file/) - await test.expect(assetRows.nth(5)).toHaveText(/^e file/) - await test.expect(assetRows.nth(6)).toHaveText(/^f secret/) - await test.expect(assetRows.nth(7)).toHaveText(/^H secret/) + await test.expect(assetRows.nth(0)).toHaveText(/^d file/) + await test.expect(assetRows.nth(1)).toHaveText(/^C project/) + await test.expect(assetRows.nth(2)).toHaveText(/^G directory/) + await test.expect(assetRows.nth(3)).toHaveText(/^e file/) + await test.expect(assetRows.nth(4)).toHaveText(/^a directory/) + await test.expect(assetRows.nth(5)).toHaveText(/^f secret/) + await test.expect(assetRows.nth(6)).toHaveText(/^H secret/) + await test.expect(assetRows.nth(7)).toHaveText(/^b project/) // Sorting should be unset. await modifiedHeading.click() diff --git a/app/ide-desktop/lib/dashboard/src/authentication/cognito.ts b/app/ide-desktop/lib/dashboard/src/authentication/cognito.ts index 8f882b9c90b1..a47eb9d6b408 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/cognito.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/cognito.ts @@ -148,6 +148,7 @@ export enum CognitoErrorType { notAuthorized = 'NotAuthorized', userNotConfirmed = 'UserNotConfirmed', userNotFound = 'UserNotFound', + userBrokenState = 'UserBrokenState', amplifyError = 'AmplifyError', authError = 'AuthError', noCurrentUser = 'NoCurrentUser', @@ -546,7 +547,10 @@ export function intoSignInWithPasswordErrorOrThrow(error: AmplifyError): SignInW /** An error that may occur when requesting a password reset. */ export interface ForgotPasswordError extends CognitoError { - readonly type: CognitoErrorType.userNotConfirmed | CognitoErrorType.userNotFound + readonly type: + | CognitoErrorType.userBrokenState + | CognitoErrorType.userNotConfirmed + | CognitoErrorType.userNotFound readonly message: string } @@ -571,6 +575,14 @@ export function intoForgotPasswordErrorOrThrow(error: AmplifyError): ForgotPassw 'Cannot reset password for user with an unverified email. ' + 'Please verify your email first.', } + } else if ( + error.code === 'NotAuthorizedException' && + error.message === 'User password cannot be reset in the current state.' + ) { + return { + type: CognitoErrorType.userBrokenState, + message: 'User account is in a broken state. Please contact support.', + } } else { throw error } diff --git a/app/ide-desktop/lib/dashboard/src/components/ColorPicker.tsx b/app/ide-desktop/lib/dashboard/src/components/ColorPicker.tsx index 3afff684704e..bdd9236bf5e8 100644 --- a/app/ide-desktop/lib/dashboard/src/components/ColorPicker.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/ColorPicker.tsx @@ -30,7 +30,7 @@ export default function ColorPicker(props: ColorPickerProps) { backgroundColor: backend.lChColorToCssColor(currentColor), }} > -
+
))} diff --git a/app/ide-desktop/lib/dashboard/src/components/Input.tsx b/app/ide-desktop/lib/dashboard/src/components/Input.tsx index a07a881c4999..4f28a05da7f2 100644 --- a/app/ide-desktop/lib/dashboard/src/components/Input.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/Input.tsx @@ -22,13 +22,14 @@ export interface InputProps extends controlledInput.ControlledInputProps { /** A styled input that includes an icon. */ export default function Input(props: InputProps) { - const { allowShowingPassword = false, label, icon, footer, ...passthrough } = props + const { allowShowingPassword = false, label, icon, type, footer, ...passthrough } = props const [isShowingPassword, setIsShowingPassword] = React.useState(false) + const input = (
- - {props.type === 'password' && allowShowingPassword && ( + + {type === 'password' && allowShowingPassword && ( ) - return label != null || footer != null ? ( + + return label == null && footer == null ? ( + input + ) : ( - ) : ( - input ) } diff --git a/app/ide-desktop/lib/dashboard/src/components/SelectionBrush.tsx b/app/ide-desktop/lib/dashboard/src/components/SelectionBrush.tsx new file mode 100644 index 000000000000..cc9f98a56fd6 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/SelectionBrush.tsx @@ -0,0 +1,184 @@ +/** @file A selection brush to indicate the area being selected by the mouse drag action. */ +import * as React from 'react' + +import * as reactDom from 'react-dom' + +import * as animationHooks from '#/hooks/animationHooks' + +import * as modalProvider from '#/providers/ModalProvider' + +import type * as geometry from '#/utilities/geometry' + +// ====================== +// === SelectionBrush === +// ====================== + +/** Props for a {@link SelectionBrush}. */ +export interface SelectionBrushProps { + readonly onDrag: (rectangle: geometry.DetailedRectangle, event: MouseEvent) => void + readonly onDragEnd: (event: MouseEvent) => void + readonly onDragCancel: () => void +} + +/** A selection brush to indicate the area being selected by the mouse drag action. */ +export default function SelectionBrush(props: SelectionBrushProps) { + const { onDrag, onDragEnd, onDragCancel } = props + const { modalRef } = modalProvider.useModalRef() + const isMouseDownRef = React.useRef(false) + const didMoveWhileDraggingRef = React.useRef(false) + const onDragRef = React.useRef(onDrag) + const onDragEndRef = React.useRef(onDragEnd) + const onDragCancelRef = React.useRef(onDragCancel) + const lastMouseEvent = React.useRef(null) + const [anchor, setAnchor] = React.useState(null) + // This will be `null` if `anchor` is `null`. + const [position, setPosition] = React.useState(null) + const [lastSetAnchor, setLastSetAnchor] = React.useState(null) + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + const anchorAnimFactor = animationHooks.useApproach(anchor != null ? 1 : 0, 60) + const hidden = + anchor == null || + position == null || + (anchor.left === position.left && anchor.top === position.top) + + React.useEffect(() => { + onDragRef.current = onDrag + }, [onDrag]) + + React.useEffect(() => { + onDragEndRef.current = onDragEnd + }, [onDragEnd]) + + React.useEffect(() => { + onDragCancelRef.current = onDragCancel + }, [onDragCancel]) + + React.useEffect(() => { + if (anchor != null) { + anchorAnimFactor.skip() + } + }, [anchorAnimFactor, anchor]) + + React.useEffect(() => { + const onMouseDown = (event: MouseEvent) => { + if ( + modalRef.current == null && + !(event.target instanceof HTMLInputElement) && + !(event.target instanceof HTMLTextAreaElement) && + (!(event.target instanceof HTMLElement) || !event.target.isContentEditable) && + !(event.target instanceof HTMLButtonElement) && + !(event.target instanceof HTMLAnchorElement) + ) { + isMouseDownRef.current = true + didMoveWhileDraggingRef.current = false + lastMouseEvent.current = event + const newAnchor = { left: event.pageX, top: event.pageY } + setAnchor(newAnchor) + setLastSetAnchor(newAnchor) + setPosition(newAnchor) + } + } + const onMouseUp = (event: MouseEvent) => { + if (didMoveWhileDraggingRef.current) { + onDragEndRef.current(event) + } + // The `setTimeout` is required, otherwise the values are changed before the `onClick` handler + // is executed. + window.setTimeout(() => { + isMouseDownRef.current = false + didMoveWhileDraggingRef.current = false + }) + setAnchor(null) + } + const onMouseMove = (event: MouseEvent) => { + if (!(event.buttons & 1)) { + isMouseDownRef.current = false + } + if (isMouseDownRef.current) { + // Left click is being held. + didMoveWhileDraggingRef.current = true + lastMouseEvent.current = event + setPosition({ left: event.pageX, top: event.pageY }) + } + } + const onClick = (event: MouseEvent) => { + if (isMouseDownRef.current && didMoveWhileDraggingRef.current) { + event.stopImmediatePropagation() + } + } + const onDragStart = () => { + if (isMouseDownRef.current) { + isMouseDownRef.current = false + onDragCancelRef.current() + setAnchor(null) + } + } + document.addEventListener('mousedown', onMouseDown) + document.addEventListener('mouseup', onMouseUp) + document.addEventListener('dragstart', onDragStart, { capture: true }) + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('click', onClick) + return () => { + document.removeEventListener('mousedown', onMouseDown) + document.removeEventListener('mouseup', onMouseUp) + document.removeEventListener('dragstart', onDragStart, { capture: true }) + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('click', onClick) + } + }, [/* should never change */ modalRef]) + + const rectangle = React.useMemo(() => { + if (position != null && lastSetAnchor != null) { + const start: geometry.Coordinate2D = { + left: + position.left * (1 - anchorAnimFactor.value) + + lastSetAnchor.left * anchorAnimFactor.value, + top: + position.top * (1 - anchorAnimFactor.value) + lastSetAnchor.top * anchorAnimFactor.value, + } + return { + left: Math.min(position.left, start.left), + top: Math.min(position.top, start.top), + right: Math.max(position.left, start.left), + bottom: Math.max(position.top, start.top), + width: Math.abs(position.left - start.left), + height: Math.abs(position.top - start.top), + signedWidth: position.left - start.left, + signedHeight: position.top - start.top, + } + } else { + return null + } + }, [anchorAnimFactor.value, lastSetAnchor, position]) + + const selectionRectangle = React.useMemo(() => (hidden ? null : rectangle), [hidden, rectangle]) + + React.useEffect(() => { + if (selectionRectangle != null && lastMouseEvent.current != null) { + onDrag(selectionRectangle, lastMouseEvent.current) + } + // `onChange` is a callback, not a dependency. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectionRectangle]) + + const brushStyle = + rectangle == null + ? {} + : { + left: `${rectangle.left}px`, + top: `${rectangle.top}px`, + width: `${rectangle.width}px`, + height: `${rectangle.height}px`, + } + + return reactDom.createPortal( +