Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions client/src/assets/css/EditorPanel.scss
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,21 @@
.header {
border-bottom: 1px solid #d2d2d2;
background-color: #fff;

// Flex (not floats) so a crowded toolbar wraps into clean rows instead
// of collapsing the float container and leaving an empty band.
display: flex;
flex-wrap: wrap;
align-items: stretch;
}

.actions {
float: left;
display: flex;
align-items: stretch;
&.right {
float: right;
// Push the run/clear/etc. group to the right; on a narrow pane it
// wraps to its own row rather than overflowing.
margin-left: auto;
}
}

Expand Down
2 changes: 2 additions & 0 deletions client/src/components/EditorPanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from 'actions/query'

import QueryVarsEditor from 'components/QueryVarsEditor'
import RunHistoryPanel from 'components/RunHistoryPanel'
import Editor from 'containers/Editor'

import '../assets/css/EditorPanel.scss'
Expand Down Expand Up @@ -101,6 +102,7 @@ export default function EditorPanel() {
{queryOptions}

<div className='actions right'>
<RunHistoryPanel />
<button
className={classnames('action', {
actionable: isQueryDirty || hasQueryVars,
Expand Down
190 changes: 190 additions & 0 deletions client/src/components/RunHistoryPanel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/*
* SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc.
* SPDX-License-Identifier: Apache-2.0
*/

import classnames from 'classnames'
import React from 'react'
import { useDispatch, useSelector } from 'react-redux'
import TimeAgo from 'react-timeago'

import { setActiveFrame } from 'actions/frames'
import { updateQueryAndAction, updateQueryVars } from 'actions/query'
import { filterFrames, summarizeFrame } from 'lib/runHistory'

import './RunHistoryPanel.scss'

const STATUS_TITLES = {
ok: 'Completed without errors',
error: 'Completed with errors',
unknown: 'Not executed in this session',
}

export default function RunHistoryPanel() {
const dispatch = useDispatch()
const { items, frameResults, activeFrameId } = useSelector(
(store) => store.frames,
)

const [isOpen, setOpen] = React.useState(false)
const [search, setSearch] = React.useState('')
const [coords, setCoords] = React.useState(null)

const rootRef = React.useRef(null)
const buttonRef = React.useRef(null)
const searchRef = React.useRef(null)

// Position the panel below the button, right-aligned to it, but clamped
// to the viewport so a narrow editor pane never pushes it off-screen
// (it is position:fixed, so it also escapes any clipping ancestor).
const PANEL_WIDTH = 420
const computeCoords = () => {
if (!buttonRef.current) {
return
}
const rect = buttonRef.current.getBoundingClientRect()
const width = Math.min(PANEL_WIDTH, window.innerWidth - 16)
const left = Math.max(
8,
Math.min(rect.right - width, window.innerWidth - width - 8),
)
setCoords({ top: rect.bottom + 2, left, width })
}

const toggleOpen = () => {
if (isOpen) {
setOpen(false)
} else {
computeCoords()
setOpen(true)
}
}

// Close the panel on outside clicks and on Escape; keep it anchored to
// the button on resize.
React.useEffect(() => {
if (!isOpen) {
return
}
const onMouseDown = (e) => {
if (rootRef.current && !rootRef.current.contains(e.target)) {
setOpen(false)
}
}
const onKeyDown = (e) => {
if (e.key === 'Escape') {
setOpen(false)
}
}
document.addEventListener('mousedown', onMouseDown)
document.addEventListener('keydown', onKeyDown)
window.addEventListener('resize', computeCoords)
return () => {
document.removeEventListener('mousedown', onMouseDown)
document.removeEventListener('keydown', onKeyDown)
window.removeEventListener('resize', computeCoords)
}
}, [isOpen])

// Focus the search input when the panel opens.
React.useEffect(() => {
if (isOpen && searchRef.current) {
searchRef.current.focus()
}
}, [isOpen])

const selectFrame = (frame) => {
dispatch(updateQueryAndAction(frame.query, frame.action))
if (frame.action === 'query') {
dispatch(updateQueryVars(frame.queryOptions?.queryVars || []))
}
dispatch(setActiveFrame(frame.id))
setOpen(false)
}

const visibleFrames = filterFrames(items, search)

const renderRow = (frame) => {
const { status, latencyText, snippet } = summarizeFrame(frame, frameResults)
const createdAt = frame.createdAt || frame.timestamp

return (
<button
key={frame.id}
type='button'
className={classnames('run-history-row', {
active: frame.id === activeFrameId,
})}
onClick={() => selectFrame(frame)}
title={frame.query}
>
<i
className={classnames('action-icon', {
'fa fa-search': frame.action === 'query',
'far fa-edit': frame.action !== 'query',
})}
/>
<span
className={`status-dot status-${status}`}
title={STATUS_TITLES[status]}
/>
<span className='snippet'>{snippet}</span>
<span className='meta'>
{latencyText && <span className='latency'>{latencyText}</span>}
{createdAt && (
<span className='time'>
<TimeAgo date={createdAt} minPeriod={10} />
</span>
)}
</span>
</button>
)
}

return (
<div className='run-history' ref={rootRef}>
<button
ref={buttonRef}
type='button'
className={classnames('action actionable', { open: isOpen })}
onClick={toggleOpen}
title='Show run history'
>
<i className='fa fa-history' /> History
</button>

{isOpen && coords && (
<div
className='run-history-panel'
style={{
top: coords.top,
left: coords.left,
width: coords.width,
}}
>
<div className='run-history-search'>
<input
ref={searchRef}
type='text'
className='form-control form-control-sm'
placeholder='Search past queries...'
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className='run-history-list'>
{visibleFrames.length ? (
visibleFrames.map(renderRow)
) : (
<div className='run-history-empty text-muted'>
{items.length
? 'No runs match your search'
: 'No queries have been run yet'}
</div>
)}
</div>
</div>
)}
</div>
)
}
116 changes: 116 additions & 0 deletions client/src/components/RunHistoryPanel.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
* SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc.
* SPDX-License-Identifier: Apache-2.0
*/

.run-history {
display: inline-block;
position: relative;

> .action.open {
background: #f7f7f7;
}

.run-history-panel {
// Fixed + JS-computed top/left/width (see RunHistoryPanel.js) so the
// panel stays on-screen from a mid-toolbar button and is never clipped
// by a narrow editor pane or an overflow:hidden ancestor.
position: fixed;
z-index: 1000;

background: #fff;
border: 1px solid #d2d2d2;
border-radius: 2px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}

.run-history-search {
padding: 8px;
border-bottom: 1px solid #e5e5e5;
}

.run-history-list {
max-height: 320px;
overflow-y: auto;
}

.run-history-empty {
padding: 14px 12px;
text-align: center;
font-size: 13px;
}

.run-history-row {
display: flex;
align-items: center;
width: 100%;

padding: 6px 10px;
border: none;
border-bottom: 1px solid #f0f0f0;
background: transparent;
text-align: left;
font-size: 13px;
color: #333;
cursor: pointer;

&:last-child {
border-bottom: none;
}

&:hover {
background: #f7f7f7;
}

&.active {
background: #eef6fb;
}

.action-icon {
flex: 0 0 auto;
margin-right: 8px;
color: #8a8a8a;
font-size: 12px;
}

.status-dot {
flex: 0 0 auto;
width: 8px;
height: 8px;
margin-right: 8px;
border-radius: 50%;

&.status-ok {
background-color: #28a745;
}
&.status-error {
background-color: #dc3545;
}
&.status-unknown {
background-color: #adb5bd;
}
}

.snippet {
flex: 1 1 auto;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-family: monospace;
font-size: 12px;
}

.meta {
flex: 0 0 auto;
margin-left: 10px;
color: #8a8a8a;
font-size: 11px;
white-space: nowrap;

.latency {
margin-right: 8px;
color: #5b8c5a;
}
}
}
}
Loading
Loading