Line data Source code
1 : import * as React from 'react';
2 : import * as dashql from '@ankoh/dashql-core';
3 : import icons from '@ankoh/dashql-svg-symbols';
4 :
5 : import type { QueryExecutionState } from '../../query/query_execution_state.js';
6 : import { ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
7 : import { ErrorBoundary } from '../../ui/foundations/error_boundary.js';
8 : import { useFocusTrap } from '../../ui/foundations/focus.js';
9 : import { Overlay, OverlaySize } from '../../ui/foundations/overlay.js';
10 : import { ScreenFullIcon, ScreenNormalIcon, SymbolIcon } from '../../ui/foundations/symbol_icon.js';
11 : import { VerticalTabs, VerticalTabProps, VerticalTabVariant } from '../../ui/foundations/vertical_tabs.js';
12 : import { QueryResultDetails } from '../../views/query_result/query_result_details.js';
13 : import { TableColumnHeader } from '../../views/query_result/data_table_cell.js';
14 : import { QueryProfileView } from '../../views/query_profile/query_profile_view.js';
15 : import { useQueryProfile } from '../../views/query_profile/engine_profile_view.js';
16 : import { TabHeader } from '../tab_header.js';
17 : import { getPlanResultText } from '../../shell/shell_result.js';
18 : import { detectPlanEngine } from '../../views/query_profile/plan_engine.js';
19 : import { classNames } from '../../utils/classnames.js';
20 : import * as styles from './shell_query_result_overlay.module.css';
21 :
22 : export interface ShellQueryResultOverlayDependencies {
23 : QueryResultDetails: typeof QueryResultDetails;
24 : QueryProfileView: typeof QueryProfileView;
25 : useQueryProfile: typeof useQueryProfile;
26 : }
27 :
28 1 : const DEFAULT_DEPENDENCIES: ShellQueryResultOverlayDependencies = {
29 1 : QueryResultDetails,
30 1 : QueryProfileView,
31 1 : useQueryProfile,
32 1 : };
33 :
34 : interface Props {
35 : query: QueryExecutionState;
36 : onClose: () => void;
37 : /// The result failed to render, the overlay closes as well
38 : onError?: (error: unknown, info: React.ErrorInfo) => void;
39 : dismissOnClickOutside?: boolean;
40 : dependencies?: ShellQueryResultOverlayDependencies;
41 : }
42 :
43 1 : const MAX_OVERLAY_HEIGHT = 600;
44 1 : const MIN_PLAN_OVERLAY_HEIGHT = 'min(360px, 80vh)';
45 1 : const IGNORE_OUTSIDE_CLICK = () => {};
46 1 : const CloseIcon = SymbolIcon('x_16');
47 :
48 1 : const enum ResultTab {
49 1 : Data = 0,
50 1 : Plan = 1,
51 1 : }
52 :
53 : /// A result that fails to render only closes the overlay, the terminal of the shell keeps its state
54 1 : export const ShellQueryResultOverlay: React.FC<Props> = props => {
55 1 : const { onClose, onError } = props;
56 1 : const handleError = (error: unknown, info: React.ErrorInfo) => {
57 1 : onError?.(error, info);
58 1 : onClose();
59 1 : };
60 1 : return (
61 1 : <ErrorBoundary key={props.query.queryId} fallback={() => null} onError={handleError}>
62 1 : <ResultOverlay {...props} />
63 1 : </ErrorBoundary>
64 : );
65 1 : };
66 :
67 1 : const ResultOverlay: React.FC<Props> = ({
68 1 : query,
69 1 : onClose,
70 1 : dismissOnClickOutside = true,
71 1 : dependencies = DEFAULT_DEPENDENCIES,
72 1 : }) => {
73 1 : const ResultDetails = dependencies.QueryResultDetails;
74 1 : const ProfileViewComponent = dependencies.QueryProfileView;
75 1 : const closeRef = React.useRef<HTMLButtonElement>(null);
76 1 : const dialogRef = React.useRef<HTMLElement>(null);
77 1 : const [lockedHeight, setLockedHeight] = React.useState<{ queryId: number; height: number } | null>(null);
78 1 : const currentLockedHeight = lockedHeight?.queryId === query.queryId ? lockedHeight.height : null;
79 1 : const planText = React.useMemo(() => getPlanResultText(query.resultTable), [query.resultTable]);
80 1 : const planEngine = detectPlanEngine(planText) ?? dashql.buffers.profile.ProfileEngine.HYPER;
81 1 : const { plan, execution, rotate } = dependencies.useQueryProfile(planEngine, planText);
82 1 : const hasPlan = plan != null;
83 1 : const [selectedTab, setSelectedTab] = React.useState<ResultTab>(ResultTab.Data);
84 1 : const selectedPlanQueryIdRef = React.useRef<number | null>(null);
85 1 : React.useLayoutEffect(() => {
86 1 : if (hasPlan && selectedPlanQueryIdRef.current !== query.queryId) {
87 1 : const height = dialogRef.current?.getBoundingClientRect().height;
88 1 : setLockedHeight(height == null ? null : { queryId: query.queryId, height });
89 1 : selectedPlanQueryIdRef.current = query.queryId;
90 1 : setSelectedTab(ResultTab.Plan);
91 1 : } else if (!hasPlan && selectedTab === ResultTab.Plan) {
92 0 : setLockedHeight(null);
93 0 : setSelectedTab(ResultTab.Data);
94 0 : }
95 1 : }, [hasPlan, query.queryId, selectedTab]);
96 :
97 : // Only the plan tab can be expanded to fill the window
98 1 : const [planExpanded, setPlanExpanded] = React.useState(false);
99 1 : const expanded = planExpanded && selectedTab === ResultTab.Plan;
100 :
101 1 : const closeButton = React.useMemo(() => (
102 1 : <IconButton
103 1 : ref={closeRef}
104 1 : variant={ButtonVariant.Invisible}
105 1 : size={ButtonSize.Small}
106 1 : aria-label="Close shell query results"
107 1 : onClick={onClose}
108 : >
109 1 : <CloseIcon size={16} />
110 1 : </IconButton>
111 1 : ), [onClose]);
112 1 : const planActions = React.useMemo(() => (
113 1 : <>
114 1 : <IconButton
115 1 : variant={ButtonVariant.Invisible}
116 1 : size={ButtonSize.Small}
117 1 : aria-label={planExpanded ? 'Restore plan size' : 'Expand plan'}
118 1 : aria-pressed={planExpanded}
119 1 : data-testid="plan-expand-toggle"
120 1 : onClick={() => setPlanExpanded(e => !e)}
121 : >
122 1 : {planExpanded ? <ScreenNormalIcon size={16} /> : <ScreenFullIcon size={16} />}
123 1 : </IconButton>
124 1 : {closeButton}
125 1 : </>
126 1 : ), [closeButton, planExpanded]);
127 1 : const tabProps = React.useMemo<Record<ResultTab, VerticalTabProps>>(() => ({
128 1 : [ResultTab.Data]: {
129 1 : tabId: ResultTab.Data,
130 1 : icon: `${icons}#table_24`,
131 1 : labelShort: 'Data',
132 1 : ariaLabel: 'Query results',
133 1 : description: 'Query results',
134 1 : },
135 1 : [ResultTab.Plan]: {
136 1 : tabId: ResultTab.Plan,
137 1 : icon: `${icons}#plan`,
138 1 : labelShort: 'Plan',
139 1 : ariaLabel: 'Query plan',
140 1 : description: 'Query plan',
141 1 : disabled: !hasPlan,
142 1 : },
143 1 : }), [hasPlan]);
144 1 : const tabKeys = React.useMemo(() => hasPlan ? [ResultTab.Data, ResultTab.Plan] : [ResultTab.Data], [hasPlan]);
145 1 : const tabRenderers = React.useMemo(() => ({
146 1 : [ResultTab.Data]: () => (
147 1 : <ResultDetails
148 1 : query={query}
149 1 : debugMode={false}
150 1 : fitHeight
151 1 : maxHeight={MAX_OVERLAY_HEIGHT}
152 1 : columnHeader={TableColumnHeader.WithColumnPlots}
153 1 : actions={closeButton}
154 1 : />
155 : ),
156 1 : [ResultTab.Plan]: () => (
157 1 : <div className={styles.plan_tab}>
158 1 : <TabHeader title="Query Plan" actions={planActions} />
159 1 : <div className={styles.plan_body}>{plan != null && <ProfileViewComponent plan={plan} execution={execution} onRotate={rotate} />}</div>
160 1 : </div>
161 : ),
162 1 : }), [closeButton, planActions, plan, execution, rotate, ProfileViewComponent, query, ResultDetails]);
163 1 : useFocusTrap({
164 1 : containerRef: dialogRef as React.RefObject<HTMLElement>,
165 1 : initialFocusRef: dialogRef as React.RefObject<HTMLElement>,
166 1 : restoreFocusOnCleanUp: true,
167 1 : });
168 1 : return (
169 1 : <Overlay
170 1 : centered
171 1 : width={OverlaySize.XXL}
172 1 : height={OverlaySize.AUTO}
173 1 : maxHeight={OverlaySize.XL}
174 1 : expanded={expanded}
175 1 : initialFocusRef={dialogRef as React.RefObject<HTMLElement>}
176 1 : onEscape={onClose}
177 1 : onClickOutside={dismissOnClickOutside ? onClose : IGNORE_OUTSIDE_CLICK}
178 : >
179 1 : <section
180 1 : ref={dialogRef}
181 1 : className={classNames(styles.card, {
182 1 : [styles.card_locked_height]: currentLockedHeight != null && !expanded,
183 1 : [styles.card_expanded]: expanded,
184 1 : })}
185 1 : role="dialog"
186 1 : aria-modal="true"
187 1 : aria-label="Shell query results"
188 1 : tabIndex={-1}
189 1 : style={expanded ? undefined : {
190 1 : height: currentLockedHeight ?? undefined,
191 1 : minHeight: selectedTab === ResultTab.Plan ? MIN_PLAN_OVERLAY_HEIGHT : undefined,
192 1 : }}
193 : >
194 1 : <VerticalTabs
195 1 : className={styles.tabs}
196 1 : variant={VerticalTabVariant.Stacked}
197 1 : tabKeys={tabKeys}
198 1 : tabProps={tabProps}
199 1 : tabRenderers={tabRenderers}
200 1 : selectedTab={selectedTab}
201 1 : selectTab={tab => setSelectedTab(tab as ResultTab)}
202 1 : />
203 1 : </section>
204 1 : </Overlay>
205 : );
206 1 : };
|