225 lines
6.5 KiB
TypeScript
225 lines
6.5 KiB
TypeScript
import {useReactFlow, useStoreApi} from "@xyflow/react";
|
|
import clsx from "clsx";
|
|
import {useEffect, useState} from "react";
|
|
import useFlowStore from "../VisProgStores.tsx";
|
|
import {
|
|
warningSummary,
|
|
type WarningSeverity,
|
|
type EditorWarning, globalWarning
|
|
} from "./EditorWarnings.tsx";
|
|
import styles from "./WarningSidebar.module.css";
|
|
|
|
/**
|
|
* the warning sidebar, shows all warnings
|
|
*
|
|
* @returns {React.JSX.Element}
|
|
* @constructor
|
|
*/
|
|
export function WarningsSidebar() {
|
|
const warnings = useFlowStore.getState().getWarnings();
|
|
const [hide, setHide] = useState(false);
|
|
const [severityFilter, setSeverityFilter] = useState<WarningSeverity | 'ALL'>('ALL');
|
|
const [autoHide, setAutoHide] = useState(false);
|
|
|
|
// let autohide change hide status only when autohide is toggled
|
|
// and allow for user to change the hide state even if autohide is enabled
|
|
const hasWarnings = warnings.length > 0;
|
|
useEffect(() => {
|
|
if (autoHide) {
|
|
setHide(!hasWarnings);
|
|
}
|
|
}, [autoHide, hasWarnings]);
|
|
|
|
const filtered = severityFilter === 'ALL'
|
|
? warnings
|
|
: warnings.filter(w => w.severity === severityFilter);
|
|
|
|
|
|
const summary = warningSummary();
|
|
// Finds the first key where the count > 0
|
|
const getHighestSeverity = () => {
|
|
if (summary.error > 0) return styles.error;
|
|
if (summary.warning > 0) return styles.warning;
|
|
if (summary.info > 0) return styles.info;
|
|
return '';
|
|
};
|
|
|
|
return (
|
|
<aside className={`flex-row`} >
|
|
<div
|
|
className={`${styles.warningsToggleBar} ${getHighestSeverity()}`}
|
|
onClick={() => setHide(!hide)}
|
|
title={"toggle warnings"}
|
|
>
|
|
<div className={`${hide ? styles.arrowRight : styles.arrowLeft}`}></div>
|
|
</div>
|
|
<div
|
|
id="warningSidebar"
|
|
className={styles.warningsContent}
|
|
style={hide ? {display: "none"} : {display: "flex"}}
|
|
>
|
|
<WarningsHeader
|
|
severityFilter={severityFilter}
|
|
onChange={setSeverityFilter}
|
|
/>
|
|
|
|
<WarningsList warnings={filtered} />
|
|
<div className={styles.autoHide}>
|
|
<input
|
|
id="autoHideSwitch"
|
|
type={"checkbox"}
|
|
checked={autoHide}
|
|
onChange={(e) => setAutoHide(e.target.checked)}
|
|
/><label>Hide if there are no warnings</label>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
);
|
|
}
|
|
|
|
/**
|
|
* the header of the warning sidebar, contains severity filtering buttons
|
|
*
|
|
* @param {WarningSeverity | "ALL"} severityFilter
|
|
* @param {(severity: (WarningSeverity | "ALL")) => void} onChange
|
|
* @returns {React.JSX.Element}
|
|
* @constructor
|
|
*/
|
|
function WarningsHeader({
|
|
severityFilter,
|
|
onChange,
|
|
}: {
|
|
severityFilter: WarningSeverity | 'ALL';
|
|
onChange: (severity: WarningSeverity | 'ALL') => void;
|
|
}) {
|
|
const summary = warningSummary();
|
|
|
|
return (
|
|
<div className={styles.warningsHeader}>
|
|
<h3>Warnings</h3>
|
|
<div className={styles.severityTabs}>
|
|
{(['ALL', 'ERROR', 'WARNING', 'INFO'] as const).map(severity => (
|
|
<button
|
|
key={severity}
|
|
className={clsx(styles.severityTab, severityFilter === severity && styles.active)}
|
|
onClick={() => onChange(severity)}
|
|
>
|
|
{severity}
|
|
{severity !== 'ALL' && (
|
|
<span className={styles.count}>
|
|
{summary[severity.toLowerCase() as keyof typeof summary]}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
/**
|
|
* the list of warnings in the warning sidebar
|
|
*
|
|
* @param {{warnings: EditorWarning[]}} props
|
|
* @returns {React.JSX.Element}
|
|
* @constructor
|
|
*/
|
|
function WarningsList(props: { warnings: EditorWarning[] }) {
|
|
const splitWarnings = {
|
|
global: props.warnings.filter(w => w.scope.id === globalWarning),
|
|
other: props.warnings.filter(w => w.scope.id !== globalWarning),
|
|
}
|
|
if (props.warnings.length === 0) {
|
|
return (
|
|
<div className={styles.warningsEmpty}>
|
|
No warnings!
|
|
</div>
|
|
)
|
|
}
|
|
return (
|
|
<div className={styles.warningsList}>
|
|
<div className={styles.warningGroupHeader}>global:</div>
|
|
<div className={styles.warningsGroup}>
|
|
{splitWarnings.global.map((warning) => (
|
|
<WarningListItem warning={warning} key={`${warning.scope.id}|${warning.type}` + (warning.scope.handleId
|
|
? `:${warning.scope.handleId}`
|
|
: "")}
|
|
/>
|
|
))}
|
|
{splitWarnings.global.length === 0 && "No global warnings!"}
|
|
</div>
|
|
<div className={styles.warningGroupHeader}>other:</div>
|
|
<div className={styles.warningsGroup}>
|
|
{splitWarnings.other.map((warning) => (
|
|
<WarningListItem warning={warning} key={`${warning.scope.id}|${warning.type}` + (warning.scope.handleId
|
|
? `:${warning.scope.handleId}`
|
|
: "")}
|
|
/>
|
|
))}
|
|
{splitWarnings.other.length === 0 && "No other warnings!"}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* a single warning in the warning sidebar
|
|
*
|
|
* @param {{warning: EditorWarning, key: string}} props
|
|
* @returns {React.JSX.Element}
|
|
* @constructor
|
|
*/
|
|
function WarningListItem(props: { warning: EditorWarning, key: string}) {
|
|
const jumpToNode = useJumpToNode();
|
|
|
|
return (
|
|
<div
|
|
className={clsx(styles.warningItem, styles[`warning-item--${props.warning.severity.toLowerCase()}`],)}
|
|
onClick={() => jumpToNode(props.warning.scope.id)}
|
|
>
|
|
<div className={styles.itemHeader}>
|
|
<span className={styles.type}>{props.warning.type}</span>
|
|
</div>
|
|
|
|
<div className={styles.description}>
|
|
{props.warning.description}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* moves the editor to the provided node
|
|
* @returns {(nodeId: string) => void}
|
|
*/
|
|
function useJumpToNode() {
|
|
const { getNode, setCenter, getViewport } = useReactFlow();
|
|
const { addSelectedNodes } = useStoreApi().getState();
|
|
|
|
|
|
return (nodeId: string) => {
|
|
// user can't jump to global warning, so prevent further logic from running if the warning is a globalWarning
|
|
if (nodeId === globalWarning) return;
|
|
const node = getNode(nodeId);
|
|
if (!node) return;
|
|
|
|
const nodeElement = document.querySelector(`.react-flow__node[data-id="${nodeId}"]`) as HTMLElement;
|
|
const { position } = node;
|
|
const viewport = getViewport();
|
|
const { width, height } = nodeElement.getBoundingClientRect();
|
|
|
|
//move to node
|
|
setCenter(
|
|
position!.x + ((width / viewport.zoom) / 2),
|
|
position!.y + ((height / viewport.zoom) / 2),
|
|
{duration: 300, interpolate: "smooth" }
|
|
).then(() => {
|
|
addSelectedNodes([nodeId]);
|
|
});
|
|
|
|
|
|
|
|
};
|
|
} |