Line data Source code
1 : import * as React from 'react';
2 : import * as styles from './entry_status_bar.module.css';
3 :
4 : import { EntryStatus } from './entry_status_model.js';
5 : import { StatusIndicator } from '../ui/foundations/status_indicator.js';
6 : import { AnchoredOverlay } from '../ui/foundations/anchored_overlay.js';
7 : import { AnchorAlignment, AnchorSide } from '../ui/foundations/anchored_position.js';
8 : import { ButtonSize, ButtonVariant, IconButton } from '../ui/foundations/button.js';
9 : import { JsonView } from '../ui/json/json_view.js';
10 : import { SymbolIcon } from '../ui/foundations/symbol_icon.js';
11 : import { ChevronDownIcon, ChevronRightIcon } from '../ui/foundations/symbol_icon.js';
12 : import { classNames } from '../utils/classnames.js';
13 :
14 : export interface EntryStatusBarDependencies {
15 : AnchoredOverlay: typeof AnchoredOverlay;
16 : }
17 :
18 1 : const DEFAULT_DEPENDENCIES: EntryStatusBarDependencies = {
19 1 : AnchoredOverlay,
20 1 : };
21 :
22 : interface EntryStatusBarProps {
23 : /// The derived status to show. When null the caller shouldn't render the bar at all.
24 : status: EntryStatus;
25 : /// Toggle the result content below this status header.
26 : onToggleExpanded?: () => void;
27 : expanded?: boolean;
28 : controls?: string;
29 : /// Cancel the in-flight work represented by this status. When set, a visible Cancel button is
30 : /// rendered at the right edge without making the whole bar a nested button.
31 : onCancel?: () => void;
32 : cancelLabel?: string;
33 : /// Result-level actions such as execution age and Refresh. Kept outside the clickable log strip
34 : /// so the bar never nests interactive controls.
35 : actions?: React.ReactNode;
36 : /// Result tabs shown right of the expand caret. With tabs, the caret toggles on its own and the
37 : /// status message moves to the right, next to the actions.
38 : tabs?: React.ReactNode;
39 : compact?: boolean;
40 : dependencies?: EntryStatusBarDependencies;
41 : }
42 :
43 1 : const ErrorDetailCard: React.FC<{
44 : detail: Record<string, unknown>;
45 : onClose: () => void;
46 1 : }> = ({ detail, onClose }) => {
47 1 : const CloseIcon = SymbolIcon('x_16');
48 1 : return (
49 1 : <section className={styles.error_detail_card} role="dialog" aria-label="Query error details" data-testid="error-details">
50 1 : <header className={styles.error_detail_header}>
51 1 : <h2 className={styles.error_detail_title}>Query error details</h2>
52 1 : <IconButton
53 1 : variant={ButtonVariant.Invisible}
54 1 : size={ButtonSize.Small}
55 1 : onClick={onClose}
56 1 : aria-label="Close query error details"
57 1 : data-testid="error-details-close"
58 : >
59 1 : <CloseIcon size={16} />
60 1 : </IconButton>
61 1 : </header>
62 1 : <JsonView
63 1 : className={styles.error_detail_json}
64 1 : value={detail}
65 1 : collapsed={2}
66 1 : shortenTextAfterLength={100}
67 1 : />
68 1 : </section>
69 : );
70 1 : };
71 :
72 : /// The result-card header: it renders a spinner (or check/cross) plus a one-line status message and
73 : /// optionally toggles the result content below it. Failed-query details are available from a
74 : /// separate control so the toggle never contains nested interactive elements.
75 1 : export const EntryStatusBar: React.FC<EntryStatusBarProps> = ({ status, onToggleExpanded, expanded, controls, onCancel, cancelLabel = 'Cancel operation', actions, tabs, compact, dependencies = DEFAULT_DEPENDENCIES }) => {
76 1 : const [showDetail, setShowDetail] = React.useState(false);
77 1 : const hasErrorDetail = status.errorDetail != null;
78 1 : const CancelIcon = SymbolIcon('x_16');
79 1 : const InfoIcon = SymbolIcon('info_circle_16');
80 1 : const AnchoredOverlayComponent = dependencies.AnchoredOverlay;
81 :
82 1 : const message = (
83 1 : <span className={styles.status_bar_message}>
84 1 : {status.message}
85 1 : </span>
86 : );
87 : // Keep error details separate from the log button. Nesting the overlay's focusable anchor inside
88 : // that button would create two interactive controls in one another.
89 1 : const errorDetail = hasErrorDetail ? (
90 1 : <AnchoredOverlayComponent
91 1 : open={showDetail}
92 1 : onOpen={() => setShowDetail(true)}
93 1 : onClose={() => setShowDetail(false)}
94 1 : side={AnchorSide.OutsideBottom}
95 1 : align={AnchorAlignment.End}
96 1 : anchorOffset={4}
97 1 : renderAnchor={(p: object) => {
98 : const anchorProps = p as React.ButtonHTMLAttributes<HTMLButtonElement>;
99 1 : return (
100 1 : <IconButton
101 1 : {...anchorProps}
102 1 : variant={ButtonVariant.Invisible}
103 1 : className={styles.error_detail_trigger}
104 1 : aria-label="Show error details"
105 1 : data-testid="error-details-toggle"
106 : >
107 1 : <InfoIcon size={16} />
108 1 : </IconButton>
109 : );
110 1 : }}
111 : >
112 1 : <ErrorDetailCard detail={status.errorDetail!} onClose={() => setShowDetail(false)} />
113 1 : </AnchoredOverlayComponent>
114 1 : ) : null;
115 1 : const indicator = (
116 1 : <StatusIndicator
117 1 : className={styles.status_bar_spinner}
118 1 : status={status.indicator}
119 1 : width="14px"
120 1 : height="14px"
121 1 : />
122 : );
123 :
124 1 : return (
125 1 : <div className={classNames(styles.status_bar, {
126 1 : [styles.status_bar_compact]: compact,
127 1 : [styles.status_bar_collapsed]: expanded === false,
128 1 : })}>
129 1 : {tabs != null ? (
130 1 : <>
131 1 : {onToggleExpanded != null && (
132 1 : <button
133 1 : type="button"
134 1 : className={styles.status_bar_caret}
135 1 : onClick={onToggleExpanded}
136 1 : aria-label={`${expanded ? 'Collapse' : 'Expand'} result: ${status.message}`}
137 1 : aria-expanded={expanded}
138 1 : aria-controls={controls}
139 : >
140 1 : {expanded ? <ChevronDownIcon size={14} /> : <ChevronRightIcon size={14} />}
141 1 : </button>
142 : )}
143 1 : {tabs}
144 1 : <div className={classNames(styles.status_bar_content, styles.status_bar_content_trailing)}>
145 1 : {indicator}
146 1 : {message}
147 1 : </div>
148 1 : </>
149 1 : ) : onToggleExpanded != null ? (
150 0 : <button
151 0 : type="button"
152 0 : className={styles.status_bar_log_button}
153 0 : onClick={onToggleExpanded}
154 0 : aria-label={`${expanded ? 'Collapse' : 'Expand'} result: ${status.message}`}
155 0 : aria-expanded={expanded}
156 0 : aria-controls={controls}
157 : >
158 0 : {expanded ? <ChevronDownIcon size={14} /> : <ChevronRightIcon size={14} />}
159 0 : {indicator}
160 0 : {message}
161 0 : </button>
162 : ) : (
163 1 : <div className={styles.status_bar_content}>
164 1 : {indicator}
165 1 : {message}
166 1 : </div>
167 : )}
168 1 : {onCancel != null && (
169 1 : <IconButton
170 1 : variant={ButtonVariant.Invisible}
171 1 : className={styles.status_bar_cancel}
172 1 : onClick={onCancel}
173 1 : aria-label={cancelLabel}
174 : >
175 1 : <CancelIcon size={16} />
176 1 : </IconButton>
177 : )}
178 1 : {errorDetail}
179 1 : {actions != null && (
180 1 : <div className={styles.status_bar_actions}>{actions}</div>
181 : )}
182 1 : </div>
183 : );
184 1 : };
|