Line data Source code
1 : import * as React from 'react';
2 : import * as dashql from '@ankoh/dashql-core';
3 : import * as styles from '../../app/action_menu.module.css';
4 :
5 : import { ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
6 : import * as ActionList from '../../ui/foundations/action_list.js';
7 : import { AnchorAlignment, AnchorSide } from '../../ui/foundations/anchored_position.js';
8 : import { AnchoredOverlay } from '../../ui/foundations/anchored_overlay.js';
9 : import { SymbolIcon } from '../../ui/foundations/symbol_icon.js';
10 : import { type RowContextMenu, useRowContextMenuOpener } from '../../ui/foundations/row_context_menu.js';
11 :
12 : /// Where a script is stored
13 : export interface ScriptStorage {
14 : /// The file name of the script, e.g. "01_extract.sql"
15 : fileName: string;
16 : /// The full path of the script file, resolved when the storage view opens
17 : resolvePath: () => string;
18 : /// Rename the script file
19 : onRename: (name: string) => void;
20 : }
21 :
22 : interface Props {
23 : scriptName: string;
24 : formatDisabled: boolean;
25 : onFormat: (mode: dashql.buffers.formatting.FormattingMode) => void;
26 : onDelete: () => void;
27 : /// Edit the name of the script in place, the menu offers a rename action
28 : onRename?: () => void;
29 : /// Add a script above or below the script, the menu offers the add actions
30 : onAddAbove?: () => void;
31 : onAddBelow?: () => void;
32 : /// The storage of the script, the menu offers its storage view
33 : storage?: ScriptStorage;
34 : /// The size of the icon of the trigger, 16 by default
35 : iconSize?: number;
36 : /// The right click of the row that opens the menu
37 : contextMenu?: RowContextMenu;
38 : }
39 :
40 1 : const MoreIcon = SymbolIcon('kebab_horizontal');
41 1 : const AddIcon = SymbolIcon('plus_16');
42 1 : const FormatIcon = SymbolIcon('pencil_ai_16');
43 1 : const RenameIcon = SymbolIcon('pencil_16');
44 1 : const TrashIcon = SymbolIcon('trash_16');
45 1 : const StorageIcon = SymbolIcon('file_directory_16');
46 1 : const CloseIcon = SymbolIcon('x_16');
47 :
48 1 : export const ScriptActionMenu: React.FC<Props> = (props) => {
49 1 : const [open, setOpen] = React.useState(false);
50 : // The menu switches to the storage view in place
51 1 : const [view, setView] = React.useState<'actions' | 'storage'>('actions');
52 1 : const triggerRef = React.useRef<HTMLButtonElement>(null);
53 1 : useRowContextMenuOpener(props.contextMenu, () => { setView('actions'); setOpen(true); });
54 1 : const run = React.useCallback((action: () => void) => {
55 1 : setOpen(false);
56 1 : action();
57 1 : }, []);
58 :
59 1 : return (
60 1 : <AnchoredOverlay
61 1 : open={open}
62 1 : onOpen={() => { setView('actions'); setOpen(true); }}
63 1 : onClose={() => setOpen(false)}
64 1 : side={AnchorSide.OutsideBottom}
65 1 : align={AnchorAlignment.End}
66 1 : anchorOffset={4}
67 1 : anchorRef={triggerRef}
68 1 : returnFocusRef={triggerRef}
69 1 : renderAnchor={(anchorProps) => (
70 1 : <IconButton
71 1 : {...anchorProps}
72 1 : ref={triggerRef}
73 1 : data-testid="script-more-actions"
74 1 : variant={ButtonVariant.Invisible}
75 1 : size={ButtonSize.Small}
76 1 : aria-label={`More actions for ${props.scriptName} script`}
77 : >
78 1 : <MoreIcon size={props.iconSize ?? 16} />
79 1 : </IconButton>
80 : )}
81 : >
82 1 : {view === 'storage' && props.storage != null ? (
83 1 : <ScriptStorageView scriptName={props.scriptName} storage={props.storage} onClose={() => setOpen(false)} />
84 : ) : (
85 1 : <div className={styles.menu} role="dialog" aria-label={`Actions for ${props.scriptName} script`}>
86 1 : <ActionList.List aria-label={`Actions for ${props.scriptName} script`}>
87 : {/* The list takes elements only, the fragments wrap the optional items */}
88 1 : <>{props.onAddAbove != null && (
89 1 : <ActionList.ListItem data-testid="script-menu-item" data-action="add-above" onClick={() => run(props.onAddAbove!)}>
90 1 : <ActionList.Leading><AddIcon size={16} /></ActionList.Leading>
91 1 : <ActionList.ItemText>Add Above</ActionList.ItemText>
92 1 : </ActionList.ListItem>
93 1 : )}</>
94 1 : <>{props.onAddBelow != null && (
95 1 : <ActionList.ListItem data-testid="script-menu-item" data-action="add-below" onClick={() => run(props.onAddBelow!)}>
96 1 : <ActionList.Leading><AddIcon size={16} /></ActionList.Leading>
97 1 : <ActionList.ItemText>Add Below</ActionList.ItemText>
98 1 : </ActionList.ListItem>
99 1 : )}</>
100 1 : <ActionList.ListItem
101 1 : data-testid="script-menu-item"
102 1 : data-action="format-pretty"
103 1 : disabled={props.formatDisabled}
104 1 : onClick={() => run(() => props.onFormat(dashql.buffers.formatting.FormattingMode.PRETTY))}
105 : >
106 1 : <ActionList.Leading><FormatIcon size={16} /></ActionList.Leading>
107 1 : <ActionList.ItemText>Format Pretty</ActionList.ItemText>
108 1 : </ActionList.ListItem>
109 1 : <ActionList.ListItem
110 1 : data-testid="script-menu-item"
111 1 : data-action="format-compact"
112 1 : disabled={props.formatDisabled}
113 1 : onClick={() => run(() => props.onFormat(dashql.buffers.formatting.FormattingMode.COMPACT))}
114 : >
115 1 : <ActionList.Leading><FormatIcon size={16} /></ActionList.Leading>
116 1 : <ActionList.ItemText>Format Compact</ActionList.ItemText>
117 1 : </ActionList.ListItem>
118 1 : <>{props.onRename != null && (
119 1 : <ActionList.ListItem data-testid="script-menu-item" data-action="rename" onClick={() => run(props.onRename!)}>
120 1 : <ActionList.Leading><RenameIcon size={16} /></ActionList.Leading>
121 1 : <ActionList.ItemText>Rename</ActionList.ItemText>
122 1 : </ActionList.ListItem>
123 1 : )}</>
124 1 : <>{props.storage != null && (
125 1 : <ActionList.ListItem data-testid="script-menu-item" data-action="storage" onClick={() => setView('storage')}>
126 1 : <ActionList.Leading><StorageIcon size={16} /></ActionList.Leading>
127 1 : <ActionList.ItemText>Storage</ActionList.ItemText>
128 1 : </ActionList.ListItem>
129 1 : )}</>
130 1 : <ActionList.ListItem
131 1 : data-testid="script-menu-item"
132 1 : data-action="delete"
133 1 : className={styles.delete_action}
134 1 : onClick={() => run(props.onDelete)}
135 : >
136 1 : <ActionList.Leading><TrashIcon size={16} /></ActionList.Leading>
137 1 : <ActionList.ItemText>Delete</ActionList.ItemText>
138 1 : </ActionList.ListItem>
139 1 : </ActionList.List>
140 1 : </div>
141 : )}
142 1 : </AnchoredOverlay>
143 : );
144 1 : };
145 :
146 : /// Where a script is stored, the file name can be edited
147 1 : function ScriptStorageView(props: { scriptName: string; storage: ScriptStorage; onClose: () => void }) {
148 1 : const path = React.useMemo(() => props.storage.resolvePath(), [props.storage]);
149 1 : const inputRef = React.useRef<HTMLInputElement>(null);
150 1 : const skipBlurCommitRef = React.useRef(false);
151 1 : React.useEffect(() => {
152 1 : if (inputRef.current != null) inputRef.current.value = props.storage.fileName;
153 1 : }, [props.storage.fileName]);
154 1 : const commit = (input: HTMLInputElement) => {
155 1 : const name = input.value.trim();
156 1 : if (name && name !== props.storage.fileName) props.storage.onRename(name);
157 1 : };
158 1 : const commitOnBlur = (event: React.FocusEvent<HTMLInputElement>) => {
159 0 : if (!skipBlurCommitRef.current) commit(event.currentTarget);
160 0 : };
161 : // Drop the focus without a commit. blur() fires its event synchronously, and none at all while the
162 : // window lacks focus, so the keys cannot leave it to the event to commit or to reset the flag.
163 1 : const blurWithoutCommit = (input: HTMLInputElement) => {
164 1 : skipBlurCommitRef.current = true;
165 1 : input.blur();
166 1 : skipBlurCommitRef.current = false;
167 1 : };
168 1 : const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
169 1 : if (event.key === 'Enter') {
170 1 : commit(event.currentTarget);
171 1 : blurWithoutCommit(event.currentTarget);
172 1 : } else if (event.key === 'Escape') {
173 : // Abandon the edit
174 0 : event.currentTarget.value = props.storage.fileName;
175 0 : blurWithoutCommit(event.currentTarget);
176 0 : }
177 1 : event.stopPropagation();
178 1 : };
179 1 : return (
180 1 : <div className={styles.storage} role="dialog" aria-label={`Storage of ${props.scriptName} script`} data-testid="script-storage">
181 1 : <div className={styles.storage_header}>
182 1 : <span className={styles.storage_title}>Script Storage</span>
183 1 : <IconButton variant={ButtonVariant.Invisible} size={ButtonSize.Small} aria-label="Close script storage" data-testid="script-storage-close" onClick={props.onClose}>
184 1 : <CloseIcon size={16} />
185 1 : </IconButton>
186 1 : </div>
187 1 : <div className={styles.storage_body}>
188 1 : <label className={styles.storage_label} htmlFor={`script-file-${props.storage.fileName}`}>File</label>
189 1 : <input
190 1 : id={`script-file-${props.storage.fileName}`}
191 1 : ref={inputRef}
192 1 : className={styles.storage_input}
193 1 : type="text"
194 1 : aria-label="Script file name"
195 1 : data-testid="script-file-name-input"
196 1 : defaultValue={props.storage.fileName}
197 1 : spellCheck={false}
198 1 : onBlur={commitOnBlur}
199 1 : onKeyDown={onKeyDown}
200 1 : onPointerDown={event => event.stopPropagation()}
201 1 : />
202 1 : <span className={styles.storage_label}>Path</span>
203 1 : <span className={styles.storage_path} title={path}>{path}</span>
204 1 : </div>
205 1 : </div>
206 : );
207 1 : }
|