Switch from applying optimistic updates to invalidating queries (#10601)

- The update handling is no longer reactive, so it will no longer cause unnecessary re-renders

Other changes:
- Rename `Root` component to `UIProviders` to be more descriptive

# Important Notes
None
This commit is contained in:
somebody1234 2024-08-09 18:05:45 +10:00 committed by GitHub
parent b286adaae4
commit 5079b21207
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 579 additions and 636 deletions

View File

@ -2,9 +2,6 @@
"React Component": {
"prefix": ["$c", "component"],
"body": [
"/** @file $2 */",
"import * as React from 'react'",
"",
"// ====${1/./=/g}====",
"// === $1 ===",
"// ====${1/./=/g}====",
@ -18,15 +15,28 @@
"export default function $1(props: $1Props) {",
" const { ${3/(.+?):.+/$1, /g} } = props",
" return <>$4</>",
"}"
]
"}",
],
},
"React Hook": {
"prefix": ["$h", "hook"],
"body": [
"// =======${1/./=/g}====",
"// === use$1 ===",
"// =======${1/./=/g}====",
"",
"/** $2 */",
"export function use$1($3) {",
" $4",
"}",
],
},
"useState": {
"prefix": ["$s", "usestate"],
"body": ["const [$1, set${1/(.*)/${1:/pascalcase}/}] = React.useState($2)"]
"body": ["const [$1, set${1/(.*)/${1:/pascalcase}/}] = React.useState($2)"],
},
"section": {
"prefix": ["$S", "section"],
"body": ["// ====${1/./=/g}====", "// === $1 ===", "// ====${1/./=/g}===="]
}
"body": ["// ====${1/./=/g}====", "// === $1 ===", "// ====${1/./=/g}===="],
},
}

View File

@ -776,12 +776,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
await post(remoteBackendPaths.CREATE_TAG_PATH + '*', (route) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const body: backend.CreateTagRequestBody = route.request().postDataJSON()
const json: backend.Label = {
id: backend.TagId(`tag-${uniqueString.uniqueString()}`),
value: backend.LabelName(body.value),
color: body.color,
}
return json
return addLabel(body.value, body.color)
})
await post(remoteBackendPaths.CREATE_PROJECT_PATH + '*', (_route, request) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment

View File

@ -38,6 +38,7 @@ import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import * as router from 'react-router-dom'
import * as toastify from 'react-toastify'
import * as z from 'zod'
import * as detect from 'enso-common/src/detect'
@ -45,17 +46,14 @@ import * as appUtils from '#/appUtils'
import * as inputBindingsModule from '#/configurations/inputBindings'
import * as backendHooks from '#/hooks/backendHooks'
import AuthProvider, * as authProvider from '#/providers/AuthProvider'
import BackendProvider, { useLocalBackend, useRemoteBackend } from '#/providers/BackendProvider'
import BackendProvider from '#/providers/BackendProvider'
import DriveProvider from '#/providers/DriveProvider'
import DevtoolsProvider from '#/providers/EnsoDevtoolsProvider'
import * as httpClientProvider from '#/providers/HttpClientProvider'
import { useHttpClient } from '#/providers/HttpClientProvider'
import InputBindingsProvider from '#/providers/InputBindingsProvider'
import LocalStorageProvider, * as localStorageProvider from '#/providers/LocalStorageProvider'
import type * as loggerProvider from '#/providers/LoggerProvider'
import LoggerProvider from '#/providers/LoggerProvider'
import { useLogger } from '#/providers/LoggerProvider'
import ModalProvider, * as modalProvider from '#/providers/ModalProvider'
import * as navigator2DProvider from '#/providers/Navigator2DProvider'
import SessionProvider from '#/providers/SessionProvider'
@ -76,10 +74,9 @@ import type * as editor from '#/layouts/Editor'
import * as openAppWatcher from '#/layouts/OpenAppWatcher'
import VersionChecker from '#/layouts/VersionChecker'
import { RouterProvider } from '#/components/aria'
import * as devtools from '#/components/Devtools'
import * as errorBoundary from '#/components/ErrorBoundary'
import * as offlineNotificationManager from '#/components/OfflineNotificationManager'
import * as rootComponent from '#/components/Root'
import * as suspense from '#/components/Suspense'
import AboutModal from '#/modals/AboutModal'
@ -92,11 +89,10 @@ 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'
import * as authServiceModule from '#/authentication/service'
import { useInitAuthService } from '#/authentication/service'
// ============================
// === Global configuration ===
@ -110,17 +106,16 @@ declare module '#/utilities/LocalStorage' {
}
LocalStorage.registerKey('inputBindings', {
tryParse: (value) =>
typeof value !== 'object' || value == null ?
null
: Object.fromEntries(
Object.entries<unknown>({ ...value }).flatMap((kv) => {
const [k, v] = kv
return Array.isArray(v) && v.every((item): item is string => typeof item === 'string') ?
[[k, v]]
: []
}),
),
schema: z.record(z.string().array().readonly()).transform((value) =>
Object.fromEntries(
Object.entries<unknown>({ ...value }).flatMap((kv) => {
const [k, v] = kv
return Array.isArray(v) && v.every((item): item is string => typeof item === 'string') ?
[[k, v]]
: []
}),
),
),
})
// ======================
@ -141,7 +136,6 @@ function getMainPageUrl() {
/** Global configuration for the `App` component. */
export interface AppProps {
readonly vibrancy: boolean
readonly logger: loggerProvider.Logger
/** Whether the application may have the local backend running. */
readonly supportsLocalBackend: boolean
/** If true, the app can only be used in offline mode. */
@ -157,8 +151,6 @@ export interface AppProps {
readonly projectManagerUrl: string | null
readonly ydocUrl: string | null
readonly appRunner: editor.GraphEditorRunner | null
readonly portalRoot: Element
readonly httpClient: HttpClient
readonly queryClient: reactQuery.QueryClient
}
@ -262,12 +254,10 @@ 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, httpClient } = props
const { isAuthenticationDisabled, shouldShowDashboard } = props
const { onAuthenticated, projectManagerInstance } = 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 httpClient = useHttpClient()
const logger = useLogger()
const navigate = router.useNavigate()
const { getText } = textProvider.useText()
const { localStorage } = localStorageProvider.useLocalStorage()
@ -356,14 +346,8 @@ function AppRouter(props: AppRouterProps) {
},
}
}, [localStorage, inputBindingsRaw])
const mainPageUrl = getMainPageUrl()
const authService = React.useMemo(() => {
const authConfig = { navigate, ...props }
return authServiceModule.initAuthService(authConfig)
}, [props, navigate])
const authService = useInitAuthService(props)
const userSession = authService?.cognito.userSession.bind(authService.cognito) ?? null
const refreshUserSession =
authService?.cognito.refreshUserSession.bind(authService.cognito) ?? null
@ -494,90 +478,39 @@ function AppRouter(props: AppRouterProps) {
</router.Routes>
)
let result = (
<>
<MutationListener />
<VersionChecker />
{routes}
</>
return (
<DevtoolsProvider>
<RouterProvider navigate={navigate}>
<SessionProvider
saveAccessToken={authService?.cognito.saveAccessToken.bind(authService.cognito) ?? null}
mainPageUrl={mainPageUrl}
userSession={userSession}
registerAuthEventListener={registerAuthEventListener}
refreshUserSession={refreshUserSession}
>
<BackendProvider remoteBackend={remoteBackend} localBackend={localBackend}>
<AuthProvider
shouldStartInOfflineMode={isAuthenticationDisabled}
authService={authService}
onAuthenticated={onAuthenticated}
>
<InputBindingsProvider inputBindings={inputBindings}>
{/* Ideally this would be in `Drive.tsx`, but it currently must be all the way out here
* due to modals being in `TheModal`. */}
<DriveProvider>
<errorBoundary.ErrorBoundary>
<VersionChecker />
{routes}
<suspense.Suspense>
<devtools.EnsoDevtools />
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
</DriveProvider>
</InputBindingsProvider>
</AuthProvider>
</BackendProvider>
</SessionProvider>
</RouterProvider>
</DevtoolsProvider>
)
result = (
<>
{result}
<errorBoundary.ErrorBoundary>
<suspense.Suspense>
<devtools.EnsoDevtools />
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
</>
)
result = <errorBoundary.ErrorBoundary>{result}</errorBoundary.ErrorBoundary>
result = <InputBindingsProvider inputBindings={inputBindings}>{result}</InputBindingsProvider>
result = (
<AuthProvider
shouldStartInOfflineMode={isAuthenticationDisabled}
authService={authService}
onAuthenticated={onAuthenticated}
>
{result}
</AuthProvider>
)
result = (
<BackendProvider remoteBackend={remoteBackend} localBackend={localBackend}>
{result}
</BackendProvider>
)
result = (
<SessionProvider
saveAccessToken={authService?.cognito.saveAccessToken.bind(authService.cognito) ?? null}
mainPageUrl={mainPageUrl}
userSession={userSession}
registerAuthEventListener={registerAuthEventListener}
refreshUserSession={refreshUserSession}
>
{result}
</SessionProvider>
)
result = <LoggerProvider logger={logger}>{result}</LoggerProvider>
result = (
<rootComponent.Root navigate={navigate} portalRoot={portalRoot}>
{result}
</rootComponent.Root>
)
// Ideally this would be in `Drive.tsx`, but it currently must be all the way out here
// due to modals being in `TheModal`.
result = <DriveProvider>{result}</DriveProvider>
result = (
<offlineNotificationManager.OfflineNotificationManager>
{result}
</offlineNotificationManager.OfflineNotificationManager>
)
result = (
<httpClientProvider.HttpClientProvider httpClient={httpClient}>
{result}
</httpClientProvider.HttpClientProvider>
)
result = <LoggerProvider logger={logger}>{result}</LoggerProvider>
result = <DevtoolsProvider>{result}</DevtoolsProvider>
return result
}
// ========================
// === MutationListener ===
// ========================
/** A component that applies state updates for successful mutations. */
function MutationListener() {
const remoteBackend = useRemoteBackend()
const localBackend = useLocalBackend()
backendHooks.useObserveBackend(remoteBackend)
backendHooks.useObserveBackend(localBackend)
return null
}

View File

