diff --git a/frontend/app/components/Session_/BugReport/BugReportModal.tsx b/frontend/app/components/Session_/BugReport/BugReportModal.tsx index cb0610398..bd6baf1dc 100644 --- a/frontend/app/components/Session_/BugReport/BugReportModal.tsx +++ b/frontend/app/components/Session_/BugReport/BugReportModal.tsx @@ -11,6 +11,7 @@ import Title from './components/Title'; import Comments from './components/Comments'; import Steps from './components/Steps'; import { mapEvents } from './utils'; +import { fetchList as fetchMembers } from 'Duck/member'; interface Props { hideModal: () => void; @@ -25,9 +26,11 @@ interface Props { eventsList: Record[]; endTime: number; }; + fetchMembers: () => void + members: any; } -function BugReportModal({ hideModal, session, width, height, account, xrayProps }: Props) { +function BugReportModal({ hideModal, session, width, height, account, xrayProps, fetchMembers, members }: Props) { const reportRef = React.createRef(); const [isRendering, setRendering] = React.useState(false); @@ -45,10 +48,9 @@ function BugReportModal({ hideModal, session, width, height, account, xrayProps metadata, sessionId, events, + notes, } = session; - console.log(session.toJS()); - const envObject: EnvData = { Device: `${userDevice}${userDeviceType !== userDevice ? ` ${userDeviceType}` : ''}`, Resolution: `${width}x${height}`, @@ -76,9 +78,8 @@ function BugReportModal({ hideModal, session, width, height, account, xrayProps }, }; - console.log(bugReportStore) - React.useEffect(() => { + fetchMembers() bugReportStore.updateReportDefaults(defaults); bugReportStore.setDefaultSteps(mapEvents(events)); return () => bugReportStore.clearStore(); @@ -168,10 +169,10 @@ function BugReportModal({ hideModal, session, width, height, account, xrayProps className="bg-white overflow-y-scroll" style={{ maxWidth: '70vw', width: 620, height: '100vh' }} > -
+
<MetaInfo envObject={envObject} metadata={metadata} /> - <Steps xrayProps={xrayProps} /> + <Steps xrayProps={xrayProps} notes={notes} members={members} /> <Comments /> <Session user={userDisplayName} sessionId={sessionId} sessionUrl={sessionUrl} /> <div id="pdf-ignore" className="flex items-center gap-2 mt-4"> @@ -201,6 +202,8 @@ const WithUIState = connect((state) => ({ session: state.getIn(['sessions', 'current']), // @ts-ignore account: state.getIn(['user', 'account']), -}))(BugReportModal); + // @ts-ignore + members: state.getIn(['members', 'list']), +}), { fetchMembers })(BugReportModal); export default WithUIState; diff --git a/frontend/app/components/Session_/BugReport/components/Steps.tsx b/frontend/app/components/Session_/BugReport/components/Steps.tsx index d1b173e47..8fb17f518 100644 --- a/frontend/app/components/Session_/BugReport/components/Steps.tsx +++ b/frontend/app/components/Session_/BugReport/components/Steps.tsx @@ -5,8 +5,9 @@ import { RADIUS } from '../utils'; import SectionTitle from './SectionTitle'; import XRay from './StepsComponents/XRay'; import StepRenderer from './StepsComponents/StepRenderer'; -import StepRadius from './StepsComponents/StepRadius' -import SubModal from './StepsComponents/SubModal' +import StepRadius from './StepsComponents/StepRadius'; +import SubModal from './StepsComponents/SubModal'; +import { Note } from 'App/services/NotesService'; interface Props { xrayProps: { @@ -16,9 +17,11 @@ interface Props { eventsList: Record<string, any>[]; endTime: number; }; + notes: Note[]; + members: Record<string, any>[]; } -function Steps({ xrayProps }: Props) { +function Steps({ xrayProps, notes, members }: Props) { const { bugReportStore } = useStore(); const [stepPickRadius, setRadius] = React.useState(RADIUS); const [timePointer, setPointer] = React.useState(0); @@ -57,8 +60,11 @@ function Steps({ xrayProps }: Props) { <div className="flex items-center justify-between"> <div className="mt-4 mb-2 text-gray-dark flex items-center gap-4"> STEPS - - <div id="pdf-ignore">{timePointer > 0 ? <StepRadius pickRadius={stepPickRadius} setRadius={setRadius} /> : null}</div> + <div id="pdf-ignore"> + {timePointer > 0 ? ( + <StepRadius pickRadius={stepPickRadius} setRadius={setRadius} /> + ) : null} + </div> </div> <div className="text-blue cursor-pointer" id="pdf-ignore" onClick={handleStepsSelection}> {!shouldShowEventReset ? ( @@ -77,7 +83,12 @@ function Steps({ xrayProps }: Props) { } /> {bugReportStore.isSubStepModalOpen ? ( - <SubModal type={bugReportStore.subModalType} toggleModal={bugReportStore.toggleSubStepModal} xrayProps={xrayProps}/> + <SubModal + members={members} + type={bugReportStore.subModalType} + notes={notes} + xrayProps={xrayProps} + /> ) : null} </div> ); diff --git a/frontend/app/components/Session_/BugReport/components/StepsComponents/EventStep.tsx b/frontend/app/components/Session_/BugReport/components/StepsComponents/EventStep.tsx index 6d578c292..d5d47cfc3 100644 --- a/frontend/app/components/Session_/BugReport/components/StepsComponents/EventStep.tsx +++ b/frontend/app/components/Session_/BugReport/components/StepsComponents/EventStep.tsx @@ -1,60 +1,119 @@ import React from 'react'; import { Icon, ItemMenu } from 'UI'; +import { observer } from 'mobx-react-lite'; import { Step as IStep } from '../../types'; const STEP_NAMES = { CLICKRAGE: 'Multiple click', CLICK: 'Clicked', LOCATION: 'Visited' }; import { useStore } from 'App/mstore'; import cn from 'classnames'; import { Duration } from 'luxon'; +import { ErrorComp, NetworkComp, NoteComp } from './SubModalItems'; + +const SUBSTEP = { + network: NetworkComp, + note: NoteComp, + error: ErrorComp, +}; function Step({ step, ind, isDefault }: { step: IStep; ind: number; isDefault?: boolean }) { const { bugReportStore } = useStore(); const [menuOpen, setMenu] = React.useState(false); const menuItems = [ - { icon: 'quotes', text: 'Add Note', onClick: () => bugReportStore.toggleSubStepModal(true, 'note') }, - { icon: 'info-circle', text: `Add Error`, onClick: () => bugReportStore.toggleSubStepModal(true, 'error') }, - { icon: 'network', text: 'Add Fetch/XHR', onClick: () => bugReportStore.toggleSubStepModal(true, 'network') }, + { + icon: 'quotes', + text: 'Add/Remove Note', + onClick: () => bugReportStore.toggleSubStepModal(true, 'note', step.key), + }, + { + icon: 'info-circle', + text: `Add/Remove Error`, + onClick: () => bugReportStore.toggleSubStepModal(true, 'error', step.key), + }, + { + icon: 'network', + text: 'Add/Remove Network Request', + onClick: () => bugReportStore.toggleSubStepModal(true, 'network', step.key), + }, ]; return ( - <div - className={cn( - 'py-1 px-2 flex items-center gap-2 w-full rounded', - menuOpen - ? 'bg-figmaColors-secondary-outlined-hover-background' - : isDefault - ? '' - : 'hover:bg-figmaColors-secondary-outlined-hover-background group' - )} - > - <div className="rounded-3xl px-4 bg-gray-lightest">{ind + 1}</div> - <div className="flex items-center gap-2"> - {/* @ts-ignore */} - <Icon name={step.icon} size={16} color="gray-darkest" /> - <div className="px-2 text-disabled-text rounded bg-light-blue-bg"> - {Duration.fromMillis(step.time).toFormat('hh:mm:ss')} - </div> - {/* @ts-ignore */} - <div className="font-semibold">{STEP_NAMES[step.type]}</div> - <div className="text-gray-medium">{step.details}</div> - </div> + <div className="flex flex-col w-full"> <div - className={cn('group-hover:flex items-center ml-auto gap-4', menuOpen ? 'flex' : 'hidden')} + className={cn( + 'py-1 px-2 flex items-start gap-2 w-full rounded', + menuOpen + ? 'bg-figmaColors-secondary-outlined-hover-background' + : isDefault + ? '' + : 'hover:bg-figmaColors-secondary-outlined-hover-background group' + )} > - <ItemMenu - label={ - <Icon name="plus" size={16} className="cursor-pointer hover:fill-gray-darkest" /> - } - items={menuItems} - flat - onToggle={(isOpen) => setMenu(isOpen)} - /> - <div onClick={() => bugReportStore.removeStep(step)}> - <Icon name="trash" size={16} className="cursor-pointer hover:fill-gray-darkest" /> + <div className="rounded-3xl px-4 bg-gray-lightest relative z-10">{ind + 1}</div> + <div className="w-full"> + <div className="flex items-center w-full gap-2"> + {/* @ts-ignore */} + <Icon name={step.icon} size={16} color="gray-darkest" className="relative z-10"/> + <div className="px-2 text-disabled-text rounded bg-light-blue-bg"> + {Duration.fromMillis(step.time).toFormat('hh:mm:ss')} + </div> + {/* @ts-ignore */} + <div className="font-semibold">{STEP_NAMES[step.type]}</div> + <div className="text-gray-medium">{step.details}</div> + <div + className={cn( + 'group-hover:flex items-center ml-auto gap-4', + menuOpen ? 'flex' : 'hidden' + )} + > + <ItemMenu + label={ + <Icon name="plus" size={16} className="cursor-pointer hover:fill-gray-darkest" /> + } + items={menuItems} + flat + onToggle={(isOpen) => setMenu(isOpen)} + /> + <div onClick={() => bugReportStore.removeStep(step)}> + <Icon name="trash" size={16} className="cursor-pointer hover:fill-gray-darkest" /> + </div> + </div> + </div> + {step.substeps?.length ? ( + <div className="flex flex-col gap-2 w-full mt-2 relative"> + {step.substeps.map((subStep) => { + const Component = SUBSTEP[subStep.type]; + return ( + <div className="relative"> + <div + key={subStep.key} + className="rounded border py-1 px-2 w-full flex flex-col relative z-10" + style={{ background: subStep.type === 'note' ? '#FFFEF5' : 'white' }} + > + {/* @ts-ignore */} + <Component item={subStep} /> + </div> + <div + style={{ + borderBottom: '1px solid #DDDDDD', + borderLeft: '1px solid #DDDDDD', + borderBottomLeftRadius: 6, + position: 'absolute', + zIndex: 1, + left: -25, + bottom: 10, + height: '120%', + width: 50, + }} + /> + </div> + ); + })} + </div> + ) : null} </div> </div> </div> ); } -export default Step; +export default observer(Step); diff --git a/frontend/app/components/Session_/BugReport/components/StepsComponents/SubModal.tsx b/frontend/app/components/Session_/BugReport/components/StepsComponents/SubModal.tsx index 43a5620fe..5677e5f0f 100644 --- a/frontend/app/components/Session_/BugReport/components/StepsComponents/SubModal.tsx +++ b/frontend/app/components/Session_/BugReport/components/StepsComponents/SubModal.tsx @@ -1,20 +1,48 @@ import React from 'react'; import { Icon, Button } from 'UI'; -import cn from 'classnames'; +import { observer } from 'mobx-react-lite'; +import { Note } from 'App/services/NotesService'; +import { NoteItem, ErrorItem, NetworkReq, SubItem } from './SubModalItems'; +import { filterList, debounce } from 'App/utils'; +import { useStore } from 'App/mstore'; const Titles = { note: 'Note', network: 'Fetch/XHR', error: 'Console Error', }; +const Filters = { + note: 'note message or author', + network: 'url', + error: 'error name or message', +}; interface Props { type: 'note' | 'network' | 'error'; - toggleModal: (isOpen: boolean) => void; + items: SubItem[]; } +let debounceUpdate: any = () => {}; + +const SUB_ITEMS = { + note: NoteItem, + error: ErrorItem, + network: NetworkReq, +}; + function ModalContent(props: Props) { - const [selected, setSelected] = React.useState([]); + const [searchStr, setSearch] = React.useState(''); + const list = + searchStr !== '' + ? filterList(props.items, searchStr, ['url', 'name', 'title', 'message']) + : props.items; + + React.useEffect(() => { + debounceUpdate = debounce((val: string) => setSearch(val), 250); + }, []); + + const SubItem = SUB_ITEMS[props.type]; + return ( <div className="flex flex-col p-4 bg-white gap-4 w-full"> <div className="flex items-center gap-2"> @@ -22,25 +50,63 @@ function ModalContent(props: Props) { <Icon name="quotes" size={18} /> </div> <div className="text-2xl font-semibold">{`Select ${Titles[props.type]}`}</div> + <div className="ml-auto"> + <input + onChange={(e) => debounceUpdate(e.target.value)} + className="bg-white p-2 border border-borderColor-gray-light-shade rounded" + placeholder={`Filter by ${Filters[props.type]}`} + style={{ width: 250 }} + /> + </div> </div> <div - className="flex flex-col border rounded w-full" - style={{ background: props.type === 'note' ? '#FFFEF5' : 'white' }} + className="flex flex-col rounded -mx-4 px-4 py-2 bg-white" + style={{ height: '90vh', overflowY: 'scroll', maxWidth: '70vw', width: 620 }} > - <div className="p-2 border-b last:border-b-none w-full">item1</div> - <div className="p-2 border-b last:border-b-none w-full">item2</div> + {list.map((item) => ( + <React.Fragment key={item.key}> + {/* @ts-ignore */} + <SubItem item={item} /> + </React.Fragment> + ))} </div> - <div className="flex items-center gap-2"> - <Button disabled={selected.length === 0} variant="primary"> - Add Selected - </Button> - <Button variant="text-primary" onClick={() => props.toggleModal(false)}>Cancel</Button> - </div> + <ModalActionsObs /> </div> ); } +function ModalActions() { + const { bugReportStore } = useStore(); + + const removeModal = () => { + bugReportStore.toggleSubStepModal(false, bugReportStore.subModalType, undefined) + } + const saveChoice = () => { + bugReportStore.saveSubItems() + removeModal() + } + return ( + <div className="flex items-center gap-2"> + <Button + disabled={bugReportStore.pickedSubItems.size === 0} + variant="primary" + onClick={saveChoice} + > + Add Selected + </Button> + <Button + variant="text-primary" + onClick={removeModal} + > + Cancel + </Button> + </div> + ); +} + +const ModalActionsObs = observer(ModalActions); + interface ModalProps { xrayProps: { currentLocation: Record<string, any>[]; @@ -50,16 +116,56 @@ interface ModalProps { endTime: number; }; type: 'note' | 'network' | 'error'; - toggleModal: (isOpen: boolean) => void; + notes: Note[]; + members: Record<string, any>[]; } function SubModal(props: ModalProps) { + let items; + if (props.type === 'note') { + items = props.notes.map((note) => ({ + type: 'note' as const, + title: props.members.find((m) => m.id === note.userId)?.email || note.userId, + message: note.message, + time: 0, + key: note.noteId as unknown as string, + })); + } + if (props.type === 'error') { + items = props.xrayProps.exceptionsList.map((error) => ({ + type: 'error' as const, + time: error.time, + message: error.message, + name: error.name, + key: error.key, + })); + } + if (props.type === 'network') { + items = props.xrayProps.resourceList.map((fetch) => ({ + type: 'network' as const, + time: fetch.time, + url: fetch.url, + status: fetch.status, + success: fetch.success, + message: fetch.name, + key: fetch.key, + })); + } + return ( <div - className="bg-white overflow-y-scroll absolute" - style={{ maxWidth: '70vw', width: 620, height: '100vh', top: 0, right: 0, zIndex: 999 }} + className="bg-white absolute" + style={{ + maxWidth: '70vw', + overflow: 'hidden', + width: 620, + height: '100vh', + top: 0, + right: 0, + zIndex: 999, + }} > - <ModalContent type={props.type} toggleModal={props.toggleModal} /> + <ModalContent type={props.type} items={items} /> </div> ); } diff --git a/frontend/app/components/Session_/BugReport/components/StepsComponents/SubModalItems.tsx b/frontend/app/components/Session_/BugReport/components/StepsComponents/SubModalItems.tsx new file mode 100644 index 000000000..86b478250 --- /dev/null +++ b/frontend/app/components/Session_/BugReport/components/StepsComponents/SubModalItems.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { Checkbox } from 'UI'; +import { observer } from 'mobx-react-lite'; +import { useStore } from 'App/mstore'; + +interface Item { + time: number; + message: string; + type: 'note' | 'network' | 'error'; + key: string; +} + +export interface INoteItem extends Item { + title: string; +} + +export interface IError extends Item { + name?: string; +} + +export interface INetworkReq extends Item { + url: string; + status: string; + success: boolean; +} + +export type SubItem = INoteItem | IError | INetworkReq; + +const safeStr = (ogStr: string) => (ogStr.length > 60 ? ogStr.slice(0, 60) + '...' : ogStr); + +export const NetworkComp = ({ item }: { item: INetworkReq }) => ( + <div className="flex items-start flex-col z-10"> + <div className="flex items-center gap-2 text-disabled-text"> + <div>{item.time}</div> + <div>{safeStr(item.url)}</div> + </div> + <div className="flex items-center gap-2"> + <div className="px-1 bg-light-blue-bg rounded-xl font-mono">{item.status}</div> + <div className={item.success ? '' : 'text-red'}>{safeStr(item.message)}</div> + </div> + </div> +); + +export const NetworkReq = observer(({ item }: { item: INetworkReq }) => { + const { bugReportStore } = useStore(); + return ( + <SubModalItemContainer + isChecked={bugReportStore.isSubItemChecked(item)} + onChange={(isChecked) => bugReportStore.toggleSubItem(isChecked, item)} + > + <NetworkComp item={item} /> + </SubModalItemContainer> + ); +}); + +export const NoteComp = ({ item }: { item: INoteItem }) => ( + <div className="flex items-start flex-col z-10"> + <div className="font-semibold">{item.title}</div> + <div className="text-secondary">{item.message}</div> + </div> +); + +export const NoteItem = observer(({ item }: { item: INoteItem }) => { + const { bugReportStore } = useStore(); + return ( + <SubModalItemContainer + isChecked={bugReportStore.isSubItemChecked(item)} + onChange={(isChecked) => bugReportStore.toggleSubItem(isChecked, item)} + isNote + > + <NoteComp item={item} /> + </SubModalItemContainer> + ); +}); + +export const ErrorComp = ({ item }: { item: IError }) => ( + <div className="flex items-start flex-col z-10"> + <div className="text-disabled-text">{item.time}</div> + {item.name ? <div className="text-red">{item.name}</div> : null} + <div className="text-secondary">{safeStr(item.message)}</div> + </div> +); + +export const ErrorItem = observer(({ item }: { item: IError }) => { + const { bugReportStore } = useStore(); + return ( + <SubModalItemContainer + isChecked={bugReportStore.isSubItemChecked(item)} + onChange={(isChecked) => bugReportStore.toggleSubItem(isChecked, item)} + > + <ErrorComp item={item} /> + </SubModalItemContainer> + ); +}); + +export function SubModalItemContainer({ + children, + isChecked, + onChange, + isNote, +}: { + children: React.ReactNode; + isChecked: boolean; + onChange: (arg: any) => void; + isNote?: boolean; +}) { + return ( + <div + className="flex items-start p-2 gap-2 shadow-border-gray hover:shadow-border-main hover:bg-active-blue cursor-pointer" + style={{ background: isNote ? '#FFFEF5' : undefined }} + onClick={() => onChange(!isChecked)} + > + <Checkbox + name="isIncluded" + type="checkbox" + checked={isChecked} + onChange={(e: any) => onChange(e.target.checked)} + className="pt-1" + /> + {children} + </div> + ); +} diff --git a/frontend/app/components/Session_/BugReport/types.ts b/frontend/app/components/Session_/BugReport/types.ts index dbf37bf7e..2b3e3a8ad 100644 --- a/frontend/app/components/Session_/BugReport/types.ts +++ b/frontend/app/components/Session_/BugReport/types.ts @@ -1,4 +1,5 @@ import { SeverityLevels } from 'App/mstore/bugReportStore'; +import { SubItem, INoteItem, IError, INetworkReq } from './components/StepsComponents/SubModalItems'; export interface BugReportPdf extends ReportDefaults { title: string; @@ -6,9 +7,8 @@ export interface BugReportPdf extends ReportDefaults { severity: SeverityLevels; steps: Step[]; activity: { - network: NetworkError[]; - console: ConsoleError[]; - clickRage: ClickRage[]; + network: INetworkReq[]; + console: IError[]; }; } @@ -33,19 +33,6 @@ export interface EnvData { Resolution: string; } -export interface NetworkError { - time: number; -} - -export interface ConsoleError { - time: number; -} - -export interface ClickRage { - time: number; -} - -export type SubStep = Note | Error | Request; export interface Step { key: string; @@ -53,7 +40,7 @@ export interface Step { time: number; details: string; icon: string; - substeps?: SubStep[] + substeps?: SubItem[] } export interface Note { diff --git a/frontend/app/mstore/bugReportStore.ts b/frontend/app/mstore/bugReportStore.ts index 450a6fb38..1f6b20ceb 100644 --- a/frontend/app/mstore/bugReportStore.ts +++ b/frontend/app/mstore/bugReportStore.ts @@ -1,5 +1,6 @@ import { makeAutoObservable } from 'mobx'; import { BugReportPdf, ReportDefaults, Step } from 'Components/Session_/BugReport/types'; +import { SubItem } from 'App/components/Session_/BugReport/components/StepsComponents/SubModalItems'; export enum SeverityLevels { Low, @@ -20,6 +21,8 @@ export default class BugReportStore { sessionEventSteps: Step[] = []; chosenEventSteps: Step[] = []; subModalType: 'note' | 'network' | 'error'; + targetStep: string + pickedSubItems: Map<string, SubItem> = new Map() constructor() { makeAutoObservable(this); @@ -38,6 +41,8 @@ export default class BugReportStore { this.chosenEventSteps = []; this.subModalType = undefined; this.isSubStepModalOpen = false; + this.targetStep = undefined; + this.pickedSubItems = new Map(); } toggleTitleEdit(isEdit: boolean) { @@ -77,7 +82,8 @@ export default class BugReportStore { } setSteps(steps: Step[]) { - this.chosenEventSteps = steps; + this.chosenEventSteps = steps.map(step => ({ ...step, substeps: undefined })); + this.pickedSubItems = undefined; } removeStep(step: Step) { @@ -86,9 +92,34 @@ export default class BugReportStore { ); } - toggleSubStepModal(isOpen: boolean, type: 'note' | 'network' | 'error') { + toggleSubStepModal(isOpen: boolean, type: 'note' | 'network' | 'error', stepKey?: string) { this.isSubStepModalOpen = isOpen; this.subModalType = type; + this.targetStep = stepKey + } + + toggleSubItem(isAdded: boolean, item: SubItem) { + if (isAdded) { + this.pickedSubItems.set(item.key, item) + } else { + this.pickedSubItems.delete(item.key) + } + } + + isSubItemChecked(item: SubItem) { + return this.pickedSubItems.has(item.key) + } + + saveSubItems() { + const targetIndex = this.chosenEventSteps.findIndex(step => step.key === this.targetStep) + const eventStepsCopy = this.chosenEventSteps + const step = this.chosenEventSteps[targetIndex] + if (this.pickedSubItems.size > 0) { + step.substeps = Array.from(this.pickedSubItems, ([name, value]) => ({ ...value })); + } + eventStepsCopy[targetIndex] = step + + return this.chosenEventSteps = eventStepsCopy } resetSteps() {