feat(ui): fix adding/removing to the lists, add options popup, inject notes dynamically

This commit is contained in:
sylenien 2022-09-29 15:09:39 +02:00 committed by Delirium
parent 266a0bef7e
commit 65a4b1ca93
23 changed files with 552 additions and 135 deletions

View file

@ -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);

View file

@ -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 (
<>
<div
className={ cls.positionTracker }
style={ { left: `${ player.state.time * scale }%` } }
/>
<div
className={ cls.playedTimeline }
className={ cls.playedTimeline }
style={ { width: `${ player.state.time * scale }%` } }
/>
</>
);
}
export default observer(TimeTracker);
export default observer(TimeTracker);

View file

@ -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));

View file

@ -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 {
</TextEllipsis>
</div>
}
{ isLocation
{isNote ? (
<NoteEvent
userId={event.userId}
timestamp={event.timestamp}
tags={event.tags}
isPublic={event.isPublic}
message={event.message}
sessionId={event.sessionId}
date={event.createdAt}
noteId={event.noteId}
filterOutNote={filterOutNote}
/>
) : isLocation
? <Event
extended={isFirst}
key={ event.key }
@ -90,8 +105,7 @@ class EventGroupWrapper extends React.PureComponent {
presentInSearch={presentInSearch}
isLastInGroup={isLastInGroup}
whiteBg={whiteBg}
/>
}
/>}
</div>
)
}

View file

@ -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}
/>
</div>
)}

View file

@ -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 (
<div
className="flex items-start flex-col p-2 border"
style={{ background: 'rgba(253, 243, 155, 0.1)' }}
>
<div className="flex items-center w-full">
<div className="p-2 bg-gray-light rounded-full">
<Icon name="quotes" color="main" />
</div>
<div className="ml-2">
<div>{props.userId}</div>
<div className="text-disabled-text">
{formatTimeOrDate(props.date as unknown as number, timezone)}
</div>
</div>
<div className="cursor-pointer ml-auto">
<ItemMenu bolt items={menuItems} />
</div>
</div>
<div>{props.message}</div>
<div>
<div className="flex items-center gap-2 flex-wrap w-full">
{props.tags.length ? (
<div className="flex items-center gap-1">
{props.tags.map((tag) => (
<div
style={{ background: tagProps[tag], userSelect: 'none' }}
className="rounded-xl text-sm px-2 py-1 text-white"
>
{tag}
</div>
))}
</div>
) : null}
{!props.isPublic ? null : (
<>
<Icon name="user-friends" className="ml-2 mr-1" color="gray-dark" /> Team
</>
)}
</div>
</div>
</div>
);
}
export default observer(NoteEvent);

View file

@ -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 ? (
<div
key={note.noteId}
style={{
position: 'absolute',
background: 'white',
zIndex: 3,
pointerEvents: 'none',
left: `${getTimelinePosition(note.timestamp, scale)}%`,
}}
>
<Icon name="quotes" size={16} color="main" />
</div>
) : null)}
</div>
</div>
);

View file

