Skip to content

Commit

Permalink
(even more) Dashboard fixes (#6919)
Browse files Browse the repository at this point in the history
* Change cdn domain

Fixes cloud-v2/#484

* Remove modal from file upload flow

Fixes part of cloud-v2/#483

* Add optimistic UI; add more toast messages

Fixes part of cloud-v2/#483

* Handle `authentication` and `new-dashboard` flags correctly

Fixes cloud-v2/#481

* Fix bugs

* Use offline mode when `-authentication=false`

* Stop row from resizing when loading permissions

* Fix issues

* Navigate back to login screen on registration success

* Prevent scrollbar flickering

The scrollbar still blinks when switching backends, but it now does not resize. The blinking seems to be very difficult to avoid.

* Stop modal close button from overlapping text

* Fix stop icon spinning when switching backends

Remote backend not tested

* Remove modal from files table plus button

* Scrollbar-aware column count
  • Loading branch information
somebody1234 committed Jun 28, 2023
1 parent 56688ec commit b4d0a40
Show file tree
Hide file tree
Showing 20 changed files with 469 additions and 357 deletions.
3 changes: 2 additions & 1 deletion app/ide-desktop/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ const NAME = 'enso'
const DEFAULT_IMPORT_ONLY_MODULES =
'node:process|chalk|string-length|yargs|yargs\\u002Fyargs|sharp|to-ico|connect|morgan|serve-static|create-servers|electron-is-dev|fast-glob|esbuild-plugin-.+|opener|tailwindcss.*|enso-assets.*'
const ALLOWED_DEFAULT_IMPORT_MODULES = `${DEFAULT_IMPORT_ONLY_MODULES}|postcss|react-hot-toast`
const OUR_MODULES = 'enso-content-config|enso-common|enso-common\\u002Fsrc\\u002Fdetect'
const OUR_MODULES =
'enso-authentication|enso-content-config|enso-common|enso-common\\u002Fsrc\\u002Fdetect'
const RELATIVE_MODULES =
'bin\\u002Fproject-manager|bin\\u002Fserver|config\\u002Fparser|authentication|config|debug|file-associations|index|ipc|log|naming|paths|preload|security|url-associations'
const STRING_LITERAL = ':matches(Literal[raw=/^["\']/], TemplateLiteral)'
Expand Down
53 changes: 35 additions & 18 deletions app/ide-desktop/lib/content/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@

import * as semver from 'semver'

import * as authentication from 'enso-authentication'
import * as common from 'enso-common'
import * as contentConfig from 'enso-content-config'
import * as dashboard from 'enso-authentication'
import * as detect from 'enso-common/src/detect'

import * as app from '../../../../../target/ensogl-pack/linked-dist'
Expand Down Expand Up @@ -139,6 +139,15 @@ interface StringConfig {
[key: string]: StringConfig | string
}

/** Configuration options for the authentication flow and dashboard. */
interface AuthenticationConfig {
isInAuthenticationFlow: boolean
shouldUseAuthentication: boolean
shouldUseNewDashboard: boolean
initialProjectName: string | null
inputConfig: StringConfig | null
}

/** Contains the entrypoint into the IDE. */
class Main implements AppRunner {
app: app.App | null = null
Expand All @@ -150,7 +159,7 @@ class Main implements AppRunner {

/** Run an app instance with the specified configuration.
* This includes the scene to run and the WebSocket endpoints to the backend. */
async runApp(inputConfig?: StringConfig) {
async runApp(inputConfig?: StringConfig | null) {
this.stopApp()

/** FIXME: https://github.com/enso-org/enso/issues/6475
Expand Down Expand Up @@ -214,30 +223,36 @@ class Main implements AppRunner {
localStorage.setItem(INITIAL_URL_KEY, location.href)
}
if (parseOk) {
const isUsingAuthentication = contentConfig.OPTIONS.options.authentication.value
const isUsingNewDashboard =
const shouldUseAuthentication = contentConfig.OPTIONS.options.authentication.value
const shouldUseNewDashboard =
contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value
const isOpeningMainEntryPoint =
contentConfig.OPTIONS.groups.startup.options.entry.value ===
contentConfig.OPTIONS.groups.startup.options.entry.default
const initialProjectName =
contentConfig.OPTIONS.groups.startup.options.project.value || null
// This MUST be removed as it would otherwise override the `startup.project` passed
// explicitly in `ide.tsx`.
if (isOpeningMainEntryPoint && url.searchParams.has('startup.project')) {
url.searchParams.delete('startup.project')
history.replaceState(null, '', url.toString())
}
if ((isUsingAuthentication || isUsingNewDashboard) && isOpeningMainEntryPoint) {
this.runAuthentication(isInAuthenticationFlow, inputConfig)
if ((shouldUseAuthentication || shouldUseNewDashboard) && isOpeningMainEntryPoint) {
this.runAuthentication({
isInAuthenticationFlow,
shouldUseAuthentication,
shouldUseNewDashboard,
initialProjectName,
inputConfig: inputConfig ?? null,
})
} else {
void this.runApp(inputConfig)
}
}
}

/** Begins the authentication UI flow. */
runAuthentication(isInAuthenticationFlow: boolean, inputConfig?: StringConfig) {
const initialProjectName =
contentConfig.OPTIONS.groups.startup.options.project.value || null
runAuthentication(config: AuthenticationConfig) {
/** TODO [NP]: https://github.com/enso-org/cloud-v2/issues/345
* `content` and `dashboard` packages **MUST BE MERGED INTO ONE**. The IDE
* should only have one entry point. Right now, we have two. One for the cloud
Expand All @@ -247,30 +262,32 @@ class Main implements AppRunner {
* Enso main scene being initialized. As a temporary workaround we check whether
* appInstance was already ran. Target solution should move running appInstance
* where it will be called only once. */
authentication.run({
dashboard.run({
appRunner: this,
logger,
supportsLocalBackend: SUPPORTS_LOCAL_BACKEND,
supportsDeepLinks: SUPPORTS_DEEP_LINKS,
showDashboard: contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value,
initialProjectName,
isAuthenticationDisabled: !config.shouldUseAuthentication,
shouldShowDashboard: config.shouldUseNewDashboard,
initialProjectName: config.initialProjectName,
onAuthenticated: () => {
if (isInAuthenticationFlow) {
if (config.isInAuthenticationFlow) {
const initialUrl = localStorage.getItem(INITIAL_URL_KEY)
if (initialUrl != null) {
// This is not used past this point, however it is set to the initial URL
// to make refreshing work as expected.
history.replaceState(null, '', initialUrl)
}
}
if (!contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value) {
if (!config.shouldUseNewDashboard) {
document.getElementById('enso-dashboard')?.remove()
const ide = document.getElementById('root')
if (ide) {
ide.hidden = false
const ideElement = document.getElementById('root')
if (ideElement) {
ideElement.style.top = ''
ideElement.style.display = ''
}
if (this.app == null) {
void this.runApp(inputConfig)
void this.runApp(config.inputConfig)
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ const AuthContext = react.createContext<AuthContextType>({} as AuthContextType)

/** Props for an {@link AuthProvider}. */
export interface AuthProviderProps {
shouldStartInOfflineMode: boolean
authService: authServiceModule.AuthService
/** Callback to execute once the user has authenticated successfully. */
onAuthenticated: () => void
Expand All @@ -170,7 +171,7 @@ export interface AuthProviderProps {

/** A React provider for the Cognito API. */
export function AuthProvider(props: AuthProviderProps) {
const { authService, onAuthenticated, children } = props
const { shouldStartInOfflineMode, authService, onAuthenticated, children } = props
const { cognito } = authService
const { session, deinitializeSession } = sessionProvider.useSession()
const { setBackendWithoutSavingType } = backendProvider.useSetBackend()
Expand All @@ -179,6 +180,8 @@ export function AuthProvider(props: AuthProviderProps) {
// and the function call would error.
// eslint-disable-next-line no-restricted-properties
const navigate = router.useNavigate()

const [forceOfflineMode, setForceOfflineMode] = react.useState(shouldStartInOfflineMode)
const [initialized, setInitialized] = react.useState(false)
const [userSession, setUserSession] = react.useState<UserSession | null>(null)

Expand All @@ -197,8 +200,9 @@ export function AuthProvider(props: AuthProviderProps) {
* If the token has expired, automatically refreshes the token and returns the new token. */
react.useEffect(() => {
const fetchSession = async () => {
if (!navigator.onLine) {
if (!navigator.onLine || forceOfflineMode) {
goOfflineInternal()
setForceOfflineMode(false)
} else if (session.none) {
setInitialized(true)
setUserSession(null)
Expand All @@ -210,7 +214,11 @@ export function AuthProvider(props: AuthProviderProps) {
const backend = new remoteBackend.RemoteBackend(client, logger)
// The backend MUST be the remote backend before login is finished.
// This is because the "set username" flow requires the remote backend.
if (!initialized || userSession == null) {
if (
!initialized ||
userSession == null ||
userSession.type === UserSessionType.offline
) {
setBackendWithoutSavingType(backend)
}
let organization
Expand Down Expand Up @@ -300,6 +308,7 @@ export function AuthProvider(props: AuthProviderProps) {
const result = await cognito.signUp(username, password)
if (result.ok) {
toast.success(MESSAGES.signUpSuccess)
navigate(app.LOGIN_PATH)
} else {
toast.error(result.val.message)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ import * as toast from 'react-hot-toast'

import * as detect from 'enso-common/src/detect'

import * as authService from '../authentication/service'
import * as authServiceModule from '../authentication/service'
import * as hooks from '../hooks'
import * as localBackend from '../dashboard/localBackend'

import * as authProvider from '../authentication/providers/auth'
import * as backendProvider from '../providers/backend'
Expand Down Expand Up @@ -85,11 +86,13 @@ export interface AppProps {
logger: loggerProvider.Logger
/** Whether the application may have the local backend running. */
supportsLocalBackend: boolean
/** If true, the app can only be used in offline mode. */
isAuthenticationDisabled: boolean
/** Whether the application supports deep links. This is only true when using
* the installed app on macOS and Windows. */
supportsDeepLinks: boolean
/** Whether the dashboard should be rendered. */
showDashboard: boolean
shouldShowDashboard: boolean
/** The name of the project to open on startup, if any. */
initialProjectName: string | null
onAuthenticated: () => void
Expand All @@ -109,7 +112,11 @@ function App(props: AppProps) {
* will redirect the user between the login/register pages and the dashboard. */
return (
<>
<toast.Toaster position="top-center" reverseOrder={false} />
<toast.Toaster
toastOptions={{ style: { maxWidth: '100%' } }}
position="top-center"
reverseOrder={false}
/>
<Router>
<AppRouter {...props} />
</Router>
Expand All @@ -127,7 +134,7 @@ function App(props: 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: AppProps) {
const { logger, showDashboard, onAuthenticated } = props
const { logger, isAuthenticationDisabled, shouldShowDashboard, onAuthenticated } = props
const navigate = hooks.useNavigate()
// FIXME[sb]: After platform detection for Electron is merged in, `IS_DEV_MODE` should be
// set to true on `ide watch`.
Expand All @@ -136,12 +143,17 @@ function AppRouter(props: AppProps) {
window.navigate = navigate
}
const mainPageUrl = new URL(window.location.href)
const memoizedAuthService = react.useMemo(() => {
const authService = react.useMemo(() => {
const authConfig = { navigate, ...props }
return authService.initAuthService(authConfig)
return authServiceModule.initAuthService(authConfig)
}, [navigate, props])
const userSession = memoizedAuthService.cognito.userSession.bind(memoizedAuthService.cognito)
const registerAuthEventListener = memoizedAuthService.registerAuthEventListener
const userSession = authService.cognito.userSession.bind(authService.cognito)
const registerAuthEventListener = authService.registerAuthEventListener
const initialBackend: backendProvider.AnyBackendAPI = isAuthenticationDisabled
? new localBackend.LocalBackend()
: // This is safe, because the backend is always set by the authentication flow.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
null!
const routes = (
<router.Routes>
<react.Fragment>
Expand All @@ -154,7 +166,7 @@ function AppRouter(props: AppProps) {
<router.Route element={<authProvider.ProtectedLayout />}>
<router.Route
path={DASHBOARD_PATH}
element={showDashboard && <Dashboard {...props} />}
element={shouldShowDashboard && <Dashboard {...props} />}
/>
</router.Route>
{/* Semi-protected pages are visible to users currently registering. */}
Expand All @@ -175,11 +187,10 @@ function AppRouter(props: AppProps) {
userSession={userSession}
registerAuthEventListener={registerAuthEventListener}
>
{/* This is safe, because the backend is always set by the authentication flow. */}
{/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */}
<backendProvider.BackendProvider initialBackend={null!}>
<backendProvider.BackendProvider initialBackend={initialBackend}>
<authProvider.AuthProvider
authService={memoizedAuthService}
shouldStartInOfflineMode={isAuthenticationDisabled}
authService={authService}
onAuthenticated={onAuthenticated}
>
<modalProvider.ModalProvider>{routes}</modalProvider.ModalProvider>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
/** @file Modal for confirming delete of any type of asset. */
import * as react from 'react'
import toast from 'react-hot-toast'

import CloseIcon from 'enso-assets/close.svg'
Expand All @@ -17,30 +16,26 @@ export interface ConfirmDeleteModalProps {
assetType: string
name: string
doDelete: () => Promise<void>
onSuccess: () => void
onComplete: () => void
}

/** A modal for confirming the deletion of an asset. */
function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
const { assetType, name, doDelete, onSuccess } = props
const { assetType, name, doDelete, onComplete } = props
const { unsetModal } = modalProvider.useSetModal()

const [isSubmitting, setIsSubmitting] = react.useState(false)

const onSubmit = async () => {
if (!isSubmitting) {
try {
setIsSubmitting(true)
await toast.promise(doDelete(), {
loading: `Deleting ${assetType}...`,
success: `Deleted ${assetType}.`,
error: `Could not delete ${assetType}.`,
})
unsetModal()
onSuccess()
} finally {
setIsSubmitting(false)
}
unsetModal()
try {
await toast.promise(doDelete(), {
loading: `Deleting ${assetType} '${name}'...`,
success: `Deleted ${assetType} '${name}'.`,
// This is UNSAFE, as the original function's parameter is of type `any`.
error: (promiseError: Error) =>
`Error deleting ${assetType} '${name}': ${promiseError.message}`,
})
} finally {
onComplete()
}
}

Expand All @@ -58,26 +53,26 @@ function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
}}
className="relative bg-white shadow-soft rounded-lg w-96 p-2"
>
<button type="button" className="absolute right-0 top-0 m-2" onClick={unsetModal}>
<img src={CloseIcon} />
</button>
Are you sure you want to delete the {assetType} '{name}'?
<div className="flex">
{/* Padding. */}
<div className="grow" />
<button type="button" onClick={unsetModal}>
<img src={CloseIcon} />
</button>
</div>
<div className="m-2">
Are you sure you want to delete the {assetType} '{name}'?
</div>
<div className="m-1">
<button
type="submit"
disabled={isSubmitting}
className={`hover:cursor-pointer inline-block text-white bg-red-500 rounded-full px-4 py-1 m-1 ${
isSubmitting ? 'opacity-50' : ''
}`}
className="hover:cursor-pointer inline-block text-white bg-red-500 rounded-full px-4 py-1 m-1"
>
Delete
</button>
<button
type="button"
disabled={isSubmitting}
className={`hover:cursor-pointer inline-block bg-gray-200 rounded-full px-4 py-1 m-1 ${
isSubmitting ? 'opacity-50' : ''
}`}
className="hover:cursor-pointer inline-block bg-gray-200 rounded-full px-4 py-1 m-1"
onClick={unsetModal}
>
Cancel
Expand Down
Loading

0 comments on commit b4d0a40

Please sign in to comment.