diff --git a/frontend/src/framework/ModuleContext.ts b/frontend/src/framework/ModuleContext.ts index ef519a82d..6bc70e309 100644 --- a/frontend/src/framework/ModuleContext.ts +++ b/frontend/src/framework/ModuleContext.ts @@ -2,6 +2,7 @@ import React from "react"; import { BroadcastChannel } from "./Broadcaster"; import { ModuleInstance } from "./ModuleInstance"; +import { ModuleInstanceStatusController } from "./ModuleInstanceStatusController"; import { StateBaseType, StateStore, useSetStoreValue, useStoreState, useStoreValue } from "./StateStore"; import { SyncSettingKey } from "./SyncSettings"; @@ -56,4 +57,8 @@ export class ModuleContext { setInstanceTitle(title: string): void { this._moduleInstance.setTitle(title); } + + getStatusController(): ModuleInstanceStatusController { + return this._moduleInstance.getStatusController(); + } } diff --git a/frontend/src/framework/ModuleInstance.ts b/frontend/src/framework/ModuleInstance.ts index 79a154263..d94dd1d65 100644 --- a/frontend/src/framework/ModuleInstance.ts +++ b/frontend/src/framework/ModuleInstance.ts @@ -9,6 +9,7 @@ import { ModuleContext } from "./ModuleContext"; import { StateBaseType, StateOptions, StateStore } from "./StateStore"; import { SyncSettingKey } from "./SyncSettings"; import { Workbench } from "./Workbench"; +import { ModuleInstanceStatusControllerInternal } from "./internal/ModuleInstanceStatusControllerInternal"; export enum ModuleInstanceState { INITIALIZING, @@ -35,6 +36,7 @@ export class ModuleInstance { private _cachedDefaultState: StateType | null; private _cachedStateStoreOptions?: StateOptions; private _initialSettings: InitialSettings | null; + private _statusController: ModuleInstanceStatusControllerInternal; constructor( module: Module, @@ -57,6 +59,7 @@ export class ModuleInstance { this._fatalError = null; this._cachedDefaultState = null; this._initialSettings = null; + this._statusController = new ModuleInstanceStatusControllerInternal(); this._broadcastChannels = {} as Record; @@ -178,6 +181,10 @@ export class ModuleInstance { return this._module; } + getStatusController(): ModuleInstanceStatusControllerInternal { + return this._statusController; + } + subscribeToImportStateChange(cb: () => void): () => void { this._importStateSubscribers.add(cb); return () => { diff --git a/frontend/src/framework/ModuleInstanceStatusController.ts b/frontend/src/framework/ModuleInstanceStatusController.ts new file mode 100644 index 000000000..e63d1f2bc --- /dev/null +++ b/frontend/src/framework/ModuleInstanceStatusController.ts @@ -0,0 +1,20 @@ +export enum StatusMessageType { + Warning = "warning", + Error = "error", +} + +export enum StatusSource { + View = "view", + Settings = "settings", +} + +export interface ModuleInstanceStatusController { + addMessage(source: StatusSource, message: string, type: StatusMessageType): void; + clearMessages(source: StatusSource): void; + setLoading(isLoading: boolean): void; + + setDebugMessage(source: StatusSource, message: string): void; + incrementReportedComponentRenderCount(source: StatusSource): void; + + reviseAndPublishState(): void; +} diff --git a/frontend/src/framework/StatusWriter.ts b/frontend/src/framework/StatusWriter.ts new file mode 100644 index 000000000..a71aa4266 --- /dev/null +++ b/frontend/src/framework/StatusWriter.ts @@ -0,0 +1,78 @@ +import React from "react"; + +import { ModuleContext } from "./ModuleContext"; +import { ModuleInstanceStatusController, StatusMessageType, StatusSource } from "./ModuleInstanceStatusController"; + +export class ViewStatusWriter { + private _statusController: ModuleInstanceStatusController; + + constructor(statusController: ModuleInstanceStatusController) { + this._statusController = statusController; + } + + setLoading(isLoading: boolean): void { + this._statusController.setLoading(isLoading); + } + + addError(message: string): void { + this._statusController.addMessage(StatusSource.View, message, StatusMessageType.Error); + } + + addWarning(message: string): void { + this._statusController.addMessage(StatusSource.View, message, StatusMessageType.Warning); + } + + setDebugMessage(message: string): void { + this._statusController.setDebugMessage(StatusSource.View, message); + } +} + +export class SettingsStatusWriter { + private _statusController: ModuleInstanceStatusController; + + constructor(statusController: ModuleInstanceStatusController) { + this._statusController = statusController; + } + + addError(message: string): void { + this._statusController.addMessage(StatusSource.Settings, message, StatusMessageType.Error); + } + + addWarning(message: string): void { + this._statusController.addMessage(StatusSource.Settings, message, StatusMessageType.Warning); + } + + setDebugMessage(message: string): void { + this._statusController.setDebugMessage(StatusSource.Settings, message); + } +} + +export function useViewStatusWriter(moduleContext: ModuleContext): ViewStatusWriter { + const statusController = moduleContext.getStatusController(); + + const statusWriter = React.useRef(new ViewStatusWriter(statusController)); + + statusController.clearMessages(StatusSource.View); + statusController.incrementReportedComponentRenderCount(StatusSource.View); + + React.useEffect(function handleRender() { + statusController.reviseAndPublishState(); + }); + + return statusWriter.current; +} + +export function useSettingsStatusWriter(moduleContext: ModuleContext): ViewStatusWriter { + const statusController = moduleContext.getStatusController(); + + const statusWriter = React.useRef(new ViewStatusWriter(statusController)); + + statusController.clearMessages(StatusSource.Settings); + statusController.incrementReportedComponentRenderCount(StatusSource.Settings); + + React.useEffect(function handleRender() { + statusController.reviseAndPublishState(); + }); + + return statusWriter.current; +} diff --git a/frontend/src/framework/internal/ModuleInstanceStatusControllerInternal.ts b/frontend/src/framework/internal/ModuleInstanceStatusControllerInternal.ts new file mode 100644 index 000000000..bc3c01de0 --- /dev/null +++ b/frontend/src/framework/internal/ModuleInstanceStatusControllerInternal.ts @@ -0,0 +1,139 @@ +import React from "react"; + +import { + ModuleInstanceStatusController, + StatusMessageType, + StatusSource, +} from "@framework/ModuleInstanceStatusController"; + +import { cloneDeep, filter, isEqual, keys } from "lodash"; + +type StatusMessage = { + source: StatusSource; + message: string; + type: StatusMessageType; +}; + +type StatusControllerState = { + messages: StatusMessage[]; + loading: boolean; + viewDebugMessage: string; + settingsDebugMessage: string; + viewRenderCount: number | null; + settingsRenderCount: number | null; +}; + +export class ModuleInstanceStatusControllerInternal implements ModuleInstanceStatusController { + protected _stateCandidates: StatusControllerState; + protected _state: StatusControllerState = { + messages: [], + loading: false, + viewDebugMessage: "", + settingsDebugMessage: "", + viewRenderCount: null, + settingsRenderCount: null, + }; + private _subscribers: Map void>> = new Map(); + + constructor() { + this._stateCandidates = cloneDeep(this._state); + } + + addMessage(source: StatusSource, message: string, type: StatusMessageType): void { + this._stateCandidates.messages.push({ + source, + message, + type, + }); + } + + clearMessages(source: StatusSource): void { + this._stateCandidates.messages = this._stateCandidates.messages.filter((msg) => msg.source !== source); + } + + setLoading(isLoading: boolean): void { + this._stateCandidates.loading = isLoading; + } + + setDebugMessage(source: StatusSource, message: string): void { + if (source === StatusSource.View) { + this._stateCandidates.viewDebugMessage = message; + } + if (source === StatusSource.Settings) { + this._stateCandidates.settingsDebugMessage = message; + } + } + + incrementReportedComponentRenderCount(source: StatusSource): void { + if (source === StatusSource.View) { + if (this._stateCandidates.viewRenderCount === null) { + this._stateCandidates.viewRenderCount = 0; + } + this._stateCandidates.viewRenderCount++; + } + if (source === StatusSource.Settings) { + if (this._stateCandidates.settingsRenderCount === null) { + this._stateCandidates.settingsRenderCount = 0; + } + this._stateCandidates.settingsRenderCount++; + } + } + + reviseAndPublishState(): void { + const differentStateKeys = filter(keys(this._stateCandidates), (key: keyof StatusControllerState) => { + return !isEqual(this._state[key], this._stateCandidates[key]); + }) as (keyof StatusControllerState)[]; + + this._state = cloneDeep(this._stateCandidates); + + differentStateKeys.forEach((stateKey) => { + this.notifySubscribers(stateKey); + }); + } + + private notifySubscribers(stateKey: keyof StatusControllerState): void { + const subscribers = this._subscribers.get(stateKey); + if (subscribers) { + subscribers.forEach((subscriber) => { + subscriber(); + }); + } + } + + makeSnapshotGetter(stateKey: T): () => StatusControllerState[T] { + const snapshotGetter = (): any => { + return this._state[stateKey]; + }; + + return snapshotGetter; + } + + makeSubscriberFunction( + stateKey: T + ): (onStoreChangeCallback: () => void) => () => void { + // Using arrow function in order to keep "this" in context + const subscriber = (onStoreChangeCallback: () => void): (() => void) => { + const subscribers = this._subscribers.get(stateKey) || new Set(); + subscribers.add(onStoreChangeCallback); + this._subscribers.set(stateKey, subscribers); + + return () => { + subscribers.delete(onStoreChangeCallback); + }; + }; + + return subscriber; + } +} + +export function useStatusControllerStateValue( + statusController: ModuleInstanceStatusControllerInternal, + stateKey: T +): StatusControllerState[T] { + const value = React.useSyncExternalStore( + statusController.makeSubscriberFunction(stateKey), + statusController.makeSnapshotGetter(stateKey) + ); + + return value; +} diff --git a/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/header.tsx b/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/header.tsx index eb0f42009..4c4960f2a 100644 --- a/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/header.tsx +++ b/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/header.tsx @@ -1,9 +1,16 @@ import React from "react"; +import ReactDOM from "react-dom"; import { ModuleInstance } from "@framework/ModuleInstance"; +import { StatusMessageType } from "@framework/ModuleInstanceStatusController"; import { SyncSettingKey, SyncSettingsMeta } from "@framework/SyncSettings"; +import { useStatusControllerStateValue } from "@framework/internal/ModuleInstanceStatusControllerInternal"; +import { Badge } from "@lib/components/Badge"; +import { CircularProgress } from "@lib/components/CircularProgress"; +import { useElementBoundingRect } from "@lib/hooks/useElementBoundingRect"; import { isDevMode } from "@lib/utils/devMode"; -import { Close } from "@mui/icons-material"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; +import { Close, Error, Warning } from "@mui/icons-material"; export type HeaderProps = { moduleInstance: ModuleInstance; @@ -17,39 +24,145 @@ export const Header: React.FC = (props) => { props.moduleInstance.getSyncedSettingKeys() ); const [title, setTitle] = React.useState(props.moduleInstance.getTitle()); + const isLoading = useStatusControllerStateValue(props.moduleInstance.getStatusController(), "loading"); + const statusMessages = useStatusControllerStateValue(props.moduleInstance.getStatusController(), "messages"); + const [statusMessagesVisible, setStatusMessagesVisible] = React.useState(false); - React.useEffect(() => { + const ref = React.useRef(null); + const boundingRect = useElementBoundingRect(ref); + + React.useEffect(function handleMount() { function handleSyncedSettingsChange(newSyncedSettings: SyncSettingKey[]) { setSyncedSettings([...newSyncedSettings]); } - const unsubscribeFunc = props.moduleInstance.subscribeToSyncedSettingKeysChange(handleSyncedSettingsChange); - - return unsubscribeFunc; - }, []); - - React.useEffect(() => { function handleTitleChange(newTitle: string) { setTitle(newTitle); } - const unsubscribeFunc = props.moduleInstance.subscribeToTitleChange(handleTitleChange); + const unsubscribeFromSyncSettingsChange = + props.moduleInstance.subscribeToSyncedSettingKeysChange(handleSyncedSettingsChange); + const unsubscribeFromTitleChange = props.moduleInstance.subscribeToTitleChange(handleTitleChange); - return unsubscribeFunc; + return function handleUnmount() { + unsubscribeFromSyncSettingsChange(); + unsubscribeFromTitleChange(); + }; }, []); + function handlePointerDown(e: React.PointerEvent) { + props.onPointerDown(e); + setStatusMessagesVisible(false); + } + function handlePointerUp(e: React.PointerEvent) { e.stopPropagation(); } + function handleStatusPointerDown(e: React.PointerEvent) { + setStatusMessagesVisible(!statusMessagesVisible); + e.stopPropagation(); + } + + function makeStatusIndicator(): React.ReactNode { + const stateIndicators: React.ReactNode[] = []; + + if (isLoading) { + stateIndicators.push( +
+ +
+ ); + } + const numErrors = statusMessages.filter((message) => message.type === StatusMessageType.Error).length; + const numWarnings = statusMessages.filter((message) => message.type === StatusMessageType.Warning).length; + + if (numErrors > 0 || numWarnings > 0) { + stateIndicators.push( +
+ + +
+ 0, + })} + /> +
+
+
+ ); + } + + if (stateIndicators.length === 0) return null; + + return ( +
+ + {stateIndicators} + +
+ ); + } + + function makeStatusMessages(): React.ReactNode { + return ( +
+ {statusMessages.map((entry, i) => ( +
+ {entry.type === StatusMessageType.Error && } + {entry.type === StatusMessageType.Warning && } + + {entry.message} + +
+ ))} +
+ ); + } + + const hasErrors = statusMessages.some((entry) => entry.type === StatusMessageType.Error); + return (
-
+
+
+
+
{title} @@ -73,15 +186,29 @@ export const Header: React.FC = (props) => { ))}
- + {makeStatusIndicator()}
+ {statusMessagesVisible && + ReactDOM.createPortal( +
+ {makeStatusMessages()} +
, + document.body + )}
); }; diff --git a/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/viewContent.tsx b/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/viewContent.tsx index f082fe1cb..2d4e2fce5 100644 --- a/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/viewContent.tsx +++ b/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/viewContent.tsx @@ -2,6 +2,7 @@ import React from "react"; import { ImportState } from "@framework/Module"; import { ModuleInstance, ModuleInstanceState } from "@framework/ModuleInstance"; +import { StatusSource } from "@framework/ModuleInstanceStatusController"; import { Workbench } from "@framework/Workbench"; import { DebugProfiler } from "@framework/internal/components/DebugProfiler"; import { ErrorBoundary } from "@framework/internal/components/ErrorBoundary"; @@ -20,39 +21,40 @@ export const ViewContent = React.memo((props: ViewContentProps) => { ModuleInstanceState.INITIALIZING ); - React.useEffect(() => { + React.useEffect(function handleMount() { + setModuleInstanceState(props.moduleInstance.getModuleInstanceState()); setImportState(props.moduleInstance.getImportState()); function handleModuleInstanceImportStateChange() { setImportState(props.moduleInstance.getImportState()); } - const unsubscribeFunc = props.moduleInstance.subscribeToImportStateChange( - handleModuleInstanceImportStateChange - ); - - return unsubscribeFunc; - }, []); - - React.useEffect(() => { - setModuleInstanceState(props.moduleInstance.getModuleInstanceState()); - function handleModuleInstanceStateChange() { setModuleInstanceState(props.moduleInstance.getModuleInstanceState()); } - const unsubscribeFunc = props.moduleInstance.subscribeToModuleInstanceStateChange( + const unsubscribeFromImportStateChange = props.moduleInstance.subscribeToImportStateChange( + handleModuleInstanceImportStateChange + ); + + const unsubscribeFromModuleInstanceStateChange = props.moduleInstance.subscribeToModuleInstanceStateChange( handleModuleInstanceStateChange ); - return unsubscribeFunc; + return function handleUnmount() { + unsubscribeFromImportStateChange(); + unsubscribeFromModuleInstanceStateChange(); + }; }, []); - const handleModuleInstanceReload = React.useCallback(() => { - props.moduleInstance.reset().then(() => { - setModuleInstanceState(props.moduleInstance.getModuleInstanceState()); - }); - }, [props.moduleInstance]); + const handleModuleInstanceReload = React.useCallback( + function handleModuleInstanceReload() { + props.moduleInstance.reset().then(() => { + setModuleInstanceState(props.moduleInstance.getModuleInstanceState()); + }); + }, + [props.moduleInstance] + ); if (importState === ImportState.NotImported) { return
Not imported
; @@ -114,8 +116,12 @@ export const ViewContent = React.memo((props: ViewContentProps) => { const View = props.moduleInstance.getViewFC(); return ( -
- +
+ = (props) => { const [renderInfo, setRenderInfo] = React.useState(null); + const reportedRenderCount = useStatusControllerStateValue( + props.statusController, + props.source === StatusSource.View ? "viewRenderCount" : "settingsRenderCount" + ); + const customDebugMessage = useStatusControllerStateValue( + props.statusController, + props.source === StatusSource.View ? "viewDebugMessage" : "settingsDebugMessage" + ); const handleRender = React.useCallback( ( @@ -91,8 +106,18 @@ export const DebugProfiler: React.FC = (props) => {
{renderInfo && ( <> - - RC: {renderInfo.renderCount} + {reportedRenderCount !== null && ( + + Component RC: {reportedRenderCount} + + )} + {customDebugMessage && ( + + Message: {customDebugMessage} + + )} + + Tree RC: {renderInfo.renderCount} P: {renderInfo.phase} = (props) => {
- + vec.hasHistoricalVector) ?? false ); - const hasQueryError = [ - ...vectorDataQueries.filter((query) => query.isError), - ...vectorStatisticsQueries.filter((query) => query.isError), - ...historicalVectorDataQueries.filter((query) => query.isError), - ]; - if (hasQueryError.length > 0) { - return
One or more query has error state
; + const isQueryFetching = + vectorDataQueries.some((query) => query.isFetching) || + vectorStatisticsQueries.some((query) => query.isFetching) || + historicalVectorDataQueries.some((query) => query.isFetching); + + statusWriter.setLoading(isQueryFetching); + + const hasQueryError = + vectorDataQueries.some((query) => query.isError) || + vectorStatisticsQueries.some((query) => query.isError) || + historicalVectorDataQueries.some((query) => query.isError); + if (hasQueryError) { + statusWriter.addError("One or more queries have an error state."); + return One or more queries have an error state.; } // Map vector specifications and queries with data diff --git a/frontend/src/modules/_shared/components/ContentMessage/contentMessage.tsx b/frontend/src/modules/_shared/components/ContentMessage/contentMessage.tsx new file mode 100644 index 000000000..5c7682caa --- /dev/null +++ b/frontend/src/modules/_shared/components/ContentMessage/contentMessage.tsx @@ -0,0 +1,47 @@ +import React from "react"; + +import { resolveClassNames } from "@lib/utils/resolveClassNames"; + +export enum ContentMessageType { + INFO = "info", + ERROR = "error", +} + +export type ContentMessageProps = { + type: ContentMessageType; + children: React.ReactNode; +}; + +export const ContentMessage: React.FC = (props) => { + return ( +
+ {props.children} +
+ ); +}; + +ContentMessage.displayName = "ContentMessage"; + +export type ContentErrorProps = { + children: React.ReactNode; +}; + +export const ContentError: React.FC = (props) => { + return {props.children}; +}; + +ContentError.displayName = "ContentError"; + +export type ContentInfoProps = { + children: React.ReactNode; +}; + +export const ContentInfo: React.FC = (props) => { + return {props.children}; +}; + +ContentInfo.displayName = "ContentInfo"; diff --git a/frontend/src/modules/_shared/components/ContentMessage/index.ts b/frontend/src/modules/_shared/components/ContentMessage/index.ts new file mode 100644 index 000000000..496fdc502 --- /dev/null +++ b/frontend/src/modules/_shared/components/ContentMessage/index.ts @@ -0,0 +1 @@ +export { ContentInfo, ContentError } from "./contentMessage"; diff --git a/frontend/tailwind.config.cjs b/frontend/tailwind.config.cjs index a1b7b10d6..0ac9a9567 100644 --- a/frontend/tailwind.config.cjs +++ b/frontend/tailwind.config.cjs @@ -2,6 +2,19 @@ module.exports = { content: ["./src/**/*.{html,ts,tsx}"], darkMode: "class", - theme: {}, + theme: { + extend: { + keyframes: { + "linear-indefinite": { + "0%": { transform: "translateX(-100%) scaleX(1)" }, + "50%": { transform: "translateX(0%) scaleX(0.25)" }, + "100%": { transform: "translateX(100%)" }, + }, + }, + animation: { + "linear-indefinite": "linear-indefinite 3s cubic-bezier(1, 0.1, 0.1, 1) infinite", + }, + } + }, plugins: [], };