From 37cc980082fdb47960c3887e67abb77ea82ca101 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Fri, 21 Jun 2024 10:14:40 +0300 Subject: [PATCH] Offline Mode Support (#10317) #### Tl;dr Closes: enso-org/cloud-v2#1283 This PR significantly reimplements Offline mode
Demo Presentation

https://github.com/enso-org/enso/assets/61194245/752d0423-9c0a-43ba-91e3-4a6688f77034

--- #### Context: Offline mode is one of the core features of the dashboard. Unfortunately, after adding new features and a few refactoring, we lost the ability to work offline. This PR should bring this functionality back, with a few key differences: 1. We require users to sign in before using the dashboard even in local mode. 2. Once a user is logged in, we allow him to work with local files 3. If a user closes the dashboard, and then open it, he can continue using it in offline mode #### This Change: What does this change do in the larger context? Specific details to highlight for review: 1. Reimplements `` functionality, now it implemented on top of `` and ReactQuery 2. Reimplements Backend module flow, now remote backend is always created, You no longer need to check if the RemoteBackend is present 3. Introduces new `` component, which is aware of offline status 4. Introduce new offline-related hooks 5. Add a banner to the form if it's unable to submit it offline 6. Refactor `InviteUserDialog` to the new `
` component 7. Fixes redirect bug when the app doesn't redirect a user to the dashboard after logging in 8. Fixes strange behavior when `/users/me` could stuck into infinite refetch 9. Redesign the Cloud table for offline mode. 10. Adds blocking UI dialog when a user clicks "log out" button #### Test Plan: This PR requires thorough QA on the login flow across the browser and IDE. All redirect logic must stay unchanged. --- --- app/ide-desktop/.example.env | 1 - app/ide-desktop/eslint.config.js | 5 - app/ide-desktop/lib/client/src/bin/server.ts | 9 +- app/ide-desktop/lib/common/src/appConfig.js | 5 - app/ide-desktop/lib/dashboard/e2e/README.md | 2 +- app/ide-desktop/lib/dashboard/e2e/actions.ts | 92 ++-- .../dashboard/e2e/actions/DrivePageActions.ts | 15 +- .../dashboard/e2e/actions/LoginPageActions.ts | 14 +- app/ide-desktop/lib/dashboard/e2e/api.ts | 6 +- .../lib/dashboard/e2e/assetSearchBar.spec.ts | 8 +- .../dashboard/e2e/assetsTableFeatures.spec.ts | 4 +- .../lib/dashboard/e2e/labels.spec.ts | 14 +- .../lib/dashboard/e2e/membersSettings.spec.ts | 4 +- .../e2e/organizationSettings.spec.ts | 15 +- .../lib/dashboard/e2e/signUp.spec.ts | 6 - .../lib/dashboard/e2e/sort.spec.ts | 1 - .../lib/dashboard/e2e/userSettings.spec.ts | 6 +- app/ide-desktop/lib/dashboard/package.json | 6 +- app/ide-desktop/lib/dashboard/src/App.tsx | 120 +++-- .../lib/dashboard/src/ReactQueryDevtools.tsx | 14 +- .../src/authentication/cognito.mock.ts | 4 +- .../dashboard/src/authentication/cognito.ts | 25 +- .../dashboard/src/authentication/service.ts | 2 + .../components/AriaComponents/Alert/Alert.tsx | 20 +- .../AriaComponents/Button/Button.tsx | 14 +- .../AriaComponents/Dialog/Dialog.tsx | 6 +- .../AriaComponents/Dialog/Popover.tsx | 6 +- .../components/AriaComponents/Form/Form.tsx | 28 +- .../AriaComponents/Form/components/Field.tsx | 2 +- .../Form/components/FormError.tsx | 31 +- .../components/AriaComponents/Form/types.ts | 2 + .../ResizableContentEditableInput.tsx | 82 ++- .../Inputs/ResizableInput/ResizableInput.tsx | 16 +- .../Inputs/ResizableInput/variants.ts | 1 - .../AriaComponents/Radio/RadioGroup.tsx | 8 +- .../src/components/ErrorBoundary.tsx | 31 +- .../components/OfflineNotificationManager.tsx | 62 +++ .../lib/dashboard/src/components/Result.tsx | 100 ++-- .../dashboard/src/components/SubmitButton.tsx | 15 +- .../lib/dashboard/src/components/Suspense.tsx | 53 ++ .../src/components/dashboard/AssetRow.tsx | 2 +- .../src/components/dashboard/ProjectIcon.tsx | 5 +- .../dashboard/ProjectNameColumn.tsx | 4 +- .../dashboard/column/LabelsColumn.tsx | 2 +- .../dashboard/column/SharedWithColumn.tsx | 4 +- .../columnHeading/SharedWithColumnHeading.tsx | 2 +- .../lib/dashboard/src/hooks/backendHooks.ts | 14 +- .../lib/dashboard/src/hooks/gtagHooks.ts | 14 +- .../lib/dashboard/src/hooks/navigateHooks.ts | 40 -- .../lib/dashboard/src/hooks/offlineHooks.ts | 87 +++ .../src/hooks/searchParamsStateHooks.ts | 2 +- app/ide-desktop/lib/dashboard/src/index.tsx | 30 +- .../src/layouts/AssetContextMenu.tsx | 6 +- .../dashboard/src/layouts/AssetProperties.tsx | 2 +- .../lib/dashboard/src/layouts/AssetsTable.tsx | 4 +- .../src/layouts/AssetsTableContextMenu.tsx | 15 +- .../src/layouts/CategorySwitcher.tsx | 23 +- .../src/layouts/CategorySwitcher/Category.ts | 9 +- .../lib/dashboard/src/layouts/Chat.tsx | 2 +- .../dashboard/src/layouts/ChatPlaceholder.tsx | 5 +- .../lib/dashboard/src/layouts/Drive.tsx | 114 ++-- .../lib/dashboard/src/layouts/DriveBar.tsx | 65 ++- .../dashboard/src/layouts/OpenAppWatcher.tsx | 58 ++ .../lib/dashboard/src/layouts/Settings.tsx | 19 +- .../layouts/Settings/AccountSettingsTab.tsx | 4 +- .../ChangePasswordSettingsSection.tsx | 6 +- .../DeleteUserAccountSettingsSection.tsx | 28 +- .../src/layouts/Settings/MembersTable.tsx | 9 +- ...anizationProfilePictureSettingsSection.tsx | 6 +- .../Settings/OrganizationSettingsSection.tsx | 60 +-- .../ProfilePictureSettingsSection.tsx | 6 +- .../Settings/UserAccountSettingsSection.tsx | 27 +- .../src/layouts/Settings/UserRow.tsx | 2 +- .../dashboard/src/layouts/SettingsSidebar.tsx | 2 +- .../lib/dashboard/src/layouts/UserBar.tsx | 19 +- .../lib/dashboard/src/layouts/UserMenu.tsx | 152 +++--- .../src/modals/AddPaymentMethodModal.tsx | 4 +- .../InviteUsersModal/InviteUsersForm.tsx | 144 ++--- .../InviteUsersModal/InviteUsersModal.tsx | 36 +- .../InviteUsersModal/InviteUsersSuccess.tsx | 9 +- .../src/modals/SetOrganizationNameModal.tsx | 26 +- .../authentication/AuthenticationPage.tsx | 54 +- .../authentication/ConfirmRegistration.tsx | 3 +- .../pages/authentication/ForgotPassword.tsx | 4 + .../src/pages/authentication/Login.tsx | 11 + .../src/pages/authentication/Registration.tsx | 4 + .../pages/authentication/ResetPassword.tsx | 7 +- .../pages/authentication/RestoreAccount.tsx | 4 +- .../src/pages/authentication/SetUsername.tsx | 4 + .../src/pages/dashboard/Dashboard.tsx | 23 +- .../pages/subscribe/Subscribe/Subscribe.tsx | 4 +- .../src/pages/subscribe/SubscribeSuccess.tsx | 4 +- .../dashboard/src/providers/AuthProvider.tsx | 510 +++++++----------- .../src/providers/BackendProvider.tsx | 7 +- .../src/providers/HttpClientProvider.tsx | 39 ++ .../src/providers/SessionProvider.tsx | 101 ++-- .../src/providers/StripeProvider.tsx | 1 + .../lib/dashboard/src/reactQueryClient.ts | 49 ++ .../dashboard/src/services/LocalBackend.ts | 2 +- .../dashboard/src/services/RemoteBackend.ts | 9 - .../lib/dashboard/src/tailwind.css | 2 +- .../lib/dashboard/src/text/english.json | 138 ++--- .../lib/dashboard/src/utilities/HttpClient.ts | 15 +- .../lib/dashboard/vite.test.config.ts | 3 + app/ide-desktop/lib/types/globals.d.ts | 6 +- package-lock.json | 90 ++-- 106 files changed, 1663 insertions(+), 1354 deletions(-) create mode 100644 app/ide-desktop/lib/dashboard/src/components/OfflineNotificationManager.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/Suspense.tsx delete mode 100644 app/ide-desktop/lib/dashboard/src/hooks/navigateHooks.ts create mode 100644 app/ide-desktop/lib/dashboard/src/hooks/offlineHooks.ts create mode 100644 app/ide-desktop/lib/dashboard/src/layouts/OpenAppWatcher.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/providers/HttpClientProvider.tsx diff --git a/app/ide-desktop/.example.env b/app/ide-desktop/.example.env index 1127d2ba45..15d8f4e2d5 100644 --- a/app/ide-desktop/.example.env +++ b/app/ide-desktop/.example.env @@ -1,5 +1,4 @@ ENSO_CLOUD_ENSO_HOST=https://ensoanalytics.com -ENSO_CLOUD_REDIRECT=http://localhost:8080 ENSO_CLOUD_ENVIRONMENT=production ENSO_CLOUD_API_URL=https://aaaaaaaaaa.execute-api.mars.amazonaws.com ENSO_CLOUD_CHAT_URL=wss://chat.example.com diff --git a/app/ide-desktop/eslint.config.js b/app/ide-desktop/eslint.config.js index e318a9270c..e788dae5f6 100644 --- a/app/ide-desktop/eslint.config.js +++ b/app/ide-desktop/eslint.config.js @@ -476,11 +476,6 @@ export default [ rules: { 'no-restricted-properties': [ 'error', - { - object: 'router', - property: 'useNavigate', - message: 'Use `hooks.useNavigate` instead.', - }, { object: 'console', message: DEBUG_STATEMENTS_MESSAGE }, { property: 'useDebugState', message: DEBUG_STATEMENTS_MESSAGE }, { property: 'useDebugEffect', message: DEBUG_STATEMENTS_MESSAGE }, diff --git a/app/ide-desktop/lib/client/src/bin/server.ts b/app/ide-desktop/lib/client/src/bin/server.ts index 2a61e55958..afbaeb8ddf 100644 --- a/app/ide-desktop/lib/client/src/bin/server.ts +++ b/app/ide-desktop/lib/client/src/bin/server.ts @@ -337,8 +337,13 @@ export class Server { } else if (this.devServer) { this.devServer.middlewares(request, response) } else { - const url = requestUrl.split('?')[0] - const resource = url === '/' ? '/index.html' : requestUrl + const url = requestUrl.split('?')[0] ?? '' + + // if it's a path inside the IDE, we need to serve index.html + const hasExtension = path.extname(url) !== '' + + const resource = hasExtension ? requestUrl : '/index.html' + // `preload.cjs` must be specialcased here as it is loaded by electron from the root, // in contrast to all assets loaded by the window, which are loaded from `assets/` via // this server. diff --git a/app/ide-desktop/lib/common/src/appConfig.js b/app/ide-desktop/lib/common/src/appConfig.js index 673b48dddf..3570e81ad0 100644 --- a/app/ide-desktop/lib/common/src/appConfig.js +++ b/app/ide-desktop/lib/common/src/appConfig.js @@ -89,11 +89,6 @@ function stringify(value) { export function getDefines() { return { /* eslint-disable @typescript-eslint/naming-convention */ - 'process.env.ENSO_CLOUD_REDIRECT': stringify( - // The actual environment variable does not necessarily exist. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - process.env.ENSO_CLOUD_REDIRECT - ), 'process.env.ENSO_CLOUD_ENVIRONMENT': stringify( // The actual environment variable does not necessarily exist. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition diff --git a/app/ide-desktop/lib/dashboard/e2e/README.md b/app/ide-desktop/lib/dashboard/e2e/README.md index 8649a9385f..1e28515a63 100644 --- a/app/ide-desktop/lib/dashboard/e2e/README.md +++ b/app/ide-desktop/lib/dashboard/e2e/README.md @@ -32,7 +32,7 @@ test.test("test name here", ({ page }) => // If it is absolutely necessary though, please remember to `await` the method chain. // Note that the `async`/`await` pair is REQUIRED, as `Actions` subclasses are `PromiseLike`s, // not `Promise`s, which causes Playwright to output a type error. - async ({ pageActions }) => await pageActions.goToHomePage(), + async ({ pageActions }) => await pageActions.goTo.drive(), ), ); ``` diff --git a/app/ide-desktop/lib/dashboard/e2e/actions.ts b/app/ide-desktop/lib/dashboard/e2e/actions.ts index 91acff5dc0..d9cc22f699 100644 --- a/app/ide-desktop/lib/dashboard/e2e/actions.ts +++ b/app/ide-desktop/lib/dashboard/e2e/actions.ts @@ -82,13 +82,6 @@ export function locateAssetRowName(locator: test.Locator) { // === Button locators === -/** 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 locator. */ export function locateLoginButton(page: test.Locator | test.Page) { return page.getByRole('button', { name: 'Login', exact: true }).getByText('Login') @@ -130,10 +123,11 @@ export function locateStopProjectButton(page: test.Locator | test.Page) { } /** Find all labels in the labels panel (if any) on the current page. */ -export function locateLabelsPanelLabels(page: test.Page) { +export function locateLabelsPanelLabels(page: test.Page, name?: string) { return ( locateLabelsPanel(page) .getByRole('button') + .filter(name != null ? { has: page.getByText(name) } : {}) // The delete button is also a `button`. // eslint-disable-next-line no-restricted-properties .and(page.locator(':nth-child(1)')) @@ -224,17 +218,17 @@ export function locateNotEnabledStub(page: test.Locator | test.Page) { /** Find a "new folder" icon (if any) on the current page. */ export function locateNewFolderIcon(page: test.Locator | test.Page) { - return page.getByRole('button').filter({ has: page.getByAltText('New Folder') }) + return page.getByRole('button', { name: 'New Folder' }) } /** Find a "new secret" icon (if any) on the current page. */ export function locateNewSecretIcon(page: test.Locator | test.Page) { - return page.getByRole('button').filter({ has: page.getByAltText('New Secret') }) + return page.getByRole('button', { name: 'New Secret' }) } /** Find a "download files" icon (if any) on the current page. */ export function locateDownloadFilesIcon(page: test.Locator | test.Page) { - return page.getByRole('button').filter({ has: page.getByAltText('Export') }) + return page.getByRole('button', { name: 'Export' }) } /** Find a list of tags in the search bar (if any) on the current page. */ @@ -252,11 +246,6 @@ export function locateSearchBarSuggestions(page: test.Page) { return locateSearchBar(page).getByTestId('asset-search-suggestion') } -/** Find a "home page" icon (if any) on the current page. */ -export function locateHomePageIcon(page: test.Locator | test.Page) { - return page.getByRole('button').filter({ has: page.getByAltText('Home') }) -} - // === Icon locators === // These are specifically icons that are not also buttons. @@ -731,32 +720,52 @@ export async function press(page: test.Page, keyOrShortcut: string) { export async function login( { page }: MockParams, email = 'email@example.com', - password = VALID_PASSWORD + password = VALID_PASSWORD, + first = true ) { await test.test.step('Login', async () => { await page.goto('/') await locateEmailInput(page).fill(email) await locatePasswordInput(page).fill(password) await locateLoginButton(page).click() - await locateToastCloseButton(page).click() - await passTermsAndConditionsDialog({ page }) + await test.expect(page.getByText('Logging in to Enso...')).not.toBeVisible() + if (first) { + await passTermsAndConditionsDialog({ page }) + await test.expect(page.getByText('Logging in to Enso...')).not.toBeVisible() + } }) } -// ============================== -// === mockIsInPlaywrightTest === -// ============================== +// ============== +// === reload === +// ============== -/** Inject `isInPlaywrightTest` into the page. */ +/** Reload. */ // This syntax is required for Playwright to work properly. // eslint-disable-next-line no-restricted-syntax -export async function mockIsInPlaywrightTest({ page }: MockParams) { - await test.test.step('Mock `isInPlaywrightTest`', async () => { - await page.evaluate(() => { - // @ts-expect-error This is SAFE - it is a mistake for this variable to be written to - // from anywhere else. - window.isInPlaywrightTest = true - }) +export async function reload({ page }: MockParams) { + await test.test.step('Reload', async () => { + await page.reload() + await test.expect(page.getByText('Logging in to Enso...')).not.toBeVisible() + }) +} + +// ============= +// === relog === +// ============= + +/** Logout and then login again. */ +// This syntax is required for Playwright to work properly. +// eslint-disable-next-line no-restricted-syntax +export async function relog( + { page }: MockParams, + email = 'email@example.com', + password = VALID_PASSWORD +) { + await test.test.step('Relog', async () => { + await page.getByAltText('User Settings').locator('visible=true').click() + await page.getByRole('button', { name: 'Logout' }).getByText('Logout').click() + await login({ page }, email, password, false) }) } @@ -797,19 +806,11 @@ async function mockDate({ page }: MockParams) { /** Pass the Terms and conditions dialog. */ export async function passTermsAndConditionsDialog({ page }: MockParams) { - // wait for terms and conditions dialog to appear - // but don't fail if it doesn't appear - try { - await test.test.step('Accept Terms and Conditions', async () => { - // wait for terms and conditions dialog to appear - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - await page.waitForSelector('#terms-of-service-modal', { timeout: 500 }) - await page.getByRole('checkbox').click() - await page.getByRole('button', { name: 'Accept' }).click() - }) - } catch (error) { - // do nothing - } + await test.test.step('Accept Terms and Conditions', async () => { + await page.waitForSelector('#terms-of-service-modal') + await page.getByRole('checkbox').click() + await page.getByRole('button', { name: 'Accept' }).click() + }) } // =============== @@ -830,7 +831,6 @@ export const mockApi = apiModule.mockApi export async function mockAll({ page }: MockParams) { return await test.test.step('Execute all mocks', async () => { const api = await mockApi({ page }) - await mockIsInPlaywrightTest({ page }) await mockDate({ page }) return { api, pageActions: new LoginPageActions(page) } }) @@ -847,10 +847,6 @@ export async function mockAllAndLogin({ page }: MockParams) { return await test.test.step('Execute all mocks and login', async () => { const mocks = await mockAll({ page }) await login({ page }) - await passTermsAndConditionsDialog({ page }) - // This MUST run after login because globals are reset when the browser - // is navigated to another page. - await mockIsInPlaywrightTest({ page }) return { ...mocks, pageActions: new DrivePageActions(page) } }) } diff --git a/app/ide-desktop/lib/dashboard/e2e/actions/DrivePageActions.ts b/app/ide-desktop/lib/dashboard/e2e/actions/DrivePageActions.ts index 4f83c8a770..db74f74f0a 100644 --- a/app/ide-desktop/lib/dashboard/e2e/actions/DrivePageActions.ts +++ b/app/ide-desktop/lib/dashboard/e2e/actions/DrivePageActions.ts @@ -158,10 +158,7 @@ export default class DrivePageActions extends BaseActions { /** Create a new folder using the icon in the Drive Bar. */ createFolder() { return this.step('Create folder', page => - page - .getByRole('button') - .filter({ has: page.getByAltText('New Folder') }) - .click() + page.getByRole('button', { name: 'New Folder' }).click() ) } @@ -173,10 +170,7 @@ export default class DrivePageActions extends BaseActions { ) { return this.step(`Upload file '${name}'`, async page => { const fileChooserPromise = page.waitForEvent('filechooser') - await page - .getByRole('button') - .filter({ has: page.getByAltText('Import') }) - .click() + await page.getByRole('button', { name: 'Import' }).click() const fileChooser = await fileChooserPromise await fileChooser.setFiles([{ name, buffer: Buffer.from(contents), mimeType }]) }) @@ -209,10 +203,7 @@ export default class DrivePageActions extends BaseActions { /** Open the Data Link creation modal by clicking on the Data Link icon. */ openDataLinkModal() { return this.step('Open "new data link" modal', page => - page - .getByRole('button') - .filter({ has: page.getByAltText('New Datalink') }) - .click() + page.getByRole('button', { name: 'New Datalink' }).click() ).into(NewDataLinkModalActions) } diff --git a/app/ide-desktop/lib/dashboard/e2e/actions/LoginPageActions.ts b/app/ide-desktop/lib/dashboard/e2e/actions/LoginPageActions.ts index b59788c74e..c9bc414fde 100644 --- a/app/ide-desktop/lib/dashboard/e2e/actions/LoginPageActions.ts +++ b/app/ide-desktop/lib/dashboard/e2e/actions/LoginPageActions.ts @@ -1,4 +1,6 @@ /** @file Available actions for the login page. */ +import * as test from '@playwright/test' + import * as actions from '../actions' import BaseActions from './BaseActions' import DrivePageActions from './DrivePageActions' @@ -17,22 +19,22 @@ export default class LoginPageActions extends BaseActions { /** Perform a login as a new user (a user that does not yet have a username). */ loginAsNewUser(email = 'email@example.com', password = actions.VALID_PASSWORD) { - return this.step('Login', () => this.loginInternal(email, password)).into( + return this.step('Login (as new user)', () => this.loginInternal(email, password)).into( SetUsernamePageActions ) } /** Perform a failing login. */ loginThatShouldFail(email = 'email@example.com', password = actions.VALID_PASSWORD) { - return this.step('Login', () => this.loginInternal(email, password)) + return this.step('Login (should fail)', () => this.loginInternal(email, password)) } /** Internal login logic shared between all public methods. */ private async loginInternal(email: string, password: string) { await this.page.goto('/') - await actions.locateEmailInput(this.page).fill(email) - await actions.locatePasswordInput(this.page).fill(password) - await actions.locateLoginButton(this.page).click() - await actions.locateToastCloseButton(this.page).click() + await this.page.getByPlaceholder('Enter your email').fill(email) + await this.page.getByPlaceholder('Enter your password').fill(password) + await this.page.getByRole('button', { name: 'Login', exact: true }).getByText('Login').click() + await test.expect(this.page.getByText('Logging in to Enso...')).not.toBeVisible() } } diff --git a/app/ide-desktop/lib/dashboard/e2e/api.ts b/app/ide-desktop/lib/dashboard/e2e/api.ts index 1a9358726b..585f1a4184 100644 --- a/app/ide-desktop/lib/dashboard/e2e/api.ts +++ b/app/ide-desktop/lib/dashboard/e2e/api.ts @@ -504,12 +504,12 @@ export async function mockApi({ page }: MockParams) { if (assetId == null) { await route.fulfill({ status: HTTP_STATUS_BAD_REQUEST, - json: { error: 'Invalid Asset ID' }, + json: { message: 'Invalid Asset ID' }, }) } else { await route.fulfill({ status: HTTP_STATUS_NOT_FOUND, - json: { error: 'Asset does not exist' }, + json: { message: 'Asset does not exist' }, }) } } else { @@ -722,7 +722,7 @@ export async function mockApi({ page }: MockParams) { if (body.name === '') { await route.fulfill({ status: HTTP_STATUS_BAD_REQUEST, - json: { error: 'Organization name must not be empty' }, + json: { message: 'Organization name must not be empty' }, }) return } else if (currentOrganization) { diff --git a/app/ide-desktop/lib/dashboard/e2e/assetSearchBar.spec.ts b/app/ide-desktop/lib/dashboard/e2e/assetSearchBar.spec.ts index bb781ba557..44a72ce8cf 100644 --- a/app/ide-desktop/lib/dashboard/e2e/assetSearchBar.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/assetSearchBar.spec.ts @@ -42,7 +42,7 @@ test.test('labels', async ({ page }) => { api.addLabel('cccc', backend.COLORS[2]!) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion api.addLabel('dddd', backend.COLORS[3]!) - await actions.login({ page }) + await actions.reload({ page }) await searchBarInput.click() for (const label of await labels.all()) { @@ -65,7 +65,7 @@ test.test('suggestions', async ({ page }) => { api.addProject('bar') api.addSecret('baz') api.addSecret('quux') - await actions.login({ page }) + await actions.reload({ page }) await searchBarInput.click() for (const suggestion of await suggestions.all()) { @@ -86,7 +86,7 @@ test.test('suggestions (keyboard)', async ({ page }) => { api.addProject('bar') api.addSecret('baz') api.addSecret('quux') - await actions.login({ page }) + await actions.reload({ page }) await searchBarInput.click() for (const suggestion of await suggestions.all()) { @@ -105,7 +105,7 @@ test.test('complex flows', async ({ page }) => { api.addProject('bar') api.addSecret('baz') api.addSecret('quux') - await actions.login({ page }) + await actions.reload({ page }) await searchBarInput.click() await page.press('body', 'ArrowDown') diff --git a/app/ide-desktop/lib/dashboard/e2e/assetsTableFeatures.spec.ts b/app/ide-desktop/lib/dashboard/e2e/assetsTableFeatures.spec.ts index e4ee7709fe..c11851c169 100644 --- a/app/ide-desktop/lib/dashboard/e2e/assetsTableFeatures.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/assetsTableFeatures.spec.ts @@ -41,7 +41,7 @@ test.test('extra columns should stick to top of scroll container', async ({ page for (let i = 0; i < 100; i += 1) { api.addFile('a') } - await actions.login({ page }) + await actions.reload({ page }) await actions.locateAccessedByProjectsColumnToggle(page).click() await actions.locateAccessedDataColumnToggle(page).click() @@ -83,7 +83,7 @@ test.test('can drop onto root directory dropzone', async ({ page }) => { const assetRows = actions.locateAssetRows(page) const asset = api.addDirectory('a') api.addFile('b', { parentId: asset.id }) - await actions.login({ page }) + await actions.reload({ page }) await assetRows.nth(0).dblclick() const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(0)) diff --git a/app/ide-desktop/lib/dashboard/e2e/labels.spec.ts b/app/ide-desktop/lib/dashboard/e2e/labels.spec.ts index 05175f9087..a2aab19ae1 100644 --- a/app/ide-desktop/lib/dashboard/e2e/labels.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/labels.spec.ts @@ -8,8 +8,8 @@ import * as actions from './actions' test.test('drag labels onto single row', async ({ page }) => { const { api } = await actions.mockAllAndLogin({ page }) const assetRows = actions.locateAssetRows(page) - const labels = actions.locateLabelsPanelLabels(page) const label = 'aaaa' + const labelEl = actions.locateLabelsPanelLabels(page, label) api.addLabel(label, backend.COLORS[0]) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion api.addLabel('bbbb', backend.COLORS[1]!) @@ -21,9 +21,10 @@ test.test('drag labels onto single row', async ({ page }) => { api.addSecret('bar') api.addFile('baz') api.addSecret('quux') - await actions.login({ page }) + await actions.relog({ page }) - await labels.nth(0).dragTo(assetRows.nth(1)) + await test.expect(labelEl).toBeVisible() + await labelEl.dragTo(assetRows.nth(1)) await test.expect(actions.locateAssetLabels(assetRows.nth(0)).getByText(label)).not.toBeVisible() await test.expect(actions.locateAssetLabels(assetRows.nth(1)).getByText(label)).toBeVisible() await test.expect(actions.locateAssetLabels(assetRows.nth(2)).getByText(label)).not.toBeVisible() @@ -33,8 +34,8 @@ test.test('drag labels onto single row', async ({ page }) => { test.test('drag labels onto multiple rows', async ({ page }) => { const { api } = await actions.mockAllAndLogin({ page }) const assetRows = actions.locateAssetRows(page) - const labels = actions.locateLabelsPanelLabels(page) const label = 'aaaa' + const labelEl = actions.locateLabelsPanelLabels(page, label) api.addLabel(label, backend.COLORS[0]) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion api.addLabel('bbbb', backend.COLORS[1]!) @@ -46,12 +47,13 @@ test.test('drag labels onto multiple rows', async ({ page }) => { api.addSecret('bar') api.addFile('baz') api.addSecret('quux') - await actions.login({ page }) + await actions.relog({ page }) await page.keyboard.down(await actions.modModifier(page)) await actions.clickAssetRow(assetRows.nth(0)) await actions.clickAssetRow(assetRows.nth(2)) - await labels.nth(0).dragTo(assetRows.nth(2)) + await test.expect(labelEl).toBeVisible() + await labelEl.dragTo(assetRows.nth(2)) await page.keyboard.up(await actions.modModifier(page)) await test.expect(actions.locateAssetLabels(assetRows.nth(0)).getByText(label)).toBeVisible() await test.expect(actions.locateAssetLabels(assetRows.nth(1)).getByText(label)).not.toBeVisible() diff --git a/app/ide-desktop/lib/dashboard/e2e/membersSettings.spec.ts b/app/ide-desktop/lib/dashboard/e2e/membersSettings.spec.ts index 3f2314a4ff..e3deb23ff4 100644 --- a/app/ide-desktop/lib/dashboard/e2e/membersSettings.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/membersSettings.spec.ts @@ -17,14 +17,14 @@ test.test('members settings', async ({ page }) => { const otherUserName = 'second.user_' const otherUser = api.addUser(otherUserName) - await actions.login({ page }) + await actions.relog({ page }) await localActions.go(page) await test .expect(localActions.locateMembersRows(page).locator('> :nth-child(1) > :nth-child(2)')) .toHaveText([api.currentUser()?.name ?? '', otherUserName]) api.deleteUser(otherUser.userId) - await actions.login({ page }) + await actions.relog({ page }) await localActions.go(page) await test .expect(localActions.locateMembersRows(page).locator('> :nth-child(1) > :nth-child(2)')) diff --git a/app/ide-desktop/lib/dashboard/e2e/organizationSettings.spec.ts b/app/ide-desktop/lib/dashboard/e2e/organizationSettings.spec.ts index 640dc64ab1..e2db0a1d98 100644 --- a/app/ide-desktop/lib/dashboard/e2e/organizationSettings.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/organizationSettings.spec.ts @@ -9,25 +9,26 @@ test.test('organization settings', async ({ page }) => { // Setup api.setCurrentOrganization(api.defaultOrganization) - await test.test.step('initial state', () => { + await test.test.step('Initial state', () => { test.expect(api.currentOrganization()?.name).toBe(api.defaultOrganizationName) test.expect(api.currentOrganization()?.email).toBe(null) test.expect(api.currentOrganization()?.picture).toBe(null) test.expect(api.currentOrganization()?.website).toBe(null) test.expect(api.currentOrganization()?.address).toBe(null) }) + await test.expect(page.getByText('Logging in to Enso...')).not.toBeVisible() await localActions.go(page) const nameInput = localActions.locateNameInput(page) const newName = 'another organization-name' - await test.test.step('set name', async () => { + await test.test.step('Set name', async () => { await nameInput.fill(newName) await nameInput.press('Enter') test.expect(api.currentOrganization()?.name).toBe(newName) test.expect(api.currentUser()?.name).not.toBe(newName) }) - await test.test.step('unset name (should fail)', async () => { + await test.test.step('Unset name (should fail)', async () => { await nameInput.fill('') await nameInput.press('Enter') test.expect(api.currentOrganization()?.name).toBe(newName) @@ -37,7 +38,7 @@ test.test('organization settings', async ({ page }) => { const invalidEmail = 'invalid@email' const emailInput = localActions.locateEmailInput(page) - await test.test.step('set invalid email', async () => { + await test.test.step('Set invalid email', async () => { await emailInput.fill(invalidEmail) await emailInput.press('Enter') test.expect(api.currentOrganization()?.email).toBe(null) @@ -45,7 +46,7 @@ test.test('organization settings', async ({ page }) => { const newEmail = 'organization@email.com' - await test.test.step('set email', async () => { + await test.test.step('Set email', async () => { await emailInput.fill(newEmail) await emailInput.press('Enter') test.expect(api.currentOrganization()?.email).toBe(newEmail) @@ -56,7 +57,7 @@ test.test('organization settings', async ({ page }) => { const newWebsite = 'organization.org' // NOTE: It's not yet possible to unset the website or the location. - await test.test.step('set website', async () => { + await test.test.step('Set website', async () => { await websiteInput.fill(newWebsite) await websiteInput.press('Enter') test.expect(api.currentOrganization()?.website).toBe(newWebsite) @@ -66,7 +67,7 @@ test.test('organization settings', async ({ page }) => { const locationInput = localActions.locateLocationInput(page) const newLocation = 'Somewhere, CA' - await test.test.step('set location', async () => { + await test.test.step('Set location', async () => { await locationInput.fill(newLocation) await locationInput.press('Enter') test.expect(api.currentOrganization()?.address).toBe(newLocation) diff --git a/app/ide-desktop/lib/dashboard/e2e/signUp.spec.ts b/app/ide-desktop/lib/dashboard/e2e/signUp.spec.ts index 6de71ec0ca..b2fafa5200 100644 --- a/app/ide-desktop/lib/dashboard/e2e/signUp.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/signUp.spec.ts @@ -32,13 +32,10 @@ test.test('sign up with organization id', async ({ page }) => { await actions.locateConfirmPasswordInput(page).fill(actions.VALID_PASSWORD) await actions.locateRegisterButton(page).click() - await actions.passTermsAndConditionsDialog({ page }) - // Log in await actions.locateEmailInput(page).fill(actions.VALID_EMAIL) await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD) await actions.locateLoginButton(page).click() - await actions.passTermsAndConditionsDialog({ page }) // Set username @@ -63,13 +60,10 @@ test.test('sign up without organization id', async ({ page }) => { await actions.locateConfirmPasswordInput(page).fill(actions.VALID_PASSWORD) await actions.locateRegisterButton(page).click() - await actions.passTermsAndConditionsDialog({ page }) - // Log in await actions.locateEmailInput(page).fill(actions.VALID_EMAIL) await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD) await actions.locateLoginButton(page).click() - await actions.passTermsAndConditionsDialog({ page }) // Set username diff --git a/app/ide-desktop/lib/dashboard/e2e/sort.spec.ts b/app/ide-desktop/lib/dashboard/e2e/sort.spec.ts index ed28e7e629..c03ebe06c5 100644 --- a/app/ide-desktop/lib/dashboard/e2e/sort.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/sort.spec.ts @@ -49,7 +49,6 @@ test.test('sort', async ({ page }) => { // g directory // c project // d file - await page.goto('/') await actions.login({ page }) // By default, assets should be grouped by type. diff --git a/app/ide-desktop/lib/dashboard/e2e/userSettings.spec.ts b/app/ide-desktop/lib/dashboard/e2e/userSettings.spec.ts index 9aea7ce47d..64965f525c 100644 --- a/app/ide-desktop/lib/dashboard/e2e/userSettings.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/userSettings.spec.ts @@ -29,7 +29,7 @@ test.test('change password form', async ({ page }) => { .expect(localActions.locateChangeButton(page), 'incomplete form should be rejected') .toBeDisabled() - await test.test.step('invalid new password', async () => { + await test.test.step('Invalid new password', async () => { await localActions.locateCurrentPasswordInput(page).fill(actions.VALID_PASSWORD) await localActions.locateNewPasswordInput(page).fill(actions.INVALID_PASSWORD) await localActions.locateConfirmNewPasswordInput(page).fill(actions.INVALID_PASSWORD) @@ -46,7 +46,7 @@ test.test('change password form', async ({ page }) => { .toBeDisabled() }) - await test.test.step('invalid new password confirmation', async () => { + await test.test.step('Invalid new password confirmation', async () => { await localActions.locateCurrentPasswordInput(page).fill(actions.VALID_PASSWORD) await localActions.locateNewPasswordInput(page).fill(actions.VALID_PASSWORD) await localActions.locateConfirmNewPasswordInput(page).fill(actions.VALID_PASSWORD + 'a') @@ -66,7 +66,7 @@ test.test('change password form', async ({ page }) => { .toBeDisabled() }) - await test.test.step('successful password change', async () => { + await test.test.step('Successful password change', async () => { const newPassword = '1234!' + actions.VALID_PASSWORD await localActions.locateNewPasswordInput(page).fill(newPassword) await localActions.locateConfirmNewPasswordInput(page).fill(newPassword) diff --git a/app/ide-desktop/lib/dashboard/package.json b/app/ide-desktop/lib/dashboard/package.json index 96811ed9a9..ae2fbff6e3 100644 --- a/app/ide-desktop/lib/dashboard/package.json +++ b/app/ide-desktop/lib/dashboard/package.json @@ -35,9 +35,11 @@ "@hookform/resolvers": "^3.4.0", "@monaco-editor/react": "4.6.0", "@sentry/react": "^7.74.0", - "@tanstack/react-query": "5.37.1", + "@tanstack/react-query": "5.45.1", + "@tanstack/query-persist-client-core": "5.45.0", "ajv": "^8.12.0", "enso-common": "^1.0.0", + "idb-keyval": "6.2.1", "is-network-error": "^1.0.1", "monaco-editor": "0.48.0", "react": "^18.3.1", @@ -65,7 +67,7 @@ "@playwright/experimental-ct-react": "^1.40.0", "@playwright/test": "^1.40.0", "@react-types/shared": "^3.22.1", - "@tanstack/react-query-devtools": "5.37.1", + "@tanstack/react-query-devtools": "5.45.1", "@types/node": "^20.11.21", "@types/react": "^18.0.27", "@types/react-dom": "^18.0.10", diff --git a/app/ide-desktop/lib/dashboard/src/App.tsx b/app/ide-desktop/lib/dashboard/src/App.tsx index 11981f5b20..a2a212460a 100644 --- a/app/ide-desktop/lib/dashboard/src/App.tsx +++ b/app/ide-desktop/lib/dashboard/src/App.tsx @@ -49,6 +49,7 @@ import * as backendHooks from '#/hooks/backendHooks' import AuthProvider, * as authProvider from '#/providers/AuthProvider' import BackendProvider from '#/providers/BackendProvider' +import * as httpClientProvider from '#/providers/HttpClientProvider' import InputBindingsProvider from '#/providers/InputBindingsProvider' import LocalStorageProvider, * as localStorageProvider from '#/providers/LocalStorageProvider' import LoggerProvider from '#/providers/LoggerProvider' @@ -56,6 +57,7 @@ import type * as loggerProvider from '#/providers/LoggerProvider' import ModalProvider, * as modalProvider from '#/providers/ModalProvider' import * as navigator2DProvider from '#/providers/Navigator2DProvider' import SessionProvider from '#/providers/SessionProvider' +import * as textProvider from '#/providers/TextProvider' import ConfirmRegistration from '#/pages/authentication/ConfirmRegistration' import ForgotPassword from '#/pages/authentication/ForgotPassword' @@ -68,10 +70,13 @@ import Dashboard from '#/pages/dashboard/Dashboard' import * as subscribe from '#/pages/subscribe/Subscribe' import * as subscribeSuccess from '#/pages/subscribe/SubscribeSuccess' +import * as openAppWatcher from '#/layouts/OpenAppWatcher' + import * as errorBoundary from '#/components/ErrorBoundary' -import * as loader from '#/components/Loader' +import * as offlineNotificationManager from '#/components/OfflineNotificationManager' import * as paywall from '#/components/Paywall' import * as rootComponent from '#/components/Root' +import * as suspense from '#/components/Suspense' import AboutModal from '#/modals/AboutModal' import * as setOrganizationNameModal from '#/modals/SetOrganizationNameModal' @@ -79,10 +84,11 @@ import * as termsOfServiceModal from '#/modals/TermsOfServiceModal' import LocalBackend from '#/services/LocalBackend' import * as projectManager from '#/services/ProjectManager' -import type RemoteBackend from '#/services/RemoteBackend' +import RemoteBackend from '#/services/RemoteBackend' import * as appBaseUrl from '#/utilities/appBaseUrl' import * as eventModule from '#/utilities/event' +import type HttpClient from '#/utilities/HttpClient' import LocalStorage from '#/utilities/LocalStorage' import * as object from '#/utilities/object' @@ -150,6 +156,7 @@ export interface AppProps { readonly ydocUrl: string | null readonly appRunner: types.EditorRunner | null readonly portalRoot: Element + readonly httpClient: HttpClient } /** Component called by the parent module, returning the root React component for this @@ -162,6 +169,8 @@ export default function App(props: AppProps) { const { data: rootDirectoryPath } = reactQuery.useSuspenseQuery({ queryKey: ['root-directory', supportsLocalBackend], + meta: { persist: false }, + networkMode: 'always', queryFn: async () => { if (supportsLocalBackend) { const response = await fetch(`${appBaseUrl.APP_BASE_URL}/api/root-directory`) @@ -213,28 +222,39 @@ export interface AppRouterProps extends AppProps { * because the {@link AppRouter} relies on React hooks, which can't be used in the same React * component as the component that defines the provider. */ function AppRouter(props: AppRouterProps) { - const { logger, isAuthenticationDisabled, shouldShowDashboard } = props + const { logger, isAuthenticationDisabled, shouldShowDashboard, httpClient } = props const { onAuthenticated, projectManagerUrl, projectManagerRootDirectory } = props const { portalRoot } = props // `navigateHooks.useNavigate` cannot be used here as it relies on `AuthProvider`, which has not // yet been initialized at this point. // eslint-disable-next-line no-restricted-properties const navigate = router.useNavigate() + const { getText } = textProvider.useText() const { localStorage } = localStorageProvider.useLocalStorage() const { setModal } = modalProvider.useSetModal() const navigator2D = navigator2DProvider.useNavigator2D() - const [remoteBackend, setRemoteBackend] = React.useState(null) - const [localBackend] = React.useState(() => - projectManagerUrl != null && projectManagerRootDirectory != null - ? new LocalBackend(projectManagerUrl, projectManagerRootDirectory) - : null + + const localBackend = React.useMemo( + () => + projectManagerUrl != null && projectManagerRootDirectory != null + ? new LocalBackend(projectManagerUrl, projectManagerRootDirectory) + : null, + [projectManagerUrl, projectManagerRootDirectory] ) + + const remoteBackend = React.useMemo( + () => new RemoteBackend(httpClient, logger, getText), + [httpClient, logger, getText] + ) + backendHooks.useObserveBackend(remoteBackend) backendHooks.useObserveBackend(localBackend) + if (detect.IS_DEV_MODE) { // @ts-expect-error This is used exclusively for debugging. window.navigate = navigate } + const [inputBindingsRaw] = React.useState(() => inputBindingsModule.createBindings()) React.useEffect(() => { @@ -255,20 +275,6 @@ function AppRouter(props: AppRouterProps) { } }, [localStorage, inputBindingsRaw]) - React.useEffect(() => { - if (remoteBackend) { - void remoteBackend.logEvent('open_app') - const logCloseEvent = () => void remoteBackend.logEvent('close_app') - window.addEventListener('beforeunload', logCloseEvent) - return () => { - window.removeEventListener('beforeunload', logCloseEvent) - logCloseEvent() - } - } else { - return - } - }, [remoteBackend]) - const inputBindings = React.useMemo(() => { const updateLocalStorage = () => { localStorage.set( @@ -398,21 +404,23 @@ function AppRouter(props: AppRouterProps) { }> }> }> - } - /> + }> + } + /> - - }> - - - - } - /> + + + + + + } + /> + @@ -420,9 +428,9 @@ function AppRouter(props: AppRouterProps) { path={appUtils.SUBSCRIBE_SUCCESS_PATH} element={ - }> + - + } /> @@ -461,34 +469,31 @@ function AppRouter(props: AppRouterProps) { result = {result} } + result = {result} result = {result} - result = ( - - {result} - - ) result = ( {result} ) + + result = ( + + {result} + + ) + result = ( { - await refreshUserSession() - } - : null - } + refreshUserSession={refreshUserSession} > {result} @@ -499,5 +504,18 @@ function AppRouter(props: AppRouterProps) { {result} ) + result = ( + + {result} + + ) + result = ( + + {result} + + ) + + result = {result} + return result } diff --git a/app/ide-desktop/lib/dashboard/src/ReactQueryDevtools.tsx b/app/ide-desktop/lib/dashboard/src/ReactQueryDevtools.tsx index 80978ec4b1..4deda5c769 100644 --- a/app/ide-desktop/lib/dashboard/src/ReactQueryDevtools.tsx +++ b/app/ide-desktop/lib/dashboard/src/ReactQueryDevtools.tsx @@ -1,8 +1,11 @@ -/** @file Show the React Query Devtools. */ +/** + * @file Show the React Query Devtools. + */ import * as React from 'react' import * as reactQuery from '@tanstack/react-query' import * as reactQueryDevtools from '@tanstack/react-query-devtools' +import * as errorBoundary from 'react-error-boundary' const ReactQueryDevtoolsProduction = React.lazy(() => import('@tanstack/react-query-devtools/build/modern/production.js').then(d => ({ @@ -26,7 +29,12 @@ export function ReactQueryDevtools() { }, []) return ( - <> + { + resetErrorBoundary() + return null + }} + > {showDevtools && ( @@ -34,6 +42,6 @@ export function ReactQueryDevtools() { )} - + ) } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/cognito.mock.ts b/app/ide-desktop/lib/dashboard/src/authentication/cognito.mock.ts index 4f356a9ce6..41d55779e5 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/cognito.mock.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/cognito.mock.ts @@ -301,6 +301,8 @@ export interface UserSession { readonly email: string /** User's access token, used to authenticate the user (e.g., when making API calls). */ readonly accessToken: string + /** Cognito app integration client ID. */ + readonly clientId?: string } /** Parse a {@link cognito.CognitoUserSession} into a {@link UserSession}. @@ -313,7 +315,7 @@ function parseUserSession(session: cognito.CognitoUserSession): UserSession { throw new Error('Payload does not have an email field.') } else { const accessToken = `.${window.btoa(JSON.stringify({ username: email }))}.` - return { email, accessToken } + return { email, accessToken, clientId: '' } } } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/cognito.ts b/app/ide-desktop/lib/dashboard/src/authentication/cognito.ts index 2b3d5ee11e..d0c5c1002e 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/cognito.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/cognito.ts @@ -281,17 +281,22 @@ export class Cognito { const currentUser = await currentAuthenticatedUser() const refreshToken = (await amplify.Auth.currentSession()).getRefreshToken() - await new Promise((resolve, reject) => { - currentUser.unwrap().refreshSession(refreshToken, (error, session) => { - if (error instanceof Error) { - reject(error) - } else { - resolve(session) - } - }) + return await new Promise((resolve, reject) => { + currentUser + .unwrap() + .refreshSession(refreshToken, (error, session: cognito.CognitoUserSession) => { + if (error instanceof Error) { + reject(error) + } else { + resolve(session) + } + }) }) }) - return result.mapErr(intoCurrentSessionErrorType) + + return result + .map(session => parseUserSession(session, this.amplifyConfig.userPoolWebClientId)) + .unwrapOr(null) } /** Sign out the current user. */ @@ -402,7 +407,7 @@ export interface UserSession { readonly refreshUrl: string /** Time when the access token will expire, date and time in ISO 8601 format (UTC timezone). */ readonly expireAt: dateTime.Rfc3339DateTime - /** Cognito app integration client id.. */ + /** Cognito app integration client ID. */ readonly clientId: string } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/service.ts b/app/ide-desktop/lib/dashboard/src/authentication/service.ts index 73bc9bde19..930a566a50 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/service.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/service.ts @@ -242,6 +242,8 @@ function setDeepLinkHandler(logger: loggerProvider.Logger, navigate: (url: strin // @ts-expect-error `_handleAuthResponse` is a private method without typings. // eslint-disable-next-line @typescript-eslint/no-unsafe-call await amplify.Auth._handleAuthResponse(url.toString()) + + navigate(appUtils.DASHBOARD_PATH) } finally { // Restore the original `history.replaceState` function. history.replaceState = replaceState diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Alert/Alert.tsx b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Alert/Alert.tsx index b351c697b8..f058715490 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Alert/Alert.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Alert/Alert.tsx @@ -4,8 +4,6 @@ import * as React from 'react' import * as mergeRefs from '#/utilities/mergeRefs' import * as twv from '#/utilities/tailwindVariants' -import * as text from '../Text' - // ================= // === Constants === // ================= @@ -34,21 +32,9 @@ export const ALERT_STYLES = twv.tv({ }, size: { custom: '', - small: text.TEXT_STYLE({ - color: 'custom', - variant: 'body', - class: 'px-1.5 pt-1 pb-1', - }), - medium: text.TEXT_STYLE({ - color: 'custom', - variant: 'body', - class: 'px-3 pt-1 pb-1', - }), - large: text.TEXT_STYLE({ - color: 'custom', - variant: 'subtitle', - class: 'px-4 pt-2 pb-2', - }), + small: 'px-1.5 pt-1 pb-1', + medium: 'px-3 pt-1 pb-1', + large: 'px-4 pt-2 pb-2', }, }, defaultVariants: { diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Button/Button.tsx b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Button/Button.tsx index 0aea4852cc..521701685c 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Button/Button.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Button/Button.tsx @@ -101,19 +101,21 @@ export const BUTTON_STYLES = twv.tv({ base: text.TEXT_STYLE({ variant: 'body', color: 'custom', - weight: 'bold', + weight: 'semibold', className: 'flex px-[11px] py-[5px]', }), content: 'gap-2', + icon: 'mb-[-0.3cap]', extraClickZone: 'after:inset-[-6px]', }, medium: { base: text.TEXT_STYLE({ variant: 'body', color: 'custom', - weight: 'bold', + weight: 'semibold', className: 'flex px-[9px] py-[3px]', }), + icon: 'mb-[-0.3cap]', content: 'gap-2', extraClickZone: 'after:inset-[-8px]', }, @@ -121,8 +123,10 @@ export const BUTTON_STYLES = twv.tv({ base: text.TEXT_STYLE({ variant: 'body', color: 'custom', + weight: 'medium', className: 'flex px-[7px] py-[1px]', }), + icon: 'mb-[-0.3cap]', content: 'gap-1', extraClickZone: 'after:inset-[-10px]', }, @@ -130,8 +134,10 @@ export const BUTTON_STYLES = twv.tv({ base: text.TEXT_STYLE({ variant: 'body', color: 'custom', + weight: 'medium', className: 'flex px-[5px] py-[1px]', }), + icon: 'mb-[-0.3cap]', content: 'gap-1', extraClickZone: 'after:inset-[-12px]', }, @@ -148,7 +154,9 @@ export const BUTTON_STYLES = twv.tv({ extraClickZone: 'after:inset-[-12px]', }, }, - iconOnly: { true: { base: text.TEXT_STYLE({ disableLineHeightCompensation: true }) } }, + iconOnly: { + true: { base: text.TEXT_STYLE({ disableLineHeightCompensation: true }), icon: 'mb-[unset]' }, + }, rounded: { full: 'rounded-full', large: 'rounded-lg', diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/Dialog.tsx b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/Dialog.tsx index cc0a179ff5..8047585e2f 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/Dialog.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/Dialog.tsx @@ -5,8 +5,8 @@ import * as React from 'react' import * as aria from '#/components/aria' import * as ariaComponents from '#/components/AriaComponents' import * as errorBoundary from '#/components/ErrorBoundary' -import * as loader from '#/components/Loader' import * as portal from '#/components/Portal' +import * as suspense from '#/components/Suspense' import * as mergeRefs from '#/utilities/mergeRefs' import * as twv from '#/utilities/tailwindVariants' @@ -222,9 +222,9 @@ export function Dialog(props: DialogProps) { }} > - }> + {typeof children === 'function' ? children(opts) : children} - + diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/Popover.tsx b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/Popover.tsx index 1c6b0617af..5a0838a456 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/Popover.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/Popover.tsx @@ -7,8 +7,8 @@ import * as React from 'react' import * as aria from '#/components/aria' import * as errorBoundary from '#/components/ErrorBoundary' -import * as loader from '#/components/Loader' import * as portal from '#/components/Portal' +import * as suspense from '#/components/Suspense' import * as twv from '#/utilities/tailwindVariants' @@ -117,9 +117,9 @@ export function Popover(props: PopoverProps) { return ( - }> + {typeof children === 'function' ? children({ ...opts, close }) : children} - + ) diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/Form.tsx b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/Form.tsx index a35be9b599..bb0a634df1 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/Form.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/Form.tsx @@ -5,6 +5,8 @@ import * as sentry from '@sentry/react' import * as reactQuery from '@tanstack/react-query' import * as reactHookForm from 'react-hook-form' +import * as offlineHooks from '#/hooks/offlineHooks' + import * as textProvider from '#/providers/TextProvider' import * as aria from '#/components/aria' @@ -50,6 +52,7 @@ export const Form = React.forwardRef(function Form< defaultValues, gap, method, + canSubmitOffline = false, ...formProps } = props @@ -113,6 +116,20 @@ export const Form = React.forwardRef(function Form< // There is no way to avoid type casting here // eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax,@typescript-eslint/no-unsafe-argument const formOnSubmit = innerForm.handleSubmit(formMutation.mutateAsync as any) + + const { isOffline } = offlineHooks.useOffline() + + offlineHooks.useOfflineChange( + offline => { + if (offline) { + innerForm.setError('root.offline', { message: getText('unavailableOffline') }) + } else { + innerForm.clearErrors('root.offline') + } + }, + { isDisabled: canSubmitOffline } + ) + const { formState, clearErrors, @@ -188,7 +205,16 @@ export const Form = React.forwardRef(function Form< { + event.preventDefault() + event.stopPropagation() + + if (isOffline && !canSubmitOffline) { + setError('root.offline', { message: getText('unavailableOffline') }) + } else { + void formOnSubmit(event) + } + }} className={base} style={typeof style === 'function' ? style(formStateRenderProps) : style} noValidate diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/Field.tsx b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/Field.tsx index 63db5845af..74b09e0b8c 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/Field.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/Field.tsx @@ -24,7 +24,7 @@ export interface FieldComponentProps // eslint-disable-next-line @typescript-eslint/no-explicit-any readonly form?: types.FormInstance readonly isInvalid?: boolean - readonly className?: string + readonly className?: string | undefined readonly children?: React.ReactNode | ((props: FieldChildrenRenderProps) => React.ReactNode) } diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/FormError.tsx b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/FormError.tsx index 04c88fae13..94af52060d 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/FormError.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/FormError.tsx @@ -59,13 +59,32 @@ export function FormError(props: FormErrorProps) { } } + const offlineMessage = errors.root?.offline?.message ?? null const errorMessage = getSubmitError() - return errorMessage != null ? ( - - - {errorMessage} - - + const submitErrorAlert = + errorMessage != null ? ( + + + {errorMessage} + + + ) : null + + const offlineErrorAlert = + offlineMessage != null ? ( + + + {offlineMessage} + + + ) : null + + const hasSomethingToShow = submitErrorAlert || offlineErrorAlert + + return hasSomethingToShow ? ( +
+ {submitErrorAlert} {offlineErrorAlert} +
) : null } diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/types.ts b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/types.ts index 14c106f46b..1dc081aaec 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/types.ts +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/types.ts @@ -78,6 +78,8 @@ interface BaseFormProps< */ // eslint-disable-next-line @typescript-eslint/ban-types,no-restricted-syntax readonly method?: 'dialog' | (string & {}) + + readonly canSubmitOffline?: boolean } /** diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Inputs/ResizableInput/ResizableContentEditableInput.tsx b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Inputs/ResizableInput/ResizableContentEditableInput.tsx index 0e97051b78..9b39eefecc 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Inputs/ResizableInput/ResizableContentEditableInput.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Inputs/ResizableInput/ResizableContentEditableInput.tsx @@ -5,7 +5,6 @@ import * as React from 'react' import * as eventCallbackHooks from '#/hooks/eventCallbackHooks' -import * as aria from '#/components/aria' import * as ariaComponents from '#/components/AriaComponents' import * as mergeRefs from '#/utilities/mergeRefs' @@ -22,7 +21,23 @@ const CONTENT_EDITABLE_STYLES = twv.tv({ /** * Props for a {@link ResizableContentEditableInput}. */ -export interface ResizableContentEditableInputProps extends aria.TextFieldProps { +export interface ResizableContentEditableInputProps< + Schema extends ariaComponents.TSchema, + TFieldValues extends ariaComponents.FieldValues, + TFieldName extends ariaComponents.FieldPath, + // eslint-disable-next-line no-restricted-syntax + TTransformedValues extends ariaComponents.FieldValues | undefined = undefined, +> extends ariaComponents.FieldStateProps< + Omit< + React.HTMLAttributes & { value: string }, + 'aria-describedby' | 'aria-details' | 'aria-label' | 'aria-labelledby' + >, + Schema, + TFieldValues, + TFieldName, + TTransformedValues + >, + ariaComponents.FieldProps { /** * onChange is called when the content of the input changes. * There is no way to prevent the change, so the value is always the new value. @@ -30,28 +45,32 @@ export interface ResizableContentEditableInputProps extends aria.TextFieldProps * So the component is not a ***fully*** controlled component. */ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type - readonly onChange?: (value: string) => void readonly placeholder?: string - readonly description?: React.ReactNode - readonly errorMessage?: string | null } /** * A resizable input that uses a content-editable div. * This component might be useful for a text input that needs to have highlighted content inside of it. */ +// eslint-disable-next-line no-restricted-syntax export const ResizableContentEditableInput = React.forwardRef( - function ResizableContentEditableInput( - props: ResizableContentEditableInputProps, + function ResizableContentEditableInput< + Schema extends ariaComponents.TSchema, + TFieldName extends ariaComponents.FieldPath, + TFieldValues extends ariaComponents.FieldValues = ariaComponents.FieldValues, + // eslint-disable-next-line no-restricted-syntax + TTransformedValues extends ariaComponents.FieldValues | undefined = undefined, + >( + props: ResizableContentEditableInputProps, ref: React.ForwardedRef ) { const { - value = '', placeholder = '', - onChange, description = null, - errorMessage, - onBlur, + name, + isDisabled = false, + form, + defaultValue, ...textFieldProps } = props @@ -71,17 +90,23 @@ export const ResizableContentEditableInput = React.forwardRef( } ) + const { field, fieldState, formInstance } = ariaComponents.Form.useField({ + name, + isDisabled, + form, + defaultValue, + }) + const { base, description: descriptionClass, inputContainer, - error, textArea, placeholder: placeholderClass, - } = CONTENT_EDITABLE_STYLES({ isInvalid: textFieldProps.isInvalid }) + } = CONTENT_EDITABLE_STYLES({ isInvalid: fieldState.invalid }) return ( - +
{ @@ -91,7 +116,7 @@ export const ResizableContentEditableInput = React.forwardRef(
{ - onChange?.(event.currentTarget.textContent ?? '') + field.onChange(event.currentTarget.textContent ?? '') }} /> - + 0 ? 'hidden' : '' })} + > {placeholder}
@@ -117,13 +144,16 @@ export const ResizableContentEditableInput = React.forwardRef( )}
- - {errorMessage != null && ( - - {errorMessage} - - )} - + ) } -) +) as < + Schema extends ariaComponents.TSchema, + TFieldName extends ariaComponents.FieldPath, + TFieldValues extends ariaComponents.FieldValues = ariaComponents.FieldValues, + // eslint-disable-next-line no-restricted-syntax + TTransformedValues extends ariaComponents.FieldValues | undefined = undefined, +>( + props: React.RefAttributes & + ResizableContentEditableInputProps +) => React.JSX.Element diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Inputs/ResizableInput/ResizableInput.tsx b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Inputs/ResizableInput/ResizableInput.tsx index 0b30dd5f6c..fc9cd8dfcd 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Inputs/ResizableInput/ResizableInput.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Inputs/ResizableInput/ResizableInput.tsx @@ -17,7 +17,6 @@ import * as varants from './variants' export interface ResizableInputProps extends aria.TextFieldProps { readonly placeholder?: string readonly description?: React.ReactNode - readonly errorMessage?: string | null } /** @@ -27,13 +26,7 @@ export const ResizableInput = React.forwardRef(function ResizableInput( props: ResizableInputProps, ref: React.ForwardedRef ) { - const { - value = '', - placeholder = '', - description = null, - errorMessage, - ...textFieldProps - } = props + const { value = '', placeholder = '', description = null, ...textFieldProps } = props const inputRef = React.useRef(null) const resizableAreaRef = React.useRef(null) @@ -56,7 +49,6 @@ export const ResizableInput = React.forwardRef(function ResizableInput( base, description: descriptionClass, inputContainer, - error, resizableSpan, textArea, } = varants.INPUT_STYLES({ isInvalid: textFieldProps.isInvalid }) @@ -90,12 +82,6 @@ export const ResizableInput = React.forwardRef(function ResizableInput( )}
- - {errorMessage != null && ( - - {errorMessage} - - )}
) }) diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Inputs/ResizableInput/variants.ts b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Inputs/ResizableInput/variants.ts index f88e7fb611..f2660012f3 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Inputs/ResizableInput/variants.ts +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Inputs/ResizableInput/variants.ts @@ -17,7 +17,6 @@ export const INPUT_STYLES = twv.tv({ variant: 'body', }), description: 'block select-none pointer-events-none opacity-80', - error: 'block', textArea: 'block h-auto w-full max-h-full resize-none bg-transparent', resizableSpan: text.TEXT_STYLE({ className: diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Radio/RadioGroup.tsx b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Radio/RadioGroup.tsx index c5ccfd017a..25e812d002 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Radio/RadioGroup.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Radio/RadioGroup.tsx @@ -83,19 +83,19 @@ export const RadioGroup = React.forwardRef(function RadioGroup< - {detect.IS_DEV_MODE && stack != null && ( - - - {stack} - - - )} - + + {detect.IS_DEV_MODE && stack != null && ( + + + {stack} + + + )} ) } diff --git a/app/ide-desktop/lib/dashboard/src/components/OfflineNotificationManager.tsx b/app/ide-desktop/lib/dashboard/src/components/OfflineNotificationManager.tsx new file mode 100644 index 0000000000..07c1d26d23 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/OfflineNotificationManager.tsx @@ -0,0 +1,62 @@ +/** + * @file + * + * Offline Notification Manager component. + * + * This component is responsible for displaying a toast notification when the user goes offline or online. + */ + +import * as React from 'react' + +import * as toast from 'react-toastify' + +import * as offlineHooks from '#/hooks/offlineHooks' + +import * as textProvider from '#/providers/TextProvider' + +/** + * Props for {@link OfflineNotificationManager} + */ +export interface OfflineNotificationManagerProps extends React.PropsWithChildren {} + +/** + * Context props for {@link OfflineNotificationManager} + */ +interface OfflineNotificationManagerContextProps { + readonly isNested: boolean + readonly toastId?: string +} + +const OfflineNotificationManagerContext = + React.createContext({ isNested: false }) + +/** + * Offline Notification Manager component. + */ +export function OfflineNotificationManager(props: OfflineNotificationManagerProps) { + const { children } = props + const toastId = 'offline' + const { getText } = textProvider.useText() + + offlineHooks.useOfflineChange(isOffline => { + toast.toast.dismiss(toastId) + + if (isOffline) { + toast.toast.info(getText('offlineToastMessage'), { + toastId, + hideProgressBar: true, + }) + } else { + toast.toast.info(getText('onlineToastMessage'), { + toastId, + hideProgressBar: true, + }) + } + }) + + return ( + + {children} + + ) +} diff --git a/app/ide-desktop/lib/dashboard/src/components/Result.tsx b/app/ide-desktop/lib/dashboard/src/components/Result.tsx index cf400e096f..e0b5cf0cb3 100644 --- a/app/ide-desktop/lib/dashboard/src/components/Result.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/Result.tsx @@ -1,29 +1,68 @@ /** @file Display the result of an operation. */ import * as React from 'react' +import * as twv from 'tailwind-variants' + import Success from 'enso-assets/check_mark.svg' import Error from 'enso-assets/cross.svg' -import * as aria from '#/components/aria' +import * as ariaComponents from '#/components/AriaComponents' +import * as loader from '#/components/Loader' import SvgMask from '#/components/SvgMask' -import * as tailwindMerge from '#/utilities/tailwindMerge' - // ================= // === Constants === // ================= const STATUS_ICON_MAP: Readonly> = { + loading: { + icon: , + colorClassName: 'text-primary', + bgClassName: 'bg-transparent', + }, error: { icon: Error, colorClassName: 'text-red-500', bgClassName: 'bg-red-500' }, success: { icon: Success, colorClassName: 'text-green-500', bgClassName: 'bg-green' }, + info: { + icon: ( + // eslint-disable-next-line no-restricted-syntax + + ! + + ), + colorClassName: 'text-primary', + bgClassName: 'bg-primary/30', + }, } +const RESULT_STYLES = twv.tv({ + base: 'flex flex-col items-center justify-center px-6 py-4 text-center h-[max-content]', + variants: { + centered: { + horizontal: 'mx-auto', + vertical: 'my-auto', + all: 'm-auto', + none: '', + }, + }, + slots: { + statusIcon: + 'mb-2 flex h-8 w-8 flex-none items-center justify-center rounded-full bg-opacity-25 p-1 text-green', + icon: 'h-8 w-8 flex-none', + title: '', + subtitle: 'max-w-[750px]', + content: 'mt-3 w-full', + }, + defaultVariants: { + centered: 'all', + }, +}) + // ============== // === Status === // ============== /** Possible statuses for a result. */ -export type Status = 'error' | 'success' +export type Status = 'error' | 'info' | 'loading' | 'success' // ================== // === StatusIcon === @@ -31,7 +70,7 @@ export type Status = 'error' | 'success' /** The corresponding icon and color for each status. */ interface StatusIcon { - readonly icon: string + readonly icon: React.ReactElement | string readonly colorClassName: string readonly bgClassName: string } @@ -41,7 +80,9 @@ interface StatusIcon { // ============== /** Props for a {@link Result}. */ -export interface ResultProps extends React.PropsWithChildren { +export interface ResultProps + extends React.PropsWithChildren, + twv.VariantProps { readonly className?: string readonly title?: React.JSX.Element | string readonly subtitle?: React.JSX.Element | string @@ -62,32 +103,28 @@ export function Result(props: ResultProps) { className, icon, testId = 'Result', + centered, } = props const statusIcon = typeof status === 'string' ? STATUS_ICON_MAP[status] : null const showIcon = icon !== false + const classes = RESULT_STYLES({ centered }) + return ( -
+
{showIcon ? ( <> {statusIcon != null ? ( -
+ {typeof statusIcon.icon === 'string' ? ( + + ) : ( + statusIcon.icon )} - > -
) : ( status @@ -96,21 +133,22 @@ export function Result(props: ResultProps) { ) : null} {typeof title === 'string' ? ( - + {title} - + ) : ( title )} - - {subtitle} - + {typeof subtitle === 'string' ? ( + + {subtitle} + + ) : ( + subtitle + )} -
{children}
+ {children != null &&
{children}
}
) } diff --git a/app/ide-desktop/lib/dashboard/src/components/SubmitButton.tsx b/app/ide-desktop/lib/dashboard/src/components/SubmitButton.tsx index da01213106..cb324ef942 100644 --- a/app/ide-desktop/lib/dashboard/src/components/SubmitButton.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/SubmitButton.tsx @@ -3,7 +3,6 @@ import * as React from 'react' import type * as aria from '#/components/aria' import * as ariaComponents from '#/components/AriaComponents' -import SvgMask from '#/components/SvgMask' // ==================== // === SubmitButton === @@ -11,6 +10,7 @@ import SvgMask from '#/components/SvgMask' /** Props for a {@link SubmitButton}. */ export interface SubmitButtonProps { + readonly isLoading?: boolean readonly isDisabled?: boolean readonly text: string readonly icon: string @@ -19,20 +19,23 @@ export interface SubmitButtonProps { /** A styled submit button. */ export default function SubmitButton(props: SubmitButtonProps) { - const { isDisabled = false, text, icon, onPress } = props + const { isDisabled = false, text, icon, onPress, isLoading } = props return ( {text} - ) } diff --git a/app/ide-desktop/lib/dashboard/src/components/Suspense.tsx b/app/ide-desktop/lib/dashboard/src/components/Suspense.tsx new file mode 100644 index 0000000000..a69207552d --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/Suspense.tsx @@ -0,0 +1,53 @@ +/** + * @file + * + * Suspense is a component that allows you to wrap a part of your application that might suspend, + * showing a fallback to the user while waiting for the data to load. + */ + +import * as React from 'react' + +import * as offlineHooks from '#/hooks/offlineHooks' + +import * as textProvider from '#/providers/TextProvider' + +import * as result from '#/components/Result' + +import * as loader from './Loader' + +/** + * Props for {@link Suspense} component. + */ +export interface SuspenseProps extends React.SuspenseProps { + readonly loaderProps?: loader.LoaderProps + readonly offlineFallback?: React.ReactNode + readonly offlineFallbackProps?: result.ResultProps +} + +/** + * Suspense is a component that allows you to wrap a part of your application that might suspend, + * showing a fallback to the user while waiting for the data to load. + * + * Unlike the React.Suspense component, this component does not require a fallback prop. + * And handles offline scenarios. + */ +export function Suspense(props: SuspenseProps) { + const { children, loaderProps, fallback, offlineFallbackProps, offlineFallback } = props + + const { getText } = textProvider.useText() + const { isOffline } = offlineHooks.useOffline() + + const getFallbackElement = () => { + if (isOffline) { + return ( + offlineFallback ?? ( + + ) + ) + } else { + return fallback ?? + } + } + + return {children} +} diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow.tsx index 9a74ccde7b..5602eea566 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow.tsx @@ -567,7 +567,7 @@ export default function AssetRow(props: AssetRowProps) { } case AssetEventType.removeSelf: { // This is not triggered from the asset list, so it uses `item.id` instead of `key`. - if (event.id === asset.id && user != null && user.isEnabled) { + if (event.id === asset.id && user.isEnabled) { setInsertionVisibility(Visibility.hidden) try { await createPermissionMutation.mutateAsync([ diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectIcon.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectIcon.tsx index c7f3044680..648b201d69 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectIcon.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectIcon.tsx @@ -100,7 +100,7 @@ export default function ProjectIcon(props: ProjectIconProps) { }) if (!backendModule.IS_OPENING_OR_OPENED[newState]) { newProjectState = object.omit(newProjectState, 'openedBy') - } else if (user != null) { + } else { newProjectState = object.merge(newProjectState, { openedBy: user.email, }) @@ -124,7 +124,7 @@ export default function ProjectIcon(props: ProjectIconProps) { item.projectState.type !== backendModule.ProjectState.placeholder const isCloud = backend.type === backendModule.BackendType.remote const isOtherUserUsingProject = - isCloud && item.projectState.openedBy != null && item.projectState.openedBy !== user?.email + isCloud && item.projectState.openedBy != null && item.projectState.openedBy !== user.email const openProjectMutation = backendHooks.useBackendMutation(backend, 'openProject') const closeProjectMutation = backendHooks.useBackendMutation(backend, 'closeProject') @@ -174,6 +174,7 @@ export default function ProjectIcon(props: ProjectIconProps) { const openEditorMutation = reactQuery.useMutation({ mutationKey: ['openEditor', item.id], + networkMode: 'always', mutationFn: async (abortController: AbortController) => { if (!isRunningInBackground && isCloud) { toast.toast.loading(LOADING_MESSAGE, { toastId }) diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx index 59a7004da0..385ff80b96 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx @@ -59,7 +59,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { const setAsset = setAssetHooks.useSetAsset(asset, setItem) const ownPermission = asset.permissions?.find( - backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId) + backendModule.isUserPermissionAnd(permission => permission.user.userId === user.userId) ) ?? null // This is a workaround for a temporary bad state in the backend causing the `projectState` key // to be absent. @@ -75,7 +75,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { permissions.PERMISSION_ACTION_CAN_EXECUTE[ownPermission.permission])) const isCloud = backend.type === backendModule.BackendType.remote const isOtherUserUsingProject = - isCloud && projectState.openedBy != null && projectState.openedBy !== user?.email + isCloud && projectState.openedBy != null && projectState.openedBy !== user.email const createProjectMutation = backendHooks.useBackendMutation(backend, 'createProject') const updateProjectMutation = backendHooks.useBackendMutation(backend, 'updateProject') diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/column/LabelsColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/column/LabelsColumn.tsx index cd6242002f..d4b6ce3137 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/column/LabelsColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/column/LabelsColumn.tsx @@ -47,7 +47,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) { }, [labels]) const plusButtonRef = React.useRef(null) const self = asset.permissions?.find( - backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId) + backendModule.isUserPermissionAnd(permission => permission.user.userId === user.userId) ) const managesThisAsset = category !== Category.trash && diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/column/SharedWithColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/column/SharedWithColumn.tsx index 118ffa1dc6..dc04feaf8f 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/column/SharedWithColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/column/SharedWithColumn.tsx @@ -47,13 +47,13 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) { const asset = item.item const { user } = authProvider.useNonPartialUserSession() - const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user?.plan }) + const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan }) const isUnderPaywall = isFeatureUnderPaywall('share') const { setModal } = modalProvider.useSetModal() const self = asset.permissions?.find( - backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId) + backendModule.isUserPermissionAnd(permission => permission.user.userId === user.userId) ) const plusButtonRef = React.useRef(null) const managesThisAsset = diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/SharedWithColumnHeading.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/SharedWithColumnHeading.tsx index 860801d112..7efcd504b4 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/SharedWithColumnHeading.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/SharedWithColumnHeading.tsx @@ -22,7 +22,7 @@ export default function SharedWithColumnHeading(props: column.AssetColumnHeading const { user } = authProvider.useNonPartialUserSession() - const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user?.plan }) + const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan }) const isUnderPaywall = isFeatureUnderPaywall('share') diff --git a/app/ide-desktop/lib/dashboard/src/hooks/backendHooks.ts b/app/ide-desktop/lib/dashboard/src/hooks/backendHooks.ts index 097f7d15b3..17f2df3044 100644 --- a/app/ide-desktop/lib/dashboard/src/hooks/backendHooks.ts +++ b/app/ide-desktop/lib/dashboard/src/hooks/backendHooks.ts @@ -2,7 +2,6 @@ import * as React from 'react' import * as reactQuery from '@tanstack/react-query' -import invariant from 'tiny-invariant' import * as authProvider from '#/providers/AuthProvider' @@ -90,7 +89,7 @@ export function useObserveBackend(backend: Backend | null) { Parameters unknown>> >({ // Errored mutations can be safely ignored as they should not change the state. - filters: { mutationKey: [backend, method], status: 'success' }, + filters: { mutationKey: [backend?.type, method], status: 'success' }, // eslint-disable-next-line no-restricted-syntax select: mutation => mutation.state as never, }) @@ -111,7 +110,7 @@ export function useObserveBackend(backend: Backend | null) { ) => { queryClient.setQueryData< Awaited unknown>>> - >([backend, method], data => (data == null ? data : updater(data))) + >([backend?.type, method], data => (data == null ? data : updater(data))) } useObserveMutations('uploadUserPicture', state => { revokeUserPictureUrl(backend) @@ -218,9 +217,10 @@ export function useBackendQuery( readonly unknown[] >({ ...options, - queryKey: [backend, method, ...args, ...(options?.queryKey ?? [])], + queryKey: [backend?.type, method, ...args, ...(options?.queryKey ?? [])], // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return queryFn: () => (backend?.[method] as any)?.(...args), + networkMode: backend?.type === backendModule.BackendType.local ? 'always' : 'online', }) } @@ -249,9 +249,10 @@ export function useBackendMutation( unknown >({ ...options, - mutationKey: [backend, method, ...(options?.mutationKey ?? [])], + mutationKey: [backend.type, method, ...(options?.mutationKey ?? [])], // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return mutationFn: args => (backend[method] as any)(...args), + networkMode: backend.type === backendModule.BackendType.local ? 'always' : 'online', }) } @@ -269,7 +270,7 @@ export function useBackendMutationVariables( Parameters unknown>> >({ filters: { - mutationKey: [backend, method, ...(mutationKey ?? [])], + mutationKey: [backend?.type, method, ...(mutationKey ?? [])], status: 'pending', }, // eslint-disable-next-line no-restricted-syntax @@ -366,7 +367,6 @@ export function useBackendListUserGroups( backend: Backend ): readonly WithPlaceholder[] | null { const { user } = authProvider.useNonPartialUserSession() - invariant(user != null, 'User must exist for user groups to be listed.') const listUserGroupsQuery = useBackendQuery(backend, 'listUserGroups', []) const createUserGroupVariables = useBackendMutationVariables(backend, 'createUserGroup') const deleteUserGroupVariables = useBackendMutationVariables(backend, 'deleteUserGroup') diff --git a/app/ide-desktop/lib/dashboard/src/hooks/gtagHooks.ts b/app/ide-desktop/lib/dashboard/src/hooks/gtagHooks.ts index 245fd560a7..8443a39308 100644 --- a/app/ide-desktop/lib/dashboard/src/hooks/gtagHooks.ts +++ b/app/ide-desktop/lib/dashboard/src/hooks/gtagHooks.ts @@ -3,8 +3,6 @@ import * as React from 'react' import * as gtag from 'enso-common/src/gtag' -import * as authProvider from '#/providers/AuthProvider' - // ==================== // === useGtagEvent === // ==================== @@ -12,15 +10,9 @@ import * as authProvider from '#/providers/AuthProvider' /** A hook that returns a no-op if the user is offline, otherwise it returns * a transparent wrapper around `gtag.event`. */ export function useGtagEvent() { - const { type: sessionType } = authProvider.useNonPartialUserSession() - return React.useCallback( - (name: string, params?: object) => { - if (sessionType !== authProvider.UserSessionType.offline) { - gtag.event(name, params) - } - }, - [sessionType] - ) + return React.useCallback((name: string, params?: object) => { + gtag.event(name, params) + }, []) } // ============================= diff --git a/app/ide-desktop/lib/dashboard/src/hooks/navigateHooks.ts b/app/ide-desktop/lib/dashboard/src/hooks/navigateHooks.ts deleted file mode 100644 index 3225323542..0000000000 --- a/app/ide-desktop/lib/dashboard/src/hooks/navigateHooks.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** @file A wrapper around {@link router.useNavigate} that goes into offline mode when - * offline. */ -import * as React from 'react' - -import * as router from 'react-router' - -import * as appUtils from '#/appUtils' - -import * as authProvider from '#/providers/AuthProvider' - -// =================== -// === useNavigate === -// =================== - -/** A wrapper around {@link router.useNavigate} that goes into offline mode when - * offline. */ -export function useNavigate() { - const { goOffline } = authProvider.useAuth() - // This function is a wrapper around `router.useNavigate`. It should be the only place where - // `router.useNavigate` is used. - // eslint-disable-next-line no-restricted-properties - const originalNavigate = router.useNavigate() - - const navigate: router.NavigateFunction = React.useCallback( - (...args: [unknown, unknown?]) => { - const isOnline = navigator.onLine - if (!isOnline) { - void goOffline() - originalNavigate(appUtils.DASHBOARD_PATH) - } else { - // This is safe, because the arguments are being passed through transparently. - // eslint-disable-next-line no-restricted-syntax - originalNavigate(...(args as [never, never?])) - } - }, - [goOffline, originalNavigate] - ) - - return navigate -} diff --git a/app/ide-desktop/lib/dashboard/src/hooks/offlineHooks.ts b/app/ide-desktop/lib/dashboard/src/hooks/offlineHooks.ts new file mode 100644 index 0000000000..10121bd0e2 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/hooks/offlineHooks.ts @@ -0,0 +1,87 @@ +/** + * @file + * + * Provides set of hooks to work with offline status + */ +import * as React from 'react' + +import * as reactQuery from '@tanstack/react-query' + +import * as eventCallback from '#/hooks/eventCallbackHooks' + +/** + * Hook to get the offline status + */ +export function useOffline() { + const isOnline = React.useSyncExternalStore( + reactQuery.onlineManager.subscribe.bind(reactQuery.onlineManager), + () => reactQuery.onlineManager.isOnline(), + () => navigator.onLine + ) + + return { isOffline: !isOnline } +} + +/** + * Props for the {@link useOfflineChange} hook + */ +export interface UseOfflineChangeProps { + readonly triggerImmediate?: boolean | 'if-offline' | 'if-online' + readonly isDisabled?: boolean +} + +/** + * Hook to subscribe to online/offline changes + */ +export function useOfflineChange( + callback: (isOffline: boolean) => void, + props: UseOfflineChangeProps = {} +) { + const { triggerImmediate = 'if-offline', isDisabled = false } = props + + const lastCallValue = React.useRef(null) + const shouldTriggerCallback = React.useRef(false) + const triggeredImmediateRef = React.useRef(isDisabled) + + const { isOffline } = useOffline() + const isOnline = !isOffline + + const callbackEvent = eventCallback.useEventCallback((offline: boolean) => { + if (isDisabled) { + shouldTriggerCallback.current = true + } else { + if (lastCallValue.current !== offline) { + callback(offline) + } + + shouldTriggerCallback.current = false + lastCallValue.current = offline + } + }) + + if (!triggeredImmediateRef.current) { + triggeredImmediateRef.current = true + + if (triggerImmediate === 'if-offline' && isOffline) { + callbackEvent(isOffline) + } else if (triggerImmediate === 'if-online' && isOnline) { + callbackEvent(false) + } else if (triggerImmediate === true) { + callbackEvent(isOffline) + } + } + + React.useEffect( + () => + reactQuery.onlineManager.subscribe(online => { + callbackEvent(!online) + }), + [callbackEvent] + ) + + React.useEffect(() => { + if (shouldTriggerCallback.current && !isDisabled && lastCallValue.current != null) { + callbackEvent(lastCallValue.current) + } + }, [callbackEvent, isDisabled]) +} diff --git a/app/ide-desktop/lib/dashboard/src/hooks/searchParamsStateHooks.ts b/app/ide-desktop/lib/dashboard/src/hooks/searchParamsStateHooks.ts index 1cd4795584..1f3898ee5f 100644 --- a/app/ide-desktop/lib/dashboard/src/hooks/searchParamsStateHooks.ts +++ b/app/ide-desktop/lib/dashboard/src/hooks/searchParamsStateHooks.ts @@ -22,7 +22,7 @@ type SearchParamsStateReturnType = Readonly< > /** - * Hook that synchronize a state in the URL search params. It returns the value, a setter and a clear function. + * Hook to synchronize a state in the URL search params. It returns the value, a setter and a clear function. * @param key - The key to store the value in the URL search params. * @param defaultValue - The default value to use if the key is not present in the URL search params. * @param predicate - A function to check if the value is of the right type. diff --git a/app/ide-desktop/lib/dashboard/src/index.tsx b/app/ide-desktop/lib/dashboard/src/index.tsx index 34de5b78da..d1a56051a1 100644 --- a/app/ide-desktop/lib/dashboard/src/index.tsx +++ b/app/ide-desktop/lib/dashboard/src/index.tsx @@ -18,6 +18,9 @@ import * as reactQueryClientModule from '#/reactQueryClient' import LoadingScreen from '#/pages/authentication/LoadingScreen' import * as errorBoundary from '#/components/ErrorBoundary' +import * as suspense from '#/components/Suspense' + +import HttpClient from '#/utilities/HttpClient' import * as reactQueryDevtools from './ReactQueryDevtools' @@ -42,7 +45,7 @@ const SENTRY_SAMPLE_RATE = 0.005 export // This export declaration must be broken up to satisfy the `require-jsdoc` rule. // This is not a React component even though it contains JSX. // eslint-disable-next-line no-restricted-syntax -function run(props: Omit) { +function run(props: Omit) { const { vibrancy, supportsDeepLinks } = props if ( !detect.IS_DEV_MODE && @@ -85,24 +88,25 @@ function run(props: Omit) { // `supportsDeepLinks` will be incorrect when accessing the installed Electron app's pages // via the browser. - const actuallySupportsDeepLinks = supportsDeepLinks && detect.isOnElectron() + const actuallySupportsDeepLinks = detect.IS_DEV_MODE + ? supportsDeepLinks + : supportsDeepLinks && detect.isOnElectron() + + const httpClient = new HttpClient() const queryClient = reactQueryClientModule.createReactQueryClient() reactDOM.createRoot(root).render( - }> - {detect.IS_DEV_MODE ? ( - - ) : ( - - )} - + }> + + diff --git a/app/ide-desktop/lib/dashboard/src/layouts/AssetContextMenu.tsx b/app/ide-desktop/lib/dashboard/src/layouts/AssetContextMenu.tsx index 832d046ea1..2d5c91520c 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/AssetContextMenu.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/AssetContextMenu.tsx @@ -71,11 +71,11 @@ export default function AssetContextMenu(props: AssetContextMenuProps) { const toastAndLog = toastAndLogHooks.useToastAndLog() const asset = item.item const self = asset.permissions?.find( - backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId) + backendModule.isUserPermissionAnd(permission => permission.user.userId === user.userId) ) const isCloud = categoryModule.isCloud(category) - const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user?.plan }) + const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan }) const isUnderPaywall = isFeatureUnderPaywall('share') const ownsThisAsset = !isCloud || self?.permission === permissions.PermissionAction.own @@ -92,7 +92,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) { isCloud && backendModule.assetIsProject(asset) && asset.projectState.openedBy != null && - asset.projectState.openedBy !== user?.email + asset.projectState.openedBy !== user.email const setAsset = setAssetHooks.useSetAsset(asset, setItem) return category === Category.trash ? ( diff --git a/app/ide-desktop/lib/dashboard/src/layouts/AssetProperties.tsx b/app/ide-desktop/lib/dashboard/src/layouts/AssetProperties.tsx index f63876ce32..2ec6e96e8d 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/AssetProperties.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/AssetProperties.tsx @@ -74,7 +74,7 @@ export default function AssetProperties(props: AssetPropertiesProps) { ) const labels = backendHooks.useBackendListTags(backend) ?? [] const self = item.item.permissions?.find( - backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId) + backendModule.isUserPermissionAnd(permission => permission.user.userId === user.userId) ) const ownsThisAsset = self?.permission === permissions.PermissionAction.own const canEditThisAsset = diff --git a/app/ide-desktop/lib/dashboard/src/layouts/AssetsTable.tsx b/app/ide-desktop/lib/dashboard/src/layouts/AssetsTable.tsx index 0af94d1772..9343720eeb 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/AssetsTable.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/AssetsTable.tsx @@ -1505,7 +1505,7 @@ export default function AssetsTable(props: AssetsTableProps) { projectState: { type: backendModule.ProjectState.placeholder, volumeId: '', - ...(user != null ? { openedBy: user.email } : {}), + openedBy: user.email, ...(path != null ? { path } : {}), }, labels: [], @@ -1722,7 +1722,7 @@ export default function AssetsTable(props: AssetsTableProps) { projectState: { type: backendModule.ProjectState.placeholder, volumeId: '', - ...(user != null ? { openedBy: user.email } : {}), + openedBy: user.email, ...(event.original.projectState.path != null ? { path: event.original.projectState.path } : {}), diff --git a/app/ide-desktop/lib/dashboard/src/layouts/AssetsTableContextMenu.tsx b/app/ide-desktop/lib/dashboard/src/layouts/AssetsTableContextMenu.tsx index 0c46503d2c..a4124dbcf9 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/AssetsTableContextMenu.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/AssetsTableContextMenu.tsx @@ -69,14 +69,13 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp // up to date. const ownsAllSelectedAssets = !isCloud || - (user != null && - Array.from(selectedKeys, key => { - const userPermissions = nodeMapRef.current.get(key)?.item.permissions - const selfPermission = userPermissions?.find( - backendModule.isUserPermissionAnd(permission => permission.user.userId === user.userId) - ) - return selfPermission?.permission === permissions.PermissionAction.own - }).every(isOwner => isOwner)) + Array.from(selectedKeys, key => { + const userPermissions = nodeMapRef.current.get(key)?.item.permissions + const selfPermission = userPermissions?.find( + backendModule.isUserPermissionAnd(permission => permission.user.userId === user.userId) + ) + return selfPermission?.permission === permissions.PermissionAction.own + }).every(isOwner => isOwner) // This is not a React component even though it contains JSX. // eslint-disable-next-line no-restricted-syntax diff --git a/app/ide-desktop/lib/dashboard/src/layouts/CategorySwitcher.tsx b/app/ide-desktop/lib/dashboard/src/layouts/CategorySwitcher.tsx index 10d856b7ab..b692e947d1 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/CategorySwitcher.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/CategorySwitcher.tsx @@ -10,9 +10,10 @@ import type * as text from '#/text' import * as mimeTypes from '#/data/mimeTypes' +import * as offlineHooks from '#/hooks/offlineHooks' + import * as authProvider from '#/providers/AuthProvider' import * as backendProvider from '#/providers/BackendProvider' -import * as localStorageProvider from '#/providers/LocalStorageProvider' import * as modalProvider from '#/providers/ModalProvider' import * as textProvider from '#/providers/TextProvider' @@ -163,9 +164,9 @@ export default function CategorySwitcher(props: CategorySwitcherProps) { const { dispatchAssetEvent } = props const { user } = authProvider.useNonPartialUserSession() const { unsetModal } = modalProvider.useSetModal() - const { localStorage } = localStorageProvider.useLocalStorage() const { getText } = textProvider.useText() - const remoteBackend = backendProvider.useRemoteBackend() + const { isOffline } = offlineHooks.useOffline() + const localBackend = backendProvider.useLocalBackend() /** The list of *visible* categories. */ const categoryData = React.useMemo( @@ -191,10 +192,12 @@ export default function CategorySwitcher(props: CategorySwitcherProps) { return null } } - default: { - if (remoteBackend == null) { - return getText('youAreNotLoggedIn') - } else if (user?.isEnabled !== true) { + case Category.cloud: + case Category.recent: + case Category.trash: { + if (isOffline) { + return getText('unavailableOffline') + } else if (!user.isEnabled) { return getText('notEnabledSubtitle') } else { return null @@ -207,10 +210,6 @@ export default function CategorySwitcher(props: CategorySwitcherProps) { setCategory(categoryData[0]?.category ?? Category.cloud) } - React.useEffect(() => { - localStorage.set('driveCategory', category) - }, [category, localStorage]) - return ( {innerProps => ( @@ -218,6 +217,7 @@ export default function CategorySwitcher(props: CategorySwitcherProps) { {getText('category')} +
{categoryData.map(data => { const error = getCategoryError(data.category) + return ( AssetQuery.fromString('')) const [suggestions, setSuggestions] = React.useState([]) @@ -112,20 +111,18 @@ export default function Drive(props: DriveProps) { null ) const isCloud = categoryModule.isCloud(category) + const supportLocalBackend = localBackend != null + const status = !isCloud && didLoadingProjectManagerFail ? DriveStatus.noProjectManager - : isCloud && sessionType === authProvider.UserSessionType.offline + : isCloud && isOffline ? DriveStatus.offline - : isCloud && user?.isEnabled !== true + : isCloud && !user.isEnabled ? DriveStatus.notEnabled : DriveStatus.ok - const isAssetPanelVisible = isAssetPanelEnabled || isAssetPanelTemporarilyVisible - const onSetCategory = eventCallback.useEventCallback((value: Category) => { - setCategory(value) - localStorage.set('driveCategory', value) - }) + const isAssetPanelVisible = isAssetPanelEnabled || isAssetPanelTemporarilyVisible React.useEffect(() => { localStorage.set('isAssetPanelVisible', isAssetPanelEnabled) @@ -149,7 +146,7 @@ export default function Drive(props: DriveProps) { const doUploadFiles = React.useCallback( (files: File[]) => { - if (isCloud && sessionType === authProvider.UserSessionType.offline) { + if (isCloud && isOffline) { // This should never happen, however display a nice error message in case it does. toastAndLog('offlineUploadFilesError') } else { @@ -161,7 +158,7 @@ export default function Drive(props: DriveProps) { }) } }, - [isCloud, rootDirectoryId, sessionType, toastAndLog, dispatchAssetListEvent] + [isCloud, rootDirectoryId, toastAndLog, isOffline, dispatchAssetListEvent] ) const doEmptyTrash = React.useCallback(() => { @@ -217,25 +214,6 @@ export default function Drive(props: DriveProps) { ) switch (status) { - case DriveStatus.offline: { - return ( - - ) - } case DriveStatus.noProjectManager: { return (