feat(xod-client): add table log tab, show data, switch sources and sheets, copy, save

This commit is contained in:
Kirill Shumilov
2021-02-04 18:04:13 +03:00
parent 66112c52c2
commit 3efe875bb4
19 changed files with 502 additions and 39 deletions

View File

@@ -52,4 +52,10 @@
box-sizing: border-box;
padding: 6px 10px;
}
&--inline {
height: 22px;
line-height: 0;
padding: 0.2rem 0.5rem;
}
}

View File

@@ -34,7 +34,6 @@ span.data-grid-container, span.data-grid-container:focus {
}
.data-grid-container .data-grid .cell.read-only {
background: whitesmoke;
color: #999;
text-align: center;
}
@@ -92,7 +91,7 @@ span.data-grid-container, span.data-grid-container:focus {
display: block;
}
.data-grid-container tr:first-child .cell {
.data-grid.with-header tr:first-child .cell {
font-weight: bold;
text-align: right;
background-color: $grey;

View File

@@ -0,0 +1,93 @@
@mixin header-vertical-centered {
height: 22px;
padding: 8px 0;
}
.TableLog {
position: relative;
width: 100%;
height: 100%;
flex: 1;
flex-grow: 1;
flex-flow: column;
&-header {
height: 40px;
overflow: hidden;
padding: 1px 3px 1px 8px;
.sourceSelector {
@include header-vertical-centered;
float: left;
margin-right: 1rem;
label {
color: $chalk;
margin-right: .5rem;
}
select {
width: 300px;
text-overflow: ellipsis;
}
}
.sheets {
@include header-vertical-centered;
float: left;
margin-left: 1rem;
.currentSheet {
display: inline-block;
margin: 0 .5rem;
color: $chalk;
}
button {
height: 22px;
}
}
.actions {
@include header-vertical-centered;
float: right;
margin-left: 1rem;
button {
margin-left: 2px;
.fa {
margin-right: .5em;
}
}
}
}
&-content {
position: absolute;
top: 40px;
bottom: 0;
left: 0;
right: 0;
overflow: scroll;
background: $grey;
.no-data {
position: absolute;
top: 50%;
margin-top: -.5em;
width: 100%;
text-align: center;
color: $chalk;
font-size: $font-size-l;
}
.data-grid {
margin-top: -2px; // compensate border-spacing
}
@include styled-scrollbar();
}
}

View File

@@ -58,6 +58,7 @@
'components/SnappingPreviewLayer',
'components/SplitPane',
'components/Suggester',
'components/TableLog',
'components/Tabs',
'components/TabsContainer',
'components/TabsItem',

View File

@@ -36,7 +36,7 @@ export const startDebuggerSession = (
nodePinKeysMap,
tableLogNodeIds,
pinsAffectedByErrorRaisers,
currentPatchPath,
patchPath,
globals,
tetheringInetNodeId
) => ({
@@ -47,7 +47,7 @@ export const startDebuggerSession = (
nodePinKeysMap,
tableLogNodeIds,
pinsAffectedByErrorRaisers,
patchPath: currentPatchPath,
patchPath,
globals,
tetheringInetNodeId,
},

View File

@@ -24,3 +24,5 @@ export const SESSION_TYPE = {
SERIAL: 'serial',
SIMULATON: 'simulation',
};
export const NEW_SHEET = String.fromCharCode(0xc);

View File

@@ -33,7 +33,7 @@ import {
import * as EAT from '../editor/actionTypes';
import { UPLOAD_MSG_TYPE, LOG_TAB_TYPE, SESSION_TYPE } from './constants';
import { UPLOAD_MSG_TYPE, LOG_TAB_TYPE, SESSION_TYPE, NEW_SHEET } from './constants';
import * as MSG from './messages';
import { STATUS } from '../utils/constants';
import { isXodErrorMessage } from './debugProtocol';
@@ -157,7 +157,6 @@ const updateInteractiveNodeValues = R.curry((messageList, state) => {
)(watchNodeMessages);
// Prepare data for table logs
const NEW_SHEET = String.fromCharCode(0xc); // TODO: Move somewhere
const newTableLogData = R.map(
R.compose(
R.reduce(

View File

@@ -296,3 +296,20 @@ export const tetheringInetTransmitter = R.compose(
R.path(['tetheringInet', 'transmitter']),
getDebuggerState
);
// =============================================================================
//
// Table log
//
// =============================================================================
export const getTableLogValues = R.compose(
R.prop('tableLogValues'),
getDebuggerState
);
export const getTableLogSources = R.compose(R.keys, getTableLogValues);
export const getTableLogsByNodeId = R.curry((nodeId, state) =>
R.compose(R.pathOr([], ['tableLogValues', nodeId]), getDebuggerState)(state)
);

View File

@@ -69,3 +69,6 @@ export const NODE_PROPERTY_UPDATING = 'NODE_PROPERTY_UPDATING';
export const SHOW_COLORPICKER_WIDGET = 'SHOW_COLORPICKER_WIDGET';
export const HIDE_COLORPICKER_WIDGET = 'HIDE_COLORPICKER_WIDGET';
export const OPEN_TABLE_LOG_TAB = 'OPEN_TABLE_LOG_TAB';
export const CHANGE_TABLE_LOG_SHEET = 'CHANGE_TABLE_LOG_SHEET';

View File

@@ -940,3 +940,39 @@ export const tweakNodeProperty = (nodeId, kind, key, value) => (
})
);
};
export const openTableLogTab = nodeId => (dispatch, getState) => {
const sheets = DebuggerSelectors.getTableLogsByNodeId(nodeId, getState());
return dispatch({
type: ActionType.OPEN_TABLE_LOG_TAB,
payload: {
nodeId,
activeSheetIndex: sheets.length > 0 ? sheets.length - 1 : 0,
},
});
};
export const changeActiveSheet = R.curry((tabId, nodeId, newSheetIndex) => ({
type: ActionType.CHANGE_TABLE_LOG_SHEET,
payload: {
tabId,
nodeId,
newSheetIndex,
},
}));
export const changeTableLogSource = (tabId, nodeId) => (dispatch, getState) => {
const newSourceSheets = DebuggerSelectors.getTableLogsByNodeId(
nodeId,
getState()
);
const newSheetIndex = R.max(0, newSourceSheets.length - 1);
dispatch({
type: ActionType.CHANGE_TABLE_LOG_SHEET,
payload: {
tabId,
nodeId,
newSheetIndex,
},
});
};

View File

@@ -99,6 +99,7 @@ const TabtestEditor = ({
</div>
<div className="tabtest-editor">
<ReactDataSheet
className="with-header"
data={cells}
valueRenderer={R.prop('value')}
overflow={'nowrap'}

View File

@@ -64,9 +64,11 @@ export const CLIPBOARD_DATA_TYPE = 'text/xod-entities';
export const TAB_TYPES = {
PATCH: 'PATCH',
DEBUGGER: 'DEBUGGER',
TABLE_LOG: 'TABLE_LOG',
};
export const DEBUGGER_TAB_ID = 'debugger';
export const TABLE_LOG_TAB_ID = 'tablelog';
export const PANEL_IDS = {
PROJECT_BROWSER: 'PROJECT_BROWSER',

View File

@@ -39,6 +39,7 @@ import Workarea from './Workarea';
import Tabs from './Tabs';
import DragLayer from './DragLayer';
import TableLog from './TableLog';
const pickPropsToCheck = R.compose(
R.evolve({ panelSettings: R.map(R.omit(['size'])) }),
@@ -111,6 +112,11 @@ class Editor extends React.Component {
return foldMaybe(
<NoPatch />,
tab => {
// Render <Patch /> component only for PATCH or DEBUGGER
// tab types
if (tab.type !== TAB_TYPES.PATCH && tab.type !== TAB_TYPES.DEBUGGER)
return null;
// Do not render <Patch /> component if opened tab
// is in EditingCppImplementation mode.
if (tab.editedAttachment) return null;
@@ -139,12 +145,37 @@ class Editor extends React.Component {
);
}
renderTableLogTab() {
const { currentTab } = this.props;
return foldMaybe(
null,
tab => {
// Render only for TABLE_LOG tab type
if (tab.type !== TAB_TYPES.TABLE_LOG) return null;
return (
<TableLog
tabId={tab.id}
nodeId={tab.nodeId}
sheetIndex={tab.activeSheetIndex}
/>
);
},
currentTab
);
}
renderOpenedAttachmentEditorTabs() {
return foldMaybe(
null,
currentTab => {
const tabs = this.props.attachmentEditorTabs.map(
({ id, type, patchPath, editedAttachment }) => {
// Render only for PATCH or DEBUGGER tab types
if (type !== TAB_TYPES.PATCH && type !== TAB_TYPES.DEBUGGER)
return null;
const patch = XP.getPatchByPathUnsafe(
patchPath,
this.props.project
@@ -156,11 +187,6 @@ class Editor extends React.Component {
R.propOr('', editedAttachment, XP.MANAGED_ATTACHMENT_TEMPLATES)
);
const currentPatchPath = explodeMaybe(
'No currentPatchPath, but currentTab exists',
this.props.currentPatchPath
);
switch (editedAttachment) {
case XP.TABTEST_MARKER_PATH:
return (
@@ -177,11 +203,9 @@ class Editor extends React.Component {
}
isInDebuggerTab={type === TAB_TYPES.DEBUGGER}
isRunning={this.props.isTabtestRunning}
onRunClick={() =>
this.props.actions.runTabtest(currentPatchPath)
}
onRunClick={() => this.props.actions.runTabtest(patchPath)}
onClose={this.props.actions.closeAttachmentEditor}
patchPath={currentPatchPath}
patchPath={patchPath}
/>
);
@@ -200,7 +224,7 @@ class Editor extends React.Component {
}
isInDebuggerTab={type === TAB_TYPES.DEBUGGER}
onClose={this.props.actions.closeAttachmentEditor}
patchPath={currentPatchPath}
patchPath={patchPath}
/>
);
@@ -282,6 +306,7 @@ class Editor extends React.Component {
>
{this.renderOpenedPatchTab()}
{this.renderOpenedAttachmentEditorTabs()}
{this.renderTableLogTab()}
<SnackBar />
</Workarea>
</FocusTrap>

View File

@@ -382,6 +382,7 @@ const mapDispatchToProps = dispatch => ({
addInteractiveNode: ProjectActions.addInteractiveNode,
focusBoundValue: EditorActions.focusBoundValue,
focusLabel: EditorActions.focusLabel,
openTableLogTab: EditorActions.openTableLogTab,
},
dispatch
),

View File

@@ -16,6 +16,8 @@ const debuggingMode = R.merge(selectingMode, {
onNodeDoubleClick(api, nodeId, patchPath) {
if (R.contains(patchPath, R.keys(XP.MANAGED_ATTACHMENT_FILENAMES))) {
api.props.actions.openAttachmentEditor(patchPath);
} else if (XP.isTableLogPatchPath(patchPath)) {
api.props.actions.openTableLogTab(nodeId);
} else if (
XP.isConstantNodeType(patchPath) ||
XP.isTweakPath(patchPath) ||

View File

@@ -231,6 +231,8 @@ const selectingMode = {
onNodeDoubleClick(api, nodeId, patchPath) {
if (R.contains(patchPath, R.keys(XP.MANAGED_ATTACHMENT_FILENAMES))) {
api.props.actions.openAttachmentEditor(patchPath);
} else if (XP.isTableLogPatchPath(patchPath)) {
api.props.actions.openTableLogTab(nodeId);
} else if (
XP.isConstantNodeType(patchPath) ||
XP.isTweakPath(patchPath) ||

View File

@@ -0,0 +1,229 @@
import * as R from 'ramda';
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import ReactDataSheet from 'react-datasheet';
import { Icon } from 'react-fa';
import * as Actions from '../actions';
import * as DebuggerSelectors from '../../debugger/selectors';
import { addConfirmation } from '../../messages/actions';
import {
LOG_COPIED,
LOG_COPY_NOT_SUPPORTED,
logCopyError,
logSaveError,
} from '../../debugger/messages';
// :: [[String]] -> [[{ value, readOnly }]]
const tableLogToDataSheet = R.map(
R.map(
R.applySpec({
value: R.identity,
readOnly: R.T,
})
)
);
const gridToTsv = R.compose(R.join('\n'), R.map(R.join('\t')));
class TableLog extends React.Component {
constructor(props) {
super(props);
this.getSheet = this.getSheet.bind(this);
this.onPrevSheetClick = this.onPrevSheetClick.bind(this);
this.onNextSheetClick = this.onNextSheetClick.bind(this);
this.onCopyLogClicked = this.onCopyLogClicked.bind(this);
this.onSaveLogClicked = this.onSaveLogClicked.bind(this);
this.onChangeSource = this.onChangeSource.bind(this);
}
onPrevSheetClick() {
const prevSheetIndex = R.max(0, this.props.sheetIndex - 1);
this.props.actions.changeActiveSheet(
this.props.tabId, // TODO: what's about debugger?
this.props.nodeId,
prevSheetIndex
);
}
onNextSheetClick() {
const nextSheetIndex = R.unless(R.equals(this.getSheetsAmount()), R.add(1))(
this.props.sheetIndex
);
this.props.actions.changeActiveSheet(
this.props.tabId,
this.props.nodeId,
nextSheetIndex
);
}
onCopyLogClicked() {
const copy = R.path(['navigator', 'clipboard', 'writeText'], window);
if (!copy) {
this.props.actions.addError(LOG_COPY_NOT_SUPPORTED);
return;
}
const tsv = gridToTsv(this.getSheet());
window.navigator.clipboard.writeText(tsv).then(
() => {
this.props.actions.addConfirmation(LOG_COPIED);
},
err => {
this.props.actions.addError(logCopyError(err));
}
);
}
onSaveLogClicked() {
const tsv = gridToTsv(this.getSheet());
const defaultFilename = `log-${this.props.nodeId}-${
this.props.sheetIndex
}.txt`;
try {
const data = new window.Blob([tsv], { type: 'text/plain' });
const file = window.URL.createObjectURL(data);
const link = document.createElement('a');
link.download = defaultFilename;
link.href = file;
link.click();
// We need to manually revoke the object URL to avoid memory leaks.
window.URL.revokeObjectURL(file);
} catch (err) {
this.props.actions.addError(logSaveError(err));
}
}
onChangeSource(event) {
const sourceNodeId = event.target.value;
this.props.actions.changeSource(this.props.tabId, sourceNodeId);
}
getSheet() {
return R.propOr([], this.props.sheetIndex, this.props.document);
}
getSheetsAmount() {
return this.props.document.length;
}
isEmptySheet() {
return this.getSheet().length === 0;
}
render() {
const hasNoSources = this.props.sources.length === 0;
const sheetAmount =
this.getSheetsAmount() === 0 ? 1 : this.getSheetsAmount();
return (
<div className="TableLog">
<div className="TableLog-header">
<div className="sourceSelector">
<select
id="tablelog-source"
value={this.props.nodeId}
onChange={this.onChangeSource}
disabled={hasNoSources}
>
{hasNoSources ? <option>No logs</option> : null}
{this.props.sources.map(nodeId => (
<option key={nodeId} value={nodeId}>
{nodeId}
</option>
))}
</select>
</div>
<div className="sheets">
<button
className="prev Button Button--light Button--inline"
onClick={this.onPrevSheetClick}
disabled={this.props.sheetIndex === 0}
>
<Icon name="angle-left" />
</button>
<div className="currentSheet">
{this.props.sheetIndex + 1} / {sheetAmount}
</div>
<button
className="next Button Button--light Button--inline"
onClick={this.onNextSheetClick}
disabled={this.props.sheetIndex >= this.getSheetsAmount() - 1}
>
<Icon name="angle-right" />
</button>
</div>
<div className="actions">
<button
className="copy-log Button Button--light Button--inline"
onClick={this.onCopyLogClicked}
disabled={this.isEmptySheet()}
>
<Icon name="copy" title="Download Log" />
Copy
</button>
<button
className="save-log Button Button--light Button--inline"
onClick={this.onSaveLogClicked}
disabled={this.isEmptySheet()}
>
<Icon name="save" title="Download Log" />
Save
</button>
</div>
</div>
<div className="TableLog-content">
{this.isEmptySheet() ? (
<span className="no-data">No data stored</span>
) : (
<ReactDataSheet
className="tablelog-grid"
data={tableLogToDataSheet(this.getSheet())}
valueRenderer={cell => cell.value}
overflow={'nowrap'}
/>
)}
</div>
</div>
);
}
}
TableLog.propTypes = {
// from parent
tabId: PropTypes.string.isRequired,
// connect
sources: PropTypes.arrayOf(PropTypes.string).isRequired,
document: PropTypes.array.isRequired,
nodeId: PropTypes.string.isRequired,
sheetIndex: PropTypes.number.isRequired,
actions: PropTypes.objectOf(PropTypes.func),
};
const mapStateToProps = (state, { nodeId }) =>
R.applySpec({
sources: DebuggerSelectors.getTableLogSources,
document: DebuggerSelectors.getTableLogsByNodeId(nodeId),
})(state);
const mapDispatchToProps = dispatch => ({
actions: bindActionCreators(
{
changeActiveSheet: Actions.changeActiveSheet,
changeSource: Actions.changeTableLogSource,
addConfirmation,
},
dispatch
),
});
export default connect(mapStateToProps, mapDispatchToProps)(TableLog);

View File

@@ -11,6 +11,7 @@ import { DEFAULT_PANNING_OFFSET } from '../project/nodeLayout';
import { MAIN_PATCH_PATH } from '../project/constants';
import {
DEBUGGER_TAB_ID,
TABLE_LOG_TAB_ID,
FOCUS_AREAS,
SELECTION_ENTITY_TYPE,
TAB_TYPES,
@@ -130,34 +131,52 @@ const setPropsToTab = R.curry((id, props, state) =>
)(state)
);
const addTabWithProps = R.curry((id, type, patchPath, state) => {
const getTabNewIndex = state => {
const tabs = R.prop('tabs')(state);
const lastIndex = R.reduce(
(acc, tab) => R.pipe(R.prop('index'), R.max(acc))(tab),
-1,
R.values(tabs)
);
const newIndex = R.inc(lastIndex);
return R.inc(lastIndex);
};
return setPropsToTab(
const addTabWithProps = R.curry((id, type, patchPath, state) =>
setPropsToTab(
id,
{
id,
patchPath,
index: newIndex,
index: getTabNewIndex(state),
type,
offset: DEFAULT_PANNING_OFFSET,
editedAttachment: null,
},
state
);
});
)
);
const addPatchTab = R.curry((newId, patchPath, state) => {
if (!patchPath) return state;
return addTabWithProps(newId, TAB_TYPES.PATCH, patchPath, state);
});
const openTableLogTab = R.curry((nodeId, activeSheetIndex, state) =>
setPropsToTab(
TABLE_LOG_TAB_ID,
R.unless(
() => isTabOpened(TABLE_LOG_TAB_ID, state),
R.merge(R.__, { index: getTabNewIndex(state) })
)({
id: TABLE_LOG_TAB_ID,
type: TAB_TYPES.TABLE_LOG,
nodeId,
activeSheetIndex,
}),
state
)
);
const applyTabSort = (tab, payload) => {
if (R.not(R.has(tab.id, payload))) {
return tab;
@@ -711,6 +730,20 @@ const editorReducer = (state = initialState, action) => {
R.lensProp('pointingPopups'),
R.assoc('colorPickerWidget', null)
)(state);
case EAT.OPEN_TABLE_LOG_TAB:
return R.compose(
R.assoc('currentTabId', TABLE_LOG_TAB_ID),
openTableLogTab(action.payload.nodeId, action.payload.activeSheetIndex)
)(state);
case EAT.CHANGE_TABLE_LOG_SHEET:
return setPropsToTab(
action.payload.tabId,
{
nodeId: action.payload.nodeId,
activeSheetIndex: action.payload.newSheetIndex,
},
state
);
default:
return state;

View File

@@ -1,7 +1,7 @@
import * as R from 'ramda';
import { Maybe } from 'ramda-fantasy';
import { createSelector } from 'reselect';
import { mapIndexed, foldMaybe } from 'xod-func-tools';
import { mapIndexed, foldMaybe, maybeProp } from 'xod-func-tools';
import * as XP from 'xod-project';
import {
@@ -32,37 +32,49 @@ export const getCurrentTabId = R.pipe(getEditor, R.prop('currentTabId'), Maybe);
export const getCurrentTab = createSelector(
[getCurrentTabId, getTabs],
(maybeTabId, tabs) => maybeTabId.chain(R.compose(Maybe, R.prop(R.__, tabs)))
(maybeTabId, tabs) => R.chain(maybeProp(R.__, tabs))(maybeTabId)
);
export const getCurrentPatchPath = createSelector(
getCurrentTab,
R.map(R.prop('patchPath'))
R.chain(maybeProp('patchPath'))
);
export const getCurrentPatchOffset = createSelector(
getCurrentTab,
foldMaybe(DEFAULT_PANNING_OFFSET, R.prop('offset'))
foldMaybe(DEFAULT_PANNING_OFFSET, R.propOr(DEFAULT_PANNING_OFFSET, 'offset'))
);
const isTabTypeEq = R.curry((expected, tab) => tab.type === expected);
const setTabLabel = R.assoc('label');
// :: State -> EditorTabs
export const getPreparedTabs = createSelector(
[getCurrentTabId, getTabs],
(maybeCurrentTabId, tabs) => {
const currentTabId = foldMaybe(null, R.identity, maybeCurrentTabId);
return R.map(tab => {
const patchPath = tab.patchPath;
const label =
tab.type === TAB_TYPES.DEBUGGER
? 'Debugger'
: XP.getBaseName(patchPath);
return R.merge(tab, {
label,
isActive: currentTabId === tab.id,
});
}, tabs);
return R.map(
R.compose(
tab => R.assoc('isActive', currentTabId === tab.id, tab),
R.cond([
[isTabTypeEq(TAB_TYPES.TABLE_LOG), setTabLabel('Table Logs')],
[isTabTypeEq(TAB_TYPES.DEBUGGER), setTabLabel('Debugger')],
[
isTabTypeEq(TAB_TYPES.PATCH),
tab =>
R.compose(
setTabLabel(R.__, tab),
XP.getBaseName,
R.prop('patchPath')
)(tab),
],
// It should not be possible:
[R.T, setTabLabel('Unknown Tab Type')],
])
),
tabs
);
}
);