mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 02:21:54 +03:00
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:
parent
4fc6587d13
commit
fe160ac287
@ -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,
|
||||
|
@ -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}
|
||||
|
@ -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:
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
|
@ -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: {
|
||||
|
@ -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`,
|
||||
|
@ -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={() => {
|
||||
|
@ -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: {
|
||||
|
@ -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. */
|
||||
|
@ -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. */
|
||||
|
@ -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. */
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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])
|
||||
}
|
||||
|
||||
// =========================================
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user