mirror of
https://github.com/pgadmin-org/pgadmin4.git
synced 2025-02-25 18:55:31 -06:00
UX improvements of the history in the query tool.
- Added copy button for query text. - Historical queries are binned/grouped by day. Patch By: Hao Wang, Sarah McAlear
This commit is contained in:
parent
33bd9d4782
commit
5141debae7
@ -72,13 +72,14 @@ class QueryToolJourneyTest(BaseFeatureTest):
|
||||
self.__clear_query_tool()
|
||||
editor_input = self.page.find_by_id("output-panel")
|
||||
self.page.click_element(editor_input)
|
||||
self._execute_query("SELECT * FROM shoes")
|
||||
self._execute_query("SELECT * FROM table_that_doesnt_exist")
|
||||
|
||||
self.page.click_tab("History")
|
||||
self.page.click_tab("Query History")
|
||||
selected_history_entry = self.page.find_by_css_selector("#query_list .selected")
|
||||
self.assertIn("SELECT * FROM shoes", selected_history_entry.text)
|
||||
self.assertIn("SELECT * FROM table_that_doesnt_exist", selected_history_entry.text)
|
||||
failed_history_detail_pane = self.page.find_by_id("query_detail")
|
||||
self.assertIn("ERROR: relation \"shoes\" does not exist", failed_history_detail_pane.text)
|
||||
|
||||
self.assertIn("Error Message relation \"table_that_doesnt_exist\" does not exist", failed_history_detail_pane.text)
|
||||
ActionChains(self.page.driver) \
|
||||
.send_keys(Keys.ARROW_DOWN) \
|
||||
.perform()
|
||||
@ -86,10 +87,30 @@ class QueryToolJourneyTest(BaseFeatureTest):
|
||||
self.assertIn("SELECT * FROM test_table ORDER BY value", selected_history_entry.text)
|
||||
selected_history_detail_pane = self.page.find_by_id("query_detail")
|
||||
self.assertIn("SELECT * FROM test_table ORDER BY value", selected_history_detail_pane.text)
|
||||
newly_selected_history_entry = self.page.find_by_xpath("//*[@id='query_list']/ul/li[1]")
|
||||
newly_selected_history_entry = self.page.find_by_xpath("//*[@id='query_list']/ul/li[2]")
|
||||
self.page.click_element(newly_selected_history_entry)
|
||||
selected_history_detail_pane = self.page.find_by_id("query_detail")
|
||||
self.assertIn("SELECT * FROM shoes", selected_history_detail_pane.text)
|
||||
self.assertIn("SELECT * FROM table_that_doesnt_exist", selected_history_detail_pane.text)
|
||||
|
||||
self.__clear_query_tool()
|
||||
|
||||
self.page.click_element(editor_input)
|
||||
for _ in range(15):
|
||||
self._execute_query("SELECT * FROM hats")
|
||||
|
||||
self.page.click_tab("Query History")
|
||||
|
||||
query_we_need_to_scroll_to = self.page.find_by_xpath("//*[@id='query_list']/ul/li[17]")
|
||||
|
||||
self.page.click_element(query_we_need_to_scroll_to)
|
||||
self._assert_not_clickable_because_out_of_view(query_we_need_to_scroll_to)
|
||||
|
||||
for _ in range(17):
|
||||
ActionChains(self.page.driver) \
|
||||
.send_keys(Keys.ARROW_DOWN) \
|
||||
.perform()
|
||||
|
||||
self._assert_clickable(query_we_need_to_scroll_to)
|
||||
|
||||
self.__clear_query_tool()
|
||||
self.page.click_element(editor_input)
|
||||
|
@ -268,6 +268,7 @@
|
||||
.wcFrameTitleBar {
|
||||
background-color: #e8e8e8;
|
||||
height: 35px;
|
||||
border-bottom: #cccccc;
|
||||
}
|
||||
|
||||
.wcFloating .wcFrameTitleBar {
|
||||
|
@ -12,11 +12,49 @@ import 'codemirror/mode/sql/sql';
|
||||
|
||||
import CodeMirror from './code_mirror';
|
||||
import Shapes from '../../react_shapes';
|
||||
import clipboard from '../../../js/selection/clipboard';
|
||||
|
||||
export default class HistoryDetailQuery extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.copyAllHandler = this.copyAllHandler.bind(this);
|
||||
this.state = {isCopied: false};
|
||||
this.timeout = undefined;
|
||||
}
|
||||
|
||||
copyAllHandler() {
|
||||
clipboard.copyTextToClipboard(this.props.historyEntry.query);
|
||||
|
||||
this.clearPreviousTimeout();
|
||||
|
||||
this.setState({isCopied: true});
|
||||
this.timeout = setTimeout(() => {
|
||||
this.setState({isCopied: false});
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
clearPreviousTimeout() {
|
||||
if (this.timeout !== undefined) {
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
copyButtonText() {
|
||||
return this.state.isCopied ? 'Copied!' : 'Copy All';
|
||||
}
|
||||
|
||||
copyButtonClass() {
|
||||
return this.state.isCopied ? 'was-copied' : 'copy-all';
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div id="history-detail-query">
|
||||
<button className={this.copyButtonClass()}
|
||||
onClick={this.copyAllHandler}>{this.copyButtonText()}</button>
|
||||
<CodeMirror
|
||||
value={this.props.historyEntry.query}
|
||||
options={{
|
||||
|
@ -12,8 +12,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import SplitPane from 'react-split-pane';
|
||||
import QueryHistoryEntry from './query_history_entry';
|
||||
import _ from 'underscore';
|
||||
|
||||
import QueryHistoryDetail from './query_history_detail';
|
||||
import QueryHistoryEntries from './query_history_entries';
|
||||
import Shapes from '../react_shapes';
|
||||
|
||||
const queryEntryListDivStyle = {
|
||||
@ -23,9 +25,6 @@ const queryDetailDivStyle = {
|
||||
display: 'flex',
|
||||
};
|
||||
|
||||
const ARROWUP = 38;
|
||||
const ARROWDOWN = 40;
|
||||
|
||||
export default class QueryHistory extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
@ -36,28 +35,34 @@ export default class QueryHistory extends React.Component {
|
||||
selectedEntry: 0,
|
||||
};
|
||||
|
||||
this.onKeyDownHandler = this.onKeyDownHandler.bind(this);
|
||||
this.navigateUpAndDown = this.navigateUpAndDown.bind(this);
|
||||
this.selectHistoryEntry = this.selectHistoryEntry.bind(this);
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.resetCurrentHistoryDetail(this.props.historyCollection.historyList);
|
||||
this.setHistory(this.props.historyCollection.historyList);
|
||||
this.selectHistoryEntry(0);
|
||||
|
||||
this.props.historyCollection.onChange((historyList) => {
|
||||
this.resetCurrentHistoryDetail(historyList);
|
||||
this.setHistory(historyList);
|
||||
this.selectHistoryEntry(0);
|
||||
});
|
||||
|
||||
this.props.historyCollection.onReset((historyList) => {
|
||||
this.clearCurrentHistoryDetail(historyList);
|
||||
this.props.historyCollection.onReset(() => {
|
||||
this.setState({
|
||||
history: [],
|
||||
currentHistoryDetail: undefined,
|
||||
selectedEntry: 0,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.resetCurrentHistoryDetail(this.state.history);
|
||||
this.selectHistoryEntry(0);
|
||||
}
|
||||
|
||||
refocus() {
|
||||
if (this.state.history.length > 0) {
|
||||
this.retrieveSelectedQuery().parentElement.focus();
|
||||
setTimeout(() => this.retrieveSelectedQuery().parentElement.focus(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@ -66,130 +71,33 @@ export default class QueryHistory extends React.Component {
|
||||
.getElementsByClassName('selected')[0];
|
||||
}
|
||||
|
||||
getCurrentHistoryDetail() {
|
||||
return this.state.currentHistoryDetail;
|
||||
setHistory(historyList) {
|
||||
this.setState({history: this.orderedHistory(historyList)});
|
||||
}
|
||||
|
||||
setCurrentHistoryDetail(index, historyList) {
|
||||
selectHistoryEntry(index) {
|
||||
this.setState({
|
||||
history: historyList,
|
||||
currentHistoryDetail: this.retrieveOrderedHistory().value()[index],
|
||||
currentHistoryDetail: this.state.history[index],
|
||||
selectedEntry: index,
|
||||
});
|
||||
}
|
||||
|
||||
resetCurrentHistoryDetail(historyList) {
|
||||
this.setCurrentHistoryDetail(0, historyList);
|
||||
}
|
||||
|
||||
clearCurrentHistoryDetail(historyList) {
|
||||
this.setState({
|
||||
history: historyList,
|
||||
currentHistoryDetail: undefined,
|
||||
selectedEntry: 0,
|
||||
});
|
||||
}
|
||||
|
||||
retrieveOrderedHistory() {
|
||||
return _.chain(this.state.history)
|
||||
orderedHistory(historyList) {
|
||||
return _.chain(historyList)
|
||||
.sortBy(historyEntry => historyEntry.start_time)
|
||||
.reverse();
|
||||
}
|
||||
|
||||
onClickHandler(index) {
|
||||
this.setCurrentHistoryDetail(index, this.state.history);
|
||||
}
|
||||
|
||||
isInvisible(element) {
|
||||
return this.isAbovePaneTop(element) || this.isBelowPaneBottom(element);
|
||||
}
|
||||
|
||||
isBelowPaneBottom(element) {
|
||||
const paneElement = ReactDOM.findDOMNode(this).getElementsByClassName('Pane1')[0];
|
||||
return element.getBoundingClientRect().bottom > paneElement.getBoundingClientRect().bottom;
|
||||
}
|
||||
|
||||
isAbovePaneTop(element) {
|
||||
const paneElement = ReactDOM.findDOMNode(this).getElementsByClassName('Pane1')[0];
|
||||
return element.getBoundingClientRect().top < paneElement.getBoundingClientRect().top;
|
||||
}
|
||||
|
||||
navigateUpAndDown(event) {
|
||||
const arrowKeys = [ARROWUP, ARROWDOWN];
|
||||
const key = event.keyCode || event.which;
|
||||
if (arrowKeys.indexOf(key) > -1) {
|
||||
event.preventDefault();
|
||||
this.onKeyDownHandler(event);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
onKeyDownHandler(event) {
|
||||
if (this.isArrowDown(event)) {
|
||||
if (!this.isLastEntry()) {
|
||||
let nextEntry = this.state.selectedEntry + 1;
|
||||
this.setCurrentHistoryDetail(nextEntry, this.state.history);
|
||||
|
||||
if (this.isInvisible(this.getEntryFromList(nextEntry))) {
|
||||
this.getEntryFromList(nextEntry).scrollIntoView(false);
|
||||
}
|
||||
}
|
||||
} else if (this.isArrowUp(event)) {
|
||||
if (!this.isFirstEntry()) {
|
||||
let previousEntry = this.state.selectedEntry - 1;
|
||||
this.setCurrentHistoryDetail(previousEntry, this.state.history);
|
||||
|
||||
if (this.isInvisible(this.getEntryFromList(previousEntry))) {
|
||||
this.getEntryFromList(previousEntry).scrollIntoView(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getEntryFromList(entryIndex) {
|
||||
return ReactDOM.findDOMNode(this).getElementsByClassName('entry')[entryIndex];
|
||||
}
|
||||
|
||||
isFirstEntry() {
|
||||
return this.state.selectedEntry === 0;
|
||||
}
|
||||
|
||||
isLastEntry() {
|
||||
return this.state.selectedEntry === this.state.history.length - 1;
|
||||
}
|
||||
|
||||
isArrowUp(event) {
|
||||
return (event.keyCode || event.which) === ARROWUP;
|
||||
}
|
||||
|
||||
isArrowDown(event) {
|
||||
return (event.keyCode || event.which) === ARROWDOWN;
|
||||
.reverse()
|
||||
.value();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SplitPane defaultSize='50%' split='vertical' pane1Style={queryEntryListDivStyle}
|
||||
pane2Style={queryDetailDivStyle}>
|
||||
<div id='query_list'
|
||||
className='query-history'
|
||||
onKeyDown={this.navigateUpAndDown}
|
||||
tabIndex={-1}>
|
||||
<ul>
|
||||
{this.retrieveOrderedHistory()
|
||||
.map((entry, index) =>
|
||||
<li key={index} className='list-item'
|
||||
onClick={this.onClickHandler.bind(this, index)}
|
||||
tabIndex={-1}>
|
||||
<QueryHistoryEntry
|
||||
historyEntry={entry}
|
||||
isSelected={index == this.state.selectedEntry}/>
|
||||
</li>)
|
||||
.value()
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<QueryHistoryDetail historyEntry={this.getCurrentHistoryDetail()}/>
|
||||
<QueryHistoryEntries historyEntries={this.state.history}
|
||||
selectedEntry={this.state.selectedEntry}
|
||||
onSelectEntry={this.selectHistoryEntry}
|
||||
/>
|
||||
<QueryHistoryDetail historyEntry={this.state.currentHistoryDetail}/>
|
||||
</SplitPane>);
|
||||
}
|
||||
}
|
||||
|
156
web/pgadmin/static/jsx/history/query_history_entries.jsx
Normal file
156
web/pgadmin/static/jsx/history/query_history_entries.jsx
Normal file
@ -0,0 +1,156 @@
|
||||
/////////////////////////////////////////////////////////////
|
||||
//
|
||||
// pgAdmin 4 - PostgreSQL Tools
|
||||
//
|
||||
// Copyright (C) 2013 - 2017, The pgAdmin Development Team
|
||||
// This software is released under the PostgreSQL Licence
|
||||
//
|
||||
//////////////////////////////////////////////////////////////
|
||||
|
||||
/* eslint-disable react/no-find-dom-node */
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import _ from 'underscore';
|
||||
import moment from 'moment';
|
||||
|
||||
import QueryHistoryEntry from './query_history_entry';
|
||||
import QueryHistoryEntryDateGroup from './query_history_entry_date_group';
|
||||
|
||||
const ARROWUP = 38;
|
||||
const ARROWDOWN = 40;
|
||||
|
||||
export default class QueryHistoryEntries extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.navigateUpAndDown = this.navigateUpAndDown.bind(this);
|
||||
}
|
||||
|
||||
navigateUpAndDown(event) {
|
||||
let arrowKeys = [ARROWUP, ARROWDOWN];
|
||||
let key = event.keyCode || event.which;
|
||||
if (arrowKeys.indexOf(key) > -1) {
|
||||
event.preventDefault();
|
||||
this.onKeyDownHandler(event);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
onKeyDownHandler(event) {
|
||||
if (this.isArrowDown(event)) {
|
||||
if (!this.isLastEntry()) {
|
||||
let nextEntry = this.props.selectedEntry + 1;
|
||||
this.props.onSelectEntry(nextEntry);
|
||||
|
||||
if (this.isInvisible(this.getEntryFromList(nextEntry))) {
|
||||
this.getEntryFromList(nextEntry).scrollIntoView(false);
|
||||
}
|
||||
}
|
||||
} else if (this.isArrowUp(event)) {
|
||||
if (!this.isFirstEntry()) {
|
||||
let previousEntry = this.props.selectedEntry - 1;
|
||||
this.props.onSelectEntry(previousEntry);
|
||||
|
||||
if (this.isInvisible(this.getEntryFromList(previousEntry))) {
|
||||
this.getEntryFromList(previousEntry).scrollIntoView(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
retrieveGroups() {
|
||||
const sortableKeyFormat = 'YYYY MM DD';
|
||||
const entriesGroupedByDate = _.groupBy(this.props.historyEntries, entry => moment(entry.start_time).format(sortableKeyFormat));
|
||||
|
||||
const elements = this.sortDesc(entriesGroupedByDate).map((key, index) => {
|
||||
const groupElements = this.retrieveDateGroup(entriesGroupedByDate, key, index);
|
||||
const keyAsDate = moment(key, sortableKeyFormat).toDate();
|
||||
groupElements.unshift(
|
||||
<li key={'group-' + index}>
|
||||
<QueryHistoryEntryDateGroup date={keyAsDate}/>
|
||||
</li>);
|
||||
return groupElements;
|
||||
});
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{_.flatten(elements).map(element => element)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
retrieveDateGroup(entriesGroupedByDate, key, parentIndex) {
|
||||
const startingEntryIndex = _.reduce(
|
||||
_.first(this.sortDesc(entriesGroupedByDate), parentIndex),
|
||||
(memo, key) => memo + entriesGroupedByDate[key].length, 0);
|
||||
|
||||
return (
|
||||
entriesGroupedByDate[key].map((entry, index) =>
|
||||
<li key={`group-${parentIndex}-entry-${index}`}
|
||||
className='list-item'
|
||||
tabIndex={0}
|
||||
onClick={() => this.props.onSelectEntry(startingEntryIndex + index)}
|
||||
onKeyDown={this.navigateUpAndDown}>
|
||||
<QueryHistoryEntry
|
||||
historyEntry={entry}
|
||||
isSelected={(startingEntryIndex + index) === this.props.selectedEntry}/>
|
||||
</li>)
|
||||
);
|
||||
}
|
||||
|
||||
sortDesc(entriesGroupedByDate) {
|
||||
return Object.keys(entriesGroupedByDate).sort().reverse();
|
||||
}
|
||||
|
||||
isInvisible(element) {
|
||||
return this.isAbovePaneTop(element) || this.isBelowPaneBottom(element);
|
||||
}
|
||||
|
||||
isArrowUp(event) {
|
||||
return (event.keyCode || event.which) === ARROWUP;
|
||||
}
|
||||
|
||||
isArrowDown(event) {
|
||||
return (event.keyCode || event.which) === ARROWDOWN;
|
||||
}
|
||||
|
||||
isFirstEntry() {
|
||||
return this.props.selectedEntry === 0;
|
||||
}
|
||||
|
||||
isLastEntry() {
|
||||
return this.props.selectedEntry === this.props.historyEntries.length - 1;
|
||||
}
|
||||
|
||||
isAbovePaneTop(element) {
|
||||
const paneElement = ReactDOM.findDOMNode(this).parentElement;
|
||||
return element.getBoundingClientRect().top < paneElement.getBoundingClientRect().top;
|
||||
}
|
||||
|
||||
isBelowPaneBottom(element) {
|
||||
const paneElement = ReactDOM.findDOMNode(this).parentElement;
|
||||
return element.getBoundingClientRect().bottom > paneElement.getBoundingClientRect().bottom;
|
||||
}
|
||||
|
||||
getEntryFromList(entryIndex) {
|
||||
return ReactDOM.findDOMNode(this).getElementsByClassName('entry')[entryIndex];
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div id='query_list'
|
||||
className="query-history">
|
||||
{this.retrieveGroups()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
QueryHistoryEntries.propTypes = {
|
||||
historyEntries: React.PropTypes.array.isRequired,
|
||||
selectedEntry: React.PropTypes.number.isRequired,
|
||||
onSelectEntry: React.PropTypes.func.isRequired,
|
||||
};
|
@ -13,7 +13,7 @@ import moment from 'moment';
|
||||
|
||||
export default class QueryHistoryEntry extends React.Component {
|
||||
formatDate(date) {
|
||||
return (moment(date).format('MMM D YYYY [–] HH:mm:ss'));
|
||||
return (moment(date).format('HH:mm:ss'));
|
||||
}
|
||||
|
||||
renderWithClasses(outerDivStyle) {
|
||||
|
@ -0,0 +1,46 @@
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// pgAdmin 4 - PostgreSQL Tools
|
||||
//
|
||||
// Copyright (C) 2013 - 2017, The pgAdmin Development Team
|
||||
// This software is released under the PostgreSQL Licence
|
||||
//
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
|
||||
import React from 'react';
|
||||
import moment from 'moment';
|
||||
|
||||
export default class QueryHistoryEntryDateGroup extends React.Component {
|
||||
|
||||
getDatePrefix() {
|
||||
let prefix = '';
|
||||
if (this.isDaysBefore(0)) {
|
||||
prefix = 'Today - ';
|
||||
} else if (this.isDaysBefore(1)) {
|
||||
prefix = 'Yesterday - ';
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
|
||||
getDateFormatted(momentToFormat) {
|
||||
return momentToFormat.format(QueryHistoryEntryDateGroup.formatString);
|
||||
}
|
||||
|
||||
getDateMoment() {
|
||||
return moment(this.props.date);
|
||||
}
|
||||
|
||||
isDaysBefore(before) {
|
||||
return this.getDateFormatted(this.getDateMoment()) === this.getDateFormatted(moment().subtract(before, 'days'));
|
||||
}
|
||||
|
||||
render() {
|
||||
return (<div className="date-label">{this.getDatePrefix()}{this.getDateFormatted(this.getDateMoment())}</div>);
|
||||
}
|
||||
}
|
||||
|
||||
QueryHistoryEntryDateGroup.propTypes = {
|
||||
date: React.PropTypes.instanceOf(Date).isRequired,
|
||||
};
|
||||
|
||||
QueryHistoryEntryDateGroup.formatString = 'MMM DD YYYY';
|
@ -1,92 +1,14 @@
|
||||
/*doc
|
||||
---
|
||||
title: Alerts
|
||||
name: alerts
|
||||
category: alerts
|
||||
---
|
||||
|
||||
```html_example
|
||||
<div class="alert-row">
|
||||
<div class="alert alert-success text-14 alert-box">
|
||||
<div class="media">
|
||||
<div class="media-body media-middle">
|
||||
<div class="alert-icon success-icon">
|
||||
<i class="fa fa-check" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="alert-text">
|
||||
Successfully run. Total query runtime: 32 msec. 1 row retrieved
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert-row">
|
||||
<div class="alert alert-danger font-red text-14 alert-box">
|
||||
<div class="media">
|
||||
<div class="media-body media-middle">
|
||||
<div class="alert-icon error-icon">
|
||||
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="alert-text">
|
||||
Error retrieving properties - INTERNAL SERVER ERROR
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert-row">
|
||||
<div class="alert alert-info font-blue text-14 alert-box">
|
||||
<div class="media">
|
||||
<div class="media-body media-middle">
|
||||
<div class="alert-text">
|
||||
This is a neutral message
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
```
|
||||
*/
|
||||
|
||||
|
||||
// from bootstrap scss:
|
||||
|
||||
@if $enable-flex {
|
||||
.media {
|
||||
display: flex;
|
||||
}
|
||||
.media-body {
|
||||
flex: 1;
|
||||
}
|
||||
.media-middle {
|
||||
align-self: center;
|
||||
}
|
||||
.media-bottom {
|
||||
align-self: flex-end;
|
||||
}
|
||||
} @else {
|
||||
.media,
|
||||
.media-body {
|
||||
overflow: hidden;
|
||||
}
|
||||
.media-body {
|
||||
width: 10000px;
|
||||
}
|
||||
.media-left,
|
||||
.media-right,
|
||||
.media-body {
|
||||
display: inline;
|
||||
vertical-align: top;
|
||||
}
|
||||
.media-middle {
|
||||
vertical-align: middle;
|
||||
}
|
||||
.media-bottom {
|
||||
vertical-align: bottom;
|
||||
}
|
||||
.alert-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: white;
|
||||
padding: 15px 15px 15px 17px;
|
||||
width: 50px;
|
||||
min-height: 50px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
align-self: stretch;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.alert-row {
|
||||
@ -103,22 +25,12 @@ category: alerts
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.alert-icon {
|
||||
display: inline-block;
|
||||
color: white;
|
||||
padding: 15px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
background: #3a773a;
|
||||
background: $color-green-3;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
background: #d0021b;
|
||||
background: $color-red-3;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
@ -128,17 +40,27 @@ category: alerts
|
||||
.alert-text {
|
||||
display: inline-block;
|
||||
padding: 0 12px 0 10px;
|
||||
align-self: center;
|
||||
// To make sure IE picks up the correct font
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
border-color: #84acdd
|
||||
border-color: $color-blue-2;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.media-body {
|
||||
vertical-align: top;
|
||||
width: initial;
|
||||
.alert-danger {
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.grid-error, .graph-error {
|
||||
.alert-row {
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.ajs-message {
|
||||
@ -147,19 +69,36 @@ category: alerts
|
||||
}
|
||||
}
|
||||
|
||||
.alert, .ajs-message {
|
||||
.media {
|
||||
.media-body {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
.alert-icon {
|
||||
display: inline-block;
|
||||
}
|
||||
.alert-text {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pg-prop-status-bar {
|
||||
padding: 5px;
|
||||
|
||||
.media-body {
|
||||
display: flex;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.alert-icon {
|
||||
padding: 8px;
|
||||
padding: 8px 8px 8px 10.5px;
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
border-top-left-radius: 4px;
|
||||
border-bottom-left-radius: 4px;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.alert-text {
|
||||
@ -167,8 +106,8 @@ category: alerts
|
||||
border: 1px solid $color-red-2;
|
||||
border-top-right-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
padding: 7px 12px 5px 10px;
|
||||
border-left: 0px;
|
||||
padding: 7px 12px 6px 10px;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.error-in-footer {
|
||||
@ -193,7 +132,28 @@ category: alerts
|
||||
height: 35px;
|
||||
|
||||
.alert-text {
|
||||
border: 0px;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Internet Explorer specific CSS
|
||||
@media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) {
|
||||
.styleguide {
|
||||
.alert-danger {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
@ -1,63 +1,3 @@
|
||||
/*doc
|
||||
---
|
||||
title: Grays
|
||||
name: Grays
|
||||
category: colors
|
||||
---
|
||||
For text, avoid using black or #000 to lower the contrast between the background and text.
|
||||
|
||||
```html_example
|
||||
<div class="row">
|
||||
<div class="row">
|
||||
<div class="col-xs-6 col-md-3">
|
||||
<div class="color-chip bg-gray-1">
|
||||
#f9f9f9
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-6 col-md-3">
|
||||
<div class="color-chip bg-gray-2">
|
||||
#e8e8e8
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-6 col-md-3">
|
||||
<div class="color-chip bg-gray-3">
|
||||
#cccccc
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-6 col-md-3">
|
||||
<div class="color-chip bg-gray-4">
|
||||
#888888
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-6 col-md-3">
|
||||
<div class="color-chip bg-gray-5 font-white">
|
||||
#555555
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-6 col-md-3">
|
||||
<div class="color-chip bg-gray-6 font-white">
|
||||
#333333
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
*/
|
||||
|
||||
.color-chip {
|
||||
align-items: center;
|
||||
border-radius: 3px;
|
||||
box-shadow: inset 0 -3px 0 0 rgba(0, 0, 0, .15);
|
||||
color: rgba(0, 0, 0, .65);
|
||||
display: flex;
|
||||
font-size: 1.25em;
|
||||
height: 100px;
|
||||
justify-content: center;
|
||||
margin: 0 0 1em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
$color-gray-1: #f9f9f9;
|
||||
$color-gray-2: #e8e8e8;
|
||||
$color-gray-3: #cccccc;
|
||||
@ -120,7 +60,3 @@ $color-gray-6: #333333;
|
||||
.font-gray-6 {
|
||||
color: $color-gray-6;
|
||||
}
|
||||
|
||||
.font-white {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
@ -1,99 +1,22 @@
|
||||
/*doc
|
||||
---
|
||||
title: Others
|
||||
name: z-othercolors
|
||||
category: colors
|
||||
---
|
||||
These colors should be used to highlight hover options in dropdown menus and catalog browser or to tell the user when something is right or wrong.
|
||||
|
||||
|
||||
```html_example
|
||||
<div class="row">
|
||||
<div class="row">
|
||||
<div class="col-xs-6 col-md-3">
|
||||
<div class="color-chip bg-blue-1">
|
||||
#e7f2ff
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-6 col-md-3">
|
||||
<div class="color-chip bg-blue-2">
|
||||
#84acdd
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br>
|
||||
<div class="row">
|
||||
<div class="row">
|
||||
<div class="col-xs-6 col-md-3">
|
||||
<div class="color-chip bg-red-1">
|
||||
#f2dede
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-6 col-md-3">
|
||||
<div class="color-chip bg-red-2">
|
||||
#de8585
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-6 col-md-3">
|
||||
<div class="color-chip bg-red-3">
|
||||
#d0021b
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br>
|
||||
<div class="row">
|
||||
<div class="row">
|
||||
<div class="col-xs-6 col-md-3">
|
||||
<div class="color-chip bg-green-1">
|
||||
#dff0d7
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-6 col-md-3">
|
||||
<div class="color-chip bg-green-2">
|
||||
#a2c189
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-6 col-md-3">
|
||||
<div class="color-chip bg-green-3">
|
||||
#3a773a
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
*/
|
||||
|
||||
.color-chip {
|
||||
align-items: center;
|
||||
border-radius: 3px;
|
||||
box-shadow: inset 0 -3px 0 0 rgba(0, 0, 0, .15);
|
||||
color: rgba(0, 0, 0, .65);
|
||||
display: flex;
|
||||
font-size: 1.25em;
|
||||
height: 100px;
|
||||
justify-content: center;
|
||||
margin: 0 0 1em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
$color-blue-1: #e7f2ff;
|
||||
$color-blue-2: #84acdd;
|
||||
$color-red-1: #f2dede;
|
||||
$color-red-2: #de8585;
|
||||
$color-red-3: #d0021b;
|
||||
$color-green-1: #dff0d7;
|
||||
$color-green-2: #a2c189;
|
||||
$color-green-3: #3a773a;
|
||||
|
||||
.bg-white-1 {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.bg-blue-1 {
|
||||
background-color: #e7f2ff;
|
||||
background-color: $color-blue-1;
|
||||
}
|
||||
|
||||
.bg-blue-2 {
|
||||
background-color: #84acdd;
|
||||
background-color: $color-blue-2;
|
||||
}
|
||||
|
||||
.bg-red-1 {
|
||||
@ -109,23 +32,23 @@ $color-green-2: #a2c189;
|
||||
}
|
||||
|
||||
.bg-green-1 {
|
||||
background-color: #dff0d7;
|
||||
background-color: $color-green-1;
|
||||
}
|
||||
|
||||
.bg-green-2 {
|
||||
background-color: #a2c189;
|
||||
background-color: $color-green-2;
|
||||
}
|
||||
|
||||
.bg-green-3 {
|
||||
background-color: #3a773a;
|
||||
background-color: $color-green-3;
|
||||
}
|
||||
|
||||
.border-blue-1 {
|
||||
border-color: #e7f2ff;
|
||||
border-color: $color-blue-1;
|
||||
}
|
||||
|
||||
.border-blue-2 {
|
||||
border-color: #84acdd;
|
||||
border-color: $color-blue-2;
|
||||
}
|
||||
|
||||
.border-red-1 {
|
||||
@ -141,15 +64,15 @@ $color-green-2: #a2c189;
|
||||
}
|
||||
|
||||
.border-green-1 {
|
||||
border-color: #dff0d7;
|
||||
border-color: $color-green-1;
|
||||
}
|
||||
|
||||
.border-green-2 {
|
||||
border-color: #a2c189;
|
||||
border-color: $color-green-2;
|
||||
}
|
||||
|
||||
.border-green-3 {
|
||||
border-color: #3a773a;
|
||||
border-color: $color-green-3;
|
||||
}
|
||||
|
||||
.font-red-3 {
|
||||
@ -157,9 +80,5 @@ $color-green-2: #a2c189;
|
||||
}
|
||||
|
||||
.font-green-3 {
|
||||
color: #3a773a;
|
||||
}
|
||||
|
||||
.font-white {
|
||||
color: #FFFFFF;
|
||||
color: $color-green-3;
|
||||
}
|
||||
|
@ -1,39 +1,3 @@
|
||||
/*doc
|
||||
---
|
||||
title: Primary blue
|
||||
name: colors-primaryblue
|
||||
category: colors
|
||||
---
|
||||
This color should be used to call attention to the main part of the app. Use sparingly.
|
||||
|
||||
```html_example
|
||||
<div class="row">
|
||||
<div class="row">
|
||||
<div class="col-xs-6 col-md-3">
|
||||
<div class="color-chip bg-primary-blue font-white">
|
||||
#2c76b4
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
```
|
||||
|
||||
*/
|
||||
|
||||
.color-chip {
|
||||
align-items: center;
|
||||
border-radius: 3px;
|
||||
box-shadow: inset 0 -3px 0 0 rgba(0, 0, 0, .15);
|
||||
color: rgba(0, 0, 0, .65);
|
||||
display: flex;
|
||||
font-size: 1.25em;
|
||||
height: 100px;
|
||||
justify-content: center;
|
||||
margin: 0 0 1em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
$primary-blue: #2c76b4;
|
||||
|
||||
.bg-primary-blue {
|
||||
@ -46,4 +10,4 @@ $primary-blue: #2c76b4;
|
||||
|
||||
.font-primary-blue {
|
||||
color: $primary-blue;
|
||||
}
|
||||
}
|
@ -1,68 +1,25 @@
|
||||
/*doc
|
||||
---
|
||||
title: Typography
|
||||
name: typography
|
||||
category: typography
|
||||
---
|
||||
|
||||
Font Typography
|
||||
|
||||
```html_example_table
|
||||
<div class="text-14">
|
||||
Body 14 px Helvetica Neue
|
||||
</div>
|
||||
|
||||
<div class="text-14 text-bold">
|
||||
Body 14 px Helvetica Neue bold
|
||||
</div>
|
||||
|
||||
<div class="text-13">
|
||||
Body 13 px Helvetica Neue
|
||||
</div>
|
||||
|
||||
<div class="text-13 text-bold">
|
||||
Body 13 px Helvetica Neue bold
|
||||
</div>
|
||||
|
||||
<div class="text-12">
|
||||
Body 12 px Helvetica Neue
|
||||
</div>
|
||||
|
||||
<div class="text-12 text-bold">
|
||||
Body 12 px Helvetica Neue bold
|
||||
</div>
|
||||
|
||||
<div class="text-11">
|
||||
Body 11 px Helvetica Neue
|
||||
</div>
|
||||
|
||||
<div class="text-11 text-bold">
|
||||
Body 11 px Helvetica Neue bold
|
||||
</div>
|
||||
```
|
||||
|
||||
*/
|
||||
$font-family-1: "Helvetica Neue", Arial, sans-serif;
|
||||
|
||||
.text-bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.text-14 {
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-family: $font-family-1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.text-13 {
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-family: $font-family-1;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.text-12 {
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-family: $font-family-1;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.text-11 {
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-family: $font-family-1;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
@ -7,10 +7,10 @@
|
||||
.entry {
|
||||
@extend .text-14;
|
||||
@extend .bg-white-1;
|
||||
padding: -2px 18px -2px 8px;
|
||||
font-family: monospace;
|
||||
border: 2px solid transparent;
|
||||
margin-left: 1px;
|
||||
padding: 0 8px 0 5px;
|
||||
|
||||
.other-info {
|
||||
@extend .text-13;
|
||||
@ -33,6 +33,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
.date-label {
|
||||
font-family: monospace;
|
||||
background: #e8e8e8;
|
||||
padding: 2px 9px;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
color: #888888;
|
||||
border-bottom: 1px solid #cccccc;
|
||||
}
|
||||
|
||||
.entry.error {
|
||||
@extend .bg-red-1;
|
||||
}
|
||||
@ -114,6 +124,32 @@
|
||||
margin-right: 10px;
|
||||
height: 0;
|
||||
position: relative;
|
||||
|
||||
.copy-all, .was-copied {
|
||||
float: left;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
border: 1px solid $color-gray-3;
|
||||
color: $primary-blue;
|
||||
font-size: 12px;
|
||||
box-shadow: 1px 2px 4px 0px $color-gray-3;
|
||||
padding: 3px 12px 3px 10px;
|
||||
font-weight: 500;
|
||||
min-width: 75px;
|
||||
}
|
||||
|
||||
.copy-all {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.was-copied {
|
||||
background-color: $color-blue-1;
|
||||
border-color: $color-blue-2;
|
||||
}
|
||||
|
||||
.CodeMirror-scroll {
|
||||
padding-top: 25px;
|
||||
}
|
||||
}
|
||||
|
||||
.block-divider {
|
||||
|
@ -215,7 +215,7 @@ define('tools.querytool', [
|
||||
|
||||
var history = new pgAdmin.Browser.Panel({
|
||||
name: 'history',
|
||||
title: gettext("History"),
|
||||
title: gettext("Query History"),
|
||||
width: '100%',
|
||||
height:'100%',
|
||||
isCloseable: false,
|
||||
|
@ -12,10 +12,15 @@
|
||||
import jasmineEnzyme from 'jasmine-enzyme';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import moment from 'moment';
|
||||
|
||||
import QueryHistory from '../../../pgadmin/static/jsx/history/query_history';
|
||||
import QueryHistoryEntry from '../../../pgadmin/static/jsx/history/query_history_entry';
|
||||
import QueryHistoryEntryDateGroup from '../../../pgadmin/static/jsx/history/query_history_entry_date_group';
|
||||
import QueryHistoryEntries from '../../../pgadmin/static/jsx/history/query_history_entries';
|
||||
import QueryHistoryDetail from '../../../pgadmin/static/jsx/history/query_history_detail';
|
||||
import HistoryCollection from '../../../pgadmin/static/js/history/history_collection';
|
||||
import clipboard from '../../../pgadmin/static/js/selection/clipboard';
|
||||
|
||||
import {mount} from 'enzyme';
|
||||
|
||||
@ -50,7 +55,7 @@ describe('QueryHistory', () => {
|
||||
done();
|
||||
});
|
||||
|
||||
it('nothing is displayed on right panel', (done) => {
|
||||
it('nothing is displayed in the history details panel', (done) => {
|
||||
let foundChildren = historyWrapper.find(QueryHistoryDetail);
|
||||
expect(foundChildren.length).toBe(1);
|
||||
done();
|
||||
@ -58,254 +63,397 @@ describe('QueryHistory', () => {
|
||||
});
|
||||
|
||||
describe('when there is history', () => {
|
||||
let historyObjects;
|
||||
let queryEntries;
|
||||
let queryDetail;
|
||||
let isInvisibleSpy;
|
||||
|
||||
beforeEach(function () {
|
||||
historyObjects = [{
|
||||
query: 'first sql statement',
|
||||
start_time: new Date(2017, 5, 3, 14, 3, 15, 150),
|
||||
status: true,
|
||||
row_affected: 12345,
|
||||
total_time: '14 msec',
|
||||
message: 'something important ERROR: message from first sql query',
|
||||
}, {
|
||||
query: 'second sql statement',
|
||||
start_time: new Date(2016, 11, 11, 1, 33, 5, 99),
|
||||
status: false,
|
||||
row_affected: 1,
|
||||
total_time: '234 msec',
|
||||
message: 'something important ERROR: message from second sql query',
|
||||
}];
|
||||
historyCollection = new HistoryCollection(historyObjects);
|
||||
describe('when two SQL queries were executed', () => {
|
||||
|
||||
historyWrapper = mount(<QueryHistory historyCollection={historyCollection}/>);
|
||||
});
|
||||
beforeEach(() => {
|
||||
const historyObjects = [{
|
||||
query: 'first sql statement',
|
||||
start_time: new Date(2017, 5, 3, 14, 3, 15, 150),
|
||||
status: true,
|
||||
row_affected: 12345,
|
||||
total_time: '14 msec',
|
||||
message: 'something important ERROR: message from first sql query',
|
||||
}, {
|
||||
query: 'second sql statement',
|
||||
start_time: new Date(2016, 11, 11, 1, 33, 5, 99),
|
||||
status: false,
|
||||
row_affected: 1,
|
||||
total_time: '234 msec',
|
||||
message: 'something important ERROR: message from second sql query',
|
||||
}];
|
||||
historyCollection = new HistoryCollection(historyObjects);
|
||||
|
||||
describe('when all query entries are visible in the pane', () => {
|
||||
describe('when two SQL queries were executed', () => {
|
||||
let foundChildren;
|
||||
let queryDetail;
|
||||
historyWrapper = mount(<QueryHistory historyCollection={historyCollection}/>);
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(historyWrapper.node, 'isInvisible').and.returnValue(false);
|
||||
foundChildren = historyWrapper.find(QueryHistoryEntry);
|
||||
const queryHistoryEntriesComponent = historyWrapper.find(QueryHistoryEntries);
|
||||
isInvisibleSpy = spyOn(queryHistoryEntriesComponent.node, 'isInvisible')
|
||||
.and.returnValue(false);
|
||||
|
||||
queryDetail = historyWrapper.find(QueryHistoryDetail);
|
||||
queryEntries = queryHistoryEntriesComponent.find(QueryHistoryEntry);
|
||||
queryDetail = historyWrapper.find(QueryHistoryDetail);
|
||||
});
|
||||
|
||||
describe('the history entries panel', () => {
|
||||
it('has two query history entries', () => {
|
||||
expect(queryEntries.length).toBe(2);
|
||||
});
|
||||
|
||||
describe('the main pane', () => {
|
||||
it('has two query history entries', () => {
|
||||
expect(foundChildren.length).toBe(2);
|
||||
});
|
||||
it('displays the query history entries in order', () => {
|
||||
expect(queryEntries.at(0).text()).toContain('first sql statement');
|
||||
expect(queryEntries.at(1).text()).toContain('second sql statement');
|
||||
});
|
||||
|
||||
it('displays the query history entries in order', () => {
|
||||
expect(foundChildren.at(0).text()).toContain('first sql statement');
|
||||
expect(foundChildren.at(1).text()).toContain('second sql statement');
|
||||
});
|
||||
it('displays the formatted timestamp of the queries in chronological order by most recent first', () => {
|
||||
expect(queryEntries.at(0).find('.timestamp').text()).toBe('14:03:15');
|
||||
expect(queryEntries.at(1).find('.timestamp').text()).toBe('01:33:05');
|
||||
});
|
||||
|
||||
it('displays the formatted timestamp of the queries in chronological order by most recent first', () => {
|
||||
expect(foundChildren.at(0).text()).toContain('Jun 3 2017 – 14:03:15');
|
||||
expect(foundChildren.at(1).text()).toContain('Dec 11 2016 – 01:33:05');
|
||||
});
|
||||
it('renders the most recent query as selected', () => {
|
||||
expect(queryEntries.at(0).nodes.length).toBe(1);
|
||||
expect(queryEntries.at(0).hasClass('selected')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders the most recent query as selected', () => {
|
||||
expect(foundChildren.at(0).nodes.length).toBe(1);
|
||||
expect(foundChildren.at(0).hasClass('selected')).toBeTruthy();
|
||||
});
|
||||
it('renders the older query as not selected', () => {
|
||||
expect(queryEntries.at(1).nodes.length).toBe(1);
|
||||
expect(queryEntries.at(1).hasClass('selected')).toBeFalsy();
|
||||
expect(queryEntries.at(1).hasClass('error')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders the older query as not selected', () => {
|
||||
expect(foundChildren.at(1).nodes.length).toBe(1);
|
||||
expect(foundChildren.at(1).hasClass('selected')).toBeFalsy();
|
||||
expect(foundChildren.at(1).hasClass('error')).toBeTruthy();
|
||||
});
|
||||
describe('when the selected query is the most recent', () => {
|
||||
describe('when we press arrow down', () => {
|
||||
beforeEach(() => {
|
||||
pressArrowDownKey(queryEntries.parent().at(0));
|
||||
});
|
||||
|
||||
describe('when the selected query is the most recent', () => {
|
||||
describe('when we press arrow down', () => {
|
||||
beforeEach(() => {
|
||||
pressArrowDownKey(foundChildren.parent().at(0));
|
||||
});
|
||||
it('should select the next query', () => {
|
||||
expect(queryEntries.at(1).nodes.length).toBe(1);
|
||||
expect(queryEntries.at(1).hasClass('selected')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should select the next query', () => {
|
||||
expect(foundChildren.at(1).nodes.length).toBe(1);
|
||||
expect(foundChildren.at(1).hasClass('selected')).toBeTruthy();
|
||||
});
|
||||
it('should display the corresponding detail on the right pane', () => {
|
||||
expect(queryDetail.at(0).text()).toContain('message from second sql query');
|
||||
});
|
||||
|
||||
it('should display the corresponding detail on the right pane', () => {
|
||||
expect(queryDetail.at(0).text()).toContain('message from second sql query');
|
||||
});
|
||||
describe('when arrow down pressed again', () => {
|
||||
it('should not change the selected query', () => {
|
||||
pressArrowDownKey(queryEntries.parent().at(0));
|
||||
|
||||
describe('when arrow down pressed again', () => {
|
||||
it('should not change the selected query', () => {
|
||||
pressArrowDownKey(foundChildren.parent().at(0));
|
||||
|
||||
expect(foundChildren.at(1).nodes.length).toBe(1);
|
||||
expect(foundChildren.at(1).hasClass('selected')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when arrow up is pressed', () => {
|
||||
it('should select the most recent query', () => {
|
||||
pressArrowUpKey(foundChildren.parent().at(0));
|
||||
|
||||
expect(foundChildren.at(0).nodes.length).toBe(1);
|
||||
expect(foundChildren.at(0).hasClass('selected')).toBeTruthy();
|
||||
});
|
||||
expect(queryEntries.at(1).nodes.length).toBe(1);
|
||||
expect(queryEntries.at(1).hasClass('selected')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when arrow up is pressed', () => {
|
||||
it('should not change the selected query', () => {
|
||||
pressArrowUpKey(foundChildren.parent().at(0));
|
||||
expect(foundChildren.at(0).nodes.length).toBe(1);
|
||||
expect(foundChildren.at(0).hasClass('selected')).toBeTruthy();
|
||||
it('should select the most recent query', () => {
|
||||
pressArrowUpKey(queryEntries.parent().at(0));
|
||||
|
||||
expect(queryEntries.at(0).nodes.length).toBe(1);
|
||||
expect(queryEntries.at(0).hasClass('selected')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when arrow up is pressed', () => {
|
||||
it('should not change the selected query', () => {
|
||||
pressArrowUpKey(queryEntries.parent().at(0));
|
||||
expect(queryEntries.at(0).nodes.length).toBe(1);
|
||||
expect(queryEntries.at(0).hasClass('selected')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('the historydetails panel', () => {
|
||||
let copyAllButton;
|
||||
|
||||
beforeEach(() => {
|
||||
copyAllButton = () => queryDetail.find('#history-detail-query > button');
|
||||
});
|
||||
it('displays the formatted timestamp', () => {
|
||||
expect(queryDetail.at(0).text()).toContain('6-3-17 14:03:15Date');
|
||||
});
|
||||
|
||||
it('displays the number of rows affected', () => {
|
||||
if (/PhantomJS/.test(window.navigator.userAgent)) {
|
||||
expect(queryDetail.at(0).text()).toContain('12345Rows Affected');
|
||||
} else {
|
||||
expect(queryDetail.at(0).text()).toContain('12,345Rows Affected');
|
||||
}
|
||||
});
|
||||
|
||||
it('displays the total time', () => {
|
||||
expect(queryDetail.at(0).text()).toContain('14 msecDuration');
|
||||
});
|
||||
|
||||
it('displays the full message', () => {
|
||||
expect(queryDetail.at(0).text()).toContain('message from first sql query');
|
||||
});
|
||||
|
||||
it('displays first query SQL', (done) => {
|
||||
setTimeout(() => {
|
||||
expect(queryDetail.at(0).text()).toContain('first sql statement');
|
||||
done();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
describe('when the "Copy All" button is clicked', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(clipboard, 'copyTextToClipboard');
|
||||
copyAllButton().simulate('click');
|
||||
});
|
||||
|
||||
it('copies the query to the clipboard', () => {
|
||||
expect(clipboard.copyTextToClipboard).toHaveBeenCalledWith('first sql statement');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Copy button', () => {
|
||||
beforeEach(() => {
|
||||
jasmine.clock().install();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jasmine.clock().uninstall();
|
||||
});
|
||||
|
||||
it('should have text \'Copy All\'', () => {
|
||||
expect(copyAllButton().text()).toBe('Copy All');
|
||||
});
|
||||
|
||||
it('should not have the class \'was-copied\'', () => {
|
||||
expect(copyAllButton().hasClass('was-copied')).toBe(false);
|
||||
});
|
||||
|
||||
describe('when the copy button is clicked', () => {
|
||||
beforeEach(() => {
|
||||
copyAllButton().simulate('click');
|
||||
});
|
||||
|
||||
describe('before 1.5 seconds', () => {
|
||||
beforeEach(() => {
|
||||
jasmine.clock().tick(1499);
|
||||
});
|
||||
|
||||
it('should change the button text to \'Copied!\'', () => {
|
||||
expect(copyAllButton().text()).toBe('Copied!');
|
||||
});
|
||||
|
||||
it('should have the class \'was-copied\'', () => {
|
||||
expect(copyAllButton().hasClass('was-copied')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('after 1.5 seconds', () => {
|
||||
beforeEach(() => {
|
||||
jasmine.clock().tick(1501);
|
||||
});
|
||||
|
||||
it('should change the button text back to \'Copy All\'', () => {
|
||||
expect(copyAllButton().text()).toBe('Copy All');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when is clicked again after 1s', () => {
|
||||
beforeEach(() => {
|
||||
jasmine.clock().tick(1000);
|
||||
copyAllButton().simulate('click');
|
||||
|
||||
});
|
||||
|
||||
describe('before 2.5 seconds', () => {
|
||||
beforeEach(() => {
|
||||
jasmine.clock().tick(1499);
|
||||
});
|
||||
|
||||
it('should change the button text to \'Copied!\'', () => {
|
||||
expect(copyAllButton().text()).toBe('Copied!');
|
||||
});
|
||||
|
||||
it('should have the class \'was-copied\'', () => {
|
||||
expect(copyAllButton().hasClass('was-copied')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('after 2.5 seconds', () => {
|
||||
beforeEach(() => {
|
||||
jasmine.clock().tick(1501);
|
||||
});
|
||||
|
||||
it('should change the button text back to \'Copy All\'', () => {
|
||||
expect(copyAllButton().text()).toBe('Copy All');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('the details pane', () => {
|
||||
it('displays the formatted timestamp', () => {
|
||||
expect(queryDetail.at(0).text()).toContain('6-3-17 14:03:15Date');
|
||||
describe('when the query failed', () => {
|
||||
let failedEntry;
|
||||
|
||||
beforeEach(() => {
|
||||
failedEntry = queryEntries.at(1);
|
||||
failedEntry.simulate('click');
|
||||
});
|
||||
|
||||
it('displays the number of rows affected', () => {
|
||||
if (/PhantomJS/.test(window.navigator.userAgent)) {
|
||||
expect(queryDetail.at(0).text()).toContain('12345Rows Affected');
|
||||
} else {
|
||||
expect(queryDetail.at(0).text()).toContain('12,345Rows Affected');
|
||||
}
|
||||
it('displays the error message on top of the details pane', () => {
|
||||
expect(queryDetail.at(0).text()).toContain('Error Message message from second sql query');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('displays the total time', () => {
|
||||
expect(queryDetail.at(0).text()).toContain('14 msecDuration');
|
||||
describe('when the older query is clicked on', () => {
|
||||
let firstEntry, secondEntry;
|
||||
|
||||
beforeEach(() => {
|
||||
firstEntry = queryEntries.at(0);
|
||||
secondEntry = queryEntries.at(1);
|
||||
secondEntry.simulate('click');
|
||||
});
|
||||
|
||||
it('displays the query in the right pane', () => {
|
||||
expect(queryDetail.at(0).text()).toContain('second sql statement');
|
||||
});
|
||||
|
||||
it('deselects the first history entry', () => {
|
||||
expect(firstEntry.nodes.length).toBe(1);
|
||||
expect(firstEntry.hasClass('selected')).toBeFalsy();
|
||||
|
||||
});
|
||||
|
||||
it('selects the second history entry', () => {
|
||||
expect(secondEntry.nodes.length).toBe(1);
|
||||
expect(secondEntry.hasClass('selected')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the first query is outside the visible area', () => {
|
||||
beforeEach(() => {
|
||||
isInvisibleSpy.and.callFake((element) => {
|
||||
return element.textContent.contains('first sql statement');
|
||||
});
|
||||
});
|
||||
|
||||
it('displays the full message', () => {
|
||||
expect(queryDetail.at(0).text()).toContain('message from first sql query');
|
||||
});
|
||||
|
||||
it('displays first query SQL', (done) => {
|
||||
setTimeout(() => {
|
||||
expect(queryDetail.at(0).text()).toContain('first sql statement');
|
||||
done();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
describe('when the query failed', () => {
|
||||
let failedEntry;
|
||||
describe('when the first query is the selected query', () => {
|
||||
describe('when refocus function is called', () => {
|
||||
let selectedListItem;
|
||||
|
||||
beforeEach(() => {
|
||||
failedEntry = foundChildren.at(1);
|
||||
failedEntry.simulate('click');
|
||||
selectedListItem = ReactDOM.findDOMNode(historyWrapper.node)
|
||||
.getElementsByClassName('selected')[0].parentElement;
|
||||
|
||||
spyOn(selectedListItem, 'focus');
|
||||
|
||||
jasmine.clock().install();
|
||||
});
|
||||
it('displays the error message on top of the details pane', () => {
|
||||
expect(queryDetail.at(0).text()).toContain('Error Message message from second sql query');
|
||||
|
||||
afterEach(() => {
|
||||
jasmine.clock().uninstall();
|
||||
});
|
||||
|
||||
it('the first query scrolls into view', () => {
|
||||
historyWrapper.node.refocus();
|
||||
expect(selectedListItem.focus).toHaveBeenCalledTimes(0);
|
||||
jasmine.clock().tick(1);
|
||||
expect(selectedListItem.focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the older query is clicked on', () => {
|
||||
let firstEntry, secondEntry;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
firstEntry = foundChildren.at(0);
|
||||
secondEntry = foundChildren.at(1);
|
||||
secondEntry.simulate('click');
|
||||
describe('when a third SQL query is executed', () => {
|
||||
beforeEach(() => {
|
||||
historyCollection.add({
|
||||
query: 'third sql statement',
|
||||
start_time: new Date(2017, 11, 11, 1, 33, 5, 99),
|
||||
status: false,
|
||||
row_affected: 5,
|
||||
total_time: '26 msec',
|
||||
message: 'pretext ERROR: third sql message',
|
||||
});
|
||||
|
||||
it('displays the query in the right pane', () => {
|
||||
expect(queryDetail.at(0).text()).toContain('second sql statement');
|
||||
});
|
||||
|
||||
it('deselects the first history entry', () => {
|
||||
expect(firstEntry.nodes.length).toBe(1);
|
||||
expect(firstEntry.hasClass('selected')).toBe(false);
|
||||
});
|
||||
|
||||
it('selects the second history entry', () => {
|
||||
expect(secondEntry.nodes.length).toBe(1);
|
||||
expect(secondEntry.hasClass('selected')).toBe(true);
|
||||
});
|
||||
queryEntries = historyWrapper.find(QueryHistoryEntry);
|
||||
});
|
||||
|
||||
describe('when the user clicks inside the main pane but not in any history entry', () => {
|
||||
let queryList;
|
||||
let firstEntry, secondEntry;
|
||||
|
||||
beforeEach(() => {
|
||||
firstEntry = foundChildren.at(0);
|
||||
secondEntry = foundChildren.at(1);
|
||||
queryList = historyWrapper.find('#query_list');
|
||||
|
||||
secondEntry.simulate('click');
|
||||
queryList.simulate('click');
|
||||
});
|
||||
|
||||
it('should not change the selected entry', () => {
|
||||
expect(firstEntry.hasClass('selected')).toBe(false);
|
||||
expect(secondEntry.hasClass('selected')).toBe(true);
|
||||
});
|
||||
|
||||
describe('when up arrow is keyed', () => {
|
||||
beforeEach(() => {
|
||||
pressArrowUpKey(queryList);
|
||||
});
|
||||
|
||||
it('selects the first history entry', () => {
|
||||
expect(firstEntry.nodes.length).toBe(1);
|
||||
expect(firstEntry.hasClass('selected')).toBe(true);
|
||||
});
|
||||
|
||||
it('deselects the second history entry', () => {
|
||||
expect(secondEntry.nodes.length).toBe(1);
|
||||
expect(secondEntry.hasClass('selected')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when a third SQL query is executed', () => {
|
||||
beforeEach(() => {
|
||||
historyCollection.add({
|
||||
query: 'third sql statement',
|
||||
start_time: new Date(2017, 11, 11, 1, 33, 5, 99),
|
||||
status: false,
|
||||
row_affected: 5,
|
||||
total_time: '26 msec',
|
||||
message: 'pretext ERROR: third sql message',
|
||||
});
|
||||
|
||||
foundChildren = historyWrapper.find(QueryHistoryEntry);
|
||||
});
|
||||
|
||||
it('displays third query SQL in the right pane', () => {
|
||||
expect(queryDetail.at(0).text()).toContain('third sql statement');
|
||||
});
|
||||
it('displays third query SQL in the right pane', () => {
|
||||
expect(queryDetail.at(0).text()).toContain('third sql statement');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the first query is outside the visible area', () => {
|
||||
describe('when several days of queries were executed', () => {
|
||||
let queryEntryDateGroups;
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(historyWrapper.node, 'isInvisible').and.callFake((element) => {
|
||||
return element.textContent.contains('first sql statement');
|
||||
});
|
||||
jasmine.clock().install();
|
||||
const mockedCurrentDate = moment('2017-07-01 13:30:00');
|
||||
jasmine.clock().mockDate(mockedCurrentDate.toDate());
|
||||
|
||||
const historyObjects = [{
|
||||
query: 'first today sql statement',
|
||||
start_time: mockedCurrentDate.toDate(),
|
||||
status: true,
|
||||
row_affected: 12345,
|
||||
total_time: '14 msec',
|
||||
message: 'message from first today sql query',
|
||||
}, {
|
||||
query: 'second today sql statement',
|
||||
start_time: mockedCurrentDate.clone().subtract(1, 'hours').toDate(),
|
||||
status: false,
|
||||
row_affected: 1,
|
||||
total_time: '234 msec',
|
||||
message: 'message from second today sql query',
|
||||
}, {
|
||||
query: 'first yesterday sql statement',
|
||||
start_time: mockedCurrentDate.clone().subtract(1, 'days').toDate(),
|
||||
status: true,
|
||||
row_affected: 12345,
|
||||
total_time: '14 msec',
|
||||
message: 'message from first yesterday sql query',
|
||||
}, {
|
||||
query: 'second yesterday sql statement',
|
||||
start_time: mockedCurrentDate.clone().subtract(1, 'days').subtract(1, 'hours').toDate(),
|
||||
status: false,
|
||||
row_affected: 1,
|
||||
total_time: '234 msec',
|
||||
message: 'message from second yesterday sql query',
|
||||
}, {
|
||||
query: 'older than yesterday sql statement',
|
||||
start_time: mockedCurrentDate.clone().subtract(3, 'days').toDate(),
|
||||
status: true,
|
||||
row_affected: 12345,
|
||||
total_time: '14 msec',
|
||||
message: 'message from older than yesterday sql query',
|
||||
}];
|
||||
historyCollection = new HistoryCollection(historyObjects);
|
||||
|
||||
historyWrapper = mount(<QueryHistory historyCollection={historyCollection}/>);
|
||||
|
||||
const queryHistoryEntriesComponent = historyWrapper.find(QueryHistoryEntries);
|
||||
isInvisibleSpy = spyOn(queryHistoryEntriesComponent.node, 'isInvisible')
|
||||
.and.returnValue(false);
|
||||
|
||||
queryEntries = queryHistoryEntriesComponent.find(QueryHistoryEntry);
|
||||
queryEntryDateGroups = queryHistoryEntriesComponent.find(QueryHistoryEntryDateGroup);
|
||||
});
|
||||
|
||||
describe('when the first query is the selected query', () => {
|
||||
describe('when refocus function is called', () => {
|
||||
let selectedListItem;
|
||||
afterEach(() => {
|
||||
jasmine.clock().uninstall();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
selectedListItem = ReactDOM.findDOMNode(historyWrapper.node)
|
||||
.getElementsByClassName('list-item')[0];
|
||||
describe('the history entries panel', () => {
|
||||
it('has three query history entry data groups', () => {
|
||||
expect(queryEntryDateGroups.length).toBe(3);
|
||||
});
|
||||
|
||||
spyOn(selectedListItem, 'focus');
|
||||
historyWrapper.node.refocus();
|
||||
});
|
||||
|
||||
it('the first query scrolls into view', function () {
|
||||
expect(selectedListItem.focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('has title above', () => {
|
||||
expect(queryEntryDateGroups.at(0).text()).toBe('Today - Jul 01 2017');
|
||||
expect(queryEntryDateGroups.at(1).text()).toBe('Yesterday - Jun 30 2017');
|
||||
expect(queryEntryDateGroups.at(2).text()).toBe('Jun 28 2017');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -3438,11 +3438,11 @@ hyphenate-style-name@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.0.2.tgz#31160a36930adaf1fc04c6074f7eb41465d4ec4b"
|
||||
|
||||
iconv-lite@0.4.15, iconv-lite@~0.4.13:
|
||||
iconv-lite@0.4.15:
|
||||
version "0.4.15"
|
||||
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.15.tgz#fe265a218ac6a57cfe854927e9d04c19825eddeb"
|
||||
|
||||
iconv-lite@^0.4.15:
|
||||
iconv-lite@^0.4.15, iconv-lite@~0.4.13:
|
||||
version "0.4.18"
|
||||
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.18.tgz#23d8656b16aae6742ac29732ea8f0336a4789cf2"
|
||||
|
||||
@ -5848,7 +5848,7 @@ read-pkg@^2.0.0:
|
||||
normalize-package-data "^2.3.2"
|
||||
path-type "^2.0.0"
|
||||
|
||||
"readable-stream@>=1.0.33-1 <1.1.0-0", readable-stream@^1.0.33, readable-stream@~1.0.2:
|
||||
"readable-stream@>=1.0.33-1 <1.1.0-0", readable-stream@~1.0.2:
|
||||
version "1.0.34"
|
||||
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c"
|
||||
dependencies:
|
||||
@ -5857,6 +5857,15 @@ read-pkg@^2.0.0:
|
||||
isarray "0.0.1"
|
||||
string_decoder "~0.10.x"
|
||||
|
||||
readable-stream@^1.0.33, readable-stream@~1.1.9:
|
||||
version "1.1.14"
|
||||
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
|
||||
dependencies:
|
||||
core-util-is "~1.0.0"
|
||||
inherits "~2.0.1"
|
||||
isarray "0.0.1"
|
||||
string_decoder "~0.10.x"
|
||||
|
||||
readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.4, readable-stream@^2.1.5, readable-stream@^2.2.6:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.1.tgz#84e26965bb9e785535ed256e8d38e92c69f09d10"
|
||||
@ -5869,15 +5878,6 @@ readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable
|
||||
string_decoder "~1.0.0"
|
||||
util-deprecate "~1.0.1"
|
||||
|
||||
readable-stream@~1.1.9:
|
||||
version "1.1.14"
|
||||
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
|
||||
dependencies:
|
||||
core-util-is "~1.0.0"
|
||||
inherits "~2.0.1"
|
||||
isarray "0.0.1"
|
||||
string_decoder "~0.10.x"
|
||||
|
||||
readable-stream@~2.0.0:
|
||||
version "2.0.6"
|
||||
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.0.6.tgz#8f90341e68a53ccc928788dacfcd11b36eb9b78e"
|
||||
|
Loading…
Reference in New Issue
Block a user