@ -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 (
<div
className={stl.noteTooltip}
style={{
top: -250,
top: -260,
width: 350,
left: offset - 20,
display: isVisible ? 'block' : 'none',
left: 'calc(50% - 175px)',
display: isVisible ? 'flex' : 'none',
flexDirection: 'column',
gap: '1rem',
}}
onClick={stopEvents}
>
<div className="flex items-center">
<div className="flex items-center bg-gray-lightest">
<Icon name="quotes" size={20} />
<h3 className="text-xl ml-2 mr-4 font-semibold">Add Note</h3>
<div className="flex items-center cursor-pointer">
<Checkbox />
<div className="flex items-center cursor-pointer" onClick={() => setUseTs(!useTimestamp)}>
<Checkbox checked={useTimestamp} />
<span className="ml-1">{`at ${duration}`}</span>
</div>
<div className="ml-auto cursor-pointer">
<div className="ml-auto cursor-pointer" onClick={closeTooltip}>
<Icon name="close" size={20} />
</div>
</div>
<div>text field</div>
<div className="">
<textarea
name="message"
id="message"
placeholder="Note..."
rows={3}
value={text}
onChange={(e) => setText(e.target.value)}
style={{ border: 'solid thin #ddd', borderRadius: 3, resize: 'none', background: '#ffff' }}
/>
</div>
<div className="flex items-center mt-4">
<div className="flex items-center">
{TAGS.map((tag) => (
<div className="rounded-xl px-2 py-1 mr-2 bg-gray-medium"> {tag} </div>
<div
key={tag}
style={{ background: tagActive(tag) ? tagProps[tag] : 'rgba(0,0,0, 0.38)', userSelect: 'none' }}
className="cursor-pointer rounded-xl px-2 py-1 mr-2 text-white"
onClick={() => tagActive(tag) ? removeTag(tag) : addTag(tag)}
>
{tag}
</div>
))}
</div>
<div className="flex mt-4">
<Button variant="primary" className="mr-4">
<div className="flex">
<Button variant="primary" className="mr-4" onClick={onSubmit}>
Add Note
</Button>
<div className="flex items-center cursor-pointer">
<Checkbox />
<div className="flex items-center cursor-pointer" onClick={() => setPublic(!isPublic)}>
<Checkbox checked={isPublic} />
<Icon name="user-friends" size={16} className="mx-1" />
Visible to the team
</div>
</div>
<div className={stl.arrow} />
</div>
);
}
export default connect((state) => {
const { offset = 0, isVisible, time = 0 } = state.getIn(['sessions', 'noteTooltip']);
return { offset, isVisible, time };
})(NoteTooltip);
// @ts-ignore
const { isVisible, time = 0 } = state.getIn(['sessions', 'noteTooltip']);
// @ts-ignore
const sessionId = state.getIn(['sessions', 'current', 'sessionId'])
return { isVisible, time, sessionId };
}, { setNoteTooltip, addNote })(NoteTooltip);

View file

