Line data Source code
1 : import * as React from 'react';
2 : import * as dashql from '@ankoh/dashql-core';
3 : import * as styles from './script_diagnostics.module.css';
4 :
5 : import { AlertIcon, XCircleFillIcon, XIcon } from '../../ui/foundations/symbol_icon.js';
6 :
7 : import type { ScriptData } from './session_scripts.js';
8 : import type { ScriptReferenceReport } from './script_reference_check.js';
9 : import { formattingDialectForConnector } from './script_format.js';
10 : import type { ConnectorType } from '../../connections/connector_info.js';
11 : import { AnchorAlignment, AnchorSide } from '../../ui/foundations/anchored_position.js';
12 : import { AnchoredOverlay } from '../../ui/foundations/anchored_overlay.js';
13 : import { ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
14 : import { JsonView } from '../../ui/json/json_view.js';
15 : import { Overlay, OverlaySize } from '../../ui/foundations/overlay.js';
16 :
17 1 : export const SCRIPT_FORMATTING_WARNING = 'This script cannot be formatted';
18 :
19 : export interface ScriptDiagnostic {
20 : severity: 'error' | 'warning';
21 : message: string;
22 : details: Record<string, unknown>;
23 : }
24 :
25 1 : function formattingDetails(scriptData: ScriptData, connectorType?: ConnectorType): Record<string, unknown> {
26 1 : const details: Record<string, unknown> = {
27 1 : source: 'formatter',
28 1 : severity: 'warning',
29 1 : message: SCRIPT_FORMATTING_WARNING,
30 1 : scriptKey: scriptData.scriptKey,
31 1 : };
32 1 : try {
33 1 : const dialect = formattingDialectForConnector(connectorType);
34 1 : const config = new dashql.buffers.formatting.FormattingConfigT(
35 1 : dialect,
36 1 : dashql.buffers.formatting.FormattingMode.PRETTY,
37 1 : 80,
38 1 : 4,
39 1 : );
40 1 : details.configuration = {
41 1 : dialect: dashql.buffers.formatting.FormattingDialect[dialect],
42 1 : mode: 'PRETTY',
43 1 : maxWidth: 80,
44 1 : indentationWidth: 4,
45 1 : };
46 1 : details.fullyFormattable = scriptData.scriptSession.isFullyFormattable(config, true);
47 1 : } catch (error) {
48 1 : details.inspectionError = error instanceof Error ? error.message : String(error);
49 1 : }
50 1 : return details;
51 1 : }
52 :
53 1 : export function collectScriptDiagnostics(scriptData: ScriptData, isFormattable: boolean,
54 1 : connectorType?: ConnectorType, formatting: Record<string, unknown> | null = null,
55 1 : references: ScriptReferenceReport | null = null): ScriptDiagnostic[] {
56 1 : const diagnostics: ScriptDiagnostic[] = (scriptData.editorUpdate?.diagnostics ?? []).map(diagnostic => {
57 1 : const severity = diagnostic.severity === dashql.buffers.editor.EditorDiagnosticSeverity.WARNING
58 1 : ? 'warning'
59 1 : : 'error';
60 1 : const source = dashql.buffers.editor.EditorDiagnosticSource[diagnostic.source].toLowerCase();
61 1 : const span = diagnostic.textSpan;
62 1 : const textSpan = span == null ? null : {
63 1 : offset: Number(span.offset),
64 1 : length: Number(span.length),
65 1 : end: Number(span.offset + span.length),
66 1 : };
67 1 : const message = typeof diagnostic.message === 'string' ? diagnostic.message : '';
68 1 : return {
69 1 : severity,
70 1 : message,
71 1 : details: { source, severity, message, scriptKey: scriptData.scriptKey, textSpan },
72 1 : };
73 1 : });
74 :
75 : // The errors of the script references, of the analyzed text
76 1 : if (references != null && references.textVersion === scriptData.editorUpdate?.textVersion) {
77 1 : for (const mark of references.marks) {
78 1 : for (const message of mark.errors) {
79 1 : const textSpan = { offset: mark.from, length: mark.to - mark.from, end: mark.to };
80 1 : diagnostics.push({
81 1 : severity: 'error',
82 1 : message,
83 1 : details: { source: 'script references', severity: 'error', message, scriptKey: scriptData.scriptKey, textSpan },
84 1 : });
85 1 : }
86 : }
87 : }
88 :
89 1 : if (!isFormattable) {
90 1 : diagnostics.push({
91 1 : severity: 'warning',
92 1 : message: SCRIPT_FORMATTING_WARNING,
93 1 : details: formatting ?? formattingDetails(scriptData, connectorType),
94 1 : });
95 : }
96 1 : return diagnostics;
97 1 : }
98 :
99 1 : export const ScriptDiagnosticsButton: React.FC<{
100 : scriptData: ScriptData;
101 : isFormattable: boolean;
102 : connectorType?: ConnectorType;
103 : /// The size of the icon of the trigger, 16 by default
104 : iconSize?: number;
105 : /// The checked script references of the script
106 : references?: ScriptReferenceReport | null;
107 1 : }> = ({ scriptData, isFormattable, connectorType, iconSize = 16, references = null }) => {
108 1 : const [isOpen, setIsOpen] = React.useState(false);
109 1 : const [selectedDiagnostic, setSelectedDiagnostic] = React.useState<ScriptDiagnostic | null>(null);
110 1 : const triggerRef = React.useRef<HTMLButtonElement | null>(null);
111 : // Inspecting the formatter is a full pass over the script, only repeat it when the text changes
112 1 : const formatting = React.useMemo(
113 1 : () => isFormattable ? null : formattingDetails(scriptData, connectorType),
114 1 : [scriptData.scriptSession, scriptData.editorUpdate?.textVersion, isFormattable, connectorType],
115 1 : );
116 1 : const diagnostics = React.useMemo(
117 1 : () => collectScriptDiagnostics(scriptData, isFormattable, connectorType, formatting, references),
118 1 : [scriptData.editorUpdate, isFormattable, connectorType, formatting, references],
119 1 : );
120 1 : React.useEffect(() => {
121 1 : if (diagnostics.length === 0) {
122 1 : if (isOpen) setIsOpen(false);
123 1 : if (selectedDiagnostic != null) setSelectedDiagnostic(null);
124 1 : }
125 1 : }, [diagnostics.length, isOpen, selectedDiagnostic]);
126 1 : if (diagnostics.length === 0) return null;
127 :
128 1 : const hasErrors = diagnostics.some(diagnostic => diagnostic.severity === 'error');
129 1 : const errorCount = diagnostics.filter(diagnostic => diagnostic.severity === 'error').length;
130 1 : const warningCount = diagnostics.length - errorCount;
131 1 : const label = hasErrors ? 'Show script errors' : 'Show script warnings';
132 1 : const summary = [
133 1 : errorCount > 0 ? `${errorCount} ${errorCount === 1 ? 'error' : 'errors'}` : null,
134 1 : warningCount > 0 ? `${warningCount} ${warningCount === 1 ? 'warning' : 'warnings'}` : null,
135 1 : ].filter(Boolean).join(', ');
136 :
137 1 : return (
138 1 : <>
139 1 : <AnchoredOverlay
140 1 : open={isOpen}
141 1 : onOpen={() => setIsOpen(true)}
142 1 : onClose={() => setIsOpen(false)}
143 1 : side={AnchorSide.OutsideBottom}
144 1 : align={AnchorAlignment.End}
145 1 : anchorOffset={4}
146 1 : width={OverlaySize.M}
147 1 : anchorRef={triggerRef}
148 1 : returnFocusRef={triggerRef}
149 1 : focusZoneSettings={{ disabled: true }}
150 1 : renderAnchor={(anchorProps) => (
151 1 : <IconButton
152 1 : {...anchorProps}
153 1 : ref={triggerRef}
154 1 : className={hasErrors ? styles.error_trigger : styles.warning_trigger}
155 1 : variant={ButtonVariant.Invisible}
156 1 : size={ButtonSize.Small}
157 1 : aria-label={label}
158 : >
159 1 : {hasErrors
160 1 : ? <XCircleFillIcon size={iconSize} aria-hidden="true" />
161 1 : : <AlertIcon size={iconSize} aria-hidden="true" />}
162 1 : </IconButton>
163 : )}
164 : >
165 1 : <section className={styles.overlay} role="dialog" aria-label="Script diagnostics">
166 1 : <header className={styles.header}>
167 1 : <h2 className={styles.title}>Script diagnostics</h2>
168 1 : <span className={styles.summary}>{summary}</span>
169 1 : </header>
170 1 : <ul className={styles.list}>
171 1 : {diagnostics.map((diagnostic, index) => (
172 1 : <li key={`${diagnostic.severity}-${index}`}>
173 1 : <button
174 1 : type="button"
175 1 : className={styles.item}
176 1 : onClick={() => {
177 1 : setIsOpen(false);
178 1 : setSelectedDiagnostic(diagnostic);
179 1 : }}
180 1 : aria-label={`Show details: ${diagnostic.message}`}
181 : >
182 1 : <span
183 1 : className={diagnostic.severity === 'error' ? styles.error_icon : styles.warning_icon}
184 1 : aria-hidden="true"
185 : >
186 1 : {diagnostic.severity === 'error'
187 1 : ? <XCircleFillIcon size={16} />
188 1 : : <AlertIcon size={16} />}
189 1 : </span>
190 1 : <span className={styles.message}>{diagnostic.message}</span>
191 1 : </button>
192 1 : </li>
193 1 : ))}
194 1 : </ul>
195 1 : </section>
196 1 : </AnchoredOverlay>
197 1 : {selectedDiagnostic != null && (
198 1 : <Overlay
199 1 : centered
200 1 : width={OverlaySize.L}
201 1 : maxHeight={OverlaySize.XL}
202 1 : returnFocusRef={triggerRef}
203 1 : onEscape={() => setSelectedDiagnostic(null)}
204 1 : onClickOutside={() => setSelectedDiagnostic(null)}
205 : >
206 1 : <section className={styles.detail_overlay} role="dialog" aria-label="Diagnostic details">
207 1 : <header className={styles.detail_header}>
208 1 : <h2 className={styles.title}>Diagnostic details</h2>
209 1 : <IconButton
210 1 : variant={ButtonVariant.Invisible}
211 1 : size={ButtonSize.Small}
212 1 : aria-label="Close diagnostic details"
213 1 : onClick={() => setSelectedDiagnostic(null)}
214 : >
215 1 : <XIcon size={16} />
216 1 : </IconButton>
217 1 : </header>
218 1 : <div className={styles.json_details}>
219 1 : <JsonView
220 1 : value={selectedDiagnostic.details}
221 1 : collapsed={2}
222 1 : shortenTextAfterLength={100}
223 1 : />
224 1 : </div>
225 1 : </section>
226 1 : </Overlay>
227 : )}
228 1 : </>
229 : );
230 1 : };
|