diff --git a/frontend/app/api_client.js b/frontend/app/api_client.js index 33f7ffe66..0e4699359 100644 --- a/frontend/app/api_client.js +++ b/frontend/app/api_client.js @@ -26,6 +26,7 @@ const siteIdRequiredPaths = [ '/dashboards', '/metrics', '/unprocessed', + '/notes', // '/custom_metrics/sessions', ]; @@ -37,7 +38,7 @@ const noStoringFetchPathStarts = [ // null? export const clean = (obj, forbidenValues = [ undefined, '' ]) => { - const keys = Array.isArray(obj) + const keys = Array.isArray(obj) ? new Array(obj.length).fill().map((_, i) => i) : Object.keys(obj); const retObj = Array.isArray(obj) ? [] : {}; @@ -49,7 +50,7 @@ export const clean = (obj, forbidenValues = [ undefined, '' ]) => { retObj[key] = value; } }); - + return retObj; } @@ -70,7 +71,7 @@ export default class APIClient { this.siteId = siteId; } - fetch(path, params, options = { clean: true }) { + fetch(path, params, options = { clean: true }) { if (params !== undefined) { const cleanedParams = options.clean ? clean(params) : params; this.init.body = JSON.stringify(cleanedParams); diff --git a/frontend/app/components/Session/Layout/Player/TimeTracker.js b/frontend/app/components/Session/Layout/Player/TimeTracker.js index 731f414ac..d9927a921 100644 --- a/frontend/app/components/Session/Layout/Player/TimeTracker.js +++ b/frontend/app/components/Session/Layout/Player/TimeTracker.js @@ -1,22 +1,21 @@ import React from 'react'; import { observer } from 'mobx-react-lite'; -import { connectPlayer } from 'Player'; import cls from './timeTracker.module.css'; function TimeTracker({ player, scale }) { - return ( + return ( <>
); } -export default observer(TimeTracker); \ No newline at end of file +export default observer(TimeTracker); diff --git a/frontend/app/components/Session/WebPlayer.js b/frontend/app/components/Session/WebPlayer.js index b5b1f86af..6ef5bbba1 100644 --- a/frontend/app/components/Session/WebPlayer.js +++ b/frontend/app/components/Session/WebPlayer.js @@ -3,11 +3,11 @@ import { connect } from 'react-redux'; import { Loader } from 'UI'; import { toggleFullscreen, closeBottomBlock } from 'Duck/components/player'; import { fetchList } from 'Duck/integrations'; -import { PlayerProvider, connectPlayer, init as initPlayer, clean as cleanPlayer, Controls } from 'Player'; +import { PlayerProvider, injectNotes, connectPlayer, init as initPlayer, clean as cleanPlayer, Controls } from 'Player'; import cn from 'classnames'; import RightBlock from './RightBlock'; import withLocationHandlers from 'HOCs/withLocationHandlers'; - +import { useStore } from 'App/mstore' import PlayerBlockHeader from '../Session_/PlayerBlockHeader'; import PlayerBlock from '../Session_/PlayerBlock'; import styles from '../Session_/session.module.css'; @@ -62,6 +62,7 @@ function RightMenu({ live, tabs, activeTab, setActiveTab, fullscreen }) { function WebPlayer(props) { const { session, toggleFullscreen, closeBottomBlock, live, fullscreen, jwt, fetchList } = props; + const { notesStore } = useStore() const [activeTab, setActiveTab] = useState(''); @@ -69,6 +70,10 @@ function WebPlayer(props) { fetchList('issues'); initPlayer(session, jwt); + notesStore.fetchSessionNotes(session.sessionId).then(r => { + injectNotes(r) + }) + const jumptTime = props.query.get('jumpto'); if (jumptTime) { Controls.jump(parseInt(jumptTime)); diff --git a/frontend/app/components/Session_/EventsBlock/EventGroupWrapper.js b/frontend/app/components/Session_/EventsBlock/EventGroupWrapper.js index c68dac880..49694ca5f 100644 --- a/frontend/app/components/Session_/EventsBlock/EventGroupWrapper.js +++ b/frontend/app/components/Session_/EventsBlock/EventGroupWrapper.js @@ -6,10 +6,11 @@ import withToggle from 'HOCs/withToggle'; import { TYPES } from 'Types/session/event'; import Event from './Event' import stl from './eventGroupWrapper.module.css'; +import NoteEvent from './NoteEvent'; // TODO: incapsulate toggler in LocationEvent @withToggle("showLoadInfo", "toggleLoadInfo") -class EventGroupWrapper extends React.PureComponent { +class EventGroupWrapper extends React.Component { toggleLoadInfo = (e) => { e.stopPropagation(); @@ -42,6 +43,8 @@ class EventGroupWrapper extends React.PureComponent { showLoadInfo, isFirst, presentInSearch, + isNote, + filterOutNote, } = this.props; const isLocation = event.type === TYPES.LOCATION; @@ -64,7 +67,19 @@ class EventGroupWrapper extends React.PureComponent {
} - { isLocation + {isNote ? ( + + ) : isLocation ? - } + />}
) } diff --git a/frontend/app/components/Session_/EventsBlock/EventsBlock.js b/frontend/app/components/Session_/EventsBlock/EventsBlock.js index e690ce3cc..7fd9cc75e 100644 --- a/frontend/app/components/Session_/EventsBlock/EventsBlock.js +++ b/frontend/app/components/Session_/EventsBlock/EventsBlock.js @@ -5,7 +5,7 @@ import { Icon } from 'UI'; import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from "react-virtualized"; import { TYPES } from 'Types/session/event'; import { setSelected } from 'Duck/events'; -import { setEventFilter } from 'Duck/sessions'; +import { setEventFilter, filterOutNote } from 'Duck/sessions'; import { show as showTargetDefiner } from 'Duck/components/targetDefiner'; import EventGroupWrapper from './EventGroupWrapper'; import styles from './eventsBlock.module.css'; @@ -21,7 +21,8 @@ import EventSearch from './EventSearch/EventSearch'; }), { showTargetDefiner, setSelected, - setEventFilter + setEventFilter, + filterOutNote }) export default class EventsBlock extends React.PureComponent { state = { @@ -123,21 +124,28 @@ export default class EventsBlock extends React.PureComponent { onMouseOver = () => this.setState({ mouseOver: true }) onMouseLeave = () => this.setState({ mouseOver: false }) + get eventsList() { + const { session: { notesWithEvents }, filteredEvents } = this.props + const usedEvents = filteredEvents || notesWithEvents + + return usedEvents + } + renderGroup = ({ index, key, style, parent }) => { const { - session: { events }, selectedEvents, currentTimeEventIndex, testsAvaliable, playing, eventsIndex, - filteredEvents + filterOutNote, } = this.props; const { query } = this.state; - const _events = filteredEvents || events; + const _events = this.eventsList const isLastEvent = index === _events.size - 1; const isLastInGroup = isLastEvent || _events.get(index + 1).type === TYPES.LOCATION; const event = _events.get(index); + const isNote = !!event.noteId const isSelected = selectedEvents.includes(event); const isCurrent = index === currentTimeEventIndex; const isEditing = this.state.editingEvent === event; @@ -166,6 +174,8 @@ export default class EventsBlock extends React.PureComponent { isCurrent={ isCurrent } isEditing={ isEditing } showSelection={ testsAvaliable && !playing } + isNote={isNote} + filterOutNote={filterOutNote} /> )} diff --git a/frontend/app/components/Session_/EventsBlock/NoteEvent.tsx b/frontend/app/components/Session_/EventsBlock/NoteEvent.tsx new file mode 100644 index 000000000..ae7a7245a --- /dev/null +++ b/frontend/app/components/Session_/EventsBlock/NoteEvent.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { Icon } from 'UI'; +import { tagProps, iTag } from 'App/services/NotesService'; +import { formatTimeOrDate } from 'App/date'; +import { useStore } from 'App/mstore'; +import { observer } from 'mobx-react-lite'; +import { ItemMenu } from 'UI'; +import copy from 'copy-to-clipboard'; +import { toast } from 'react-toastify'; +import { session } from 'App/routes'; +import { confirm } from 'UI'; +import { filterOutNote as filterOutTimelineNote } from 'Player'; + +interface Props { + userId: number; + timestamp: number; + tags: iTag[]; + isPublic: boolean; + message: string; + sessionId: string; + date: string; + noteId: number; + filterOutNote: (id: number) => void; +} + +function NoteEvent(props: Props) { + const { settingsStore, notesStore } = useStore(); + const { timezone } = settingsStore.sessionSettings; + + const onEdit = () => {}; + + const onCopy = () => { + copy(`${session(props.sessionId)}${props.timestamp > 0 ? '?jumpto=' + props.timestamp : ''}`); + toast.success('Note URL copied to clipboard'); + }; + + const onDelete = async () => { + if ( + await confirm({ + header: 'Confirm', + confirmButton: 'Yes, delete', + confirmation: `Are you sure you want to delete this note?`, + }) + ) { + notesStore.deleteNote(props.noteId).then((r) => { + props.filterOutNote(props.noteId); + filterOutTimelineNote(props.noteId) + toast.success('Note deleted'); + }); + } + }; + const menuItems = [ + { icon: 'pencil', text: 'Edit', onClick: onEdit }, + { icon: 'link-45deg', text: 'Copy URL', onClick: onCopy }, + { icon: 'trash', text: 'Delete', onClick: onDelete }, + ]; + return ( +
+
+
+ +
+
+
{props.userId}
+
+ {formatTimeOrDate(props.date as unknown as number, timezone)} +
+
+
+ +
+
+
{props.message}
+
+
+ {props.tags.length ? ( +
+ {props.tags.map((tag) => ( +
+ {tag} +
+ ))} +
+ ) : null} + {!props.isPublic ? null : ( + <> + Team + + )} +
+
+
+ ); +} + +export default observer(NoteEvent); diff --git a/frontend/app/components/Session_/Player/Controls/Timeline.js b/frontend/app/components/Session_/Player/Controls/Timeline.js index e53f567f0..8059629fd 100644 --- a/frontend/app/components/Session_/Player/Controls/Timeline.js +++ b/frontend/app/components/Session_/Player/Controls/Timeline.js @@ -1,5 +1,6 @@ import React from 'react'; import { connect } from 'react-redux'; +import { Icon } from 'UI' import { connectPlayer, Controls, toggleTimetravel } from 'Player'; import TimeTracker from './TimeTracker'; import stl from './timeline.module.css'; @@ -30,22 +31,12 @@ let debounceTooltipChange = () => null; disabled: state.cssLoading || state.messagesLoading || state.markedTargets, endTime: state.endTime, live: state.live, - logList: state.logList, - exceptionsList: state.exceptionsList, - resourceList: state.resourceList, - stackList: state.stackList, - fetchList: state.fetchList, + notes: state.notes, })) @connect( (state) => ({ issues: state.getIn(['sessions', 'current', 'issues']), startedAt: state.getIn(['sessions', 'current', 'startedAt']), - clickRageTime: - state.getIn(['sessions', 'current', 'clickRage']) && - state.getIn(['sessions', 'current', 'clickRageTime']), - returningLocationTime: - state.getIn(['sessions', 'current', 'returningLocation']) && - state.getIn(['sessions', 'current', 'returningLocationTime']), tooltipVisible: state.getIn(['sessions', 'timeLineTooltip', 'isVisible']), }), { setTimelinePointer, setTimelineHoverTime } @@ -170,7 +161,7 @@ export default class Timeline extends React.PureComponent { }; render() { - const { events, skip, skipIntervals, disabled, endTime, live } = this.props; + const { events, skip, skipIntervals, disabled, endTime, live, notes } = this.props; const scale = 100 / endTime; @@ -228,6 +219,20 @@ export default class Timeline extends React.PureComponent { style={{ left: `${getTimelinePosition(e.time, scale)}%` }} /> ))} + {notes.map((note) => note.timestamp > 0 ? ( +
+ +
+ ) : null)} ); diff --git a/frontend/app/components/Session_/Player/Controls/components/NoteTooltip.tsx b/frontend/app/components/Session_/Player/Controls/components/NoteTooltip.tsx index ad3de3079..a694cc4b5 100644 --- a/frontend/app/components/Session_/Player/Controls/components/NoteTooltip.tsx +++ b/frontend/app/components/Session_/Player/Controls/components/NoteTooltip.tsx @@ -2,72 +2,138 @@ import React from 'react'; import { Icon, Button, Checkbox } from 'UI'; import { Duration } from 'luxon'; import { connect } from 'react-redux'; +import { WriteNote, tagProps, TAGS, iTag, Note } from 'App/services/NotesService' +import { setNoteTooltip, addNote } from 'Duck/sessions'; import stl from './styles.module.css'; +import { useStore } from 'App/mstore' +import { toast } from 'react-toastify'; +import { injectNotes } from 'Player'; interface Props { - offset: number; isVisible: boolean; time: number; + setNoteTooltip: (state: any) => void + addNote: (note: Note) => void + sessionId: string } -const TAGS = ['QUERY', 'ISSUE', 'TASK', 'OTHER']; +function NoteTooltip({ isVisible, time, setNoteTooltip, sessionId, addNote }: Props) { + const [text, setText] = React.useState('') + const [isPublic, setPublic] = React.useState(false) + const [tags, setTags] = React.useState([]) + const [useTimestamp, setUseTs] = React.useState(false) + + const { notesStore } = useStore(); -function NoteTooltip({ offset, isVisible, time }: Props) { const duration = Duration.fromMillis(time).toFormat('mm:ss'); - const stopEvents = (e: any) => { e.stopPropagation(); }; + const onSubmit = () => { + const note: WriteNote = { + message: text, + tags, + timestamp: useTimestamp ? time : -1, + isPublic, + } + + notesStore.addNote(sessionId, note).then(r => { + toast.success('Note added') + notesStore.fetchSessionNotes(sessionId).then(notes => { + injectNotes(notes) + addNote(r) + }) + }).catch(e => { + toast.error('Error adding note') + console.error(e) + }).finally(() => { + setNoteTooltip({ isVisible: false, time: 0 }) + setText('') + setTags([]) + }) + } + + const closeTooltip = () => { + setNoteTooltip({ isVisible: false, time: 0 }) + } + + const tagActive = (tag: iTag) => tags.includes(tag) + const removeTag = (tag: iTag) => { + setTags(tags.filter(t => t !== tag)) + } + const addTag = (tag: iTag) => { + setTags([...tags, tag]) + } return (
-
+

Add Note

-
- +
setUseTs(!useTimestamp)}> + {`at ${duration}`}
-
+
-
text field
+
+