Skip to content

Commit

Permalink
Hotkey improvements: Add scratchpad support, add multiple hotkeys for…
Browse files Browse the repository at this point in the history
… actions. (#16925)

* Fix displayInOverview flag. Add hotkeys to Scratchpad

* Refactoring. Add option to use multiple hotkeys for the same action. Get displayInOverview and splitKey from hotkeys options instead of hardcoded values

* Change scratchpad shortcut

* Fix hotkey to copy scratchpad content.

* Cleanup

* Only execute scratchpad related logic, when scratchpad is visible.

* Remove not needed useCallbacks.

* Add missing types for scratchpad

* Only show and do not toggle hotkeys overview when pressing related hotkey.

---------

Co-authored-by: Linus Pahl <[email protected]>
  • Loading branch information
maxiadlovskii and linuspahl authored Oct 17, 2023
1 parent 8524ebc commit dd08d70
Show file tree
Hide file tree
Showing 9 changed files with 382 additions and 269 deletions.
81 changes: 59 additions & 22 deletions graylog2-web-interface/src/components/hotkeys/HotkeysModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
import React from 'react';
import styled from 'styled-components';
import pick from 'lodash/pick';
import isArray from 'lodash/isArray';
import flattenDeep from 'lodash/flattenDeep';

import { KeyboardKey, Modal, Button } from 'components/bootstrap';
import useHotkeysContext from 'hooks/useHotkeysContext';
Expand Down Expand Up @@ -54,6 +56,7 @@ const KeysList = styled.div`
display: inline-flex;
gap: 5px;
justify-content: right;
flex-wrap: wrap;
`;

const KeySeparator = styled.div`
Expand All @@ -74,31 +77,57 @@ type KeyProps = {
description: string,
isEnabled: boolean,
isMacOS: boolean,
keys: string,
keys: string | Array<string>,
splitKey: string,
}

const Key = ({ description, keys, combinationKey, isEnabled, isMacOS }: KeyProps) => {
const keysArray = keys.split(combinationKey);
type ShortcutKeysProps = {
keys: string | Array<string>,
splitKey: string,
combinationKey: string,
isEnabled: boolean,
isMacOS: boolean
}

return (
<ShortcutListItem>
{description}
<KeysList>
{keysArray.map((key, index) => {
const isLast = index === keysArray.length - 1;
const ShortcutKeys = ({ keys, splitKey, combinationKey, isEnabled, isMacOS }: ShortcutKeysProps) => {
const shortcutsArray = isArray(keys) ? keys : [keys];
const splitShortcutsArray = flattenDeep(shortcutsArray.map((key) => key.split(splitKey)));

return (
<React.Fragment key={key}>
<KeyboardKey bsStyle={isEnabled ? 'info' : 'default'}>{keyMapper(key, isMacOS)}</KeyboardKey>
{!isLast && <KeySeparator>{combinationKey}</KeySeparator>}
</React.Fragment>
);
})}
</KeysList>
</ShortcutListItem>
return (
<>
{splitShortcutsArray.map((keysStr, keysStrIndex) => {
const keysArray = keysStr.split(combinationKey);
const isLastSplit = keysStrIndex === splitShortcutsArray.length - 1;

return (
<React.Fragment key={keysStr}>
{keysArray.map((key, index) => {
const isLast = index === keysArray.length - 1;

return (
<React.Fragment key={key}>
<KeyboardKey bsStyle={isEnabled ? 'info' : 'default'}>{keyMapper(key, isMacOS)}</KeyboardKey>
{!isLast && <KeySeparator>{combinationKey}</KeySeparator>}
</React.Fragment>
);
})}
{!isLastSplit && <KeySeparator>or</KeySeparator>}
</React.Fragment>
);
})}
</>
);
};

const Key = ({ description, keys, combinationKey, splitKey, isEnabled, isMacOS }: KeyProps) => (
<ShortcutListItem>
{description}
<KeysList>
<ShortcutKeys keys={keys} combinationKey={combinationKey} splitKey={splitKey} isEnabled={isEnabled} isMacOS={isMacOS} />
</KeysList>
</ShortcutListItem>
);

type HotkeyCollectionSectionProps = {
collection: HotkeyCollection,
scope: ScopeName,
Expand All @@ -108,7 +137,11 @@ type HotkeyCollectionSectionProps = {
const HotkeyCollectionSection = ({ collection, scope, isMacOS }: HotkeyCollectionSectionProps) => {
const { activeHotkeys } = useHotkeysContext();
const { title, description, actions } = collection;
const filtratedActions = Object.entries(actions).filter(([actionKey]) => activeHotkeys.has(`${scope}.${actionKey}`));
const filtratedActions = Object.entries(actions).filter(([actionKey]) => {
const key: `${ScopeName}.${string}` = `${scope}.${actionKey}`;

return activeHotkeys.has(key) && activeHotkeys.get(key).options.displayInOverview !== false;
});

if (!filtratedActions.length) {
return null;
Expand All @@ -119,15 +152,19 @@ const HotkeyCollectionSection = ({ collection, scope, isMacOS }: HotkeyCollectio
<p className="description">{description}</p>
<ShortcutList>
{filtratedActions.map(([actionKey, { description: keyDescription, keys, displayKeys }]) => {
const isEnabled = activeHotkeys.get(`${scope}.${actionKey}`)?.options?.enabled !== false;
const isEnabled = !!activeHotkeys.get(`${scope}.${actionKey}`)?.options?.enabled;
const splitKey = activeHotkeys.get(`${scope}.${actionKey}`)?.options?.splitKey;
const combinationKey = activeHotkeys.get(`${scope}.${actionKey}`)?.options?.combinationKey;
const reactKey = isArray(keys) ? keys.join(',') : keys;

return (
<Key description={keyDescription}
keys={displayKeys ?? keys}
combinationKey="+"
combinationKey={combinationKey}
splitKey={splitKey}
isEnabled={isEnabled}
isMacOS={isMacOS}
key={keys} />
key={reactKey} />
);
})}
</ShortcutList>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const HotkeysModalContainer = () => {

useHotkey({
actionKey: 'show-hotkeys-modal',
callback: toggleModal,
callback: () => setShow(true),
scope: 'general',
});

Expand Down
236 changes: 4 additions & 232 deletions graylog2-web-interface/src/components/scratchpad/Scratchpad.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,245 +14,17 @@
* along with this program. If not, see
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
import React, { useContext, useState, useEffect, useRef } from 'react';
import styled, { css } from 'styled-components';
import chroma from 'chroma-js';
import ClipboardJS from 'clipboard';
import debounce from 'lodash/debounce';
import React, { useContext } from 'react';

import { OverlayTrigger } from 'components/common';
import { Alert, Button, ButtonGroup, Tooltip, BootstrapModalConfirm } from 'components/bootstrap';
import { ScratchpadContext } from 'contexts/ScratchpadProvider';
import InteractableModal from 'components/common/InteractableModal';
import Icon from 'components/common/Icon';
import Store from 'logic/local-storage/Store';

const DEFAULT_SCRATCHDATA = '';
const TEXTAREA_ID = 'scratchpad-text-content';
const STATUS_CLEARED = 'Cleared.';
const STATUS_COPIED = 'Copied!';
const STATUS_AUTOSAVED = 'Auto saved.';
const STATUS_DEFAULT = '';

const ContentArea = styled.div`
display: flex;
flex-direction: column;
height: 100%;
`;

const Textarea = styled.textarea<{ $copied: boolean }>(({ $copied, theme }) => css`
width: 100%;
padding: 3px;
resize: none;
flex: 1;
margin: 15px 0 7px;
border: 1px solid ${$copied ? theme.colors.variant.success : theme.colors.variant.lighter.default};
box-shadow: inset 1px 1px 1px rgb(0 0 0 / 7.5%)${$copied && `, 0 0 8px ${chroma(theme.colors.variant.success).alpha(0.4).css()}`};
transition: border 150ms ease-in-out, box-shadow 150ms ease-in-out;
font-family: ${theme.fonts.family.monospace};
font-size: ${theme.fonts.size.body};
&:focus {
border-color: ${theme.colors.variant.light.info};
outline: none;
}
`);

const StyledAlert = styled(Alert)`
&& {
padding: 6px 12px;
margin: 6px 0 0;
display: flex;
align-items: center;
}
`;

const AlertNote = styled.em`
margin-left: 6px;
flex: 1;
`;

const Footer = styled.footer(({ theme }) => css`
display: flex;
align-items: center;
padding: 7px 0 9px;
border-top: 1px solid ${theme.colors.gray[80]};
`);

const StatusMessage = styled.span<{ $visible: boolean }>(({ theme, $visible }) => css`
flex: 1;
color: ${theme.colors.variant.success};
font-style: italic;
opacity: ${$visible ? '1' : '0'};
transition: opacity 150ms ease-in-out;
`);
import ScratchpadModal from 'components/scratchpad/ScratchpadModal';

const Scratchpad = () => {
const clipboard = useRef<ClipboardJS>();
const textareaRef = useRef<HTMLTextAreaElement>();
const statusTimeout = useRef<ReturnType<typeof setTimeout>>();
const { isScratchpadVisible, setScratchpadVisibility, localStorageItem } = useContext(ScratchpadContext);
const scratchpadStore = Store.get(localStorageItem) || {};
const [isSecurityWarningConfirmed, setSecurityWarningConfirmed] = useState<boolean>(scratchpadStore.securityConfirmed || false);
const [scratchData, setScratchData] = useState<string>(scratchpadStore.value || DEFAULT_SCRATCHDATA);
const [size, setSize] = useState<{ width: string, height: string } | undefined>(scratchpadStore.size || undefined);
const [position, setPosition] = useState<{ x:number, y:number } | undefined>(scratchpadStore.position || undefined);
const [statusMessage, setStatusMessage] = useState<string>(STATUS_DEFAULT);
const [showStatusMessage, setShowStatusMessage] = useState<boolean>(false);
const [showModal, setShowModal] = useState<boolean>(false);

const writeData = (newData) => {
const currentStorage = Store.get(localStorageItem);

Store.set(localStorageItem, { ...currentStorage, ...newData });
};

const resetStatusTimer = () => {
if (statusTimeout.current) {
clearTimeout(statusTimeout.current);
}

statusTimeout.current = setTimeout(() => {
setShowStatusMessage(false);
}, 1000);
};

const updateStatusMessage = (message) => {
setStatusMessage(message);
setShowStatusMessage(true);
resetStatusTimer();
};

const handleChange = debounce(() => {
const { value } = textareaRef.current;

setScratchData(value);
updateStatusMessage(STATUS_AUTOSAVED);
writeData({ value });
}, 500);

const handleDrag = (newPosition) => {
setPosition(newPosition);
writeData({ position: newPosition });
};

const handleSize = (newSize) => {
setSize(newSize);
writeData({ size: newSize });
};

const handleGotIt = () => {
setSecurityWarningConfirmed(true);
writeData({ securityConfirmed: true });
};

const openConfirmClear = () => {
setShowModal(true);
};

const handleCancelClear = () => {
setShowModal(false);
};

const handleClearText = () => {
setScratchData(DEFAULT_SCRATCHDATA);
writeData({ value: DEFAULT_SCRATCHDATA });
handleCancelClear();
updateStatusMessage(STATUS_CLEARED);
};

useEffect(() => {
clipboard.current = new ClipboardJS('[data-clipboard-button]', {});

clipboard.current.on('success', () => {
updateStatusMessage(STATUS_COPIED);
});

return () => {
clipboard.current.destroy();

if (statusTimeout.current) {
clearTimeout(statusTimeout.current);
}
};
});

useEffect(() => {
if (textareaRef.current && isScratchpadVisible) {
textareaRef.current.focus();
textareaRef.current.value = scratchData;
}
}, [scratchData, isScratchpadVisible]);
const { isScratchpadVisible } = useContext(ScratchpadContext);

if (!isScratchpadVisible) return null;

return (
<InteractableModal title="Scratchpad"
onClose={() => setScratchpadVisibility(false)}
onDrag={handleDrag}
onResize={handleSize}
size={size}
position={position}>
<ContentArea>
{!isSecurityWarningConfirmed && (
<StyledAlert bsStyle="warning" bsSize="sm">
<AlertNote>
We recommend you do <strong>not</strong> store any sensitive information, such as passwords, in
this area.
</AlertNote>
<Button bsStyle="link" bsSize="sm" onClick={handleGotIt}>Got It!</Button>
</StyledAlert>
)}

<Textarea ref={textareaRef}
onChange={handleChange}
id={TEXTAREA_ID}
$copied={statusMessage === STATUS_COPIED}
spellCheck={false} />

<Footer>
<OverlayTrigger placement="right"
trigger={['hover', 'focus']}
overlay={(
<Tooltip id="scratchpad-help" show>
You can use this space to store personal notes and other information while interacting with
Graylog, without leaving your browser window. For example, store timestamps, user IDs, or IP
addresses you need in various investigations.
</Tooltip>
)}>
<Button bsStyle="link">
<Icon name="question-circle" />
</Button>
</OverlayTrigger>

<StatusMessage $visible={showStatusMessage}>
<Icon name={statusMessage === STATUS_COPIED ? 'copy' : 'hdd'} type="regular" /> {statusMessage}
</StatusMessage>

<ButtonGroup>
<Button data-clipboard-button
data-clipboard-target={`#${TEXTAREA_ID}`}
id="scratchpad-actions"
title="Copy">
<Icon name="copy" />
</Button>
<Button onClick={openConfirmClear} title="Clear">
<Icon name="trash-alt" />
</Button>
</ButtonGroup>

</Footer>

</ContentArea>

<BootstrapModalConfirm showModal={showModal}
title="Are you sure?"
onConfirm={handleClearText}
onCancel={handleCancelClear}>
This will clear out your Scratchpad content, do you wish to proceed?
</BootstrapModalConfirm>
</InteractableModal>
);
return <ScratchpadModal />;
};

export default Scratchpad;
Loading

0 comments on commit dd08d70

Please sign in to comment.