diff --git a/app/common/package.json b/app/common/package.json index a3bda304841a..b193595dc5b9 100644 --- a/app/common/package.json +++ b/app/common/package.json @@ -15,6 +15,7 @@ "./src/backendQuery": "./src/backendQuery.ts", "./src/queryClient": "./src/queryClient.ts", "./src/utilities/data/array": "./src/utilities/data/array.ts", + "./src/utilities/errors": "./src/utilities/errors.ts", "./src/utilities/data/dateTime": "./src/utilities/data/dateTime.ts", "./src/utilities/data/newtype": "./src/utilities/data/newtype.ts", "./src/utilities/data/object": "./src/utilities/data/object.ts", diff --git a/app/common/src/queryClient.ts b/app/common/src/queryClient.ts index ab69795436d0..9c8829c8f736 100644 --- a/app/common/src/queryClient.ts +++ b/app/common/src/queryClient.ts @@ -83,7 +83,7 @@ export function createQueryClient( storage: persisterStorage, // Prefer online first and don't rely on the local cache if user is online // fallback to the local cache only if the user is offline - maxAge: queryCore.onlineManager.isOnline() ? -1 : DEFAULT_QUERY_PERSIST_TIME_MS, + maxAge: DEFAULT_QUERY_PERSIST_TIME_MS, buster: DEFAULT_BUSTER, filters: { predicate: query => query.meta?.persist !== false }, prefix: 'enso:query-persist:', @@ -130,6 +130,9 @@ export function createQueryClient( defaultOptions: { queries: { ...(persister != null ? { persister } : {}), + // Default set to 'always' to don't pause ongoing queries + // and make them fail. + networkMode: 'always', refetchOnReconnect: 'always', staleTime: DEFAULT_QUERY_STALE_TIME_MS, retry: (failureCount, error: unknown) => { diff --git a/app/common/src/text/english.json b/app/common/src/text/english.json index cd6307e5271f..ee0cb1004315 100644 --- a/app/common/src/text/english.json +++ b/app/common/src/text/english.json @@ -1,6 +1,10 @@ { "submit": "Submit", "retry": "Retry", + + "arbitraryFetchError": "An error occurred while fetching data", + "arbitraryFetchImageError": "An error occurred while fetching an image", + "createFolderError": "Could not create new folder", "createProjectError": "Could not create new project", "createDatalinkError": "Could not create new Datalink", @@ -17,6 +21,9 @@ "deleteAssetError": "Could not delete '$0'", "restoreAssetError": "Could not restore '$0'", + "refetchQueriesPending": "Getting latest updates...", + "refetchQueriesError": "Could not get latest updates. Some information may be outdated", + "localBackendDatalinkError": "Cannot create Datalinks on the local drive", "localBackendSecretError": "Cannot create secrets on the local drive", "offlineUploadFilesError": "Cannot upload files when offline", @@ -177,6 +184,7 @@ "getCustomerPortalUrlBackendError": "Could not get customer portal URL", "duplicateLabelError": "This label already exists.", "emptyStringError": "This value must not be empty.", + "resolveProjectAssetPathBackendError": "Could not get asset", "directoryAssetType": "folder", "directoryDoesNotExistError": "Unable to find directory. Does it exist?", @@ -480,9 +488,9 @@ "hidePassword": "Hide password", "showPassword": "Show password", "copiedToClipboard": "Copied to clipboard", - "noResultsFound": "No results found.", - "youAreOffline": "You are offline.", - "cannotCreateAssetsHere": "You do not have the permissions to create assets here.", + "noResultsFound": "No results found", + "youAreOffline": "You are offline", + "cannotCreateAssetsHere": "You do not have the permissions to create assets here", "enableVersionChecker": "Enable Version Checker", "enableVersionCheckerDescription": "Show a dialog if the current version of the desktop app does not match the latest version.", "disableAnimations": "Disable animations", diff --git a/app/common/src/utilities/errors.ts b/app/common/src/utilities/errors.ts new file mode 100644 index 000000000000..a359fd3e1a7a --- /dev/null +++ b/app/common/src/utilities/errors.ts @@ -0,0 +1,52 @@ +/** + * An error that occurs when a network request fails. + * + * This error is used to indicate that a network request failed due to a network error, + * such as a timeout or a connection error. + */ +export class NetworkError extends Error { + /** + * Create a new {@link NetworkError} with the specified message. + * @param message - The message to display when the error is thrown. + */ + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'NetworkError' + } +} + +/** + * An error that occurs when the user is offline. + * + * This error is used to indicate that the user is offline, such as when they are + * not connected to the internet or when they are on an airplane. + */ +export class OfflineError extends Error { + /** + * Create a new {@link OfflineError} with the specified message. + * @param message - The message to display when the error is thrown. + */ + constructor(message: string = 'User is offline', options?: ErrorOptions) { + super(message, options) + this.name = 'OfflineError' + } +} + +/** + * An error with a display message. + * + * This message can be shown to a user. + */ +export class ErrorWithDisplayMessage extends Error { + readonly displayMessage: string + /** + * Create a new {@link ErrorWithDisplayMessage} with the specified message and display message. + * @param message - The message to display when the error is thrown. + * @param options - The options to pass to the error. + */ + constructor(message: string, options: ErrorOptions & { displayMessage: string }) { + super(message, options) + this.name = 'ErrorWithDisplayMessage' + this.displayMessage = options.displayMessage + } +} diff --git a/app/gui/integration-test/dashboard/actions/api.ts b/app/gui/integration-test/dashboard/actions/api.ts index 458b13141770..975fac1e9b39 100644 --- a/app/gui/integration-test/dashboard/actions/api.ts +++ b/app/gui/integration-test/dashboard/actions/api.ts @@ -16,6 +16,7 @@ import type { FeatureFlags } from '#/providers/FeatureFlagsProvider' import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' +import invariant from 'tiny-invariant' // ================= // === Constants === @@ -1190,17 +1191,23 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { }) }) - await get(remoteBackendPaths.getProjectAssetPath(GLOB_PROJECT_ID, '*'), (route, request) => { - const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1] - if (!maybeId) return - const projectId = backend.ProjectId(maybeId) - called('getProjectAsset', { projectId }) - return route.fulfill({ - // This is a mock SVG image. Just a square with a black background. - body: '/mock/svg.svg', - contentType: 'text/plain', - }) - }) + await get( + remoteBackendPaths.getProjectAssetPath(GLOB_PROJECT_ID, '*'), + async (route, request) => { + const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1] + + invariant(maybeId, 'Unable to parse the ID provided') + + const projectId = backend.ProjectId(maybeId) + + called('getProjectAsset', { projectId }) + + return route.fulfill({ + // This is a mock SVG image. Just a square with a black background. + path: join(__dirname, '../mock/example.png'), + }) + }, + ) await page.route('mock/svg.svg', (route) => { return route.fulfill({ body: MOCK_SVG, contentType: 'image/svg+xml' }) diff --git a/app/gui/integration-test/dashboard/assetPanel.spec.ts b/app/gui/integration-test/dashboard/assetPanel.spec.ts index 9282cf573724..37e8ffd05102 100644 --- a/app/gui/integration-test/dashboard/assetPanel.spec.ts +++ b/app/gui/integration-test/dashboard/assetPanel.spec.ts @@ -5,7 +5,7 @@ import { EmailAddress, UserId } from '#/services/Backend' import { PermissionAction } from '#/utilities/permissions' -import { mockAllAndLogin } from './actions' +import { mockAllAndLogin, TEXT } from './actions' /** Find an asset panel. */ function locateAssetPanel(page: Page) { @@ -87,4 +87,25 @@ test('Asset Panel documentation view', ({ page }) => await expect(assetPanel.getByTestId('asset-panel-tab-panel-docs')).toBeVisible() await expect(assetPanel.getByTestId('asset-docs-content')).toBeVisible() await expect(assetPanel.getByTestId('asset-docs-content')).toHaveText(/Project Goal/) + await expect(assetPanel.getByText(TEXT.arbitraryFetchImageError)).not.toBeVisible() })) + +test('Assets Panel docs images', ({ page }) => { + return mockAllAndLogin({ + page, + setupAPI: (api) => { + api.addProject({}) + }, + }) + .do(() => {}) + .driveTable.clickRow(0) + .toggleDocsAssetPanel() + .withAssetPanel(async (assetPanel) => { + await expect(assetPanel.getByTestId('asset-docs-content')).toBeVisible() + + for (const image of await assetPanel.getByRole('img').all()) { + await expect(image).toBeVisible() + await expect(image).toHaveJSProperty('complete', true) + } + }) +}) diff --git a/app/gui/integration-test/dashboard/mock/example.png b/app/gui/integration-test/dashboard/mock/example.png new file mode 100644 index 000000000000..b4d6d8b3cb9c Binary files /dev/null and b/app/gui/integration-test/dashboard/mock/example.png differ diff --git a/app/gui/src/dashboard/App.tsx b/app/gui/src/dashboard/App.tsx index 5f208d1e93b3..46293fc1f2b4 100644 --- a/app/gui/src/dashboard/App.tsx +++ b/app/gui/src/dashboard/App.tsx @@ -97,6 +97,8 @@ import { STATIC_QUERY_OPTIONS } from '#/utilities/reactQuery' import { useInitAuthService } from '#/authentication/service' import { InvitedToOrganizationModal } from '#/modals/InvitedToOrganizationModal' +import { useMutation } from '@tanstack/react-query' +import { useOffline } from './hooks/offlineHooks' // ============================ // === Global configuration === @@ -214,6 +216,9 @@ export default function App(props: AppProps) { }, }) + const { isOffline } = useOffline() + const { getText } = textProvider.useText() + const queryClient = props.queryClient // Force all queries to be stale @@ -235,6 +240,24 @@ export default function App(props: AppProps) { refetchInterval: 2 * 60 * 1000, }) + const { mutate: executeBackgroundUpdate } = useMutation({ + mutationKey: ['refetch-queries', { isOffline }], + scope: { id: 'refetch-queries' }, + mutationFn: () => queryClient.refetchQueries({ type: 'all' }), + networkMode: 'online', + onError: () => { + toastify.toast.error(getText('refetchQueriesError'), { + position: 'bottom-right', + }) + }, + }) + + React.useEffect(() => { + if (!isOffline) { + executeBackgroundUpdate() + } + }, [executeBackgroundUpdate, isOffline]) + // Both `BackendProvider` and `InputBindingsProvider` depend on `LocalStorageProvider`. // Note that the `Router` must be the parent of the `AuthProvider`, because the `AuthProvider` // will redirect the user between the login/register pages and the dashboard. diff --git a/app/gui/src/dashboard/assets/offline_filled.svg b/app/gui/src/dashboard/assets/offline_filled.svg new file mode 100644 index 000000000000..a85d8b336489 --- /dev/null +++ b/app/gui/src/dashboard/assets/offline_filled.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/gui/src/dashboard/assets/offline_outline.svg b/app/gui/src/dashboard/assets/offline_outline.svg new file mode 100644 index 000000000000..da5fe69221db --- /dev/null +++ b/app/gui/src/dashboard/assets/offline_outline.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/gui/src/dashboard/components/AriaComponents/Form/components/FormError.tsx b/app/gui/src/dashboard/components/AriaComponents/Form/components/FormError.tsx index 17e4e259f76b..9d3bbd006c26 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Form/components/FormError.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Form/components/FormError.tsx @@ -3,6 +3,7 @@ * * Form error component. */ +import Offline from '#/assets/offline_filled.svg' import { Alert, Text, type AlertProps } from '#/components/AriaComponents' import { useFormError, type UseFormErrorProps } from './useFormError' @@ -11,7 +12,7 @@ export interface FormErrorProps extends Omit, UseFormErr /** Form error component. */ export function FormError(props: FormErrorProps) { - const { size = 'large', variant = 'error', rounded = 'large', ...alertProps } = props + const { size = 'large', variant = 'error', rounded = 'xxlarge', ...alertProps } = props const errors = useFormError(props) @@ -24,9 +25,10 @@ export function FormError(props: FormErrorProps) { {errors.map((error) => { const testId = `form-submit-${error.type}` const finalVariant = error.type === 'offline' ? 'outline' : variant + const icon = error.type === 'offline' ? Offline : null return ( - + {error.message} diff --git a/app/gui/src/dashboard/components/ErrorBoundary.tsx b/app/gui/src/dashboard/components/ErrorBoundary.tsx index ee5dc324d4dc..71b71194f997 100644 --- a/app/gui/src/dashboard/components/ErrorBoundary.tsx +++ b/app/gui/src/dashboard/components/ErrorBoundary.tsx @@ -1,4 +1,5 @@ /** @file Catches errors in child components. */ +import Offline from '#/assets/offline_filled.svg' import * as React from 'react' import * as sentry from '@sentry/react' @@ -7,8 +8,6 @@ import * as errorBoundary from 'react-error-boundary' import * as detect from 'enso-common/src/detect' -import * as offlineHooks from '#/hooks/offlineHooks' - import * as textProvider from '#/providers/TextProvider' import * as ariaComponents from '#/components/AriaComponents' @@ -16,6 +15,8 @@ import * as result from '#/components/Result' import { useEventCallback } from '#/hooks/eventCallbackHooks' import * as errorUtils from '#/utilities/error' +import { OfflineError } from '#/utilities/HttpClient' +import SvgMask from './SvgMask' // ===================== // === ErrorBoundary === @@ -38,7 +39,9 @@ export interface ErrorBoundaryProps > > { /** Called before the fallback is shown. */ - readonly onBeforeFallbackShown?: (args: OnBeforeFallbackShownArgs) => void + readonly onBeforeFallbackShown?: ( + args: OnBeforeFallbackShownArgs, + ) => React.ReactNode | null | undefined readonly title?: string readonly subtitle?: string } @@ -53,7 +56,7 @@ export function ErrorBoundary(props: ErrorBoundaryProps) { FallbackComponent = ErrorDisplay, onError = () => {}, onReset = () => {}, - onBeforeFallbackShown = () => {}, + onBeforeFallbackShown = () => null, title, subtitle, ...rest @@ -63,15 +66,19 @@ export function ErrorBoundary(props: ErrorBoundaryProps) { {({ reset }) => ( ( - - )} + FallbackComponent={(fallbackProps) => { + const displayMessage = errorUtils.extractDisplayMessage(fallbackProps.error) + + return ( + + ) + }} onError={(error, info) => { sentry.captureException(error, { extra: { info } }) onError(error, info) @@ -90,39 +97,52 @@ export function ErrorBoundary(props: ErrorBoundaryProps) { /** Props for a {@link ErrorDisplay}. */ export interface ErrorDisplayProps extends errorBoundary.FallbackProps { readonly status?: result.ResultProps['status'] - readonly onBeforeFallbackShown?: (args: OnBeforeFallbackShownArgs) => void + readonly onBeforeFallbackShown?: (args: OnBeforeFallbackShownArgs) => React.ReactNode | undefined readonly resetQueries?: () => void - readonly title?: string | undefined - readonly subtitle?: string | undefined + readonly title?: string | null | undefined + readonly subtitle?: string | null | undefined readonly error: unknown } /** Default fallback component to show when there is an error. */ export function ErrorDisplay(props: ErrorDisplayProps): React.JSX.Element { const { getText } = textProvider.useText() - const { isOffline } = offlineHooks.useOffline() const { error, resetErrorBoundary, - title = getText('somethingWentWrong'), - subtitle = isOffline ? getText('offlineErrorMessage') : getText('arbitraryErrorSubtitle'), - status = isOffline ? 'info' : 'error', + title, + subtitle, + status, onBeforeFallbackShown, resetQueries = () => {}, } = props + const isOfflineError = error instanceof OfflineError + const message = errorUtils.getMessageOrToString(error) const stack = errorUtils.tryGetStack(error) - onBeforeFallbackShown?.({ error, resetErrorBoundary, resetQueries }) + const render = onBeforeFallbackShown?.({ error, resetErrorBoundary, resetQueries }) const onReset = useEventCallback(() => { resetErrorBoundary() }) - return ( - + const finalTitle = title ?? getText('somethingWentWrong') + const finalSubtitle = + subtitle ?? + (isOfflineError ? getText('offlineErrorMessage') : getText('arbitraryErrorSubtitle')) + const finalStatus = + status ?? (isOfflineError ? : 'error') + + const defaultRender = ( + ) + + return <>{render ?? defaultRender} } export { useErrorBoundary, withErrorBoundary } from 'react-error-boundary' diff --git a/app/gui/src/dashboard/components/MarkdownViewer/MarkdownViewer.tsx b/app/gui/src/dashboard/components/MarkdownViewer/MarkdownViewer.tsx index 96970d66f50e..1531ff5e6974 100644 --- a/app/gui/src/dashboard/components/MarkdownViewer/MarkdownViewer.tsx +++ b/app/gui/src/dashboard/components/MarkdownViewer/MarkdownViewer.tsx @@ -1,10 +1,12 @@ -/** @file A WYSIWYG editor using Lexical.js. */ +/** @file A Markdown viewer component. */ +import { useLogger } from '#/providers/LoggerProvider' +import { useText } from '#/providers/TextProvider' import { useSuspenseQuery } from '@tanstack/react-query' import type { RendererObject } from 'marked' import { marked } from 'marked' -import { useMemo } from 'react' -import { BUTTON_STYLES, TEXT_STYLE, type TestIdProps } from '../AriaComponents' +import { type TestIdProps } from '../AriaComponents' +import { DEFAULT_RENDERER } from './defaultRenderer' /** Props for a {@link MarkdownViewer}. */ export interface MarkdownViewerProps extends TestIdProps { @@ -14,67 +16,43 @@ export interface MarkdownViewerProps extends TestIdProps { readonly renderer?: RendererObject } -const defaultRenderer: RendererObject = { - /** The renderer for headings. */ - heading({ depth, tokens }) { - return `${this.parser.parseInline(tokens)}` - }, - /** The renderer for paragraphs. */ - paragraph({ tokens }) { - return `

${this.parser.parseInline(tokens)}

` - }, - /** The renderer for list items. */ - listitem({ tokens }) { - return `
  • ${this.parser.parseInline(tokens)}
  • ` - }, - /** The renderer for lists. */ - list({ items }) { - return `
      ${items.map((item) => this.listitem(item)).join('\n')}
    ` - }, - /** The renderer for links. */ - link({ href, tokens }) { - return `${this.parser.parseInline(tokens)}` - }, - /** The renderer for images. */ - image({ href, title }) { - return `${title}` - }, - /** The renderer for code. */ - code({ text }) { - return ` -
    ${text}
    -
    ` - }, - /** The renderer for blockquotes. */ - blockquote({ tokens }) { - return `
    ${this.parser.parse(tokens)}
    ` - }, -} - /** * Markdown viewer component. * Parses markdown passed in as a `text` prop into HTML and displays it. */ export function MarkdownViewer(props: MarkdownViewerProps) { - const { text, imgUrlResolver, renderer = defaultRenderer, testId } = props + const { text, imgUrlResolver, renderer = {}, testId } = props - const markedInstance = useMemo( - () => marked.use({ renderer: Object.assign({}, defaultRenderer, renderer), async: true }), - [renderer], - ) + const { getText } = useText() + const logger = useLogger() + + const markedInstance = marked.use({ renderer: Object.assign({}, DEFAULT_RENDERER, renderer) }) const { data: markdownToHtml } = useSuspenseQuery({ - queryKey: ['markdownToHtml', { text }], - queryFn: () => - markedInstance.parse(text, { + queryKey: ['markdownToHtml', { text, imgUrlResolver, markedInstance }] as const, + queryFn: ({ queryKey: [, args] }) => + args.markedInstance.parse(args.text, { async: true, walkTokens: async (token) => { if (token.type === 'image' && 'href' in token && typeof token.href === 'string') { - token.href = await imgUrlResolver(token.href) + const href = token.href + + token.raw = href + token.href = await args + .imgUrlResolver(href) + .then((url) => { + return url + }) + .catch((error) => { + logger.error(error) + return null + }) + token.text = getText('arbitraryFetchImageError') } }, }), }) + return (
    = { + /** The renderer for headings. */ + heading({ depth, tokens }) { + const variant = depth === 1 ? 'h1' : 'subtitle' + return `${this.parser.parseInline(tokens)}` + }, + /** The renderer for paragraphs. */ + paragraph({ tokens }) { + return `

    ${this.parser.parseInline(tokens)}

    ` + }, + /** The renderer for list items. */ + listitem({ tokens }) { + return `
  • ${this.parser.parseInline(tokens)}
  • ` + }, + /** The renderer for lists. */ + list({ items }) { + return `
      ${items.map((item) => this.listitem(item)).join('\n')}
    ` + }, + /** The renderer for links. */ + link({ href, tokens }) { + return `${this.parser.parseInline(tokens)}` + }, + /** The renderer for images. */ + image({ href, title, raw }) { + const alt = title ?? '' + + return ` + ${alt} + ` + }, + /** The renderer for code. */ + code({ text }) { + return ` +
    ${text}
    +
    ` + }, + /** The renderer for blockquotes. */ + blockquote({ tokens }) { + return `
    ${this.parser.parse(tokens)}
    ` + }, +} diff --git a/app/gui/src/dashboard/components/OfflineNotificationManager.tsx b/app/gui/src/dashboard/components/OfflineNotificationManager.tsx index 5776459e34cc..3f2b4377aca6 100644 --- a/app/gui/src/dashboard/components/OfflineNotificationManager.tsx +++ b/app/gui/src/dashboard/components/OfflineNotificationManager.tsx @@ -32,21 +32,24 @@ export function OfflineNotificationManager(props: OfflineNotificationManagerProp 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, - }) - } - }) + 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, + }) + } + }, + { triggerImmediate: false }, + ) return ( diff --git a/app/gui/src/dashboard/components/Suspense.tsx b/app/gui/src/dashboard/components/Suspense.tsx index bf4342246a34..fd833df7fdce 100644 --- a/app/gui/src/dashboard/components/Suspense.tsx +++ b/app/gui/src/dashboard/components/Suspense.tsx @@ -7,26 +7,13 @@ import * as React from 'react' -import * as reactQuery from '@tanstack/react-query' - -import * as debounceValue from '#/hooks/debounceValueHooks' -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 } -const OFFLINE_FETCHING_TOGGLE_DELAY_MS = 250 - /** * 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. @@ -35,19 +22,10 @@ const OFFLINE_FETCHING_TOGGLE_DELAY_MS = 250 * And handles offline scenarios. */ export function Suspense(props: SuspenseProps) { - const { children, loaderProps, fallback, offlineFallback, offlineFallbackProps } = props + const { children, loaderProps, fallback } = props return ( - - } - > + }> {children} ) @@ -58,8 +36,6 @@ export function Suspense(props: SuspenseProps) { */ interface LoaderProps extends loader.LoaderProps { readonly fallback?: SuspenseProps['fallback'] - readonly offlineFallback?: SuspenseProps['offlineFallback'] - readonly offlineFallbackProps?: SuspenseProps['offlineFallbackProps'] } /** @@ -74,35 +50,7 @@ interface LoaderProps extends loader.LoaderProps { * we want to know if there are ongoing requests once React renders the fallback in suspense */ export function Loader(props: LoaderProps) { - const { fallback, offlineFallbackProps, offlineFallback, ...loaderProps } = props - - const { getText } = textProvider.useText() - - const { isOffline } = offlineHooks.useOffline() - - const paused = reactQuery.useIsFetching({ fetchStatus: 'paused' }) - - const fetching = reactQuery.useIsFetching({ - predicate: (query) => - query.state.fetchStatus === 'fetching' || - query.state.status === 'pending' || - query.state.status === 'success', - }) - - // we use small debounce to avoid flickering when query is resolved, - // but fallback is still showing - const shouldDisplayOfflineMessage = debounceValue.useDebounceValue( - isOffline && paused >= 0 && fetching === 0, - OFFLINE_FETCHING_TOGGLE_DELAY_MS, - ) + const { fallback, ...loaderProps } = props - if (shouldDisplayOfflineMessage) { - return ( - offlineFallback ?? ( - - ) - ) - } else { - return fallback ?? - } + return fallback ?? } diff --git a/app/gui/src/dashboard/hooks/backendHooks.tsx b/app/gui/src/dashboard/hooks/backendHooks.tsx index 3b2cd55f775a..bdc7b72c8831 100644 --- a/app/gui/src/dashboard/hooks/backendHooks.tsx +++ b/app/gui/src/dashboard/hooks/backendHooks.tsx @@ -344,8 +344,6 @@ export function listDirectoryQueryOptions(options: ListDirectoryQueryOptions) { } } }, - - meta: { persist: false }, }) } diff --git a/app/gui/src/dashboard/hooks/offlineHooks.ts b/app/gui/src/dashboard/hooks/offlineHooks.ts index 0c2d509f57cd..c20f0c075891 100644 --- a/app/gui/src/dashboard/hooks/offlineHooks.ts +++ b/app/gui/src/dashboard/hooks/offlineHooks.ts @@ -10,7 +10,7 @@ export function useOffline() { const isOnline = React.useSyncExternalStore( reactQuery.onlineManager.subscribe.bind(reactQuery.onlineManager), () => reactQuery.onlineManager.isOnline(), - () => navigator.onLine, + () => false, ) return { isOffline: !isOnline } diff --git a/app/gui/src/dashboard/layouts/Drive.tsx b/app/gui/src/dashboard/layouts/Drive.tsx index 87a10d4bb2ec..e8310d846cf7 100644 --- a/app/gui/src/dashboard/layouts/Drive.tsx +++ b/app/gui/src/dashboard/layouts/Drive.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import * as appUtils from '#/appUtils' +import Offline from '#/assets/offline_filled.svg' import * as offlineHooks from '#/hooks/offlineHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' @@ -25,6 +26,7 @@ import * as ariaComponents from '#/components/AriaComponents' import * as result from '#/components/Result' import { ErrorBoundary, useErrorBoundary } from '#/components/ErrorBoundary' +import SvgMask from '#/components/SvgMask' import { listDirectoryQueryOptions } from '#/hooks/backendHooks' import { useEventCallback } from '#/hooks/eventCallbackHooks' import { useTargetDirectory } from '#/providers/DriveProvider' @@ -32,6 +34,7 @@ import { DirectoryDoesNotExistError, Plan } from '#/services/Backend' import AssetQuery from '#/utilities/AssetQuery' import * as download from '#/utilities/download' import * as github from '#/utilities/github' +import { OfflineError } from '#/utilities/HttpClient' import { tryFindSelfPermission } from '#/utilities/permissions' import * as tailwindMerge from '#/utilities/tailwindMerge' import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query' @@ -58,7 +61,7 @@ const CATEGORIES_TO_DISPLAY_START_MODAL = ['cloud', 'local', 'local-directory'] /** Contains directory path and directory contents (projects, folders, secrets and files). */ function Drive(props: DriveProps) { - const { category, resetCategory } = props + const { category, resetCategory, setCategory } = props const { isOffline } = offlineHooks.useOffline() const toastAndLog = toastAndLogHooks.useToastAndLog() @@ -122,6 +125,18 @@ function Drive(props: DriveProps) { resetQueries() resetErrorBoundary() } + + if (error instanceof OfflineError) { + return ( + { + setCategory(nextCategory) + resetErrorBoundary() + }} + /> + ) + } }} > @@ -152,7 +167,6 @@ function DriveAssetsView(props: DriveProps) { const { user } = authProvider.useFullUserSession() const localBackend = backendProvider.useLocalBackend() const backend = backendProvider.useBackend(category) - const { getText } = textProvider.useText() const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent() const [query, setQuery] = React.useState(() => AssetQuery.fromString('')) @@ -263,26 +277,7 @@ function DriveAssetsView(props: DriveProps) {
    {status === 'offline' ? - - {supportLocalBackend && ( - { - setCategory({ type: 'local' }) - }} - > - {getText('switchToLocal')} - - )} - + :