@ -121,6 +121,7 @@ function PlayerControls(props: Props) {
</div>
{Object.keys(skipIntervals).map((interval) => (
<div
key={interval}
onClick={() => {
toggleTooltip();
setSkipInterval(parseInt(interval, 10));

View file

@ -3,10 +3,11 @@ import { Icon } from 'UI';
import Autoplay from './Autoplay';
import Bookmark from 'Shared/Bookmark';
import SharePopup from '../shared/SharePopup/SharePopup';
import { connectPlayer, pause } from 'Player';
import copy from 'copy-to-clipboard';
import { Tooltip } from 'react-tippy';
import Issues from './Issues/Issues';
import NotePopup from './components/NotePopup';
import { connectPlayer } from 'Player';
function SubHeader(props) {
const [isCopied, setCopied] = React.useState(false);
@ -18,9 +19,6 @@ function SubHeader(props) {
? `${props.currentLocation.slice(0, 60)}...`
: props.currentLocation;
const toggleNotePopup = () => {
pause();
};
return (
<div className="w-full px-4 py-2 flex items-center border-b">
{location && (
@ -50,13 +48,7 @@ function SubHeader(props) {
className="ml-auto text-sm flex items-center color-gray-medium"
style={{ width: 'max-content' }}
>
<div
onClick={toggleNotePopup}
className="cursor-pointer mr-4 hover:bg-gray-light-shade rounded-md p-1 flex items-center"
>
<Icon name="quotes" size="16" className="mr-2" />
Add note
</div>
<NotePopup />
<div className="cursor-pointer mr-4 hover:bg-gray-light-shade rounded-md p-1">
{props.jiraConfig && props.jiraConfig.token && <Issues sessionId={props.sessionId} />}
</div>
@ -86,6 +78,8 @@ function SubHeader(props) {
);
}
const SubH = connectPlayer((state) => ({ currentLocation: state.location }))(SubHeader);
const SubH = connectPlayer(
(state) => ({ currentLocation: state.location })
)(SubHeader);
export default React.memo(SubH);

View file

@ -0,0 +1,34 @@
import React from 'react'
import { Icon } from 'UI'
import { connectPlayer, pause } from 'Player';
import { connect } from 'react-redux'
import { setNoteTooltip } from 'Duck/sessions';
function NotePopup({ setNoteTooltip, time }: { setNoteTooltip: (args: any) => void, time: number }) {
const toggleNotePopup = () => {
pause();
setNoteTooltip({ time: time, isVisible: true })
};
return (
<div
onClick={toggleNotePopup}
className="cursor-pointer mr-4 hover:bg-gray-light-shade rounded-md p-1 flex items-center"
>
<Icon name="quotes" size="16" className="mr-2" />
Add note
</div>
)
}
const NotePopupPl = connectPlayer(
// @ts-ignore
(state) => ({ time: state.time })
)(React.memo(NotePopup));
const NotePopupComp = connect(
null, { setNoteTooltip }
)(NotePopupPl)
export default React.memo(NotePopupComp)

View file

@ -15,7 +15,7 @@ interface Props {
viewed: boolean;
sessionId: string;
onClick?: () => void;
queryParams: any;
queryParams?: any;
}
export default function PlayLink(props: Props) {
const { isAssist, viewed, sessionId, onClick = null, queryParams } = props;

View file

@ -2,16 +2,36 @@ import React from 'react';
import SessionList from './components/SessionList';
import SessionHeader from './components/SessionHeader';
import NotesList from './components/Notes/NoteList';
import { connect } from 'react-redux'
import { connect } from 'react-redux';
import { fetchList as fetchMembers } from 'Duck/member';
function SessionListContainer({ activeTab }: { activeTab: string }) {
return (
<div className="widget-wrapper">
<SessionHeader />
<div className="border-b" />
{activeTab !== 'notes' ? <SessionList /> : <NotesList />}
</div>
);
function SessionListContainer({
activeTab,
fetchMembers,
members,
}: {
activeTab: string;
fetchMembers: () => void;
members: object[];
}) {
React.useEffect(() => {
fetchMembers();
}, []);
return (
<div className="widget-wrapper">
<SessionHeader />
<div className="border-b" />
{activeTab !== 'notes' ? <SessionList /> : <NotesList members={members} />}
</div>
);
}
export default connect(state => ({ activeTab: state.getIn(['search', 'activeTab', 'type'])}))(SessionListContainer);
export default connect(
(state) => ({
// @ts-ignore
activeTab: state.getIn(['search', 'activeTab', 'type']),
// @ts-ignore
members: state.getIn(['members', 'list']),
}),
{ fetchMembers }
)(SessionListContainer);

View file

@ -1,46 +1,92 @@
import React from 'react'
import { Icon } from 'UI'
import PlayLink from 'Shared/SessionItem/PlayLink'
enum Tags {
QUERY,
ISSUE,
TASK,
OTHER
}
import React from 'react';
import { Icon } from 'UI';
import PlayLink from 'Shared/SessionItem/PlayLink';
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'
interface Props {
author: string
timestamp: number
tags: string[]
isPrivate: boolean
description: string
sessionId: string
userId: number;
timestamp: number;
tags: iTag[];
isPublic: boolean;
description: string;
sessionId: string;
date: string;
noteId: number;
userEmail: string;
}
function NoteItem(props: Props) {
const { settingsStore, notesStore } = useStore();
const { timezone } = settingsStore.sessionSettings;
const onCopy = () => {
copy(`${window.location.origin}${session(props.sessionId)}${props.timestamp > 0 ? '?jumpto=' + props.timestamp : ''}`);
toast.success('Note URL copied to clipboard')
}
const onDelete = () => {
notesStore.deleteNote(props.noteId).then(r => {
notesStore.fetchNotes()
toast.success('Note deleted')
})
};
const menuItems = [
{ icon: 'link-45deg', text: 'Copy URL', onClick: onCopy },
{ icon: 'trash', text: 'Delete', onClick: onDelete },
]
return (
<div className="flex items-center p-4 border-b hover:backdrop-opacity-25" style={{ background: 'rgba(253, 243, 155, 0.1)' }}>
<div className="flex flex-col">
<div>{props.description}</div>
<div className="flex items-center gap-2">
<div>{props.tags}</div>
<div className='text-disabled-text flex items-center'>
<div
className="flex items-center p-4 border-b"
style={{ background: 'rgba(253, 243, 155, 0.1)' }}
>
<div className="flex flex-col gap-1">
<div>{props.description}</div>
<div className="flex items-center gap-2">
{props.tags.length ? (
<div className="flex items-center gap-1">
{props.tags.map((tag) => (
<div
key={tag}
style={{ background: tagProps[tag], userSelect: 'none' }}
className="rounded-xl px-2 py-1 mr-2 text-white"
>
{tag}
</div>
))}
</div>
) : null}
<div className="text-disabled-text flex items-center">
<span className="text-figmaColors-text-primary mr-1">By </span>
{props.author}, {props.timestamp}
{props.isPrivate ? null : (
{props.userEmail}, {formatTimeOrDate(props.date as unknown as number, timezone)}
{!props.isPublic ? null : (
<>
<Icon name="user-friends" className="ml-4 mr-1" color="gray-dark" /> Team
</>
)}
</div>
</div>
</div>
<div className="ml-auto"><PlayLink isAssist={false} viewed={false} sessionId={props.sessionId} queryParams="noteurlparams" /></div>
<div className="ml-2 cursor-pointer"><Icon name="ellipsis-v" size={20} /></div>
</div>
)
</div>
<div className="ml-auto">
<PlayLink
isAssist={false}
viewed={false}
sessionId={props.sessionId + (props.timestamp > 0 ? '?jumpto=' + props.timestamp : '')}
/>
</div>
<div className="ml-2 cursor-pointer">
<ItemMenu
bold
items={menuItems}
/>
</div>
</div>
);
}
export default NoteItem
export default observer(NoteItem);

View file

@ -5,7 +5,7 @@ import NoteItem from './NoteItem';
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
function NotesList() {
function NotesList({ members }: {members: Array<Record<string, any>>}) {
const { notesStore } = useStore()
React.useEffect(() => {
@ -15,7 +15,6 @@ function NotesList() {
}, [])
const list = notesStore.notes
console.log(list)
return (
<NoContent
show={list.length === 0}
@ -30,18 +29,21 @@ function NotesList() {
{sliceListPerPage(list, notesStore.page - 1, notesStore.pageSize).map(note => (
<React.Fragment key={note.noteId}>
<NoteItem
author={note.author}
userId={note.userId}
tags={note.tags}
timestamp={note.timestamp}
isPrivate={note.isPublic}
isPublic={note.isPublic}
description={note.message}
sessionId={'123123'} // note.sessionId
date={note.createdAt}
noteId={note.noteId}
sessionId={note.sessionId}
userEmail={members.find(m => m.id === note.userId).email || note.userId}
/>
</React.Fragment>
))}
</div>
<div className="w-full flex items-center justify-between pt-4 px-6">
<div className="w-full flex items-center justify-between py-4 px-6">
<div className="text-disabled-text">
Showing <span className="font-semibold">{Math.min(list.length, notesStore.pageSize)}</span> out
of <span className="font-semibold">{list.length}</span> notes

View file

@ -56,13 +56,12 @@ export default class ItemMenu extends React.PureComponent {
<div className={cn(styles.menu, { [styles.menuDim]: !bold })} data-displayed={displayed}>
{items
.filter(({ hidden }) => !hidden)
.map(({ onClick, text, icon, disabled = false, disabledMessage = '' }) => (
.map(({ onClick, text, icon, disabled = false }) => (
<div
key={text}
onClick={!disabled ? this.onClick(onClick) : () => {}}
className={disabled ? 'cursor-not-allowed' : ''}
role="menuitem"
tabIndex="-1"
>
<div className={cn(styles.menuItem, 'text-neutral-700', { disabled: disabled })}>
{icon && (

View file

@ -139,6 +139,8 @@ export const reduceThenFetchResource =
const filter = getState().getIn(['search', 'instance']).toData();
const activeTab = getState().getIn(['search', 'activeTab']);
if (activeTab.type === 'notes') return;
if (activeTab.type !== 'all' && activeTab.type !== 'bookmark' && activeTab.type !== 'vault') {
const tmpFilter = filtersMap[FilterKey.ISSUE];
tmpFilter.value = [activeTab.type];

View file

@ -26,6 +26,9 @@ const TOGGLE_CHAT_WINDOW = 'sessions/TOGGLE_CHAT_WINDOW';
const SET_FUNNEL_PAGE_FLAG = 'sessions/SET_FUNNEL_PAGE_FLAG';
const SET_TIMELINE_POINTER = 'sessions/SET_TIMELINE_POINTER';
const SET_TIMELINE_HOVER_POINTER = 'sessions/SET_TIMELINE_HOVER_POINTER';
const SET_NOTE_TOOLTIP = 'sessions/SET_NOTE_TOOLTIP'
const FILTER_OUT_NOTE = 'sessions/FILTER_OUT_NOTE'
const ADD_NOTE = 'sessions/ADD_NOTE'
const SET_SESSION_PATH = 'sessions/SET_SESSION_PATH';
const LAST_PLAYED_SESSION_ID = `${name}/LAST_PLAYED_SESSION_ID`;
@ -64,7 +67,7 @@ const initialState = Map({
sessionPath: {},
lastPlayedSessionId: null,
timeLineTooltip: { time: 0, offset: 0, isVisible: false },
noteTooltip: { time: 100, offset: 100, isVisible: true },
noteTooltip: { time: 0, isVisible: false },
});
const reducer = (state = initialState, action = {}) => {
@ -193,6 +196,22 @@ const reducer = (state = initialState, action = {}) => {
return state.set('timelinePointer', action.pointer);
case SET_TIMELINE_HOVER_POINTER:
return state.set('timeLineTooltip', action.timeLineTooltip);
case SET_NOTE_TOOLTIP:
return state.set('noteTooltip', action.noteTooltip);
case FILTER_OUT_NOTE:
return state.updateIn(['current', 'notesWithEvents'], (list) =>
list.filter(evt => !evt.noteId || evt.noteId !== action.noteId)
)
case ADD_NOTE:
console.log(action.note)
return state.updateIn(['current', 'notesWithEvents'], (list) =>
list.push(action.note).sort((a, b) => {
const aTs = a.time || a.timestamp
const bTs = b.time || b.timestamp
return aTs - bTs
})
)
case SET_SESSION_PATH:
return state.set('sessionPath', action.path);
case LAST_PLAYED_SESSION_ID:
@ -363,6 +382,27 @@ export function setTimelineHoverTime(timeLineTooltip) {
};
}
export function setNoteTooltip(noteTooltip) {
return {
type: SET_NOTE_TOOLTIP,
noteTooltip
}
}
export function filterOutNote(noteId) {
return {
type: FILTER_OUT_NOTE,
noteId
}
}
export function addNote(note) {
return {
type: ADD_NOTE,
note
}
}
export function setSessionPath(path) {
return {
type: SET_SESSION_PATH,

View file

@ -1,6 +1,6 @@
import { makeAutoObservable } from "mobx"
import { notesService } from "App/services"
import { Note } from 'App/services/NotesService'
import { Note, WriteNote } from 'App/services/NotesService'
interface SessionNotes {
[sessionId: string]: Note[]
@ -8,7 +8,7 @@ interface SessionNotes {
export default class NotesStore {
notes: Note[] = []
sessionNotes: SessionNotes
sessionNotes: SessionNotes = {}
loading: boolean
page = 1
pageSize = 15
@ -35,6 +35,7 @@ export default class NotesStore {
try {
const notes = await notesService.getNotesBySessionId(sessionId)
this.sessionNotes[sessionId] = notes
return notes;
} catch (e) {
console.error(e)
} finally {
@ -42,7 +43,7 @@ export default class NotesStore {
}
}
async addNote(sessionId: string, note: Note) {
async addNote(sessionId: string, note: WriteNote) {
this.loading = true
try {
const addedNote = await notesService.addNote(sessionId, note)
@ -54,7 +55,7 @@ export default class NotesStore {
}
}
async deleteNote(noteId: string) {
async deleteNote(noteId: number) {
this.loading = true
try {
const deleted = await notesService.deleteNote(noteId)

View file

@ -1,6 +1,7 @@
import { goTo as listsGoTo } from './lists';
import { update, getState } from './store';
import MessageDistributor, { INITIAL_STATE as SUPER_INITIAL_STATE } from './MessageDistributor/MessageDistributor';
import { Note } from 'App/services/NotesService';
const fps = 60;
const performance = window.performance || { now: Date.now.bind(Date) };
@ -46,6 +47,7 @@ export const INITIAL_STATE = {
live: false,
livePlay: false,
liveTimeTravel: false,
notes: [],
} as const;
@ -269,6 +271,15 @@ export default class Player extends MessageDistributor {
this.cursor.toggleUserName(name)
}
injectNotes(notes: Note[]) {
update({ notes })
}
filterOutNote(noteId: number) {
const { notes } = getState()
update({ notes: notes.filter((note: Note) => note.noteId !== noteId) })
}
clean() {
this.pause();
super.clean();

View file

@ -84,6 +84,8 @@ export const toggleAnnotation = initCheck((...args) => instance.assistManager.to
export const toggleTimetravel = initCheck((...args) => instance.toggleTimetravel(...args))
export const jumpToLive = initCheck((...args) => instance.jumpToLive(...args))
export const toggleUserName = initCheck((...args) => instance.toggleUserName(...args))
export const injectNotes = initCheck((...args) => instance.injectNotes(...args))
export const filterOutNote = initCheck((...args) => instance.filterOutNote(...args))
export const Controls = {
jump,

View file

@ -1,8 +1,18 @@
import APIClient from 'App/api_client';
export interface Note {
export const tagProps = {
'QUERY': '#3EAAAF',
'ISSUE': '#CC0000',
'TASK': '#7986CB',
'OTHER': 'rgba(0, 0, 0, 0.26)',
}
export type iTag = keyof typeof tagProps
export const TAGS = Object.keys(tagProps) as unknown as (keyof typeof tagProps)[]
export interface WriteNote {
message: string
tags: string[]
isPublic: boolean
@ -11,6 +21,19 @@ export interface Note {
author?: string
}
export interface Note {
createdAt: string
deletedAt: string | null
isPublic: boolean
message: string
noteId: number
projectId: number
sessionId: string
tags: iTag[]
timestamp: number
userId: number
}
export default class NotesService {
private client: APIClient;
@ -25,7 +48,7 @@ export default class NotesService {
getNotes(): Promise<Note[]> {
return this.client.get('/notes').then(r => {
if (r.ok) {
return r.json()
return r.json().then(r => r.data)
} else {
throw new Error('Error getting notes: ' + r.status)
}
@ -34,21 +57,45 @@ export default class NotesService {
getNotesBySessionId(sessionID: string): Promise<Note[]> {
return this.client.get(`/sessions/${sessionID}/notes`)
.then(r => r.json())
.then(r => {
if (r.ok) {
return r.json().then(r => r.data)
} else {
throw new Error('Error getting notes for ' +sessionID + ' cuz: ' + r.status)
}
})
}
addNote(sessionID: string, note: Note): Promise<Note> {
addNote(sessionID: string, note: WriteNote): Promise<Note> {
return this.client.post(`/sessions/${sessionID}/notes`, note)
.then(r => r.json())
.then(r => {
if (r.ok) {
return r.json().then(r => r.data)
} else {
throw new Error('Error adding note: ' + r.status)
}
})
}
updateNote(noteID: string, note: Note): Promise<Note> {
return this.client.post(`/notes/${noteID}`, note)
.then(r => r.json())
.then(r => {
if (r.ok) {
return r.json().then(r => r.data)
} else {
throw new Error('Error updating note: ' + r.status)
}
})
}
deleteNote(noteID: string) {
deleteNote(noteID: number) {
return this.client.delete(`/notes/${noteID}`)
.then(r => r.json())
.then(r => {
if (r.ok) {
return r.json().then(r => r.data)
} else {
throw new Error('Error deleting note: ' + r.status)
}
})
}
}

View file

@ -1,16 +1,13 @@
import Record from 'Types/Record';
import { List, Map } from 'immutable';
import { DateTime, Duration } from 'luxon';
import { Duration } from 'luxon';
import SessionEvent, { TYPES } from './event';
import Log from './log';
import StackEvent from './stackEvent';
import Resource from './resource';
import CustomField from './customField';
import SessionError from './error';
import Issue from './issue';
const SOURCE_JS = 'js_exception';
const HASH_MOD = 1610612741;
const HASH_P = 53;
function hashString(s: string): number {
@ -82,7 +79,9 @@ export default Record({
userSessionsCount: 0,
agentIds: [],
isCallActive: false,
agentToken: ''
agentToken: '',
notes: [],
notesWithEvents: [],
}, {
fromJS:({
startTs=0,
@ -93,9 +92,11 @@ export default Record({
errors,
stackEvents = [],
issues = [],
sessionId, sessionID,
sessionId,
sessionID,
domURL = [],
mobsUrl = [],
notes = [],
...session
}) => {
const duration = Duration.fromMillis(session.duration < 1000 ? 1000 : session.duration);
@ -130,6 +131,19 @@ export default Record({
const issuesList = List(issues)
.map(e => Issue({ ...e, time: e.timestamp - startedAt }))
const rawEvents = !session.events
? []
// @ts-ignore
: session.events.map(evt => ({ ...evt, time: evt.timestamp - startedAt })).filter(({ type, time }) => type !== TYPES.CONSOLE && time <= durationSeconds) || []
const rawNotes = notes
const notesWithEvents = [...rawEvents, ...rawNotes].sort((a, b) => {
const aTs = a.time || a.timestamp
const bTs = b.time || b.timestamp
return aTs - bTs
})
return {
...session,
isIOS: session.platform === "ios",
@ -154,6 +168,8 @@ export default Record({
userId: session.userId || session.userID,
domURL: Array.isArray(domURL) ? domURL : [ domURL ],
mobsUrl: Array.isArray(mobsUrl) ? mobsUrl : [ mobsUrl ],
notes,
notesWithEvents: List(notesWithEvents),
};
},
idKey: "sessionId",