@ -1,14 +1,17 @@
/** @file Provides an {@link AuthService} which consists of an underyling `Cognito` API
* wrapper, along with some convenience callbacks to make URL redirects for the authentication flows
* work with Electron. */
import * as React from 'react'
import * as amplify from '@aws-amplify/auth'
import { useNavigate } from 'react-router'
import * as common from 'enso-common'
import * as detect from 'enso-common/src/detect'
import * as appUtils from '#/appUtils'
import type * as loggerProvider from '#/providers/LoggerProvider'
import { useLogger, type Logger } from '#/providers/LoggerProvider'
import type * as saveAccessTokenModule from '#/utilities/accessToken'
@ -90,16 +93,9 @@ export function toNestedAmplifyConfig(config: AmplifyConfig): NestedAmplifyConfi
/** Configuration for the authentication service. */
export interface AuthConfig {
/** Logger for the authentication service. */
readonly logger: loggerProvider.Logger
/** Whether the application supports deep links. This is only true when using
* the installed app on macOS and Windows. */
readonly supportsDeepLinks: boolean
/** Function to navigate to a given (relative) URL.
*
* Used to redirect to pages like the password reset page with the query parameters set in the
* URL (e.g., `?verification_code=...`). */
readonly navigate: (url: string) => void
}
// ===================
@ -118,24 +114,28 @@ export interface AuthService {
*
* # Warning
*
* This function should only be called once, and the returned service should be used throughout the
* application. This is because it performs global configuration of the Amplify library. */
export function initAuthService(authConfig: AuthConfig): AuthService | null {
const { logger, supportsDeepLinks, navigate } = authConfig
const amplifyConfig = loadAmplifyConfig(logger, supportsDeepLinks, navigate)
const cognito =
amplifyConfig == null ? null : (
new cognitoModule.Cognito(logger, supportsDeepLinks, amplifyConfig)
)
* This hook should only be called in a single place, as it performs global configuration of the
* Amplify library. */
export function useInitAuthService(authConfig: AuthConfig): AuthService | null {
const { supportsDeepLinks } = authConfig
const logger = useLogger()
const navigate = useNavigate()
return React.useMemo(() => {
const amplifyConfig = loadAmplifyConfig(logger, supportsDeepLinks, navigate)
const cognito =
amplifyConfig == null ? null : (
new cognitoModule.Cognito(logger, supportsDeepLinks, amplifyConfig)
)
return cognito == null ? null : (
{ cognito, registerAuthEventListener: listen.registerAuthEventListener }
)
return cognito == null ? null : (
{ cognito, registerAuthEventListener: listen.registerAuthEventListener }
)
}, [logger, navigate, supportsDeepLinks])
}
/** Return the appropriate Amplify configuration for the current platform. */
function loadAmplifyConfig(
logger: loggerProvider.Logger,
logger: Logger,
supportsDeepLinks: boolean,
navigate: (url: string) => void,
): AmplifyConfig | null {
@ -213,7 +213,7 @@ function loadAmplifyConfig(
*
* All URLs that don't have a pathname that starts with `AUTHENTICATION_PATHNAME_BASE` will be
* ignored by this handler. */
function setDeepLinkHandler(logger: loggerProvider.Logger, navigate: (url: string) => void) {
function setDeepLinkHandler(logger: Logger, navigate: (url: string) => void) {
window.authenticationApi.setDeepLinkHandler((urlString: string) => {
const url = new URL(urlString)
logger.log(`Parsed pathname: ${url.pathname}`)

View File

@ -25,9 +25,9 @@ export interface ErrorBoundaryProps
extends Readonly<React.PropsWithChildren>,
Readonly<Pick<errorBoundary.ErrorBoundaryProps, 'FallbackComponent' | 'onError' | 'onReset'>> {}
/** Catches errors in the child components
/** Catches errors in child components
* Shows a fallback UI when there is an error.
* The error can also be logged. to an error reporting service. */
* The error can also be logged to an error reporting service. */
export function ErrorBoundary(props: ErrorBoundaryProps) {
const {
FallbackComponent = ErrorDisplay,

View File

@ -1,32 +0,0 @@
/** @file The root component with required providers */
import * as React from 'react'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import * as portal from '#/components/Portal'
// ============
// === Root ===
// ============
/** Props for {@link Root}. */
export interface RootProps extends React.PropsWithChildren {
readonly portalRoot: Element
readonly navigate: (path: string) => void
readonly locale?: string
}
/** The root component with required providers. */
export function Root(props: RootProps) {
const { children, navigate, locale = 'en-US', portalRoot } = props
return (
<portal.PortalProvider value={portalRoot}>
<aria.RouterProvider navigate={navigate}>
<aria.I18nProvider locale={locale}>
<ariaComponents.DialogStackProvider>{children}</ariaComponents.DialogStackProvider>
</aria.I18nProvider>
</aria.RouterProvider>
</portal.PortalProvider>
)
}

View File

@ -0,0 +1,28 @@
/** @file A wrapper containing all UI-related React Provdiers. */
import * as React from 'react'
import { I18nProvider } from '#/components/aria'
import { DialogStackProvider } from '#/components/AriaComponents'
import { PortalProvider } from '#/components/Portal'
// ===================
// === UIProviders ===
// ===================
/** Props for a {@link UIProviders}. */
export interface UIProvidersProps extends Readonly<React.PropsWithChildren> {
readonly portalRoot: Element
readonly locale: string
}
/** A wrapper containing all UI-related React Provdiers. */
export default function UIProviders(props: UIProvidersProps) {
const { portalRoot, locale, children } = props
return (
<PortalProvider value={portalRoot}>
<DialogStackProvider>
<I18nProvider locale={locale}>{children}</I18nProvider>
</DialogStackProvider>
</PortalProvider>
)
}

View File

@ -1,11 +1,12 @@
/** @file A table row for an arbitrary asset. */
import * as React from 'react'
import { useMutation } from '@tanstack/react-query'
import { useStore } from 'zustand'
import BlankIcon from '#/assets/blank.svg'
import * as backendHooks from '#/hooks/backendHooks'
import { backendMutationOptions } from '#/hooks/backendHooks'
import * as dragAndDropHooks from '#/hooks/dragAndDropHooks'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks'
@ -138,23 +139,25 @@ export default function AssetRow(props: AssetRowProps) {
: outerVisibility
const hidden = hiddenRaw || visibility === Visibility.hidden
const copyAssetMutation = backendHooks.useBackendMutation(backend, 'copyAsset')
const updateAssetMutation = backendHooks.useBackendMutation(backend, 'updateAsset')
const deleteAssetMutation = backendHooks.useBackendMutation(backend, 'deleteAsset')
const undoDeleteAssetMutation = backendHooks.useBackendMutation(backend, 'undoDeleteAsset')
const openProjectMutation = backendHooks.useBackendMutation(backend, 'openProject')
const closeProjectMutation = backendHooks.useBackendMutation(backend, 'closeProject')
const getProjectDetailsMutation = backendHooks.useBackendMutation(backend, 'getProjectDetails')
const getFileDetailsMutation = backendHooks.useBackendMutation(backend, 'getFileDetails')
const getDatalinkMutation = backendHooks.useBackendMutation(backend, 'getDatalink')
const createPermissionMutation = backendHooks.useBackendMutation(backend, 'createPermission')
const associateTagMutation = backendHooks.useBackendMutation(backend, 'associateTag')
const copyAssetMutate = copyAssetMutation.mutateAsync
const updateAssetMutate = updateAssetMutation.mutateAsync
const deleteAssetMutate = deleteAssetMutation.mutateAsync
const undoDeleteAssetMutate = undoDeleteAssetMutation.mutateAsync
const openProjectMutate = openProjectMutation.mutateAsync
const closeProjectMutate = closeProjectMutation.mutateAsync
const copyAssetMutation = useMutation(backendMutationOptions(backend, 'copyAsset'))
const updateAssetMutation = useMutation(backendMutationOptions(backend, 'updateAsset'))
const deleteAssetMutation = useMutation(backendMutationOptions(backend, 'deleteAsset'))
const undoDeleteAssetMutation = useMutation(backendMutationOptions(backend, 'undoDeleteAsset'))
const openProjectMutation = useMutation(backendMutationOptions(backend, 'openProject'))
const closeProjectMutation = useMutation(backendMutationOptions(backend, 'closeProject'))
const getProjectDetailsMutation = useMutation(
backendMutationOptions(backend, 'getProjectDetails'),
)
const getFileDetailsMutation = useMutation(backendMutationOptions(backend, 'getFileDetails'))
const getDatalinkMutation = useMutation(backendMutationOptions(backend, 'getDatalink'))
const createPermissionMutation = useMutation(backendMutationOptions(backend, 'createPermission'))
const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag'))
const copyAsset = copyAssetMutation.mutateAsync
const updateAsset = updateAssetMutation.mutateAsync
const deleteAsset = deleteAssetMutation.mutateAsync
const undoDeleteAsset = undoDeleteAssetMutation.mutateAsync
const openProject = openProjectMutation.mutateAsync
const closeProject = closeProjectMutation.mutateAsync
const { data: projectState } = useQuery({
// This is SAFE, as `isOpened` is only true for projects.
@ -207,7 +210,7 @@ export default function AssetRow(props: AssetRowProps) {
}),
)
newParentId ??= rootDirectoryId
const copiedAsset = await copyAssetMutate([
const copiedAsset = await copyAsset([
asset.id,
newParentId,
asset.title,
@ -234,7 +237,7 @@ export default function AssetRow(props: AssetRowProps) {
asset,
item.key,
toastAndLog,
copyAssetMutate,
copyAsset,
nodeMap,
setAsset,
dispatchAssetListEvent,
@ -290,7 +293,7 @@ export default function AssetRow(props: AssetRowProps) {
item: newAsset,
})
setAsset(newAsset)
await updateAssetMutate([
await updateAsset([
asset.id,
{ parentDirectoryId: newParentId ?? rootDirectoryId, description: null },
asset.title,
@ -327,7 +330,7 @@ export default function AssetRow(props: AssetRowProps) {
item.directoryKey,
item.key,
toastAndLog,
updateAssetMutate,
updateAsset,
setAsset,
dispatchAssetListEvent,
],
@ -362,15 +365,15 @@ export default function AssetRow(props: AssetRowProps) {
asset.projectState.type !== backendModule.ProjectState.placeholder &&
asset.projectState.type !== backendModule.ProjectState.closed
) {
await openProjectMutate([asset.id, null, asset.title])
await openProject([asset.id, null, asset.title])
}
try {
await closeProjectMutate([asset.id, asset.title])
await closeProject([asset.id, asset.title])
} catch {
// Ignored. The project was already closed.
}
}
await deleteAssetMutate([asset.id, { force: forever }, asset.title])
await deleteAsset([asset.id, { force: forever }, asset.title])
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
} catch (error) {
setInsertionVisibility(Visibility.visible)
@ -381,9 +384,9 @@ export default function AssetRow(props: AssetRowProps) {
backend,
dispatchAssetListEvent,
asset,
openProjectMutate,
closeProjectMutate,
deleteAssetMutate,
openProject,
closeProject,
deleteAsset,
item.key,
toastAndLog,
],
@ -393,13 +396,13 @@ export default function AssetRow(props: AssetRowProps) {
// Visually, the asset is deleted from the Trash view.
setInsertionVisibility(Visibility.hidden)
try {
await undoDeleteAssetMutate([asset.id, asset.title])
await undoDeleteAsset([asset.id, asset.title])
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
} catch (error) {
setInsertionVisibility(Visibility.visible)
toastAndLog('restoreAssetError', error, asset.title)
}
}, [dispatchAssetListEvent, asset, toastAndLog, undoDeleteAssetMutate, item.key])
}, [dispatchAssetListEvent, asset, toastAndLog, undoDeleteAsset, item.key])
const doTriggerDescriptionEdit = React.useCallback(() => {
setModal(

View File

@ -1,9 +1,11 @@
/** @file The icon and name of a {@link backendModule.SecretAsset}. */
import * as React from 'react'
import { useMutation } from '@tanstack/react-query'
import DatalinkIcon from '#/assets/datalink.svg'
import * as backendHooks from '#/hooks/backendHooks'
import { backendMutationOptions } from '#/hooks/backendHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
@ -48,7 +50,7 @@ export default function DatalinkNameColumn(props: DatalinkNameColumnProps) {
const asset = item.item
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
const createDatalinkMutation = backendHooks.useBackendMutation(backend, 'createDatalink')
const createDatalinkMutation = useMutation(backendMutationOptions(backend, 'createDatalink'))
const setIsEditing = (isEditingName: boolean) => {
if (isEditable) {

View File

@ -1,10 +1,12 @@
/** @file The icon and name of a {@link backendModule.DirectoryAsset}. */
import * as React from 'react'
import { useMutation } from '@tanstack/react-query'
import FolderIcon from '#/assets/folder.svg'
import FolderArrowIcon from '#/assets/folder_arrow.svg'
import * as backendHooks from '#/hooks/backendHooks'
import { backendMutationOptions } from '#/hooks/backendHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
@ -59,8 +61,8 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
const isExpanded = item.children != null && item.isExpanded
const createDirectoryMutation = backendHooks.useBackendMutation(backend, 'createDirectory')
const updateDirectoryMutation = backendHooks.useBackendMutation(backend, 'updateDirectory')
const createDirectoryMutation = useMutation(backendMutationOptions(backend, 'createDirectory'))
const updateDirectoryMutation = useMutation(backendMutationOptions(backend, 'updateDirectory'))
const setIsEditing = (isEditingName: boolean) => {
if (isEditable) {

View File

@ -1,7 +1,9 @@
/** @file The icon and name of a {@link backendModule.FileAsset}. */
import * as React from 'react'
import * as backendHooks from '#/hooks/backendHooks'
import { useMutation } from '@tanstack/react-query'
import { backendMutationOptions } from '#/hooks/backendHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
@ -50,13 +52,15 @@ export default function FileNameColumn(props: FileNameColumnProps) {
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
const isCloud = backend.type === backendModule.BackendType.remote
const updateFileMutation = backendHooks.useBackendMutation(backend, 'updateFile')
const uploadFileMutation = backendHooks.useBackendMutation(backend, 'uploadFile', {
meta: {
invalidates: [['assetVersions', item.item.id, item.item.title]],
awaitInvalidates: true,
},
})
const updateFileMutation = useMutation(backendMutationOptions(backend, 'updateFile'))
const uploadFileMutation = useMutation(
backendMutationOptions(backend, 'uploadFile', {
meta: {
invalidates: [['assetVersions', item.item.id, item.item.title]],
awaitInvalidates: true,
},
}),
)
const setIsEditing = (isEditingName: boolean) => {
if (isEditable) {

View File

@ -1,9 +1,11 @@
/** @file Permissions for a specific user or user group on a specific asset. */
import * as React from 'react'
import { useMutation } from '@tanstack/react-query'
import type * as text from 'enso-common/src/text'
import * as backendHooks from '#/hooks/backendHooks'
import { backendMutationOptions } from '#/hooks/backendHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as textProvider from '#/providers/TextProvider'
@ -58,7 +60,9 @@ export default function Permission(props: PermissionProps) {
const isDisabled = isOnlyOwner && permissionId === self.user.userId
const assetTypeName = getText(ASSET_TYPE_TO_TEXT_ID[asset.type])
const createPermissionMutation = backendHooks.useBackendMutation(backend, 'createPermission')
const createPermission = useMutation(
backendMutationOptions(backend, 'createPermission'),
).mutateAsync
React.useEffect(() => {
setPermission(initialPermission)
@ -68,7 +72,7 @@ export default function Permission(props: PermissionProps) {
try {
setPermission(newPermission)
outerSetPermission(newPermission)
await createPermissionMutation.mutateAsync([
await createPermission([
{
actorsIds: [backendModule.getAssetPermissionId(newPermission)],
resourceId: asset.id,

View File

@ -1,11 +1,11 @@
/** @file The icon and name of a {@link backendModule.ProjectAsset}. */
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import NetworkIcon from '#/assets/network.svg'
import * as backendHooks from '#/hooks/backendHooks'
import { backendMutationOptions } from '#/hooks/backendHooks'
import * as projectHooks from '#/hooks/projectHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
@ -61,7 +61,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
isOpened,
} = props
const { backend, nodeMap } = state
const client = reactQuery.useQueryClient()
const client = useQueryClient()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { user } = authProvider.useNonPartialUserSession()
const { getText } = textProvider.useText()
@ -96,16 +96,20 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
const isOtherUserUsingProject =
isCloud && projectState.openedBy != null && projectState.openedBy !== user.email
const createProjectMutation = backendHooks.useBackendMutation(backend, 'createProject')
const updateProjectMutation = backendHooks.useBackendMutation(backend, 'updateProject')
const duplicateProjectMutation = backendHooks.useBackendMutation(backend, 'duplicateProject')
const getProjectDetailsMutation = backendHooks.useBackendMutation(backend, 'getProjectDetails')
const uploadFileMutation = backendHooks.useBackendMutation(backend, 'uploadFile', {
meta: {
invalidates: [['assetVersions', item.item.id, item.item.title]],
awaitInvalidates: true,
},
})
const createProjectMutation = useMutation(backendMutationOptions(backend, 'createProject'))
const updateProjectMutation = useMutation(backendMutationOptions(backend, 'updateProject'))
const duplicateProjectMutation = useMutation(backendMutationOptions(backend, 'duplicateProject'))
const getProjectDetailsMutation = useMutation(
backendMutationOptions(backend, 'getProjectDetails'),
)
const uploadFileMutation = useMutation(
backendMutationOptions(backend, 'uploadFile', {
meta: {
invalidates: [['assetVersions', item.item.id, item.item.title]],
awaitInvalidates: true,
},
}),
)
const setIsEditing = (isEditingName: boolean) => {
if (isEditable) {

View File

@ -1,9 +1,11 @@
/** @file The icon and name of a {@link backendModule.SecretAsset}. */
import * as React from 'react'
import { useMutation } from '@tanstack/react-query'
import KeyIcon from '#/assets/key.svg'
import * as backendHooks from '#/hooks/backendHooks'
import { backendMutationOptions } from '#/hooks/backendHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
@ -52,8 +54,8 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
}
const asset = item.item
const createSecretMutation = backendHooks.useBackendMutation(backend, 'createSecret')
const updateSecretMutation = backendHooks.useBackendMutation(backend, 'updateSecret')
const createSecretMutation = useMutation(backendMutationOptions(backend, 'createSecret'))
const updateSecretMutation = useMutation(backendMutationOptions(backend, 'updateSecret'))
const setIsEditing = (isEditingName: boolean) => {
if (isEditable) {

View File

@ -41,7 +41,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
const { setModal, unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const labels = backendHooks.useBackendListTags(backend)
const labels = backendHooks.useListTags(backend)
const labelsByName = React.useMemo(() => {
return new Map(labels?.map((label) => [label.value, label]))
}, [labels])

View File

@ -16,16 +16,17 @@ import * as uniqueString from '#/utilities/uniqueString'
// === revokeUserPictureUrl ===
// ============================
const USER_PICTURE_URL_REVOKERS = new WeakMap<Backend, () => void>()
const USER_PICTURE_URLS = new Map<backendModule.BackendType, string>()
/** Create the corresponding "user picture" URL for the given backend. */
function createUserPictureUrl(backend: Backend | null, picture: Blob) {
if (backend != null) {
USER_PICTURE_URL_REVOKERS.get(backend)?.()
function createUserPictureUrl(
backendType: backendModule.BackendType | null | undefined,
picture: Blob,
) {
if (backendType != null) {
revokeUserPictureUrl(backendType)
const url = URL.createObjectURL(picture)
USER_PICTURE_URL_REVOKERS.set(backend, () => {
URL.revokeObjectURL(url)
})
USER_PICTURE_URLS.set(backendType, url)
return url
} else {
// This should never happen, so use an arbitrary URL.
@ -34,9 +35,12 @@ function createUserPictureUrl(backend: Backend | null, picture: Blob) {
}
/** Revoke the corresponding "user picture" URL for the given backend. */
function revokeUserPictureUrl(backend: Backend | null) {
if (backend != null) {
USER_PICTURE_URL_REVOKERS.get(backend)?.()
function revokeUserPictureUrl(backendType: backendModule.BackendType | null | undefined) {
if (backendType != null) {
const url = USER_PICTURE_URLS.get(backendType)
if (url != null) {
URL.revokeObjectURL(url)
}
}
}
@ -44,16 +48,17 @@ function revokeUserPictureUrl(backend: Backend | null) {
// === revokeOrganizationPictureUrl ===
// ====================================
const ORGANIZATION_PICTURE_URL_REVOKERS = new WeakMap<Backend, () => void>()
const ORGANIZATION_PICTURE_URLS = new Map<backendModule.BackendType, string>()
/** Create the corresponding "organization picture" URL for the given backend. */
function createOrganizationPictureUrl(backend: Backend | null, picture: Blob) {
if (backend != null) {
ORGANIZATION_PICTURE_URL_REVOKERS.get(backend)?.()
function createOrganizationPictureUrl(
backendType: backendModule.BackendType | null | undefined,
picture: Blob,
) {
if (backendType != null) {
revokeOrganizationPictureUrl(backendType)
const url = URL.createObjectURL(picture)
ORGANIZATION_PICTURE_URL_REVOKERS.set(backend, () => {
URL.revokeObjectURL(url)
})
ORGANIZATION_PICTURE_URLS.set(backendType, url)
return url
} else {
// This should never happen, so use an arbitrary URL.
@ -62,102 +67,70 @@ function createOrganizationPictureUrl(backend: Backend | null, picture: Blob) {
}
/** Revoke the corresponding "organization picture" URL for the given backend. */
function revokeOrganizationPictureUrl(backend: Backend | null) {
if (backend != null) {
ORGANIZATION_PICTURE_URL_REVOKERS.get(backend)?.()
function revokeOrganizationPictureUrl(backendType: backendModule.BackendType | null | undefined) {
if (backendType != null) {
const url = ORGANIZATION_PICTURE_URLS.get(backendType)
if (url != null) {
URL.revokeObjectURL(url)
}
}
}
// =========================
// === useObserveBackend ===
// =========================
// ============================
// === DefineBackendMethods ===
// ============================
/** Listen to all mutations and update state as appropriate when they succeed.
* MUST be unconditionally called exactly once for each backend type. */
export function useObserveBackend(backend: Backend | null) {
const queryClient = reactQuery.useQueryClient()
const [seen] = React.useState(new WeakSet())
const useObserveMutations = <Method extends backendQuery.BackendMethods>(
method: Method,
onSuccess: (
state: reactQuery.MutationState<
Awaited<ReturnType<Backend[Method]>>,
Error,
Parameters<Backend[Method]>
>,
) => void,
) => {
const states = reactQuery.useMutationState<Parameters<Backend[Method]>>({
// Errored mutations can be safely ignored as they should not change the state.
filters: { mutationKey: [backend?.type, method], status: 'success' },
// eslint-disable-next-line no-restricted-syntax
select: (mutation) => mutation.state as never,
})
for (const state of states) {
if (!seen.has(state)) {
seen.add(state)
// This is SAFE - it is just too highly dynamic for TypeScript to typecheck.
// eslint-disable-next-line no-restricted-syntax
onSuccess(state as never)
}
}
}
const setQueryData = <Method extends backendQuery.BackendMethods>(
method: Method,
updater: (
variable: Awaited<ReturnType<Backend[Method]>>,
) => Awaited<ReturnType<Backend[Method]>>,
) => {
queryClient.setQueryData<Awaited<ReturnType<Backend[Method]>>>(
[backend?.type, method],
(data) => (data == null ? data : updater(data)),
)
}
useObserveMutations('uploadUserPicture', (state) => {
revokeUserPictureUrl(backend)
setQueryData('usersMe', (user) => state.data ?? user)
})
useObserveMutations('updateOrganization', (state) => {
setQueryData('getOrganization', (organization) => state.data ?? organization)
})
useObserveMutations('uploadOrganizationPicture', (state) => {
revokeOrganizationPictureUrl(backend)
setQueryData('getOrganization', (organization) => state.data ?? organization)
})
useObserveMutations('createUserGroup', (state) => {
if (state.data != null) {
const data = state.data
setQueryData('listUserGroups', (userGroups) => [data, ...userGroups])
}
})
useObserveMutations('deleteUserGroup', (state) => {
setQueryData('listUserGroups', (userGroups) =>
userGroups.filter((userGroup) => userGroup.id !== state.variables?.[0]),
)
})
useObserveMutations('changeUserGroup', (state) => {
if (state.variables != null) {
const [userId, body] = state.variables
setQueryData('listUsers', (users) =>
users.map((user) =>
user.userId !== userId ? user : { ...user, userGroups: body.userGroups },
),
)
}
})
useObserveMutations('createTag', (state) => {
if (state.data != null) {
const data = state.data
setQueryData('listTags', (tags) => [...tags, data])
}
})
useObserveMutations('deleteTag', (state) => {
if (state.variables != null) {
const [tagId] = state.variables
setQueryData('listTags', (tags) => tags.filter((tag) => tag.id !== tagId))
}
})
}
/** Ensure that the given type contains only names of backend methods. */
// eslint-disable-next-line no-restricted-syntax
type DefineBackendMethods<T extends keyof Backend> = T
// ======================
// === MutationMethod ===
// ======================
/** Names of methods corresponding to mutations. */
export type MutationMethod = DefineBackendMethods<
| 'associateTag'
| 'changeUserGroup'
| 'closeProject'
| 'copyAsset'
| 'createCheckoutSession'
| 'createDatalink'
| 'createDirectory'
| 'createPermission'
| 'createProject'
| 'createSecret'
| 'createTag'
| 'createUser'
| 'createUserGroup'
| 'deleteAsset'
| 'deleteDatalink'
| 'deleteInvitation'
| 'deleteTag'
| 'deleteUser'
| 'deleteUserGroup'
| 'duplicateProject'
// TODO: `get*` are not mutations, but are currently used in some places.
| 'getDatalink'
| 'getFileDetails'
| 'getProjectDetails'
| 'inviteUser'
| 'logEvent'
| 'openProject'
| 'removeUser'
| 'resendInvitation'
| 'undoDeleteAsset'
| 'updateAsset'
| 'updateDirectory'
| 'updateFile'
| 'updateOrganization'
| 'updateProject'
| 'updateSecret'
| 'updateUser'
| 'uploadFile'
| 'uploadOrganizationPicture'
| 'uploadUserPicture'
>
// =======================
// === useBackendQuery ===
@ -209,7 +182,19 @@ export function useBackendQuery<Method extends backendQuery.BackendMethods>(
// === useBackendMutation ===
// ==========================
export function useBackendMutation<Method extends backendQuery.BackendMethods>(
const INVALIDATION_MAP: Partial<Record<MutationMethod, readonly backendQuery.BackendMethods[]>> = {
updateUser: ['usersMe'],
uploadUserPicture: ['usersMe'],
updateOrganization: ['getOrganization'],
uploadOrganizationPicture: ['getOrganization'],
createUserGroup: ['listUserGroups'],
deleteUserGroup: ['listUserGroups'],
changeUserGroup: ['listUsers'],
createTag: ['listTags'],
deleteTag: ['listTags'],
}
export function backendMutationOptions<Method extends MutationMethod>(
backend: Backend,
method: Method,
options?: Omit<
@ -220,12 +205,12 @@ export function useBackendMutation<Method extends backendQuery.BackendMethods>(
>,
'mutationFn'
>,
): reactQuery.UseMutationResult<
): reactQuery.UseMutationOptions<
Awaited<ReturnType<Backend[Method]>>,
Error,
Parameters<Backend[Method]>
>
export function useBackendMutation<Method extends backendQuery.BackendMethods>(
export function backendMutationOptions<Method extends MutationMethod>(
backend: Backend | null,
method: Method,
options?: Omit<
@ -236,14 +221,13 @@ export function useBackendMutation<Method extends backendQuery.BackendMethods>(
>,
'mutationFn'
>,
): reactQuery.UseMutationResult<
// eslint-disable-next-line no-restricted-syntax
): reactQuery.UseMutationOptions<
Awaited<ReturnType<Backend[Method]>> | undefined,
Error,
Parameters<Backend[Method]>
>
/** Wrap a backend method call in a React Query Mutation. */
export function useBackendMutation<Method extends backendQuery.BackendMethods>(
export function backendMutationOptions<Method extends MutationMethod>(
backend: Backend | null,
method: Method,
options?: Omit<
@ -254,18 +238,25 @@ export function useBackendMutation<Method extends backendQuery.BackendMethods>(
>,
'mutationFn'
>,
) {
return reactQuery.useMutation<
Awaited<ReturnType<Backend[Method]>>,
Error,
Parameters<Backend[Method]>
>({
): reactQuery.UseMutationOptions<
Awaited<ReturnType<Backend[Method]>>,
Error,
Parameters<Backend[Method]>
> {
return {
...options,
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',
})
meta: {
invalidates: [
...(options?.meta?.invalidates ?? []),
...(INVALIDATION_MAP[method]?.map((queryMethod) => [backend?.type, queryMethod]) ?? []),
],
awaitInvalidates: options?.meta?.awaitInvalidates ?? true,
},
}
}
// ===================================
@ -288,32 +279,6 @@ export function useBackendMutationVariables<Method extends backendQuery.BackendM
})
}
// =======================================
// === useBackendMutationWithVariables ===
// =======================================
/** Wrap a backend method call in a React Query Mutation, and access its variables. */
export function useBackendMutationWithVariables<Method extends backendQuery.BackendMethods>(
backend: Backend,
method: Method,
options?: Omit<
reactQuery.UseMutationOptions<
Awaited<ReturnType<Backend[Method]>>,
Error,
Parameters<Backend[Method]>
>,
'mutationFn'
>,
) {
const mutation = useBackendMutation(backend, method, options)
return {
mutation,
mutate: mutation.mutate,
mutateAsync: mutation.mutateAsync,
variables: useBackendMutationVariables(backend, method, options?.mutationKey),
}
}
// ===================
// === Placeholder ===
// ===================
@ -339,12 +304,12 @@ function toNonPlaceholder<T extends object>(object: T) {
return { ...object, isPlaceholder: false }
}
// ===========================
// === useBackendListUsers ===
// ===========================
// ====================
// === useListUsers ===
// ====================
/** A list of users, taking into account optimistic state. */
export function useBackendListUsers(
export function useListUsers(
backend: Backend,
): readonly WithPlaceholder<backendModule.User>[] | null {
const listUsersQuery = useBackendQuery(backend, 'listUsers', [])
@ -367,12 +332,12 @@ export function useBackendListUsers(
}, [changeUserGroupVariables, listUsersQuery.data])
}
// ================================
// === useBackendListUserGroups ===
// ================================
// =========================
// === useListUserGroups ===
// =========================
/** A list of user groups, taking into account optimistic state. */
export function useBackendListUserGroups(
export function useListUserGroups(
backend: Backend,
): readonly WithPlaceholder<backendModule.UserGroupInfo>[] | null {
const { user } = authProvider.useNonPartialUserSession()
@ -405,9 +370,9 @@ export function useBackendListUserGroups(
])
}
// =========================================
// === useBackendListUserGroupsWithUsers ===
// =========================================
// ==================================
// === useListUserGroupsWithUsers ===
// ==================================
/** A user group, as well as the users that are a part of the user group. */
export interface UserGroupInfoWithUsers extends backendModule.UserGroupInfo {
@ -415,14 +380,14 @@ export interface UserGroupInfoWithUsers extends backendModule.UserGroupInfo {
}
/** A list of user groups, taking into account optimistic state. */
export function useBackendListUserGroupsWithUsers(
export function useListUserGroupsWithUsers(
backend: Backend,
): readonly WithPlaceholder<UserGroupInfoWithUsers>[] | null {
const userGroupsRaw = useBackendListUserGroups(backend)
const userGroupsRaw = useListUserGroups(backend)
// Old user list
const listUsersQuery = useBackendQuery(backend, 'listUsers', [])
// Current user list, including optimistic updates
const users = useBackendListUsers(backend)
const users = useListUsers(backend)
return React.useMemo(() => {
if (userGroupsRaw == null || listUsersQuery.data == null || users == null) {
return null
@ -447,12 +412,12 @@ export function useBackendListUserGroupsWithUsers(
}, [listUsersQuery.data, userGroupsRaw, users])
}
// ==========================
// === useBackendListTags ===
// ==========================
// ===================
// === useListTags ===
// ===================
/** A list of asset tags, taking into account optimistic state. */
export function useBackendListTags(
export function useListTags(
backend: Backend | null,
): readonly WithPlaceholder<backendModule.Label>[] | null {
const listTagsQuery = useBackendQuery(backend, 'listTags', [])
@ -479,12 +444,12 @@ export function useBackendListTags(
}, [createTagVariables, deleteTagVariables, listTagsQuery.data])
}
// =========================
// === useBackendUsersMe ===
// =========================
// ==================
// === useUsersMe ===
// ==================
/** The current user, taking into account optimistic state. */
export function useBackendUsersMe(backend: Backend | null) {
export function useUsersMe(backend: Backend | null) {
const usersMeQuery = useBackendQuery(backend, 'usersMe', [])
const updateUserVariables = useBackendMutationVariables(backend, 'updateUser')
const uploadUserPictureVariables = useBackendMutationVariables(backend, 'uploadUserPicture')
@ -501,7 +466,7 @@ export function useBackendUsersMe(backend: Backend | null) {
for (const [, file] of uploadUserPictureVariables) {
result = {
...result,
profilePicture: backendModule.HttpsUrl(createUserPictureUrl(backend, file)),
profilePicture: backendModule.HttpsUrl(createUserPictureUrl(backend?.type, file)),
}
}
return result
@ -509,12 +474,12 @@ export function useBackendUsersMe(backend: Backend | null) {
}, [backend, usersMeQuery.data, updateUserVariables, uploadUserPictureVariables])
}
// =================================
// === useBackendGetOrganization ===
// =================================
// ==========================
// === useGetOrganization ===
// ==========================
/** The current user's organization, taking into account optimistic state. */
export function useBackendGetOrganization(backend: Backend | null) {
export function useGetOrganization(backend: Backend | null) {
const getOrganizationQuery = useBackendQuery(backend, 'getOrganization', [])
const updateOrganizationVariables = useBackendMutationVariables(backend, 'updateOrganization')
const uploadOrganizationPictureVariables = useBackendMutationVariables(
@ -532,7 +497,7 @@ export function useBackendGetOrganization(backend: Backend | null) {
for (const [, file] of uploadOrganizationPictureVariables) {
result = {
...result,
picture: backendModule.HttpsUrl(createOrganizationPictureUrl(backend, file)),
picture: backendModule.HttpsUrl(createOrganizationPictureUrl(backend?.type, file)),
}
}
return result

View File

@ -4,7 +4,7 @@
import * as React from 'react'
import * as sentry from '@sentry/react'
import * as reactQuery from '@tanstack/react-query'
import { QueryClientProvider } from '@tanstack/react-query'
import * as reactDOM from 'react-dom/client'
import * as reactRouter from 'react-router-dom'
import invariant from 'tiny-invariant'
@ -14,11 +14,16 @@ import * as detect from 'enso-common/src/detect'
import type * as app from '#/App'
import App from '#/App'
import { HttpClientProvider } from '#/providers/HttpClientProvider'
import LoggerProvider, { type Logger } from '#/providers/LoggerProvider'
import LoadingScreen from '#/pages/authentication/LoadingScreen'
import * as devtools from '#/components/Devtools'
import * as errorBoundary from '#/components/ErrorBoundary'
import * as suspense from '#/components/Suspense'
import { ReactQueryDevtools } from '#/components/Devtools'
import { ErrorBoundary } from '#/components/ErrorBoundary'
import { OfflineNotificationManager } from '#/components/OfflineNotificationManager'
import { Suspense } from '#/components/Suspense'
import UIProviders from '#/components/UIProviders'
import HttpClient from '#/utilities/HttpClient'
@ -35,6 +40,15 @@ const ROOT_ELEMENT_ID = 'enso-dashboard'
/** The fraction of non-erroring interactions that should be sampled by Sentry. */
const SENTRY_SAMPLE_RATE = 0.005
// ======================
// === DashboardProps ===
// ======================
/** Props for the dashboard. */
export interface DashboardProps extends app.AppProps {
readonly logger: Logger
}
// ===========
// === run ===
// ===========
@ -47,8 +61,8 @@ 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<app.AppProps, 'httpClient' | 'portalRoot'>) {
const { vibrancy, supportsDeepLinks, queryClient } = props
function run(props: DashboardProps) {
const { vibrancy, supportsDeepLinks, queryClient, logger } = props
if (
!detect.IS_DEV_MODE &&
process.env.ENSO_CLOUD_SENTRY_DSN != null &&
@ -99,20 +113,23 @@ function run(props: Omit<app.AppProps, 'httpClient' | 'portalRoot'>) {
React.startTransition(() => {
reactDOM.createRoot(root).render(
<React.StrictMode>
<reactQuery.QueryClientProvider client={queryClient}>
<errorBoundary.ErrorBoundary>
<suspense.Suspense fallback={<LoadingScreen />}>
<App
{...props}
supportsDeepLinks={actuallySupportsDeepLinks}
portalRoot={portalRoot}
httpClient={httpClient}
/>
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
<QueryClientProvider client={queryClient}>
<ErrorBoundary>
<Suspense fallback={<LoadingScreen />}>
<OfflineNotificationManager>
<LoggerProvider logger={logger}>
<HttpClientProvider httpClient={httpClient}>
<UIProviders locale="en-US" portalRoot={portalRoot}>
<App {...props} supportsDeepLinks={actuallySupportsDeepLinks} />
</UIProviders>
</HttpClientProvider>
</LoggerProvider>
</OfflineNotificationManager>
</Suspense>
</ErrorBoundary>
<devtools.ReactQueryDevtools />
</reactQuery.QueryClientProvider>
<ReactQueryDevtools />
</QueryClientProvider>
</React.StrictMode>,
)
})

View File

@ -1,6 +1,8 @@
/** @file A panel containing the description and settings for an asset. */
import * as React from 'react'
import * as z from 'zod'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as textProvider from '#/providers/TextProvider'
@ -14,7 +16,6 @@ import * as ariaComponents from '#/components/AriaComponents'
import type Backend from '#/services/Backend'
import * as backendModule from '#/services/Backend'
import * as array from '#/utilities/array'
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
import LocalStorage from '#/utilities/LocalStorage'
import * as tailwindMerge from '#/utilities/tailwindMerge'
@ -41,9 +42,8 @@ declare module '#/utilities/LocalStorage' {
}
}
const TABS = Object.values(AssetPanelTab)
LocalStorage.registerKey('assetPanelTab', {
tryParse: (value) => (array.includes(TABS, value) ? value : null),
schema: z.nativeEnum(AssetPanelTab),
})
// ==================

View File

@ -1,11 +1,13 @@
/** @file Display and modify the properties of an asset. */
import * as React from 'react'
import { useMutation } from '@tanstack/react-query'
import PenIcon from '#/assets/pen.svg'
import * as datalinkValidator from '#/data/datalinkValidator'
import * as backendHooks from '#/hooks/backendHooks'
import { backendMutationOptions, useListTags } from '#/hooks/backendHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
@ -71,7 +73,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
},
[setItemRaw],
)
const labels = backendHooks.useBackendListTags(backend) ?? []
const labels = useListTags(backend) ?? []
const self = item.item.permissions?.find(
backendModule.isUserPermissionAnd((permission) => permission.user.userId === user.userId),
)
@ -89,10 +91,10 @@ export default function AssetProperties(props: AssetPropertiesProps) {
localBackend?.getProjectPath(item.item.id) ?? null
: localBackendModule.extractTypeAndId(item.item.id).id
const createDatalinkMutation = backendHooks.useBackendMutation(backend, 'createDatalink')
const getDatalinkMutation = backendHooks.useBackendMutation(backend, 'getDatalink')
const updateAssetMutation = backendHooks.useBackendMutation(backend, 'updateAsset')
const getDatalinkMutate = getDatalinkMutation.mutateAsync
const createDatalinkMutation = useMutation(backendMutationOptions(backend, 'createDatalink'))
const getDatalinkMutation = useMutation(backendMutationOptions(backend, 'getDatalink'))
const updateAssetMutation = useMutation(backendMutationOptions(backend, 'updateAsset'))
const getDatalink = getDatalinkMutation.mutateAsync
React.useEffect(() => {
setDescription(item.item.description ?? '')
@ -101,13 +103,13 @@ export default function AssetProperties(props: AssetPropertiesProps) {
React.useEffect(() => {
void (async () => {
if (item.item.type === backendModule.AssetType.datalink) {
const value = await getDatalinkMutate([item.item.id, item.item.title])
const value = await getDatalink([item.item.id, item.item.title])
setDatalinkValue(value)
setEditedDatalinkValue(value)
setIsDatalinkFetched(true)
}
})()
}, [backend, item.item, getDatalinkMutate])
}, [backend, item.item, getDatalink])
const doEditDescription = async () => {
setIsEditingDescription(false)

View File

@ -141,7 +141,7 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
const querySource = React.useRef(QuerySource.external)
const rootRef = React.useRef<HTMLLabelElement | null>(null)
const searchRef = React.useRef<HTMLInputElement | null>(null)
const labels = backendHooks.useBackendListTags(backend) ?? []
const labels = backendHooks.useListTags(backend) ?? []
areSuggestionsVisibleRef.current = areSuggestionsVisible
React.useEffect(() => {

View File

@ -1,14 +1,16 @@
/** @file Table displaying a list of projects. */
import * as React from 'react'
import { useMutation } from '@tanstack/react-query'
import * as toast from 'react-toastify'
import * as z from 'zod'
import DropFilesImage from '#/assets/drop_files.svg'
import * as mimeTypes from '#/data/mimeTypes'
import * as autoScrollHooks from '#/hooks/autoScrollHooks'
import * as backendHooks from '#/hooks/backendHooks'
import { backendMutationOptions, useBackendQuery, useListTags } from '#/hooks/backendHooks'
import * as intersectionHooks from '#/hooks/intersectionHooks'
import * as projectHooks from '#/hooks/projectHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
@ -92,16 +94,12 @@ import Visibility from '#/utilities/Visibility'
declare module '#/utilities/LocalStorage' {
/** */
interface LocalStorageData {
readonly enabledColumns: columnUtils.Column[]
readonly enabledColumns: readonly columnUtils.Column[]
}
}
LocalStorage.registerKey('enabledColumns', {
tryParse: (value) => {
const possibleColumns = Array.isArray(value) ? value : []
const values = possibleColumns.filter(array.includesPredicate(columnUtils.CLOUD_COLUMNS))
return values.length === 0 ? null : values
},
schema: z.enum(columnUtils.CLOUD_COLUMNS).array().readonly(),
})
// =================
@ -389,7 +387,7 @@ export default function AssetsTable(props: AssetsTableProps) {
const { user } = authProvider.useNonPartialUserSession()
const backend = backendProvider.useBackend(category)
const labels = backendHooks.useBackendListTags(backend)
const labels = useListTags(backend)
const { setModal, unsetModal } = modalProvider.useSetModal()
const { localStorage } = localStorageProvider.useLocalStorage()
const { getText } = textProvider.useText()
@ -635,7 +633,7 @@ export default function AssetsTable(props: AssetsTableProps) {
true,
)
const updateSecretMutation = backendHooks.useBackendMutation(backend, 'updateSecret')
const updateSecret = useMutation(backendMutationOptions(backend, 'updateSecret')).mutateAsync
React.useEffect(() => {
previousCategoryRef.current = category
})
@ -988,7 +986,7 @@ export default function AssetsTable(props: AssetsTableProps) {
}
}, [backend, category])
const rootDirectoryQuery = backendHooks.useBackendQuery(
const rootDirectoryQuery = useBackendQuery(
backend,
'listDirectory',
[
@ -1263,7 +1261,7 @@ export default function AssetsTable(props: AssetsTableProps) {
name={item.item.title}
doCreate={async (_name, value) => {
try {
await updateSecretMutation.mutateAsync([id, { value }, item.item.title])
await updateSecret([id, { value }, item.item.title])
} catch (error) {
toastAndLog(null, error)
}

View File

@ -1,10 +1,12 @@
/** @file A list of selectable labels. */
import * as React from 'react'
import { useMutation } from '@tanstack/react-query'
import PlusIcon from '#/assets/plus.svg'
import Trash2Icon from '#/assets/trash2.svg'
import * as backendHooks from '#/hooks/backendHooks'
import { backendMutationOptions, useListTags } from '#/hooks/backendHooks'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
@ -47,13 +49,14 @@ export default function Labels(props: LabelsProps) {
const { setModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const dispatchAssetEvent = useDispatchAssetEvent()
const labels = backendHooks.useBackendListTags(backend) ?? []
const deleteTagMutation = backendHooks.useBackendMutation(backend, 'deleteTag', {
onSuccess: (_data, [, labelName]) => {
dispatchAssetEvent({ type: AssetEventType.deleteLabel, labelName })
},
})
const labels = useListTags(backend) ?? []
const deleteTag = useMutation(
backendMutationOptions(backend, 'deleteTag', {
onSuccess: (_data, [, labelName]) => {
dispatchAssetEvent({ type: AssetEventType.deleteLabel, labelName })
},
}),
).mutate
return (
<FocusArea direction="vertical">
@ -127,7 +130,7 @@ export default function Labels(props: LabelsProps) {
<ConfirmDeleteModal
actionText={getText('deleteLabelActionText', label.value)}
doDelete={() => {
deleteTagMutation.mutate([label.id, label.value])
deleteTag([label.id, label.value])
}}
/>,
)

View File

@ -1,11 +1,11 @@
/** @file Settings screen. */
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import BurgerMenuIcon from '#/assets/burger_menu.svg'
import * as backendHooks from '#/hooks/backendHooks'
import { backendMutationOptions, useGetOrganization } from '#/hooks/backendHooks'
import * as searchParamsState from '#/hooks/searchParamsStateHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
@ -41,6 +41,7 @@ export interface SettingsProps {
/** Settings screen. */
export default function Settings() {
const queryClient = useQueryClient()
const backend = backendProvider.useRemoteBackendStrict()
const localBackend = backendProvider.useLocalBackend()
const [tab, setTab] = searchParamsState.useSearchParamsState(
@ -49,24 +50,20 @@ export default function Settings() {
array.includesPredicate(Object.values(SettingsTabType)),
)
const { user, accessToken } = authProvider.useNonPartialUserSession()
const { authQueryKey } = authProvider.useAuth()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const [query, setQuery] = React.useState('')
const root = portal.useStrictPortalContext()
const [isSidebarPopoverOpen, setIsSidebarPopoverOpen] = React.useState(false)
const organization = backendHooks.useBackendGetOrganization(backend)
const organization = useGetOrganization(backend)
const isQueryBlank = !/\S/.test(query)
const client = reactQuery.useQueryClient()
const updateUserMutation = backendHooks.useBackendMutation(backend, 'updateUser', {
meta: { invalidates: [authQueryKey], awaitInvalidates: true },
})
const updateOrganizationMutation = backendHooks.useBackendMutation(backend, 'updateOrganization')
const updateUser = updateUserMutation.mutateAsync
const updateOrganization = updateOrganizationMutation.mutateAsync
const updateUser = useMutation(backendMutationOptions(backend, 'updateUser')).mutateAsync
const updateOrganization = useMutation(
backendMutationOptions(backend, 'updateOrganization'),
).mutateAsync
const updateLocalRootPathMutation = reactQuery.useMutation({
const updateLocalRootPath = useMutation({
mutationKey: [localBackend?.type, 'updateRootPath'],
mutationFn: (value: string) => {
if (localBackend) {
@ -75,9 +72,7 @@ export default function Settings() {
return Promise.resolve()
},
meta: { invalidates: [[localBackend?.type, 'listDirectory']], awaitInvalidates: true },
})
const updateLocalRootPath = updateLocalRootPathMutation.mutateAsync
}).mutateAsync
const context = React.useMemo<settingsData.SettingsContext>(
() => ({
@ -91,7 +86,7 @@ export default function Settings() {
updateLocalRootPath,
toastAndLog,
getText,
queryClient: client,
queryClient,
}),
[
accessToken,
@ -104,7 +99,7 @@ export default function Settings() {
updateOrganization,
updateUser,
user,
client,
queryClient,
],
)

View File

@ -78,7 +78,7 @@ export default function ActivityLogSettingsSection(props: ActivityLogSettingsSec
const [emailIndices, setEmailIndices] = React.useState<readonly number[]>(() => [])
const [sortInfo, setSortInfo] =
React.useState<sorting.SortInfo<ActivityLogSortableColumn> | null>(null)
const users = backendHooks.useBackendListUsers(backend)
const users = backendHooks.useListUsers(backend)
const allEmails = React.useMemo(() => (users ?? []).map((user) => user.email), [users])
const logsQuery = backendHooks.useBackendQuery(backend, 'getLogEvents', [])
const logs = logsQuery.data

View File

@ -1,9 +1,9 @@
/** @file Settings tab for viewing and editing organization members. */
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import { useMutation, useSuspenseQueries } from '@tanstack/react-query'
import * as backendHooks from '#/hooks/backendHooks'
import { backendMutationOptions } from '#/hooks/backendHooks'
import * as billingHooks from '#/hooks/billing'
import * as authProvider from '#/providers/AuthProvider'
@ -36,7 +36,7 @@ export default function MembersSettingsSection() {
const { isFeatureUnderPaywall, getFeature } = billingHooks.usePaywall({ plan: user.plan })
const [{ data: members }, { data: invitations }] = reactQuery.useSuspenseQueries({
const [{ data: members }, { data: invitations }] = useSuspenseQueries({
queries: [
{
queryKey: ['listUsers'],
@ -169,9 +169,11 @@ function ResendInvitationButton(props: ResendInvitationButtonProps) {
const { invitation, backend } = props
const { getText } = textProvider.useText()
const resendMutation = backendHooks.useBackendMutation(backend, 'resendInvitation', {
mutationKey: [invitation.userEmail],
})
const resendMutation = useMutation(
backendMutationOptions(backend, 'resendInvitation', {
mutationKey: [invitation.userEmail],
}),
)
return (
<ariaComponents.Button
@ -202,10 +204,12 @@ function RemoveMemberButton(props: RemoveMemberButtonProps) {
const { backend, userId } = props
const { getText } = textProvider.useText()
const removeMutation = backendHooks.useBackendMutation(backend, 'removeUser', {
mutationKey: [userId],
meta: { invalidates: [['listUsers']], awaitInvalidates: true },
})
const removeMutation = useMutation(
backendMutationOptions(backend, 'removeUser', {
mutationKey: [userId],
meta: { invalidates: [['listUsers']], awaitInvalidates: true },
}),
)
return (
<ariaComponents.Button
@ -234,10 +238,12 @@ function RemoveInvitationButton(props: RemoveInvitationButtonProps) {
const { getText } = textProvider.useText()
const removeMutation = backendHooks.useBackendMutation(backend, 'deleteInvitation', {
mutationKey: [email],
meta: { invalidates: [['listInvitations']], awaitInvalidates: true },
})
const removeMutation = useMutation(
backendMutationOptions(backend, 'deleteInvitation', {
mutationKey: [email],
meta: { invalidates: [['listInvitations']], awaitInvalidates: true },
}),
)
return (
<ariaComponents.Button

View File

@ -44,7 +44,7 @@ export default function MembersTable(props: MembersTableProps) {
const bodyRef = React.useRef<HTMLTableSectionElement>(null)
const userWithPlaceholder = React.useMemo(() => ({ isPlaceholder: false, ...user }), [user])
const backendListUsers = backendHooks.useBackendListUsers(backend)
const backendListUsers = backendHooks.useListUsers(backend)
const users = React.useMemo(
() => backendListUsers ?? (populateWithSelf ? [userWithPlaceholder] : null),

View File

@ -1,9 +1,11 @@
/** @file The input for viewing and changing the organization's profile picture. */
import * as React from 'react'
import { useMutation } from '@tanstack/react-query'
import DefaultUserIcon from '#/assets/default_user.svg'
import * as backendHooks from '#/hooks/backendHooks'
import { backendMutationOptions, useGetOrganization } from '#/hooks/backendHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as textProvider from '#/providers/TextProvider'
@ -29,23 +31,18 @@ export default function OrganizationProfilePictureInput(
const { backend } = props
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { getText } = textProvider.useText()
const organization = backendHooks.useBackendGetOrganization(backend)
const organization = useGetOrganization(backend)
const uploadOrganizationPictureMutation = backendHooks.useBackendMutation(
backend,
'uploadOrganizationPicture',
)
const uploadOrganizationPicture = useMutation(
backendMutationOptions(backend, 'uploadOrganizationPicture'),
).mutate
const doUploadOrganizationPicture = async (event: React.ChangeEvent<HTMLInputElement>) => {
const doUploadOrganizationPicture = (event: React.ChangeEvent<HTMLInputElement>) => {
const image = event.target.files?.[0]
if (image == null) {
toastAndLog('noNewProfilePictureError')
} else {
try {
await uploadOrganizationPictureMutation.mutateAsync([{ fileName: image.name }, image])
} catch (error) {
toastAndLog(null, error)
}
uploadOrganizationPicture([{ fileName: image.name }, image])
}
// Reset selected files, otherwise the file input will do nothing if the same file is
// selected again. While technically not undesired behavior, it is unintuitive for the user.

View File

@ -1,12 +1,13 @@
/** @file The input for viewing and changing the user's profile picture. */
import * as React from 'react'
import { useMutation } from '@tanstack/react-query'
import DefaultUserIcon from '#/assets/default_user.svg'
import * as backendHooks from '#/hooks/backendHooks'
import { backendMutationOptions, useUsersMe } from '#/hooks/backendHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
@ -27,26 +28,17 @@ export interface ProfilePictureInputProps {
export default function ProfilePictureInput(props: ProfilePictureInputProps) {
const { backend } = props
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { setUser } = authProvider.useAuth()
const user = backendHooks.useBackendUsersMe(backend)
const user = useUsersMe(backend)
const { getText } = textProvider.useText()
const uploadUserPictureMutation = backendHooks.useBackendMutation(backend, 'uploadUserPicture')
const uploadUserPicture = useMutation(backendMutationOptions(backend, 'uploadUserPicture')).mutate
const doUploadUserPicture = async (event: React.ChangeEvent<HTMLInputElement>) => {
const doUploadUserPicture = (event: React.ChangeEvent<HTMLInputElement>) => {
const image = event.target.files?.[0]
if (image == null) {
toastAndLog('noNewProfilePictureError')
} else {
try {
const newUser = await uploadUserPictureMutation.mutateAsync([
{ fileName: image.name },
image,
])
setUser(newUser)
} catch (error) {
toastAndLog(null, error)
}
uploadUserPicture([{ fileName: image.name }, image])
}
// Reset selected files, otherwise the file input will do nothing if the same file is
// selected again. While technically not undesired behavior, it is unintuitive for the user.

View File

@ -1,9 +1,15 @@
/** @file Settings tab for viewing and editing roles for all users in the organization. */
import * as React from 'react'
import { useMutation } from '@tanstack/react-query'
import * as mimeTypes from '#/data/mimeTypes'
import * as backendHooks from '#/hooks/backendHooks'
import {
backendMutationOptions,
useListUserGroupsWithUsers,
useListUsers,
} from '#/hooks/backendHooks'
import * as billingHooks from '#/hooks/billing'
import * as scrollHooks from '#/hooks/scrollHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
@ -43,12 +49,16 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
const { getText } = textProvider.useText()
const { user } = authProvider.useFullUserSession()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const users = backendHooks.useBackendListUsers(backend)
const userGroups = backendHooks.useBackendListUserGroupsWithUsers(backend)
const users = useListUsers(backend)
const userGroups = useListUserGroupsWithUsers(backend)
const rootRef = React.useRef<HTMLDivElement>(null)
const bodyRef = React.useRef<HTMLTableSectionElement>(null)
const changeUserGroup = backendHooks.useBackendMutation(backend, 'changeUserGroup')
const deleteUserGroup = backendHooks.useBackendMutation(backend, 'deleteUserGroup')
const changeUserGroup = useMutation(
backendMutationOptions(backend, 'changeUserGroup'),
).mutateAsync
const deleteUserGroup = useMutation(
backendMutationOptions(backend, 'deleteUserGroup'),
).mutateAsync
const usersMap = React.useMemo(
() => new Map((users ?? []).map((otherUser) => [otherUser.userId, otherUser])),
[users],
@ -90,7 +100,7 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
if (!groups.includes(userGroupId)) {
try {
const newUserGroups = [...groups, userGroupId]
await changeUserGroup.mutateAsync([
await changeUserGroup([
newUser.userId,
{ userGroups: newUserGroups },
newUser.name,
@ -108,7 +118,7 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
const doDeleteUserGroup = async (userGroup: backendModule.UserGroupInfo) => {
try {
await deleteUserGroup.mutateAsync([userGroup.id, userGroup.groupName])
await deleteUserGroup([userGroup.id, userGroup.groupName])
} catch (error) {
toastAndLog('deleteUserGroupError', error, userGroup.groupName)
}
@ -122,11 +132,7 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
const intermediateUserGroups =
otherUser.userGroups?.filter((userGroupId) => userGroupId !== userGroup.id) ?? null
const newUserGroups = intermediateUserGroups?.length === 0 ? null : intermediateUserGroups
await changeUserGroup.mutateAsync([
otherUser.userId,
{ userGroups: newUserGroups ?? [] },
otherUser.name,
])
await changeUserGroup([otherUser.userId, { userGroups: newUserGroups ?? [] }, otherUser.name])
} catch (error) {
toastAndLog('removeUserFromUserGroupError', error, otherUser.name, userGroup.groupName)
}

View File

@ -1,10 +1,10 @@
/** @file A modal with inputs for user email and permission level. */
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import { useMutation, useSuspenseQueries } from '@tanstack/react-query'
import isEmail from 'validator/es/lib/isEmail'
import * as backendHooks from '#/hooks/backendHooks'
import { backendMutationOptions } from '#/hooks/backendHooks'
import * as billingHooks from '#/hooks/billing'
import * as eventCallbackHooks from '#/hooks/eventCallbackHooks'
@ -38,11 +38,13 @@ export function InviteUsersForm(props: InviteUsersFormProps) {
const { user } = authProvider.useFullUserSession()
const { isFeatureUnderPaywall, getFeature } = billingHooks.usePaywall({ plan: user.plan })
const inviteUserMutation = backendHooks.useBackendMutation(backend, 'inviteUser', {
meta: { invalidates: [['listInvitations']], awaitInvalidates: true },
})
const inviteUserMutation = useMutation(
backendMutationOptions(backend, 'inviteUser', {
meta: { invalidates: [['listInvitations']], awaitInvalidates: true },
}),
)
const [{ data: usersCount }, { data: invitationsCount }] = reactQuery.useSuspenseQueries({
const [{ data: usersCount }, { data: invitationsCount }] = useSuspenseQueries({
queries: [
{
queryKey: ['listInvitations'],

View File

@ -1,7 +1,9 @@
/** @file A modal to select labels for an asset. */
import * as React from 'react'
import * as backendHooks from '#/hooks/backendHooks'
import { useMutation } from '@tanstack/react-query'
import { backendMutationOptions, useListTags } from '#/hooks/backendHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as modalProvider from '#/providers/ModalProvider'
@ -56,7 +58,7 @@ export default function ManageLabelsModal<
const { unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const allLabels = backendHooks.useBackendListTags(backend)
const allLabels = useListTags(backend)
const [labels, setLabelsRaw] = React.useState(item.labels ?? [])
const [query, setQuery] = React.useState('')
const [color, setColor] = React.useState<backendModule.LChColor | null>(null)
@ -73,8 +75,8 @@ export default function ManageLabelsModal<
)
const canCreateNewLabel = canSelectColor
const createTagMutation = backendHooks.useBackendMutation(backend, 'createTag')
const associateTagMutation = backendHooks.useBackendMutation(backend, 'associateTag')
const createTag = useMutation(backendMutationOptions(backend, 'createTag')).mutateAsync
const associateTag = useMutation(backendMutationOptions(backend, 'associateTag')).mutateAsync
const setLabels = React.useCallback(
(valueOrUpdater: React.SetStateAction<backendModule.LabelName[]>) => {
@ -98,7 +100,7 @@ export default function ManageLabelsModal<
labelNames.has(name) ? labels.filter((label) => label !== name) : [...labels, name]
setLabels(newLabels)
try {
await associateTagMutation.mutateAsync([item.id, newLabels, item.title])
await associateTag([item.id, newLabels, item.title])
} catch (error) {
toastAndLog(null, error)
setLabels(labels)
@ -110,9 +112,9 @@ export default function ManageLabelsModal<
const labelName = backendModule.LabelName(query)
setLabels((oldLabels) => [...oldLabels, labelName])
try {
await createTagMutation.mutateAsync([{ value: labelName, color: color ?? leastUsedColor }])
await createTag([{ value: labelName, color: color ?? leastUsedColor }])
setLabels((newLabels) => {
associateTagMutation.mutate([item.id, newLabels, item.title])
void associateTag([item.id, newLabels, item.title])
return newLabels
})
} catch (error) {

View File

@ -1,11 +1,11 @@
/** @file A modal with inputs for user email and permission level. */
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import { useMutation, useQuery } from '@tanstack/react-query'
import * as toast from 'react-toastify'
import isEmail from 'validator/es/lib/isEmail'
import * as backendHooks from '#/hooks/backendHooks'
import { backendMutationOptions } from '#/hooks/backendHooks'
import * as billingHooks from '#/hooks/billing'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
@ -70,14 +70,14 @@ export default function ManagePermissionsModal<
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
const isUnderPaywall = isFeatureUnderPaywall('shareFull')
const listedUsers = reactQuery.useQuery({
const listedUsers = useQuery({
queryKey: ['listUsers'],
queryFn: () => remoteBackend.listUsers(),
enabled: !isUnderPaywall,
select: (data) => (isUnderPaywall ? [] : data),
})
const listedUserGroups = reactQuery.useQuery({
const listedUserGroups = useQuery({
queryKey: ['listUserGroups'],
queryFn: () => remoteBackend.listUserGroups(),
})
@ -122,10 +122,9 @@ export default function ManagePermissionsModal<
[user.userId, permissions, self.permission],
)
const inviteUserMutation = backendHooks.useBackendMutation(remoteBackend, 'inviteUser')
const createPermissionMutation = backendHooks.useBackendMutation(
remoteBackend,
'createPermission',
const inviteUserMutation = useMutation(backendMutationOptions(remoteBackend, 'inviteUser'))
const createPermissionMutation = useMutation(
backendMutationOptions(remoteBackend, 'createPermission'),
)
React.useEffect(() => {

View File

@ -1,8 +1,9 @@
/** @file A modal for creating a new label. */
import * as React from 'react'
import * as backendHooks from '#/hooks/backendHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import { useMutation } from '@tanstack/react-query'
import { backendMutationOptions, useListTags } from '#/hooks/backendHooks'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
@ -39,13 +40,12 @@ export interface NewLabelModalProps {
/** A modal for creating a new label. */
export default function NewLabelModal(props: NewLabelModalProps) {
const { backend, eventTarget } = props
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const [value, setValue] = React.useState('')
const [color, setColor] = React.useState<backendModule.LChColor | null>(null)
const position = React.useMemo(() => eventTarget.getBoundingClientRect(), [eventTarget])
const labelsRaw = backendHooks.useBackendListTags(backend)
const labelsRaw = useListTags(backend)
const labels = React.useMemo(() => labelsRaw ?? [], [labelsRaw])
const labelNames = React.useMemo(
() => new Set<string>(labels.map((label) => label.value)),
@ -54,16 +54,12 @@ export default function NewLabelModal(props: NewLabelModalProps) {
const leastUsedColor = React.useMemo(() => backendModule.leastUsedColor(labels), [labels])
const canSubmit = Boolean(value && !labelNames.has(value))
const createTagMutation = backendHooks.useBackendMutation(backend, 'createTag')
const createTag = useMutation(backendMutationOptions(backend, 'createTag')).mutate
const doSubmit = async () => {
const doSubmit = () => {
if (value !== '') {
unsetModal()
try {
await createTagMutation.mutateAsync([{ value, color: color ?? leastUsedColor }])
} catch (error) {
toastAndLog(null, error)
}
createTag([{ value, color: color ?? leastUsedColor }])
}
}
@ -84,7 +80,7 @@ export default function NewLabelModal(props: NewLabelModalProps) {
event.preventDefault()
// Consider not calling `onSubmit()` here to make it harder to accidentally
// delete an important asset.
void doSubmit()
doSubmit()
}}
>
<aria.Heading level={2} className="relative text-sm font-semibold">

View File

@ -1,7 +1,9 @@
/** @file A modal to create a user group. */
import * as React from 'react'
import * as backendHooks from '#/hooks/backendHooks'
import { useMutation } from '@tanstack/react-query'
import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as modalProvider from '#/providers/ModalProvider'
@ -34,7 +36,7 @@ export default function NewUserGroupModal(props: NewUserGroupModalProps) {
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const [name, setName] = React.useState('')
const listUserGroupsQuery = backendHooks.useBackendQuery(backend, 'listUserGroups', [])
const listUserGroupsQuery = useBackendQuery(backend, 'listUserGroups', [])
const userGroups = listUserGroupsQuery.data ?? null
const userGroupNames = React.useMemo(
() =>
@ -47,14 +49,16 @@ export default function NewUserGroupModal(props: NewUserGroupModalProps) {
userGroupNames != null && userGroupNames.has(string.normalizeName(name)) ?
getText('duplicateUserGroupError')
: null
const createUserGroupMutation = backendHooks.useBackendMutation(backend, 'createUserGroup')
const createUserGroup = useMutation(
backendMutationOptions(backend, 'createUserGroup'),
).mutateAsync
const canSubmit = nameError == null && name !== '' && userGroupNames != null
const onSubmit = async () => {
if (canSubmit) {
unsetModal()
try {
await createUserGroupMutation.mutateAsync([{ name }])
await createUserGroup([{ name }])
} catch (error) {
toastAndLog(null, error)
}

View File

@ -1,9 +1,11 @@
/** @file Modal for setting the organization name. */
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
import * as router from 'react-router'
import { backendMutationOptions } from '#/hooks/backendHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import type { GetText } from '#/providers/TextProvider'
@ -35,18 +37,16 @@ export function SetOrganizationNameModal() {
const userId = user?.userId ?? null
const userPlan = user?.plan ?? null
const { data: organizationName } = reactQuery.useSuspenseQuery({
const { data: organizationName } = useSuspenseQuery({
queryKey: ['organization', userId],
queryFn: () => backend.getOrganization().catch(() => null),
staleTime: Infinity,
select: (data) => data?.name ?? '',
})
const submit = reactQuery.useMutation({
mutationKey: ['organization', userId],
mutationFn: (name: string) => backend.updateOrganization({ name }),
meta: { invalidates: [['organization', userId]], awaitInvalidates: true },
})
const updateOrganization = useMutation(
backendMutationOptions(backend, 'updateOrganization'),
).mutateAsync
const shouldShowModal =
userPlan != null && PLANS_TO_SPECIFY_ORG_NAME.includes(userPlan) && organizationName === ''
@ -62,7 +62,7 @@ export function SetOrganizationNameModal() {
>
<SetOrganizationNameForm
onSubmit={async (name) => {
await submit.mutateAsync(name)
await updateOrganization([{ name }])
}}
/>
</ariaComponents.Dialog>

View File

@ -116,7 +116,7 @@ export function TermsOfServiceModal() {
testId="terms-of-service-form"
method="dialog"
onSubmit={({ hash }) => {
localStorage.set('termsOfService', { versionHash: hash })
localStorage.set('termsOfService', { versionHash: hash }, { triggerRerender: true })
}}
>
{({ register }) => (

View File

@ -2,6 +2,7 @@
import * as React from 'react'
import * as router from 'react-router-dom'
import * as z from 'zod'
import AtIcon from '#/assets/at.svg'
import CreateAccountIcon from '#/assets/create_account.svg'
@ -38,7 +39,7 @@ declare module '#/utilities/LocalStorage' {
LocalStorage.registerKey('loginRedirect', {
isUserSpecific: true,
tryParse: (value) => (typeof value === 'string' ? value : null),
schema: z.string(),
})
// ====================

View File

@ -5,33 +5,12 @@ import * as common from 'enso-common'
import * as object from '#/utilities/object'
// ====================
// === LocalStorage ===
// ====================
// ===============================
// === LocalStorageKeyMetadata ===
// ===============================
/** Metadata describing runtime behavior associated with a {@link LocalStorageKey}. */
export type LocalStorageKeyMetadata<K extends LocalStorageKey> =
| LocalStorageKeyMetadataWithParseFunction<K>
| LocalStorageKeyMetadataWithSchema<K>
/**
* A {@link LocalStorageKeyMetadata} with a `tryParse` function.
*/
interface LocalStorageKeyMetadataWithParseFunction<K extends LocalStorageKey> {
readonly isUserSpecific?: boolean
/**
* A function to parse a value from the stored data.
* If this is provided, the value will be parsed using this function.
* If this is not provided, the value will be parsed using the `schema`.
*/
readonly tryParse: (value: unknown) => LocalStorageData[K] | null
readonly schema?: never
}
/**
* A {@link LocalStorageKeyMetadata} with a `schema`.
*/
interface LocalStorageKeyMetadataWithSchema<K extends LocalStorageKey> {
export interface LocalStorageKeyMetadata<K extends LocalStorageKey> {
readonly isUserSpecific?: boolean
/**
* The Zod schema to validate the value.
@ -39,16 +18,36 @@ interface LocalStorageKeyMetadataWithSchema<K extends LocalStorageKey> {
* If this is not provided, the value will be parsed using the `tryParse` function.
*/
readonly schema: z.ZodType<LocalStorageData[K]>
readonly tryParse?: never
}
// ========================
// === LocalStorageData ===
// ========================
/** The data that can be stored in a {@link LocalStorage}.
* Declaration merge into this interface to add a new key. */
export interface LocalStorageData {}
// =======================
// === LocalStorageKey ===
// =======================
/** All possible keys of a {@link LocalStorage}. */
type LocalStorageKey = keyof LocalStorageData
// =================================
// === LocalStorageMutateOptions ===
// =================================
/** Options for methods that mutate `localStorage` state (set, delete, and save). */
export interface LocalStorageMutateOptions {
readonly triggerRerender?: boolean
}
// ====================
// === LocalStorage ===
// ====================
/** A LocalStorage data manager. */
export default class LocalStorage {
// This is UNSAFE. It is assumed that `LocalStorage.register` is always called
@ -68,10 +67,7 @@ export default class LocalStorage {
// This is SAFE, as it is guarded by the `key in savedValues` check.
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, no-restricted-syntax, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
const savedValue = (savedValues as any)[key]
const value =
metadata.schema ?
metadata.schema.safeParse(savedValue).data
: metadata.tryParse(savedValue)
const value = metadata.schema.safeParse(savedValue).data
if (value != null) {
newValues[key] = value
}
@ -95,18 +91,22 @@ export default class LocalStorage {
}
/** Write an entry to the stored data, and save. */
set<K extends LocalStorageKey>(key: K, value: LocalStorageData[K]) {
set<K extends LocalStorageKey>(
key: K,
value: LocalStorageData[K],
options?: LocalStorageMutateOptions,
) {
this.values[key] = value
this.save()
this.save(options)
}
/** Delete an entry from the stored data, and save. */
delete<K extends LocalStorageKey>(key: K) {
delete<K extends LocalStorageKey>(key: K, options?: LocalStorageMutateOptions) {
const oldValue = this.values[key]
// The key being deleted is one of a statically known set of keys.
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete this.values[key]
this.save()
this.save(options)
return oldValue
}
@ -120,8 +120,11 @@ export default class LocalStorage {
}
/** Save the current value of the stored data.. */
protected save() {
protected save(options: LocalStorageMutateOptions = {}) {
const { triggerRerender = false } = options
localStorage.setItem(this.localStorageKey, JSON.stringify(this.values))
this.triggerRerender()
if (triggerRerender) {
this.triggerRerender()
}
}
}