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