Fix dashboard regressions (#7414)

* Fix dashboard regressions

* Restore templates spinner

* Fix bug

* Fix bug

* Faster reactive event implementation

* Fix event implementation; fix "New Project" button; fix `uniqueString`

* Fix infinitely loading project spinner

* Fix infinitely loading template spinner

* Minor documentation change

* Stop template spinners when switching backends
This commit is contained in:
somebody1234 2023-07-29 18:19:31 +10:00 committed by GitHub
parent 4fc6587d13
commit fe160ac287
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 178 additions and 81 deletions

View File

@ -88,7 +88,7 @@ function AssetRow(props: AssetRowProps<backendModule.AnyAsset>) {
item: rawItem,
initialRowState,
state: {
assetEvent,
assetEvents,
dispatchAssetEvent,
dispatchAssetListEvent,
markItemAsHidden,
@ -122,7 +122,7 @@ function AssetRow(props: AssetRowProps<backendModule.AnyAsset>) {
}
}
hooks.useEventHandler(assetEvent, async event => {
hooks.useEventHandler(assetEvents, async event => {
switch (event.type) {
// These events are handled in the specific NameColumn files.
case assetEventModule.AssetEventType.createProject:
@ -215,7 +215,7 @@ function AssetRow(props: AssetRowProps<backendModule.AnyAsset>) {
/** State passed through from a {@link AssetsTable} to every cell. */
export interface AssetsTableState {
appRunner: AppRunner | null
assetEvent: assetEventModule.AssetEvent | null
assetEvents: assetEventModule.AssetEvent[]
dispatchAssetEvent: (event: assetEventModule.AssetEvent) => void
dispatchAssetListEvent: (event: assetListEventModule.AssetListEvent) => void
markItemAsHidden: (key: string) => void
@ -253,9 +253,9 @@ export interface AssetsTableProps {
items: backendModule.AnyAsset[]
filter: ((item: backendModule.AnyAsset) => boolean) | null
isLoading: boolean
assetEvent: assetEventModule.AssetEvent | null
assetEvents: assetEventModule.AssetEvent[]
dispatchAssetEvent: (event: assetEventModule.AssetEvent) => void
assetListEvent: assetListEventModule.AssetListEvent | null
assetListEvents: assetListEventModule.AssetListEvent[]
dispatchAssetListEvent: (event: assetListEventModule.AssetListEvent) => void
doOpenIde: (project: backendModule.ProjectAsset) => void
doCloseIde: () => void
@ -268,9 +268,9 @@ export default function AssetsTable(props: AssetsTableProps) {
items: rawItems,
filter,
isLoading,
assetEvent,
assetEvents,
dispatchAssetEvent,
assetListEvent,
assetListEvents,
dispatchAssetListEvent,
doOpenIde,
doCloseIde: rawDoCloseIde,
@ -288,7 +288,6 @@ export default function AssetsTable(props: AssetsTableProps) {
React.useEffect(() => {
setInitialized(true)
const extraColumnsJson = localStorage.getItem(EXTRA_COLUMNS_KEY)
if (extraColumnsJson != null) {
const savedExtraColumns: unknown = JSON.parse(extraColumnsJson)
@ -452,7 +451,7 @@ export default function AssetsTable(props: AssetsTableProps) {
[items]
)
hooks.useEventHandler(assetListEvent, event => {
hooks.useEventHandler(assetListEvents, event => {
switch (event.type) {
case assetListEventModule.AssetListEventType.createDirectory: {
const directoryIndices = items
@ -513,6 +512,7 @@ export default function AssetsTable(props: AssetsTableProps) {
type: assetEventModule.AssetEventType.createProject,
placeholderId: dummyId,
templateId: event.templateId,
onSpinnerStateChange: event.onSpinnerStateChange,
})
break
}
@ -607,7 +607,7 @@ export default function AssetsTable(props: AssetsTableProps) {
// The type MUST be here to trigger excess property errors at typecheck time.
(): AssetsTableState => ({
appRunner,
assetEvent,
assetEvents,
dispatchAssetEvent,
dispatchAssetListEvent,
markItemAsHidden,
@ -620,7 +620,7 @@ export default function AssetsTable(props: AssetsTableProps) {
}),
[
appRunner,
assetEvent,
assetEvents,
doOpenManually,
doOpenIde,
doCloseIde,

View File

@ -19,6 +19,7 @@ import * as backendProvider from '../../providers/backend'
import * as loggerProvider from '../../providers/logger'
import * as modalProvider from '../../providers/modal'
import * as spinner from './spinner'
import Chat, * as chat from './chat'
import DirectoryView from './driveView'
import Ide from './ide'
@ -54,7 +55,10 @@ export default function Dashboard(props: DashboardProps) {
const { setBackend } = backendProvider.useSetBackend()
const { unsetModal } = modalProvider.useSetModal()
const [directoryId, setDirectoryId] = React.useState(
session.organization != null ? backendModule.rootDirectoryId(session.organization.id) : null
session.organization != null
? backendModule.rootDirectoryId(session.organization.id)
: // The local backend uses the empty string as the sole directory ID.
backendModule.DirectoryId('')
)
const [query, setQuery] = React.useState('')
const [isHelpChatOpen, setIsHelpChatOpen] = React.useState(false)
@ -64,7 +68,7 @@ export default function Dashboard(props: DashboardProps) {
const [project, setProject] = React.useState<backendModule.Project | null>(null)
const [nameOfProjectToImmediatelyOpen, setNameOfProjectToImmediatelyOpen] =
React.useState(initialProjectName)
const [assetListEvent, dispatchAssetListEvent] =
const [assetListEvents, dispatchAssetListEvent] =
hooks.useEvent<assetListEventModule.AssetListEvent>()
const isListingLocalDirectoryAndWillFail =
@ -116,6 +120,7 @@ export default function Dashboard(props: DashboardProps) {
backendModule.BackendType.remote
) {
setBackend(new localBackend.LocalBackend())
setDirectoryId(backendModule.DirectoryId(''))
}
// This hook MUST only run once, on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -184,26 +189,36 @@ export default function Dashboard(props: DashboardProps) {
switch (newBackendType) {
case backendModule.BackendType.local:
setBackend(new localBackend.LocalBackend())
setDirectoryId(backendModule.DirectoryId(''))
break
case backendModule.BackendType.remote: {
const headers = new Headers()
headers.append('Authorization', `Bearer ${session.accessToken ?? ''}`)
const client = new http.Client(headers)
setBackend(new remoteBackendModule.RemoteBackend(client, logger))
setDirectoryId(
session.organization != null
? backendModule.rootDirectoryId(session.organization.id)
: backendModule.DirectoryId('')
)
break
}
}
}
},
[backend.type, logger, session.accessToken, setBackend]
[backend.type, logger, session.accessToken, session.organization, setBackend]
)
const doCreateProject = React.useCallback(
(templateId?: string) => {
(
templateId: string | null,
onSpinnerStateChange?: (state: spinner.SpinnerState) => void
) => {
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.createProject,
parentId: directoryId,
templateId: templateId ?? null,
onSpinnerStateChange: onSpinnerStateChange ?? null,
})
},
[directoryId, /* should never change */ dispatchAssetListEvent]
@ -282,7 +297,7 @@ export default function Dashboard(props: DashboardProps) {
setNameOfProjectToImmediatelyOpen={setNameOfProjectToImmediatelyOpen}
directoryId={directoryId}
setDirectoryId={setDirectoryId}
assetListEvent={assetListEvent}
assetListEvents={assetListEvents}
dispatchAssetListEvent={dispatchAssetListEvent}
query={query}
doCreateProject={doCreateProject}

View File

@ -33,7 +33,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
setItem,
selected,
setSelected,
state: { assetEvent, dispatchAssetListEvent, doToggleDirectoryExpansion, getDepth },
state: { assetEvents, dispatchAssetListEvent, doToggleDirectoryExpansion, getDepth },
rowState,
setRowState,
} = props
@ -52,7 +52,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
}
}
hooks.useEventHandler(assetEvent, async event => {
hooks.useEventHandler(assetEvents, async event => {
switch (event.type) {
case assetEventModule.AssetEventType.createProject:
case assetEventModule.AssetEventType.uploadFiles:

View File

@ -16,7 +16,7 @@ import * as backendProvider from '../../providers/backend'
/** Props for a {@link DriveBar}. */
export interface DriveBarProps {
doCreateProject: () => void
doCreateProject: (templateId: string | null) => void
doCreateDirectory: () => void
doUploadFiles: (files: FileList) => void
}
@ -42,7 +42,9 @@ export default function DriveBar(props: DriveBarProps) {
<div className="flex gap-2.5">
<button
className="flex items-center bg-frame-bg rounded-full h-8 px-2.5"
onClick={doCreateProject}
onClick={() => {
doCreateProject(null)
}}
>
<span className="font-semibold leading-5 h-6 py-px">New Project</span>
</button>

View File

@ -43,11 +43,11 @@ export interface DirectoryViewProps {
nameOfProjectToImmediatelyOpen: string | null
setNameOfProjectToImmediatelyOpen: (nameOfProjectToImmediatelyOpen: string | null) => void
directoryId: backendModule.DirectoryId | null
setDirectoryId: (directoryId: backendModule.DirectoryId | null) => void
assetListEvent: assetListEventModule.AssetListEvent | null
setDirectoryId: (directoryId: backendModule.DirectoryId) => void
assetListEvents: assetListEventModule.AssetListEvent[]
dispatchAssetListEvent: (directoryEvent: assetListEventModule.AssetListEvent) => void
query: string
doCreateProject: (templateId?: string) => void
doCreateProject: (templateId: string | null) => void
doOpenIde: (project: backendModule.ProjectAsset) => void
doCloseIde: () => void
appRunner: AppRunner | null
@ -67,7 +67,7 @@ export default function DirectoryView(props: DirectoryViewProps) {
directoryId,
setDirectoryId,
query,
assetListEvent,
assetListEvents,
dispatchAssetListEvent,
doCreateProject,
doOpenIde,
@ -87,7 +87,7 @@ export default function DirectoryView(props: DirectoryViewProps) {
const [isLoadingAssets, setIsLoadingAssets] = React.useState(true)
const [directoryStack, setDirectoryStack] = React.useState<backendModule.DirectoryAsset[]>([])
const [isFileBeingDragged, setIsFileBeingDragged] = React.useState(false)
const [assetEvent, dispatchAssetEvent] = hooks.useEvent<assetEventModule.AssetEvent>()
const [assetEvents, dispatchAssetEvent] = hooks.useEvent<assetEventModule.AssetEvent>()
const assetFilter = React.useMemo(() => {
if (query === '') {
@ -275,9 +275,9 @@ export default function DirectoryView(props: DirectoryViewProps) {
filter={assetFilter}
isLoading={isLoadingAssets}
appRunner={appRunner}
assetEvent={assetEvent}
assetEvents={assetEvents}
dispatchAssetEvent={dispatchAssetEvent}
assetListEvent={assetListEvent}
assetListEvents={assetListEvents}
dispatchAssetListEvent={dispatchAssetListEvent}
doOpenIde={doOpenIde}
doCloseIde={doCloseIde}

View File

@ -29,7 +29,7 @@ export default function FileNameColumn(props: FileNameColumnProps) {
item,
setItem,
selected,
state: { assetEvent, dispatchAssetListEvent, getDepth },
state: { assetEvents, dispatchAssetListEvent, getDepth },
rowState,
setRowState,
} = props
@ -43,7 +43,7 @@ export default function FileNameColumn(props: FileNameColumnProps) {
return await Promise.resolve(null)
}
hooks.useEventHandler(assetEvent, async event => {
hooks.useEventHandler(assetEvents, async event => {
switch (event.type) {
case assetEventModule.AssetEventType.createProject:
case assetEventModule.AssetEventType.createDirectory:
@ -52,7 +52,7 @@ export default function FileNameColumn(props: FileNameColumnProps) {
case assetEventModule.AssetEventType.cancelOpeningAllProjects:
case assetEventModule.AssetEventType.deleteMultiple: {
// Ignored. These events should all be unrelated to projects.
// `deleteMultiple` is handled in `AssetRow`.
// `deleteMultiple` is handled by `AssetRow`.
break
}
case assetEventModule.AssetEventType.uploadFiles: {

View File

@ -71,10 +71,11 @@ export enum CheckState {
/** Props for a {@link ProjectIcon}. */
export interface ProjectIconProps {
keyProp: string
project: backendModule.ProjectAsset
rowState: assetsTable.AssetRowState
setRowState: React.Dispatch<React.SetStateAction<assetsTable.AssetRowState>>
assetEvent: assetEventModule.AssetEvent | null
assetEvents: assetEventModule.AssetEvent[]
/** Called when the project is opened via the {@link ProjectIcon}. */
doOpenManually: (projectId: backendModule.ProjectId) => void
onClose: () => void
@ -85,10 +86,11 @@ export interface ProjectIconProps {
/** An interactive icon indicating the status of a project. */
export default function ProjectIcon(props: ProjectIconProps) {
const {
keyProp: key,
project,
rowState,
setRowState,
assetEvent,
assetEvents,
appRunner,
doOpenManually,
onClose,
@ -98,9 +100,9 @@ export default function ProjectIcon(props: ProjectIconProps) {
const { unsetModal } = modalProvider.useSetModal()
const shouldCheckIfActuallyOpen =
backend.type === backendModule.BackendType.remote &&
(project.projectState.type === backendModule.ProjectState.opened ||
project.projectState.type === backendModule.ProjectState.openInProgress)
project.projectState.type === backendModule.ProjectState.openInProgress ||
(backend.type === backendModule.BackendType.remote &&
project.projectState.type === backendModule.ProjectState.opened)
const [state, setState] = React.useState(() => {
if (shouldCheckIfActuallyOpen) {
@ -111,6 +113,9 @@ export default function ProjectIcon(props: ProjectIconProps) {
})
const [checkState, setCheckState] = React.useState(CheckState.notChecking)
const [spinnerState, setSpinnerState] = React.useState(REMOTE_SPINNER_STATE[state])
const [onSpinnerStateChange, setOnSpinnerStateChange] = React.useState<
((state: spinner.SpinnerState | null) => void) | null
>(null)
const [shouldOpenWhenReady, setShouldOpenWhenReady] = React.useState(false)
const [toastId, setToastId] = React.useState<toast.Id | null>(null)
@ -165,8 +170,18 @@ export default function ProjectIcon(props: ProjectIconProps) {
? REMOTE_SPINNER_STATE[state]
: LOCAL_SPINNER_STATE[state]
setSpinnerState(newSpinnerState)
onSpinnerStateChange?.(
state === backendModule.ProjectState.closed ? null : newSpinnerState
)
})
}, [state, backend.type])
}, [state, backend.type, onSpinnerStateChange])
React.useEffect(() => {
onSpinnerStateChange?.(spinner.SpinnerState.initial)
return () => {
onSpinnerStateChange?.(null)
}
}, [onSpinnerStateChange])
React.useEffect(() => {
if (toastId != null && state !== backendModule.ProjectState.openInProgress) {
@ -181,15 +196,22 @@ export default function ProjectIcon(props: ProjectIconProps) {
}
}, [shouldCheckIfActuallyOpen])
hooks.useEventHandler(assetEvent, event => {
hooks.useEventHandler(assetEvents, event => {
switch (event.type) {
default: {
// Ignore; all other events are handled by `ProjectRow`.
case assetEventModule.AssetEventType.createDirectory:
case assetEventModule.AssetEventType.uploadFiles:
case assetEventModule.AssetEventType.createSecret:
case assetEventModule.AssetEventType.deleteMultiple: {
// Ignored. Any missing project-related events should be handled by
// `ProjectNameColumn`. `deleteMultiple` is handled by `AssetRow`.
break
}
case assetEventModule.AssetEventType.openProject: {
if (event.id !== project.id) {
setShouldOpenWhenReady(false)
if (state === backendModule.ProjectState.opened) {
void closeProject(false)
}
} else {
setShouldOpenWhenReady(true)
void openProject()
@ -201,11 +223,16 @@ export default function ProjectIcon(props: ProjectIconProps) {
// to actually cancel an open action. Instead, the project should not be opened
// automatically.
setShouldOpenWhenReady(false)
onSpinnerStateChange?.(null)
setOnSpinnerStateChange(null)
break
}
case assetEventModule.AssetEventType.createProject: {
if (event.placeholderId === project.id) {
if (event.placeholderId === key) {
setState(backendModule.ProjectState.openInProgress)
setOnSpinnerStateChange(() => event.onSpinnerStateChange)
} else if (event.onSpinnerStateChange === onSpinnerStateChange) {
setOnSpinnerStateChange(null)
}
break
}
@ -268,9 +295,8 @@ export default function ProjectIcon(props: ProjectIconProps) {
let previousTimestamp = 0
const checkProjectResources = async () => {
if (backend.type === backendModule.BackendType.local) {
// This should never happen, but still should be handled.
await backend.openProject(project.id, null, project.title)
setState(backendModule.ProjectState.opened)
setCheckState(CheckState.done)
} else {
try {
// This call will error if the VM is not ready yet.
@ -306,13 +332,25 @@ export default function ProjectIcon(props: ProjectIconProps) {
}
}, [checkState, project.id, project.title, backend])
const closeProject = async () => {
onClose()
const closeProject = async (triggerOnClose = true) => {
if (triggerOnClose) {
onClose()
}
setShouldOpenWhenReady(false)
setState(backendModule.ProjectState.closed)
onSpinnerStateChange?.(null)
setOnSpinnerStateChange(null)
appRunner?.stopApp()
setCheckState(CheckState.notChecking)
try {
if (
backend.type === backendModule.BackendType.local &&
state === backendModule.ProjectState.openInProgress
) {
// Projects that are not opened cannot be closed.
// This is the only way to wait until the project is open.
await backend.openProject(project.id, null, project.title)
}
await backend.closeProject(project.id, project.title)
} finally {
// This is not 100% correct, but it is better than never setting `isRunning` to `false`,

View File

@ -35,7 +35,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
setRowState,
state: {
appRunner,
assetEvent,
assetEvents,
dispatchAssetEvent,
dispatchAssetListEvent,
doOpenManually,
@ -65,7 +65,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
}
}
hooks.useEventHandler(assetEvent, async event => {
hooks.useEventHandler(assetEvents, async event => {
switch (event.type) {
case assetEventModule.AssetEventType.createDirectory:
case assetEventModule.AssetEventType.uploadFiles:
@ -73,12 +73,15 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
case assetEventModule.AssetEventType.openProject:
case assetEventModule.AssetEventType.cancelOpeningAllProjects:
case assetEventModule.AssetEventType.deleteMultiple: {
// Ignored. Any missing project-related events should be handled by
// `ProjectIcon`. `deleteMultiple` is handled in `AssetRow`.
// Ignored. Any missing project-related events should be handled by `ProjectIcon`.
// `deleteMultiple` is handled by `AssetRow`.
break
}
case assetEventModule.AssetEventType.createProject: {
if (key === event.placeholderId) {
// This should only run before this project gets replaced with the actual project
// by this event handler. In both cases `key` will match, so using `key` here
// is a mistake.
if (item.id === event.placeholderId) {
rowState.setPresence(presence.Presence.inserting)
try {
const createdProject = await backend.createProject({
@ -142,10 +145,11 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
}}
>
<ProjectIcon
keyProp={key}
project={item}
rowState={rowState}
setRowState={setRowState}
assetEvent={assetEvent}
assetEvents={assetEvents}
doOpenManually={doOpenManually}
appRunner={appRunner}
openIde={() => {

View File

@ -30,7 +30,7 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
item,
setItem,
selected,
state: { assetEvent, dispatchAssetListEvent, getDepth },
state: { assetEvents, dispatchAssetListEvent, getDepth },
rowState,
setRowState,
} = props
@ -44,7 +44,7 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
await Promise.resolve(null)
}
hooks.useEventHandler(assetEvent, async event => {
hooks.useEventHandler(assetEvents, async event => {
switch (event.type) {
case assetEventModule.AssetEventType.createProject:
case assetEventModule.AssetEventType.createDirectory:
@ -53,7 +53,7 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
case assetEventModule.AssetEventType.cancelOpeningAllProjects:
case assetEventModule.AssetEventType.deleteMultiple: {
// Ignored. These events should all be unrelated to secrets.
// `deleteMultiple` is handled in `AssetRow`.
// `deleteMultiple` is handled by `AssetRow`.
break
}
case assetEventModule.AssetEventType.createSecret: {

View File

@ -26,6 +26,8 @@ const MAX_WIDTH_NEEDING_SCROLL = 1031
const PADDING_HEIGHT = 16
/** The size (both width and height) of the spinner, in pixels. */
const SPINNER_SIZE = 64
/** The duration of the "spinner done" animation. */
const SPINNER_DONE_DURATION_MS = 1000
// =============
// === Types ===
@ -101,7 +103,10 @@ export const TEMPLATES: [Template, ...Template[]] = [
/** Props for an {@link EmptyProjectButton}. */
interface InternalEmptyProjectButtonProps {
onTemplateClick: () => void
onTemplateClick: (
name: null,
onSpinnerStateChange: (spinnerState: spinner.SpinnerState | null) => void
) => void
}
/** A button that, when clicked, creates and opens a new blank project. */
@ -113,7 +118,14 @@ function EmptyProjectButton(props: InternalEmptyProjectButtonProps) {
<button
onClick={() => {
setSpinnerState(spinner.SpinnerState.initial)
onTemplateClick()
onTemplateClick(null, newSpinnerState => {
setSpinnerState(newSpinnerState)
if (newSpinnerState === spinner.SpinnerState.done) {
setTimeout(() => {
setSpinnerState(null)
}, SPINNER_DONE_DURATION_MS)
}
})
}}
className="cursor-pointer relative text-primary h-40"
>
@ -140,7 +152,10 @@ function EmptyProjectButton(props: InternalEmptyProjectButtonProps) {
/** Props for a {@link TemplateButton}. */
interface InternalTemplateButtonProps {
template: Template
onTemplateClick: (name: string) => void
onTemplateClick: (
name: string | null,
onSpinnerStateChange: (spinnerState: spinner.SpinnerState | null) => void
) => void
}
/** A button that, when clicked, creates and opens a new project based on a template. */
@ -148,13 +163,25 @@ function TemplateButton(props: InternalTemplateButtonProps) {
const { template, onTemplateClick } = props
const [spinnerState, setSpinnerState] = React.useState<spinner.SpinnerState | null>(null)
const onSpinnerStateChange = React.useCallback(
(newSpinnerState: spinner.SpinnerState | null) => {
setSpinnerState(newSpinnerState)
if (newSpinnerState === spinner.SpinnerState.done) {
setTimeout(() => {
setSpinnerState(null)
}, SPINNER_DONE_DURATION_MS)
}
},
[]
)
return (
<button
key={template.title}
className="h-40 cursor-pointer"
onClick={() => {
setSpinnerState(spinner.SpinnerState.initial)
onTemplateClick(template.id)
onTemplateClick(template.id, onSpinnerStateChange)
}}
>
<div
@ -185,7 +212,10 @@ function TemplateButton(props: InternalTemplateButtonProps) {
interface InternalTemplatesRenderProps {
// Later this data may be requested and therefore needs to be passed dynamically.
templates: Template[]
onTemplateClick: (name?: string) => void
onTemplateClick: (
name: string | null,
onSpinnerStateChange: (spinnerState: spinner.SpinnerState | null) => void
) => void
}
/** Render all templates, and a button to create an empty project. */
@ -212,7 +242,10 @@ function TemplatesRender(props: InternalTemplatesRenderProps) {
/** Props for a {@link Templates}. */
export interface TemplatesProps {
onTemplateClick: (name?: string) => void
onTemplateClick: (
name: string | null,
onSpinnerStateChange: (state: spinner.SpinnerState | null) => void
) => void
}
/** A container for a {@link TemplatesRender} which passes it a list of templates. */

View File

@ -1,6 +1,8 @@
/** @file Events related to changes in asset state. */
import * as backendModule from '../backend'
import * as spinner from '../components/spinner'
// This is required, to whitelist this event.
// eslint-disable-next-line no-restricted-syntax
declare module '../../hooks' {
@ -55,6 +57,7 @@ type SanityCheck<
export interface AssetCreateProjectEvent extends AssetBaseEvent<AssetEventType.createProject> {
placeholderId: backendModule.ProjectId
templateId: string | null
onSpinnerStateChange: ((state: spinner.SpinnerState) => void) | null
}
/** A signal to create a directory. */

View File

@ -1,6 +1,8 @@
/** @file Events related to changes in the asset list. */
import * as backend from '../backend'
import * as spinner from '../components/spinner'
// This is required, to whitelist this event.
// eslint-disable-next-line no-restricted-syntax
declare module '../../hooks' {
@ -55,6 +57,7 @@ interface AssetListCreateDirectoryEvent
interface AssetListCreateProjectEvent extends AssetListBaseEvent<AssetListEventType.createProject> {
parentId: backend.DirectoryId | null
templateId: string | null
onSpinnerStateChange: ((state: spinner.SpinnerState) => void) | null
}
/** A signal to upload files. */

View File

@ -208,6 +208,8 @@ export class LocalBackend extends backend.Backend {
title != null ? `'${title}'` : `with ID '${projectId}'`
}: ${errorModule.tryGetMessage(error) ?? 'unknown error'}.`
)
} finally {
LocalBackend.currentlyOpeningProjectId = null
}
}
}

View File

@ -1,9 +1,7 @@
/** @file Module containing common custom React hooks used throughout out Dashboard. */
import * as React from 'react'
import * as toastify from 'react-toastify'
import * as reactDom from 'react-dom'
import * as router from 'react-router'
import * as toastify from 'react-toastify'
import * as app from './components/app'
import * as auth from './authentication/providers/auth'
@ -146,28 +144,25 @@ type KnownEvent = KnownEventsMap[keyof KnownEventsMap]
/** A wrapper around `useState` that calls `flushSync` after every `setState`.
* This is required so that no events are dropped. */
export function useEvent<T extends KnownEvent>(): [
event: T | null,
dispatchEvent: (event: T) => void
] {
const [event, rawDispatchEvent] = React.useState<T | null>(null)
export function useEvent<T extends KnownEvent>(): [events: T[], dispatchEvent: (event: T) => void] {
const [events, setEvents] = React.useState<T[]>([])
React.useEffect(() => {
if (events.length !== 0) {
setEvents([])
}
}, [events])
const dispatchEvent = React.useCallback(
(innerEvent: T) => {
setTimeout(() => {
reactDom.flushSync(() => {
rawDispatchEvent(innerEvent)
})
}, 0)
setEvents([...events, innerEvent])
},
[rawDispatchEvent]
[events]
)
return [event, dispatchEvent]
return [events, dispatchEvent]
}
/** A wrapper around `useEffect` that has `event` as its sole dependency. */
export function useEventHandler<T extends KnownEvent>(
event: T | null,
events: T[],
effect: (event: T) => Promise<void> | void
) {
let hasEffectRun = false
@ -184,10 +179,12 @@ export function useEventHandler<T extends KnownEvent>(
hasEffectRun = true
}
}
if (event != null) {
void effect(event)
}
}, [event])
void (async () => {
for (const event of events) {
await effect(event)
}
})()
}, [events])
}
// =========================================

View File

@ -5,7 +5,7 @@
// ====================
// This is initialized to an unusual number, to minimize the chances of collision.
let counter = Number(new Date()) >> 2
let counter = Number(new Date()) >>> 2
/** Returns a new, mostly unique string. */
export function uniqueString(): string {