Co-Study4Grid / frontend /src /components /AppSidebar.tsx
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
12 kB
// Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
// you can obtain one at http://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
// This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study.
import React, { useMemo } from 'react';
import Select, { type MultiValue } from 'react-select';
import type { ActionOverviewFilters } from '../types';
import SidebarSummary from './SidebarSummary';
import { colors, radius, space } from '../styles/tokens';
interface ContingencyOption {
value: string;
label: string;
}
interface AppSidebarProps {
/** Currently APPLIED contingency (list of element IDs disconnected). */
selectedContingency: string[];
/** Pending list the user is composing — committed via Apply button. */
pendingContingency: string[];
branches: string[];
nameMap: Record<string, string>;
n1LinesOverloaded: string[] | undefined;
n1LinesOverloadedRho: number[] | undefined;
/** N-state pre-existing overloads, surfaced in the SidebarSummary info bubble. */
nLinesOverloaded?: string[];
nLinesOverloadedRho?: number[];
selectedOverloads: Set<string> | null | undefined;
/** Replace the pending list with the user's current selection. */
onPendingContingencyChange: (next: string[]) => void;
/** Commit ``pendingContingency`` as the applied contingency. */
onContingencyApply: () => void;
displayName: (id: string) => string;
onContingencyZoom: (assetName: string) => void;
onOverloadClick: (actionId: string, assetName: string, tab: 'n' | 'contingency') => void;
/** Toggle an N-1 overload's inclusion in the analysis monitoring set.
* Forwarded to SidebarSummary's info bubble. */
onToggleOverload?: (overload: string) => void;
monitorDeselected?: boolean;
onToggleMonitorDeselected?: () => void;
monitoringHint?: string | null;
/** Drop the current contingency to investigate a new one. Triggers
* the confirmation dialog at the call site. */
onClearContingency?: () => void;
/** Hide the "Select Contingency" card. Used after a contingency has
* been committed AND its overloads detected, since the sticky banner
* then carries the same info plus a Clear shortcut. */
hideContingencyPicker?: boolean;
/** Collapsed mode: the sidebar shrinks to a thin strip so the
* visualization panel can take the full width. */
collapsed?: boolean;
onToggleCollapsed?: () => void;
/** Shared severity + action-type filters; forwarded to
* SidebarSummary so the persistent strip can host the filter
* rings alongside the contingency / overload lines. */
overviewFilters?: ActionOverviewFilters;
onOverviewFiltersChange?: (next: ActionOverviewFilters) => void;
/** Whether the action feed has any card to filter right now. */
hasActions?: boolean;
children: React.ReactNode;
}
/**
* Left-sidebar layout shell:
*
* - A COMPACT sticky strip at the top (<SidebarSummary>) keeps only
* the clickable fields of interest visible while scrolling
* (selected contingency → zoom active tab; contingency overloads →
* jump to the contingency tab + zoom). The strip also hosts the
* Clear-contingency shortcut and the overload-info bubble.
* - The Select Contingency card with the multi-select picker + Trigger
* button shows below the strip — but only as long as no contingency
* has been committed (after that, the strip carries the same info
* and the picker would be redundant).
* - The ActionFeed (rendered as ``children``) sits below.
*
* The shell can be collapsed to a thin strip when the operator wants
* to expand the visualization panel.
*/
export default function AppSidebar({
selectedContingency,
pendingContingency,
branches,
nameMap,
n1LinesOverloaded,
n1LinesOverloadedRho,
nLinesOverloaded,
nLinesOverloadedRho,
selectedOverloads,
onPendingContingencyChange,
onContingencyApply,
displayName,
onContingencyZoom,
onOverloadClick,
onToggleOverload,
monitorDeselected,
onToggleMonitorDeselected,
monitoringHint,
onClearContingency,
hideContingencyPicker,
collapsed,
onToggleCollapsed,
overviewFilters,
onOverviewFiltersChange,
hasActions,
children,
}: AppSidebarProps) {
// Pending differs from applied → user has unconfirmed edits to the
// contingency that won't take effect until they hit Trigger.
const samePendingApplied =
pendingContingency.length === selectedContingency.length &&
pendingContingency.every((e, i) => e === selectedContingency[i]);
const dirty = !samePendingApplied;
const branchOptions: ContingencyOption[] = useMemo(
() => branches.map(b => ({
value: b,
label: nameMap[b] ? `${nameMap[b]}${b}` : b,
})),
[branches, nameMap],
);
const optionByValue = useMemo(() => {
const m = new Map<string, ContingencyOption>();
for (const o of branchOptions) m.set(o.value, o);
return m;
}, [branchOptions]);
const selectedOptions: ContingencyOption[] = useMemo(
() => pendingContingency.map(id =>
optionByValue.get(id) ?? { value: id, label: nameMap[id] ? `${nameMap[id]}${id}` : id }
),
[pendingContingency, optionByValue, nameMap],
);
if (collapsed) {
return (
<div
data-testid="sidebar"
data-collapsed="true"
style={{
width: '32px',
background: colors.borderSubtle,
borderRight: `1px solid ${colors.border}`,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: `${space[2]} 0`,
}}
>
<button
data-testid="sidebar-expand-button"
onClick={onToggleCollapsed}
title="Expand sidebar"
style={{
background: colors.surface,
border: `1px solid ${colors.border}`,
borderRadius: radius.sm,
cursor: 'pointer',
width: '24px',
height: '24px',
fontSize: '12px',
color: colors.textSecondary,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 0,
}}
>
</button>
</div>
);
}
return (
<div data-testid="sidebar" style={{ width: '25%', background: colors.borderSubtle, borderRight: `1px solid ${colors.border}`, display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'relative' }}>
{onToggleCollapsed && (
<button
data-testid="sidebar-collapse-button"
onClick={onToggleCollapsed}
title="Collapse sidebar to widen the visualization panel"
style={{
position: 'absolute',
top: '4px',
right: '4px',
zIndex: 5,
background: colors.surface,
border: `1px solid ${colors.border}`,
borderRadius: radius.sm,
cursor: 'pointer',
width: '20px',
height: '20px',
fontSize: '11px',
color: colors.textSecondary,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 0,
lineHeight: 1,
}}
>
</button>
)}
<SidebarSummary
selectedContingency={selectedContingency}
n1LinesOverloaded={n1LinesOverloaded}
n1LinesOverloadedRho={n1LinesOverloadedRho}
nLinesOverloaded={nLinesOverloaded}
nLinesOverloadedRho={nLinesOverloadedRho}
selectedOverloads={selectedOverloads}
displayName={displayName}
onContingencyZoom={onContingencyZoom}
onOverloadClick={onOverloadClick}
onToggleOverload={onToggleOverload}
monitorDeselected={monitorDeselected}
onToggleMonitorDeselected={onToggleMonitorDeselected}
monitoringHint={monitoringHint}
onClearContingency={onClearContingency}
overviewFilters={overviewFilters}
onOverviewFiltersChange={onOverviewFiltersChange}
hasActions={hasActions}
/>
<div style={{ flex: 1, overflowY: 'auto', padding: space[4], minHeight: 0, display: 'flex', flexDirection: 'column', gap: space[4] }}>
{branches.length > 0 && !hideContingencyPicker && (
<div style={{ flexShrink: 0, padding: `${space[3]} ${space[4]}`, background: colors.surface, borderRadius: radius.lg, border: `1px solid ${colors.border}`, boxShadow: '0 2px 4px rgba(0,0,0,0.05)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: space[2], marginBottom: '5px' }}>
<label style={{ fontSize: '0.8rem', fontWeight: 'bold' }}>⚡ Select Contingency</label>
<button
type="button"
data-testid="contingency-trigger"
onClick={onContingencyApply}
disabled={pendingContingency.length === 0 || !dirty}
title={
pendingContingency.length === 0
? 'Pick at least one element first'
: dirty
? `Trigger contingency (${pendingContingency.length} element${pendingContingency.length > 1 ? 's' : ''})`
: 'Already triggered'
}
style={{
padding: `${space[1]} ${space[3]}`,
background: pendingContingency.length === 0 || !dirty ? colors.disabled : colors.accent,
color: colors.textOnBrand,
border: 'none',
borderRadius: radius.sm,
cursor: pendingContingency.length === 0 || !dirty ? 'not-allowed' : 'pointer',
fontWeight: 'bold',
fontSize: '0.78rem',
whiteSpace: 'nowrap',
}}
>
▶ Trigger
</button>
</div>
<Select<ContingencyOption, true>
isMulti
isClearable={false}
options={branchOptions}
value={selectedOptions}
onChange={(next: MultiValue<ContingencyOption>) =>
onPendingContingencyChange(next.map(o => o.value))
}
placeholder="Search line/bus…"
noOptionsMessage={() => 'No matching elements'}
classNamePrefix="cs4g-contingency"
styles={{
control: (base) => ({
...base,
minHeight: 36,
borderRadius: radius.sm,
borderColor: colors.border,
background: colors.surface,
fontSize: '0.85rem',
}),
input: (base) => ({ ...base, color: colors.textPrimary }),
placeholder: (base) => ({ ...base, color: colors.textTertiary }),
multiValue: (base) => ({
...base,
background: colors.borderSubtle,
border: `1px solid ${colors.border}`,
}),
multiValueLabel: (base) => ({
...base,
color: colors.textPrimary,
fontSize: '0.78rem',
}),
option: (base, state) => ({
...base,
fontSize: '0.85rem',
background: state.isFocused ? colors.borderSubtle : 'transparent',
color: colors.textPrimary,
cursor: 'pointer',
}),
menu: (base) => ({ ...base, zIndex: 30, background: colors.surface }),
}}
/>
</div>
)}
{children}
</div>
</div>
);
}