Line data Source code
1 : import * as React from 'react';
2 : import * as styles from './feed_entry_footer.module.css';
3 :
4 : import icons from '@ankoh/dashql-svg-symbols';
5 :
6 : import { QueryExecutionState } from '../../query/query_execution_state.js';
7 : import { QueryResultView } from '../../views/query_result/query_result_view.js';
8 : import { TableColumnHeader } from '../../views/query_result/data_table_cell.js';
9 : import { classNames } from '../../utils/classnames.js';
10 : import { VisualizationDispatch } from '../../views/visualization/visualization_dispatch.js';
11 : import { ResolvedVisualizeQuery } from '../scripts/script_types.js';
12 : import { TraceLogPanel } from '../trace_log_panel.js';
13 : import { hasQueryProgress, QueryProgressPanel } from '../../views/query_progress/query_progress_panel.js';
14 : import { TabHeader, useResultRowCount, formatRowCountDetail } from '../tab_header.js';
15 : import { QueryResultToolbar, useQueryResultRowCounts } from '../../views/query_result/query_result_toolbar.js';
16 : import { QueryResultDownloadButton } from '../../views/query_result/query_result_download_button.js';
17 : import { QueryResultActionMenu } from '../../views/query_result/query_result_action_menu.js';
18 : import { QueryResultHeader } from '../../views/query_result/query_result_header.js';
19 : import { ChartDownloadButton } from '../../views/visualization/chart_download_button.js';
20 :
21 1 : const FEED_LIMIT_RESULT_ROWS = 6;
22 : /// The Log tab's viewport auto-expands to fit its rows and caps at this many (then scrolls).
23 1 : const FEED_LIMIT_LOG_ROWS = 6;
24 : /// Cap the Vega-Lite chart height in the feed footer so a single vis entry doesn't dominate the
25 : /// feed while scrolling. Only the vega-lite renderer honors this.
26 1 : const FEED_VISUALIZATION_HEIGHT = 180;
27 : /// The height of the plan in the Progress tab. The plan and its statistics are fetched while the tab is open.
28 1 : const FEED_PROFILE_HEIGHT = 240;
29 :
30 : /// The result tabs of a feed entry. The tab strip lives in the entry status bar and the panel of the
31 : /// selected tab below it, so the row owns the selection through `useFeedEntryTabs`.
32 1 : export enum FeedEntryTab {
33 1 : ExecutionLog = 0,
34 1 : Table = 1,
35 1 : Visualization = 2,
36 1 : Progress = 3,
37 1 : }
38 :
39 : export interface FeedEntryTabProps {
40 : tab: FeedEntryTab;
41 : icon: string;
42 : description: string;
43 : }
44 :
45 1 : const FEED_ENTRY_TAB_PROPS: Record<FeedEntryTab, FeedEntryTabProps> = {
46 1 : [FeedEntryTab.Progress]: {
47 1 : tab: FeedEntryTab.Progress,
48 1 : icon: `${icons}#pulse_24`,
49 1 : description: 'Query progress',
50 1 : },
51 1 : [FeedEntryTab.ExecutionLog]: {
52 1 : tab: FeedEntryTab.ExecutionLog,
53 1 : icon: `${icons}#log_24`,
54 1 : description: 'Execution log',
55 1 : },
56 1 : [FeedEntryTab.Table]: {
57 1 : tab: FeedEntryTab.Table,
58 1 : icon: `${icons}#table_24`,
59 1 : description: 'Query results',
60 1 : },
61 1 : [FeedEntryTab.Visualization]: {
62 1 : tab: FeedEntryTab.Visualization,
63 1 : icon: `${icons}#graph_24`,
64 1 : description: 'Visualization',
65 1 : },
66 1 : };
67 :
68 : export interface FeedEntryTabs {
69 : /// The usable tabs, in display order
70 : tabs: FeedEntryTabProps[];
71 : selectedTab: FeedEntryTab;
72 : selectTab: (tab: FeedEntryTab) => void;
73 : }
74 :
75 : /// Track the tabs of a feed entry result. A query with structured progress (Trino) selects its progress
76 : /// tab, which sits right of the log. A result that arrives switches to its table (or to its chart
77 : /// when the script visualizes it), a result that goes away falls back to the progress or the log. The
78 : /// Details view passes the tab it was opened on as `initialTab`.
79 1 : export function useFeedEntryTabs(
80 1 : queryState: QueryExecutionState | null,
81 1 : visualizeQuery: ResolvedVisualizeQuery | null,
82 1 : dependencies: Pick<FeedEntryFooterDependencies, 'useResultRowCount'> = DEFAULT_DEPENDENCIES,
83 1 : initialTab?: FeedEntryTab,
84 1 : ): FeedEntryTabs {
85 1 : const { hasResult } = dependencies.useResultRowCount(queryState);
86 1 : const hasVisualization = hasResult && visualizeQuery != null && visualizeQuery.renderer !== 'table';
87 1 : const hasLog = queryState?.traceId != null;
88 1 : const hasProgress = hasQueryProgress(queryState);
89 1 : const fallbackTab = hasProgress ? FeedEntryTab.Progress : FeedEntryTab.ExecutionLog;
90 :
91 1 : const [selectedTab, selectTab] = React.useState<FeedEntryTab>(
92 1 : () => initialTab != null ? initialTab
93 1 : : hasVisualization ? FeedEntryTab.Visualization
94 1 : : hasResult ? FeedEntryTab.Table
95 1 : : fallbackTab
96 1 : );
97 :
98 1 : const prevHasResult = React.useRef(hasResult);
99 1 : const prevHasVisualization = React.useRef(hasVisualization);
100 1 : const prevHasProgress = React.useRef(hasProgress);
101 1 : React.useEffect(() => {
102 1 : if (hasResult && !prevHasResult.current) {
103 1 : selectTab(hasVisualization ? FeedEntryTab.Visualization : FeedEntryTab.Table);
104 1 : } else if (!hasResult && prevHasResult.current) {
105 1 : selectTab(fallbackTab);
106 1 : } else if (hasVisualization && !prevHasVisualization.current) {
107 : // A chart for the shown result, e.g. written by the agent after it ran the query
108 1 : selectTab(FeedEntryTab.Visualization);
109 1 : } else if (hasProgress && !prevHasProgress.current && !hasResult) {
110 : // The first progress of a running query replaces the log
111 1 : selectTab(current => current === FeedEntryTab.ExecutionLog ? FeedEntryTab.Progress : current);
112 : }
113 1 : prevHasResult.current = hasResult;
114 1 : prevHasVisualization.current = hasVisualization;
115 1 : prevHasProgress.current = hasProgress;
116 1 : }, [hasResult, hasVisualization, hasProgress, fallbackTab]);
117 :
118 : // Only surface the tabs that are actually usable
119 1 : const tabs = React.useMemo(() => {
120 1 : const tabs: FeedEntryTabProps[] = [];
121 1 : if (hasLog) tabs.push(FEED_ENTRY_TAB_PROPS[FeedEntryTab.ExecutionLog]);
122 1 : if (hasProgress) tabs.push(FEED_ENTRY_TAB_PROPS[FeedEntryTab.Progress]);
123 1 : if (hasResult) tabs.push(FEED_ENTRY_TAB_PROPS[FeedEntryTab.Table]);
124 1 : if (hasVisualization) tabs.push(FEED_ENTRY_TAB_PROPS[FeedEntryTab.Visualization]);
125 1 : return tabs;
126 1 : }, [hasProgress, hasLog, hasResult, hasVisualization]);
127 1 : React.useEffect(() => {
128 1 : if (tabs.length > 0 && !tabs.some(t => t.tab === selectedTab)) {
129 1 : selectTab(tabs[0].tab);
130 : }
131 1 : }, [tabs, selectedTab]);
132 :
133 1 : return { tabs, selectedTab, selectTab };
134 1 : }
135 :
136 : interface FeedEntryTabStripProps {
137 : tabs: FeedEntryTabs;
138 : /// Is the result shown? A collapsed result selects none of the tabs.
139 : expanded?: boolean;
140 : /// Called after a tab was picked, e.g. to expand a collapsed result
141 : onSelect?: () => void;
142 : }
143 :
144 : /// The result tabs as a compact strip for the entry status bar
145 1 : export const FeedEntryTabStrip: React.FC<FeedEntryTabStripProps> = ({ tabs, expanded = true, onSelect }) => {
146 1 : if (tabs.tabs.length === 0) {
147 0 : return null;
148 : }
149 1 : return (
150 1 : <div className={styles.tab_strip} role="tablist" aria-label="Result views">
151 1 : {tabs.tabs.map(t => {
152 1 : const active = expanded && t.tab === tabs.selectedTab;
153 1 : return (
154 1 : <button
155 1 : key={t.tab}
156 1 : type="button"
157 1 : role="tab"
158 1 : className={classNames(styles.tab, { [styles.tab_active]: active })}
159 1 : aria-selected={active}
160 1 : aria-label={t.description}
161 1 : title={t.description}
162 1 : data-tab={t.tab}
163 1 : onClick={() => {
164 1 : tabs.selectTab(t.tab);
165 1 : onSelect?.();
166 1 : }}
167 : >
168 1 : <svg width="14px" height="14px">
169 1 : <use xlinkHref={t.icon} />
170 1 : </svg>
171 1 : </button>
172 : );
173 1 : })}
174 1 : </div>
175 : );
176 1 : };
177 :
178 : export interface FeedEntryFooterDependencies {
179 : useResultRowCount: typeof useResultRowCount;
180 : useQueryResultRowCounts: typeof useQueryResultRowCounts;
181 : QueryResultToolbar: typeof QueryResultToolbar;
182 : QueryResultDownloadButton: typeof QueryResultDownloadButton;
183 : QueryResultActionMenu: typeof QueryResultActionMenu;
184 : ChartDownloadButton: typeof ChartDownloadButton;
185 : QueryResultView: typeof QueryResultView;
186 : }
187 :
188 1 : const DEFAULT_DEPENDENCIES: FeedEntryFooterDependencies = {
189 1 : useResultRowCount,
190 1 : useQueryResultRowCounts,
191 1 : QueryResultToolbar,
192 1 : QueryResultDownloadButton,
193 1 : QueryResultActionMenu,
194 1 : ChartDownloadButton,
195 1 : QueryResultView,
196 1 : };
197 :
198 : interface FeedEntryFooterProps {
199 : sessionId: string;
200 : /// The latest query execution for this script.
201 : queryState: QueryExecutionState | null;
202 : visualizeQuery: ResolvedVisualizeQuery | null;
203 : /// The tab to show, picked in the tab strip of the entry status bar
204 : selectedTab: FeedEntryTab;
205 : /// Open the full result table, makes the header and the column names of the preview clickable
206 : onShowTable?: () => void;
207 : dependencies?: FeedEntryFooterDependencies;
208 : }
209 :
210 1 : export const FeedEntryFooter: React.FC<FeedEntryFooterProps> = (props) => {
211 1 : const dependencies = props.dependencies ?? DEFAULT_DEPENDENCIES;
212 1 : const { totalRows } = dependencies.useResultRowCount(props.queryState);
213 1 : const queryTraceId = props.queryState?.traceId ?? null;
214 :
215 : // The visualization renders the full cloud (no feed row cap), so the header just shows the
216 : // total row count.
217 1 : const pointCountDetail = formatRowCountDetail(totalRows);
218 :
219 1 : switch (props.selectedTab) {
220 1 : case FeedEntryTab.Progress: return props.queryState == null ? null : (
221 1 : <QueryProgressPanel query={props.queryState} showProfile profileHeight={FEED_PROFILE_HEIGHT} />
222 : );
223 1 : case FeedEntryTab.ExecutionLog: return (
224 1 : <TraceLogPanel
225 1 : traceId={queryTraceId}
226 1 : title="Execution Logs"
227 1 : maxRows={FEED_LIMIT_LOG_ROWS}
228 1 : />
229 : );
230 1 : case FeedEntryTab.Table: return props.queryState == null ? null : (
231 1 : <>
232 1 : <QueryResultHeader
233 1 : query={props.queryState}
234 1 : maxRows={FEED_LIMIT_RESULT_ROWS}
235 1 : onShowTable={props.onShowTable}
236 1 : dependencies={dependencies}
237 1 : />
238 1 : <dependencies.QueryResultView
239 1 : query={props.queryState}
240 1 : debugMode={false}
241 1 : maxRows={FEED_LIMIT_RESULT_ROWS}
242 1 : columnHeader={TableColumnHeader.OnlyColumnName}
243 1 : cellBackground="transparent"
244 1 : onShowTable={props.onShowTable}
245 1 : compact
246 1 : />
247 1 : </>
248 : );
249 1 : case FeedEntryTab.Visualization: return (
250 1 : <div className={styles.visualization_tab}>
251 1 : <TabHeader
252 1 : title="Visualization"
253 1 : detail={pointCountDetail}
254 1 : actions={props.queryState != null && props.visualizeQuery?.renderer === 'vegalite' ? (
255 1 : <dependencies.ChartDownloadButton query={props.queryState} spec={props.visualizeQuery.vegaLiteSpec} />
256 0 : ) : undefined}
257 1 : />
258 1 : {props.queryState != null && (
259 1 : <div className={styles.visualization_body}>
260 1 : <VisualizationDispatch
261 1 : query={props.queryState}
262 1 : visualizeQuery={props.visualizeQuery}
263 1 : height={FEED_VISUALIZATION_HEIGHT}
264 1 : />
265 1 : </div>
266 : )}
267 1 : </div>
268 : );
269 1 : }
270 1 : };
|