mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 16:11:45 +03:00
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:
parent
b286adaae4
commit
5079b21207
26
app/.vscode/react.code-snippets
vendored
26
app/.vscode/react.code-snippets
vendored
@ -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}===="],
|
||||
},
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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}`)
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
28
app/dashboard/src/components/UIProviders.tsx
Normal file
28
app/dashboard/src/components/UIProviders.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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(
|
||||
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -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])
|
||||
|
@ -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
|
||||
|
@ -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>,
|
||||
)
|
||||
})
|
||||
|
@ -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),
|
||||
})
|
||||
|
||||
// ==================
|
||||
|
@ -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)
|
||||
|
@ -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(() => {
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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])
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
@ -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,
|
||||
],
|
||||
)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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),
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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'],
|
||||
|
@ -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) {
|
||||
|
@ -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(() => {
|
||||
|
@ -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">
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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 }) => (
|
||||
|
@ -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(),
|
||||
})
|
||||
|
||||
// ====================
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user