diff --git a/frontend/app/components/BugFinder/AutoComplete/AutoComplete.js b/frontend/app/components/BugFinder/AutoComplete/AutoComplete.js index 832f49768..59ed9d9e9 100644 --- a/frontend/app/components/BugFinder/AutoComplete/AutoComplete.js +++ b/frontend/app/components/BugFinder/AutoComplete/AutoComplete.js @@ -82,7 +82,7 @@ class AutoComplete extends React.PureComponent { onInputChange = ({ target: { value } }) => { changed = true; this.setState({ query: value, updated: true }) - const _value = value.trim(); + const _value = value ? value.trim() : undefined; if (_value !== '' && _value !== ' ') { this.debouncedRequestValues(_value) } @@ -95,7 +95,7 @@ class AutoComplete extends React.PureComponent { value = pasted ? this.hiddenInput.value : value; const { onSelect, name } = this.props; if (value !== this.props.value) { - const _value = value.trim(); + const _value = value ? value.trim() : undefined; onSelect(null, {name, value: _value}); } diff --git a/frontend/app/components/Dashboard/Widgets/common/widgetHOC.js b/frontend/app/components/Dashboard/Widgets/common/widgetHOC.js index 5f91413f1..341d52245 100644 --- a/frontend/app/components/Dashboard/Widgets/common/widgetHOC.js +++ b/frontend/app/components/Dashboard/Widgets/common/widgetHOC.js @@ -8,101 +8,142 @@ import { WIDGET_MAP } from 'Types/dashboard'; import Title from './Title'; import stl from './widgetHOC.module.css'; -export default ( - widgetKey, - panelProps = {}, - wrapped = true, - allowedFilters = [], -) => BaseComponent => - @connect((state, props) => { - const compare = props && props.compare; - const key = compare ? '_' + widgetKey : widgetKey; +export default (widgetKey, panelProps = {}, wrapped = true, allowedFilters = []) => + (BaseComponent) => { + @connect( + (state, props) => { + const compare = props && props.compare; + const key = compare ? '_' + widgetKey : widgetKey; - return { - loading: state.getIn([ 'dashboard', 'fetchWidget', key, 'loading' ]), - data: state.getIn([ 'dashboard', key ]), - comparing: state.getIn([ 'dashboard', 'comparing' ]), - filtersSize: state.getIn([ 'dashboard', 'filters' ]).size, - filters: state.getIn([ 'dashboard', compare ? 'filtersCompare' : 'filters' ]), - period: state.getIn([ 'dashboard', compare ? 'periodCompare' : 'period' ]), //TODO: filters - platform: state.getIn([ 'dashboard', 'platform' ]), - // appearance: state.getIn([ 'user', 'account', 'appearance' ]), + return { + loading: state.getIn(['dashboard', 'fetchWidget', key, 'loading']), + data: state.getIn(['dashboard', key]), + comparing: state.getIn(['dashboard', 'comparing']), + filtersSize: state.getIn(['dashboard', 'filters']).size, + filters: state.getIn(['dashboard', compare ? 'filtersCompare' : 'filters']), + period: state.getIn(['dashboard', compare ? 'periodCompare' : 'period']), //TODO: filters + platform: state.getIn(['dashboard', 'platform']), + // appearance: state.getIn([ 'user', 'account', 'appearance' ]), - dataCompare: state.getIn([ 'dashboard', '_' + widgetKey ]), // only for overview - loadingCompare: state.getIn([ 'dashboard', 'fetchWidget', '_' + widgetKey, 'loading' ]), - filtersCompare: state.getIn([ 'dashboard', 'filtersCompare' ]), - periodCompare: state.getIn([ 'dashboard', 'periodCompare' ]), //TODO: filters - } - }, { - fetchWidget, - // updateAppearance, - }) - class WidgetWrapper extends React.PureComponent { - constructor(props) { - super(props); - const params = panelProps.customParams ? panelProps.customParams(this.props.period.rangeName) : {}; - if(props.testId) { - params.testId = parseInt(props.testId); - } - params.compare = this.props.compare; - const filters = allowedFilters.length > 0 ? props.filters.filter(f => allowedFilters.includes(f.key)) : props.filters; - props.fetchWidget(widgetKey, props.period, props.platform, params, filters); - } + dataCompare: state.getIn(['dashboard', '_' + widgetKey]), // only for overview + loadingCompare: state.getIn(['dashboard', 'fetchWidget', '_' + widgetKey, 'loading']), + filtersCompare: state.getIn(['dashboard', 'filtersCompare']), + periodCompare: state.getIn(['dashboard', 'periodCompare']), //TODO: filters + }; + }, + { + fetchWidget, + // updateAppearance, + } + ) + class WidgetWrapper extends React.PureComponent { + constructor(props) { + super(props); + const params = panelProps.customParams + ? panelProps.customParams(this.props.period.rangeName) + : {}; + if (props.testId) { + params.testId = parseInt(props.testId); + } + params.compare = this.props.compare; + const filters = + allowedFilters.length > 0 + ? props.filters.filter((f) => allowedFilters.includes(f.key)) + : props.filters; + props.fetchWidget(widgetKey, props.period, props.platform, params, filters); + } - componentDidUpdate(prevProps) { - if (prevProps.period !== this.props.period || - prevProps.platform !== this.props.platform || - prevProps.filters.size !== this.props.filters.size) { - const params = panelProps.customParams ? panelProps.customParams(this.props.period.rangeName) : {}; - if(this.props.testId) { - params.testId = parseInt(this.props.testId); - } - params.compare = this.props.compare; - const filters = allowedFilters.length > 0 ? this.props.filters.filter(f => allowedFilters.includes(f.key)) : this.props.filters; - this.props.fetchWidget(widgetKey, this.props.period, this.props.platform, params, filters); - } + componentDidUpdate(prevProps) { + if ( + prevProps.period !== this.props.period || + prevProps.platform !== this.props.platform || + prevProps.filters.size !== this.props.filters.size + ) { + const params = panelProps.customParams + ? panelProps.customParams(this.props.period.rangeName) + : {}; + if (this.props.testId) { + params.testId = parseInt(this.props.testId); + } + params.compare = this.props.compare; + const filters = + allowedFilters.length > 0 + ? this.props.filters.filter((f) => allowedFilters.includes(f.key)) + : this.props.filters; + this.props.fetchWidget( + widgetKey, + this.props.period, + this.props.platform, + params, + filters + ); + } - // handling overview widgets - if ((!prevProps.comparing || prevProps.periodCompare !== this.props.periodCompare || prevProps.filtersCompare.size !== this.props.filtersCompare.size) && - this.props.comparing && this.props.isOverview - ) { - const params = panelProps.customParams ? panelProps.customParams(this.props.period.rangeName) : {}; - params.compare = true; - const filtersCompare = allowedFilters.length > 0 ? this.props.filtersCompare.filter(f => allowedFilters.includes(f.key)) : this.props.filtersCompare; - this.props.fetchWidget(widgetKey, this.props.periodCompare, this.props.platform, params, filtersCompare); - } - } + // handling overview widgets + if ( + (!prevProps.comparing || + prevProps.periodCompare !== this.props.periodCompare || + prevProps.filtersCompare.size !== this.props.filtersCompare.size) && + this.props.comparing && + this.props.isOverview + ) { + const params = panelProps.customParams + ? panelProps.customParams(this.props.period.rangeName) + : {}; + params.compare = true; + const filtersCompare = + allowedFilters.length > 0 + ? this.props.filtersCompare.filter((f) => allowedFilters.includes(f.key)) + : this.props.filtersCompare; + this.props.fetchWidget( + widgetKey, + this.props.periodCompare, + this.props.platform, + params, + filtersCompare + ); + } + } - handleRemove = () => { - // const { appearance } = this.props; - // this.props.updateAppearance(appearance.setIn([ 'dashboard', widgetKey ], false)); - } + handleRemove = () => { + // const { appearance } = this.props; + // this.props.updateAppearance(appearance.setIn([ 'dashboard', widgetKey ], false)); + }; - render() { - const { comparing, compare } = this.props; + render() { + const { comparing, compare } = this.props; - return ( - wrapped ? -
-
-
- {comparing &&
} - - { <CloseButton className={ cn(stl.closeButton, 'ml-auto') } onClick={ this.handleRemove } size="17" /> } - </div> - <div className="flex-1 flex flex-col"> - <BaseComponent { ...this.props } /> - </div> - </div> - </div> - : - <BaseComponent { ...this.props } /> - ) - } - } \ No newline at end of file + return wrapped ? ( + <div className={cn(stl.wrapper, { [stl.comparing]: comparing })}> + <div + className={cn(stl.panel, 'flex flex-col relative', { + [stl.fullwidth]: panelProps.fullwidth, + [stl.fitContent]: panelProps.fitContent, + [stl.minHeight]: !panelProps.fitContent, + })} + > + <div className="flex items-center mb-2"> + {comparing && ( + <div className={cn(stl.circle, { 'bg-tealx': !compare, 'bg-teal': compare })} /> + )} + <Title title={panelProps.name ? panelProps.name : WIDGET_MAP[widgetKey].name} /> + { + <CloseButton + className={cn(stl.closeButton, 'ml-auto')} + onClick={this.handleRemove} + size="17" + /> + } + </div> + <div className="flex-1 flex flex-col"> + <BaseComponent {...this.props} /> + </div> + </div> + </div> + ) : ( + <BaseComponent {...this.props} /> + ); + } + } + return WidgetWrapper; + }; diff --git a/frontend/app/components/Dashboard/components/FilterSeries/SeriesName/SeriesName.tsx b/frontend/app/components/Dashboard/components/FilterSeries/SeriesName/SeriesName.tsx index 5d25e9de9..d6a69c73d 100644 --- a/frontend/app/components/Dashboard/components/FilterSeries/SeriesName/SeriesName.tsx +++ b/frontend/app/components/Dashboard/components/FilterSeries/SeriesName/SeriesName.tsx @@ -46,7 +46,7 @@ function SeriesName(props: Props) { onFocus={() => setEditing(true)} /> ) : ( - <div className="text-base h-8 flex items-center border-transparent">{name.trim() === '' ? 'Seriess ' + (seriesIndex + 1) : name }</div> + <div className="text-base h-8 flex items-center border-transparent">{name && name.trim() === '' ? 'Series ' + (seriesIndex + 1) : name }</div> )} <div className="ml-3 cursor-pointer" onClick={() => setEditing(true)}><Icon name="pencil" size="14" /></div> diff --git a/frontend/app/components/Dashboard/components/WidgetName/WidgetName.tsx b/frontend/app/components/Dashboard/components/WidgetName/WidgetName.tsx index d0d673df2..998aaece8 100644 --- a/frontend/app/components/Dashboard/components/WidgetName/WidgetName.tsx +++ b/frontend/app/components/Dashboard/components/WidgetName/WidgetName.tsx @@ -22,7 +22,7 @@ function WidgetName(props: Props) { const onBlur = (nameInput?: string) => { setEditing(false) const toUpdate = nameInput || name - props.onUpdate(toUpdate.trim() === '' ? 'New Widget' : toUpdate) + props.onUpdate(toUpdate && toUpdate.trim() === '' ? 'New Widget' : toUpdate) } useEffect(() => { diff --git a/frontend/app/components/Funnels/FunnelSaveModal/FunnelSaveModal.js b/frontend/app/components/Funnels/FunnelSaveModal/FunnelSaveModal.js index ca35d401d..e0d43d1d7 100644 --- a/frontend/app/components/Funnels/FunnelSaveModal/FunnelSaveModal.js +++ b/frontend/app/components/Funnels/FunnelSaveModal/FunnelSaveModal.js @@ -4,12 +4,16 @@ import { Button, Modal, Form, Icon, Checkbox, Input } from 'UI'; import styles from './funnelSaveModal.module.css'; import { edit, save, fetchList as fetchFunnelsList } from 'Duck/funnels'; -@connect(state => ({ - filter: state.getIn(['search', 'instance']), - funnel: state.getIn(['funnels', 'instance']), - loading: state.getIn([ 'funnels', 'saveRequest', 'loading' ]) || - state.getIn([ 'funnels', 'updateRequest', 'loading' ]), -}), { edit, save, fetchFunnelsList }) +@connect( + (state) => ({ + filter: state.getIn(['search', 'instance']), + funnel: state.getIn(['funnels', 'instance']), + loading: + state.getIn(['funnels', 'saveRequest', 'loading']) || + state.getIn(['funnels', 'updateRequest', 'loading']), + }), + { edit, save, fetchFunnelsList } +) export default class FunnelSaveModal extends React.PureComponent { state = { name: 'Untitled', isPublic: false }; static getDerivedStateFromProps(props) { @@ -26,36 +30,33 @@ export default class FunnelSaveModal extends React.PureComponent { this.props.edit({ name: value }); }; - onChangeOption = (e, { checked, name }) => this.props.edit({ [ name ]: checked }) + onChangeOption = (e, { checked, name }) => this.props.edit({ [name]: checked }); onSave = () => { const { funnel, filter } = this.props; - if (funnel.name.trim() === '') return; - this.props.save(funnel).then(function() { - this.props.fetchFunnelsList(); - this.props.closeHandler(); - }.bind(this)); - } + if (funnel.name && funnel.name.trim() === '') return; + this.props.save(funnel).then( + function () { + this.props.fetchFunnelsList(); + this.props.closeHandler(); + }.bind(this) + ); + }; render() { - const { - show, - closeHandler, - loading, - funnel - } = this.props; - + const { show, closeHandler, loading, funnel } = this.props; + return ( - <Modal size="small" open={ show } onClose={this.props.closeHandler}> - <Modal.Header className={ styles.modalHeader }> - <div>{ 'Save Funnel' }</div> - <Icon + <Modal size="small" open={show} onClose={this.props.closeHandler}> + <Modal.Header className={styles.modalHeader}> + <div>{'Save Funnel'}</div> + <Icon role="button" tabIndex="-1" color="gray-dark" size="14" name="close" - onClick={ closeHandler } + onClick={closeHandler} /> </Modal.Header> @@ -64,11 +65,11 @@ export default class FunnelSaveModal extends React.PureComponent { <Form.Field> <label>{'Title:'}</label> <Input - autoFocus={ true } - className={ styles.name } + autoFocus={true} + className={styles.name} name="name" - value={ funnel.name } - onChange={ this.onNameChange } + value={funnel.name} + onChange={this.onNameChange} placeholder="Title" /> </Form.Field> @@ -79,11 +80,14 @@ export default class FunnelSaveModal extends React.PureComponent { name="isPublic" className="font-medium" type="checkbox" - checked={ funnel.isPublic } - onClick={ this.onChangeOption } - className="mr-3" + checked={funnel.isPublic} + onClick={this.onChangeOption} + className="mr-3" /> - <div className="flex items-center cursor-pointer" onClick={ () => this.props.edit({ 'isPublic' : !funnel.isPublic }) }> + <div + className="flex items-center cursor-pointer" + onClick={() => this.props.edit({ isPublic: !funnel.isPublic })} + > <Icon name="user-friends" size="16" /> <span className="ml-2"> Team Visible</span> </div> @@ -91,16 +95,16 @@ export default class FunnelSaveModal extends React.PureComponent { </Form.Field> </Form> </Modal.Content> - <Modal.Footer className=""> + <Modal.Footer className=""> <Button variant="primary" - onClick={ this.onSave } - loading={ loading } + onClick={this.onSave} + loading={loading} className="float-left mr-2" > - { funnel.exists() ? 'Modify' : 'Save' } + {funnel.exists() ? 'Modify' : 'Save'} </Button> - <Button onClick={ closeHandler }>{ 'Cancel' }</Button> + <Button onClick={closeHandler}>{'Cancel'}</Button> </Modal.Footer> </Modal> ); diff --git a/frontend/app/components/Session/IOSPlayer/Network.js b/frontend/app/components/Session/IOSPlayer/Network.js index ab42a61fa..3956b7031 100644 --- a/frontend/app/components/Session/IOSPlayer/Network.js +++ b/frontend/app/components/Session/IOSPlayer/Network.js @@ -10,82 +10,85 @@ import TimeTable from 'Components/Session_/TimeTable'; import FetchDetails from 'Components/Session_/Fetch/FetchDetails'; const COLUMNS = [ - { - label: "Status", - dataKey: 'status', - width: 70, - }, { - label: "Method", - dataKey: 'method', - width: 60, - }, { - label: "url", - width: 130, - render: (r) => - <Popup - content={ <div className={ cls.popupNameContent }>{ r.url }</div> } - size="mini" - position="right center" - > - <div className={ cls.popupNameTrigger }>{ r.url }</div> - </Popup> - }, - { - label: "Size", - width: 60, - render: (r) => `${r.body.length}`, - }, - { - label: "Time", - width: 80, - render: (r) => `${r.duration}ms`, - } + { + label: 'Status', + dataKey: 'status', + width: 70, + }, + { + label: 'Method', + dataKey: 'method', + width: 60, + }, + { + label: 'url', + width: 130, + render: (r) => ( + <Popup + content={<div className={cls.popupNameContent}>{r.url}</div>} + size="mini" + position="right center" + > + <div className={cls.popupNameTrigger}>{r.url}</div> + </Popup> + ), + }, + { + label: 'Size', + width: 60, + render: (r) => `${r.body.length}`, + }, + { + label: 'Time', + width: 80, + render: (r) => `${r.duration}ms`, + }, ]; - - function Network({ player }) { - const [ current, setCurrent ] = useState(null); - const [ currentIndex, setCurrentIndex ] = useState(0); - const onRowClick = useCallback((raw, index) => { - setCurrent(raw); - setCurrentIndex(index); - }); - const onNextClick = useCallback(() => { - onRowClick(player.lists[NETWORK].list[currentIndex+1], currentIndex+1) - }); - const onPrevClick = useCallback(() => { - onRowClick(player.lists[NETWORK].list[currentIndex-1], currentIndex-1) - }); - const closeModal = useCallback(() => setCurrent(null)); // TODO: handle in modal + const [current, setCurrent] = useState(null); + const [currentIndex, setCurrentIndex] = useState(0); + const onRowClick = useCallback((raw, index) => { + setCurrent(raw); + setCurrentIndex(index); + }); + const onNextClick = useCallback(() => { + onRowClick(player.lists[NETWORK].list[currentIndex + 1], currentIndex + 1); + }); + const onPrevClick = useCallback(() => { + onRowClick(player.lists[NETWORK].list[currentIndex - 1], currentIndex - 1); + }); + const closeModal = useCallback(() => setCurrent(null)); // TODO: handle in modal - return ( - <> - <SlideModal + return ( + <> + <SlideModal size="middle" title="Network Request" - isDisplayed={ current != null } - content={ current && - <FetchDetails - resource={ current } - nextClick={ onNextClick } - prevClick={ onPrevClick } - first={ currentIndex === 0 } - last={ currentIndex === player.lists[NETWORK].countNow - 1 } - /> + isDisplayed={current != null} + content={ + current && ( + <FetchDetails + resource={current} + nextClick={onNextClick} + prevClick={onPrevClick} + first={currentIndex === 0} + last={currentIndex === player.lists[NETWORK].countNow - 1} + /> + ) } - onClose={ closeModal } + onClose={closeModal} /> - <TimeTable - rows={ player.lists[NETWORK].listNow } - hoverable - tableHeight={270} - onRowClick={ onRowClick } - > - { COLUMNS } - </TimeTable> - </> - ); + <TimeTable + rows={player.lists[NETWORK].listNow} + hoverable + tableHeight={270} + onRowClick={onRowClick} + > + {COLUMNS} + </TimeTable> + </> + ); } -export default observer(Network); \ No newline at end of file +export default observer(Network); diff --git a/frontend/app/components/Session/IOSPlayer/StackEvents.js b/frontend/app/components/Session/IOSPlayer/StackEvents.js index 92470b358..f1abef414 100644 --- a/frontend/app/components/Session/IOSPlayer/StackEvents.js +++ b/frontend/app/components/Session/IOSPlayer/StackEvents.js @@ -1,11 +1,6 @@ import { observer } from 'mobx-react-lite'; -import { CUSTOM } from 'Player/ios/state'; +import { CUSTOM } from 'Player/ios/state'; import StackEvents from '../Layout/ToolPanel/StackEvents'; - -export default observer(({ player }) => - <StackEvents - stackEvents={ player.lists[CUSTOM].listNow } - /> -); \ No newline at end of file +export default observer(({ player }) => <StackEvents stackEvents={player.lists[CUSTOM].listNow} />); diff --git a/frontend/app/components/Session_/Autoscroll.js b/frontend/app/components/Session_/Autoscroll.js deleted file mode 100644 index 02af15417..000000000 --- a/frontend/app/components/Session_/Autoscroll.js +++ /dev/null @@ -1,85 +0,0 @@ -import React from 'react'; -import { IconButton } from 'UI'; -import cn from 'classnames'; -import stl from './autoscroll.module.css'; - -export default class Autoscroll extends React.PureComponent { - static defaultProps = { - bottomOffset: 10, - }; - state = { - autoScroll: true, - }; - - componentDidMount() { - if (!this.scrollableElement) return; // is necessary ? - this.scrollableElement.addEventListener('scroll', this.scrollHandler); - this.scrollableElement.scrollTop = this.scrollableElement.scrollHeight; - } - - componentDidUpdate() { - if (!this.scrollableElement) return; // is necessary ? - if (this.state.autoScroll) { - this.scrollableElement.scrollTop = this.scrollableElement.scrollHeight; - } - } - - scrollHandler = (e) => { - if (!this.scrollableElement) return; - this.setState({ - autoScroll: - this.scrollableElement.scrollHeight - this.scrollableElement.clientHeight - this.scrollableElement.scrollTop < - this.props.bottomOffset, - }); - }; - - onPrevClick = () => { - if (!this.scrollableElement) return; - const scEl = this.scrollableElement; - let prevItem; - for (let i = scEl.children.length - 1; i >= 0; i--) { - const child = scEl.children[i]; - const isScrollable = child.getAttribute('data-scroll-item') === 'true'; - if (isScrollable && child.offsetTop < scEl.scrollTop) { - prevItem = child; - break; - } - } - if (!prevItem) return; - scEl.scrollTop = prevItem.offsetTop; - }; - - onNextClick = () => { - if (!this.scrollableElement) return; - const scEl = this.scrollableElement; - let nextItem; - for (let i = 0; i < scEl.children.length; i++) { - const child = scEl.children[i]; - const isScrollable = child.getAttribute('data-scroll-item') === 'true'; - if (isScrollable && child.offsetTop > scEl.scrollTop + 20) { - // ? - nextItem = child; - break; - } - } - if (!nextItem) return; - scEl.scrollTop = nextItem.offsetTop; - }; - - render() { - const { className, navigation = false, children, ...props } = this.props; - return ( - <div className={cn('relative w-full h-full', stl.wrapper)}> - <div {...props} className={cn('relative scroll-y h-full', className)} ref={(ref) => (this.scrollableElement = ref)}> - {children} - </div> - {navigation && ( - <div className={stl.navButtons}> - <IconButton size="small" icon="chevron-up" onClick={this.onPrevClick} /> - <IconButton size="small" icon="chevron-down" onClick={this.onNextClick} className="mt-5" /> - </div> - )} - </div> - ); - } -} diff --git a/frontend/app/components/Session_/Autoscroll.tsx b/frontend/app/components/Session_/Autoscroll.tsx new file mode 100644 index 000000000..305b12dad --- /dev/null +++ b/frontend/app/components/Session_/Autoscroll.tsx @@ -0,0 +1,128 @@ +import React, { ReactNode } from 'react'; +import { IconButton } from 'UI'; +import cn from 'classnames'; +import stl from './autoscroll.module.css'; + +interface Props { + autoScrollTo?: number + children: ReactNode[] + className?: string + navigation?: boolean +} + +export default class Autoscroll extends React.PureComponent<Props, { + autoScroll: boolean + currentIndex?: number +}> { + state = { + autoScroll: true, + currentIndex: 0, + }; + scrollableElement = React.createRef<HTMLDivElement>() + + autoScroll(hard = false) { + if (this.props.autoScrollTo !== undefined && this.props.autoScrollTo !== null && this.props.autoScrollTo >= 0) { + // we have an element to scroll to + this.scrollToElement(this.props.autoScrollTo, hard) + } else if (this.scrollableElement.current) { + // no element to scroll to, scroll to bottom + this.scrollableElement.current.scrollTop = this.scrollableElement.current.scrollHeight; + } + } + + scrollToElement(elementIndex: number, hard = false) { + if (!this.scrollableElement.current) { + return; + } + + if (this.scrollableElement.current.children.length < elementIndex || elementIndex < 0) { + return; + } + + const element = this.scrollableElement.current.children[elementIndex] as (HTMLElement | undefined) + + if (element) { + if (this.scrollableElement.current.scrollTo && !hard) { + this.scrollableElement.current.scrollTo({ + left: 0, + top: element.offsetTop, + behavior: 'smooth' + }) + } else { + this.scrollableElement.current.scrollTop = element.offsetTop; + } + } + } + + componentDidMount() { + if (!this.scrollableElement.current) return; // is necessary ? + + this.scrollableElement.current.addEventListener('scroll', this.scrollHandler); + if (this.state.autoScroll) { + this.setState({ + currentIndex: this.props.autoScrollTo + }) + this.autoScroll(true) + } + } + + componentDidUpdate(nextProps: Props) { + if (!this.scrollableElement) return; // is necessary ? + + if (this.state.autoScroll) { + this.setState({ + currentIndex: this.props.autoScrollTo + }) + this.autoScroll() + } + } + + scrollHandler = (e) => { + if (!this.scrollableElement) return; + }; + + // TODO: Maybe make this handlers that allow the parent element to set a new autoscroll index + onPrevClick = () => { + if (!this.scrollableElement) return; + + const newIndex = Math.max(this.state.currentIndex - 1, 0) + this.setState({ + autoScroll: false, + currentIndex: newIndex + }) + this.scrollToElement(newIndex) + }; + + onNextClick = () => { + if (!this.scrollableElement) return; + + const newIndex = Math.min(this.state.currentIndex + 1, this.props.children.length - 1) + this.setState({ + autoScroll: false, + currentIndex: newIndex + }) + this.scrollToElement(newIndex) + }; + + render() { + const { className, navigation = false, children, ...props } = this.props; + return ( + <div className={cn('relative w-full h-full', stl.wrapper)}> + <div {...props} className={cn('relative scroll-y h-full', className)} ref={this.scrollableElement}> + {children} + </div> + + <div className={stl.navButtons}> + <label><input type={'checkbox'} checked={this.state.autoScroll} onChange={(e) => this.setState({ autoScroll: !this.state.autoScroll })} /> Autoscroll</label> + {navigation && ( + <> + <IconButton size="small" icon="chevron-up" onClick={this.onPrevClick} /> + <IconButton size="small" icon="chevron-down" onClick={this.onNextClick} className="mt-5" /> + </> + )} + </div> + + </div> + ); + } +} diff --git a/frontend/app/components/Session_/BottomBlock/Header.js b/frontend/app/components/Session_/BottomBlock/Header.js index 976456332..15dd7a0c9 100644 --- a/frontend/app/components/Session_/BottomBlock/Header.js +++ b/frontend/app/components/Session_/BottomBlock/Header.js @@ -13,7 +13,7 @@ const Header = ({ showClose = true, ...props }) => ( - <div className={ cn("relative border-r border-l", stl.header) } > + <div className={ cn("relative border-r border-l py-1", stl.header) } > <div className={ cn("w-full h-full flex justify-between items-center", className) } > <div className="w-full flex items-center justify-between">{ children }</div> { showClose && <CloseButton onClick={ closeBottomBlock } size="18" className="ml-2" /> } diff --git a/frontend/app/components/Session_/BottomBlock/InfoLine.js b/frontend/app/components/Session_/BottomBlock/InfoLine.js index 8872be906..d4607a887 100644 --- a/frontend/app/components/Session_/BottomBlock/InfoLine.js +++ b/frontend/app/components/Session_/BottomBlock/InfoLine.js @@ -11,7 +11,7 @@ const InfoLine = ({ children }) => ( const Point = ({ label, value, display=true, color, dotColor }) => display ? <div className={ cls.infoPoint } style={{ color }}> { dotColor != null && <div className={ cn(cls.dot, `bg-${dotColor}`) } /> } - <span className={cls.label}>{ `${label}:` }</span> { value } + <span className={cls.label}>{ `${label}` }</span> { value } </div> : null; diff --git a/frontend/app/components/Session_/BottomBlock/bottomBlock.module.css b/frontend/app/components/Session_/BottomBlock/bottomBlock.module.css index 41cf7e5e1..cda4ec372 100644 --- a/frontend/app/components/Session_/BottomBlock/bottomBlock.module.css +++ b/frontend/app/components/Session_/BottomBlock/bottomBlock.module.css @@ -4,6 +4,4 @@ /* padding-right: 10px; */ /* border: solid thin $gray-light; */ height: 300px; - padding-top: 2px; - border-top: thin dashed #cccccc } diff --git a/frontend/app/components/Session_/BottomBlock/infoLine.module.css b/frontend/app/components/Session_/BottomBlock/infoLine.module.css index d03a53439..b6798d1bf 100644 --- a/frontend/app/components/Session_/BottomBlock/infoLine.module.css +++ b/frontend/app/components/Session_/BottomBlock/infoLine.module.css @@ -11,13 +11,13 @@ align-items: center; &:not(:last-child):after { content: ''; - margin: 0 10px; + margin: 0 12px; height: 30px; border-right: 1px solid $gray-light-shade; } & .label { font-weight: 500; - margin-right: 3px; + margin-right: 6px; } } } diff --git a/frontend/app/components/Session_/Console/Console.js b/frontend/app/components/Session_/Console/Console.js index 5534439fb..3c4a3752c 100644 --- a/frontend/app/components/Session_/Console/Console.js +++ b/frontend/app/components/Session_/Console/Console.js @@ -12,7 +12,7 @@ export default class Console extends React.PureComponent { render() { const { logs, time, listNow } = this.props; return ( - <ConsoleContent jump={!this.props.livePlay && jump} logs={logs} lastIndex={listNow.length - 1} /> + <ConsoleContent jump={!this.props.livePlay && jump} logs={logs} lastIndex={listNow.length - 1} logsNow={listNow} /> ); } } diff --git a/frontend/app/components/Session_/Console/ConsoleContent.js b/frontend/app/components/Session_/Console/ConsoleContent.js index 29820de2e..012582849 100644 --- a/frontend/app/components/Session_/Console/ConsoleContent.js +++ b/frontend/app/components/Session_/Console/ConsoleContent.js @@ -7,6 +7,7 @@ import { LEVEL } from 'Types/session/log'; import Autoscroll from '../Autoscroll'; import BottomBlock from '../BottomBlock'; import stl from './console.module.css'; +import { Duration } from 'luxon'; const ALL = 'ALL'; const INFO = 'INFO'; @@ -14,108 +15,113 @@ const WARNINGS = 'WARNINGS'; const ERRORS = 'ERRORS'; const LEVEL_TAB = { - [LEVEL.INFO]: INFO, - [LEVEL.LOG]: INFO, - [LEVEL.WARNING]: WARNINGS, - [LEVEL.ERROR]: ERRORS, - [LEVEL.EXCEPTION]: ERRORS, + [LEVEL.INFO]: INFO, + [LEVEL.LOG]: INFO, + [LEVEL.WARNING]: WARNINGS, + [LEVEL.ERROR]: ERRORS, + [LEVEL.EXCEPTION]: ERRORS, }; const TABS = [ALL, ERRORS, WARNINGS, INFO].map((tab) => ({ text: tab, key: tab })); // eslint-disable-next-line complexity const getIconProps = (level) => { - switch (level) { - case LEVEL.INFO: - case LEVEL.LOG: - return { - name: 'console/info', - color: 'blue2', - }; - case LEVEL.WARN: - case LEVEL.WARNING: - return { - name: 'console/warning', - color: 'red2', - }; - case LEVEL.ERROR: - return { - name: 'console/error', - color: 'red', - }; - } - return null; + switch (level) { + case LEVEL.INFO: + case LEVEL.LOG: + return { + name: 'console/info', + color: 'blue2', + }; + case LEVEL.WARN: + case LEVEL.WARNING: + return { + name: 'console/warning', + color: 'red2', + }; + case LEVEL.ERROR: + return { + name: 'console/error', + color: 'red', + }; + } + return null; }; function renderWithNL(s = '') { - if (typeof s !== 'string') return ''; - return s.split('\n').map((line, i) => <div className={cn({ 'ml-20': i !== 0 })}>{line}</div>); + if (typeof s !== 'string') return ''; + return s.split('\n').map((line, i) => <div className={cn({ 'ml-20': i !== 0 })}>{line}</div>); } export default class ConsoleContent extends React.PureComponent { - state = { - filter: '', - activeTab: ALL, - }; - onTabClick = (activeTab) => this.setState({ activeTab }); - onFilterChange = ({ target: { value } }) => this.setState({ filter: value }); + state = { + filter: '', + activeTab: ALL, + }; + onTabClick = (activeTab) => this.setState({ activeTab }); + onFilterChange = ({ target: { value } }) => this.setState({ filter: value }); - render() { - const { logs, isResult, additionalHeight, lastIndex } = this.props; - const { filter, activeTab } = this.state; - const filterRE = getRE(filter, 'i'); - const filtered = logs.filter(({ level, value }) => - activeTab === ALL ? filterRE.test(value) : filterRE.test(value) && LEVEL_TAB[level] === activeTab - ); + render() { + const { logs, isResult, additionalHeight, logsNow } = this.props; + const time = logsNow.length > 0 ? logsNow[logsNow.length - 1].time : undefined; + const { filter, activeTab, currentError } = this.state; + const filterRE = getRE(filter, 'i'); + const filtered = logs.filter(({ level, value }) => + activeTab === ALL + ? filterRE.test(value) + : filterRE.test(value) && LEVEL_TAB[level] === activeTab + ); - return ( - <> - <BottomBlock style={{ height: 300 + additionalHeight + 'px' }}> - <BottomBlock.Header showClose={!isResult}> - <div className="flex items-center"> - <span className="font-semibold color-gray-medium mr-4">Console</span> - <Tabs tabs={TABS} active={activeTab} onClick={this.onTabClick} border={false} /> - </div> - <Input - className="input-small" - placeholder="Filter by keyword" - icon="search" - iconPosition="left" - name="filter" - onChange={this.onFilterChange} - /> - </BottomBlock.Header> - <BottomBlock.Content> - <NoContent - title={<div className="capitalize flex items-center mt-16"> - <Icon name="info-circle" className="mr-2" size="18" /> - No {activeTab === ALL ? 'Data' : activeTab.toLowerCase()}</div>} - size="small" - show={filtered.length === 0} - > - <Autoscroll> - {filtered.map((l, index) => ( - <div - key={l.key} - className={cn(stl.line, { - info: !l.isYellow() && !l.isRed(), - warn: l.isYellow(), - error: l.isRed(), - 'cursor-pointer': !isResult, - [stl.activeRow]: lastIndex === index, - })} - data-scroll-item={l.isRed()} - onClick={() => !isResult && jump(l.time)} - > - <Icon size="14" className={stl.icon} {...getIconProps(l.level)} /> - <div className={stl.message}>{renderWithNL(l.value)}</div> - </div> - ))} - </Autoscroll> - </NoContent> - </BottomBlock.Content> - </BottomBlock> - </> - ); - } + const lastIndex = filtered.filter((item) => item.time <= time).length - 1; + + return ( + <> + <BottomBlock style={{ height: 300 + additionalHeight + 'px' }}> + <BottomBlock.Header showClose={!isResult}> + <div className="flex items-center"> + <span className="font-semibold color-gray-medium mr-4">Console</span> + <Tabs tabs={TABS} active={activeTab} onClick={this.onTabClick} border={false} /> + </div> + <Input + className="input-small" + placeholder="Filter by keyword" + icon="search" + iconPosition="left" + name="filter" + onChange={this.onFilterChange} + /> + </BottomBlock.Header> + <BottomBlock.Content> + <NoContent size="small" show={filtered.length === 0}> + <Autoscroll autoScrollTo={Math.max(lastIndex, 0)}> + {filtered.map((l, index) => ( + <div + className={cn('flex', { + info: !l.isYellow() && !l.isRed(), + warn: l.isYellow(), + error: l.isRed(), + [stl.activeRow]: lastIndex === index, + [stl.inactiveRow]: index > lastIndex, + 'cursor-pointer': !isResult, + })} + onClick={() => !isResult && jump(l.time)} + > + <div className={cn(stl.timestamp)}> + <Icon size="14" className={stl.icon} {...getIconProps(l.level)} /> + </div> + <div className={cn(stl.timestamp, {})}> + {Duration.fromMillis(l.time).toFormat('mm:ss.SSS')} + </div> + <div key={l.key} className={cn(stl.line)} data-scroll-item={l.isRed()}> + <div className={stl.message}>{renderWithNL(l.value)}</div> + </div> + </div> + ))} + </Autoscroll> + </NoContent> + </BottomBlock.Content> + </BottomBlock> + </> + ); + } } diff --git a/frontend/app/components/Session_/Console/console.module.css b/frontend/app/components/Session_/Console/console.module.css index 55d19c7bd..66ffc32f9 100644 --- a/frontend/app/components/Session_/Console/console.module.css +++ b/frontend/app/components/Session_/Console/console.module.css @@ -18,11 +18,18 @@ border-bottom: solid thin $gray-light-shade; } +.timestamp { + padding: 7px 0 7px 15px; +} + .activeRow { - background-color: $teal !important; - color: white !important; + background-color: rgba(54, 108, 217, 0.1) !important; } .icon { padding-top: 4px; +} + +.inactiveRow { + opacity: 0.5; } \ No newline at end of file diff --git a/frontend/app/components/Session_/Exceptions/Exceptions.js b/frontend/app/components/Session_/Exceptions/Exceptions.js index 5c5e913e7..0a30a025c 100644 --- a/frontend/app/components/Session_/Exceptions/Exceptions.js +++ b/frontend/app/components/Session_/Exceptions/Exceptions.js @@ -1,117 +1,154 @@ import React from 'react'; import { connect } from 'react-redux'; import { getRE } from 'App/utils'; -import { NoContent, Loader, Input, ErrorItem, SlideModal, ErrorDetails, ErrorHeader,Link, QuestionMarkHint } from 'UI'; -import { fetchErrorStackList } from 'Duck/sessions' +import { + NoContent, + Loader, + Input, + ErrorItem, + SlideModal, + ErrorDetails, + ErrorHeader, + Link, + QuestionMarkHint, + Tabs, +} from 'UI'; +import { fetchErrorStackList } from 'Duck/sessions'; import { connectPlayer, jump } from 'Player'; import { error as errorRoute } from 'App/routes'; import Autoscroll from '../Autoscroll'; import BottomBlock from '../BottomBlock'; -@connectPlayer(state => ({ +@connectPlayer((state) => ({ logs: state.logListNow, - exceptions: state.exceptionsListNow, + exceptions: state.exceptionsList, + exceptionsNow: state.exceptionsListNow, })) -@connect(state => ({ - session: state.getIn([ 'sessions', 'current' ]), - errorStack: state.getIn([ 'sessions', 'errorStack' ]), - sourcemapUploaded: state.getIn([ 'sessions', 'sourcemapUploaded' ]), - loading: state.getIn([ 'sessions', 'fetchErrorStackList', 'loading' ]) -}), { fetchErrorStackList }) +@connect( + (state) => ({ + session: state.getIn(['sessions', 'current']), + errorStack: state.getIn(['sessions', 'errorStack']), + sourcemapUploaded: state.getIn(['sessions', 'sourcemapUploaded']), + loading: state.getIn(['sessions', 'fetchErrorStackList', 'loading']), + }), + { fetchErrorStackList } +) export default class Exceptions extends React.PureComponent { state = { filter: '', - currentError: null - } + currentError: null, + }; - onFilterChange = ({ target: { value } }) => this.setState({ filter: value }) + onFilterChange = ({ target: { value } }) => this.setState({ filter: value }); setCurrentError = (err) => { const { session } = this.props; - this.props.fetchErrorStackList(session.sessionId, err.errorId) - this.setState({ currentError: err}) - } - closeModal = () => this.setState({ currentError: null}) + this.props.fetchErrorStackList(session.sessionId, err.errorId); + this.setState({ currentError: err }); + }; + closeModal = () => this.setState({ currentError: null }); render() { const { exceptions, loading, errorStack, sourcemapUploaded } = this.props; const { filter, currentError } = this.state; const filterRE = getRE(filter, 'i'); - const filtered = exceptions.filter(e => filterRE.test(e.name) || filterRE.test(e.message)); + const filtered = exceptions.filter((e) => filterRE.test(e.name) || filterRE.test(e.message)); + + let lastIndex = -1; + filtered.forEach((item, index) => { + if ( + this.props.exceptionsNow.length > 0 && + item.time <= this.props.exceptionsNow[this.props.exceptionsNow.length - 1].time + ) { + lastIndex = index; + } + }); return ( <> - <SlideModal - title={ currentError && - <div className="mb-4"> - <div className="text-xl mb-2"> - <Link to={errorRoute(currentError.errorId)}> - <span className="font-bold">{currentError.name}</span> - </Link> - <span className="ml-2 text-sm color-gray-medium"> - {currentError.function} - </span> + <SlideModal + title={ + currentError && ( + <div className="mb-4"> + <div className="text-xl mb-2"> + <Link to={errorRoute(currentError.errorId)}> + <span className="font-bold">{currentError.name}</span> + </Link> + <span className="ml-2 text-sm color-gray-medium">{currentError.function}</span> + </div> + <div>{currentError.message}</div> </div> - <div>{currentError.message}</div> - </div> + ) } - isDisplayed={ currentError != null } - content={ currentError && - <div className="px-4"> - <Loader loading={ loading }> - <NoContent - show={ !loading && errorStack.size === 0 } - title="Nothing found!" - > - <ErrorDetails error={ currentError } errorStack={errorStack} sourcemapUploaded={sourcemapUploaded} /> - </NoContent> - </Loader> - </div> + isDisplayed={currentError != null} + content={ + currentError && ( + <div className="px-4"> + <Loader loading={loading}> + <NoContent show={!loading && errorStack.size === 0} title="Nothing found!"> + <ErrorDetails + error={currentError} + errorStack={errorStack} + sourcemapUploaded={sourcemapUploaded} + /> + </NoContent> + </Loader> + </div> + ) } - onClose={ this.closeModal } + onClose={this.closeModal} /> <BottomBlock> <BottomBlock.Header> - <div></div> <div className="flex items-center"> + <span className="font-semibold color-gray-medium mr-4">Exceptions</span> + </div> + + <div className={'flex items-center justify-between'}> <Input - // className="input-small" + className="input-small" placeholder="Filter by name or message" icon="search" iconPosition="left" name="filter" - onChange={ this.onFilterChange } + onChange={this.onFilterChange} + /> + <QuestionMarkHint + className={'ml-8'} + content={ + <> + <a + className="color-teal underline" + target="_blank" + href="https://docs.openreplay.com/installation/upload-sourcemaps" + > + Upload Source Maps{' '} + </a> + and see source code context obtained from stack traces in their original form. + </> + } + className="mr-8" /> - <div className="mx-4"> - <QuestionMarkHint - onHover={true} - content={ - <> - <a className="color-teal underline" target="_blank" href="https://docs.openreplay.com/installation/upload-sourcemaps">Upload Source Maps </a> - and see source code context obtained from stack traces in their original form. - </> - } - /> - </div> </div> </BottomBlock.Header> <BottomBlock.Content> - <NoContent - size="small" - show={ filtered.length === 0} - title="No recordings found" - > - <Autoscroll> - { filtered.map(e => ( - <ErrorItem - onJump={ () => jump(e.time) } - error={e} - key={e.key} - onErrorClick={() => this.setCurrentError(e)} - /> - )) - } + <NoContent size="small" show={filtered.length === 0} title="No recordings found"> + <Autoscroll autoScrollTo={Math.max(lastIndex, 0)}> + {filtered.map((e, index) => ( + <ErrorItem + onJump={() => jump(e.time)} + error={e} + key={e.key} + selected={lastIndex === index} + inactive={index > lastIndex} + onErrorClick={(jsEvent) => { + jsEvent.stopPropagation(); + jsEvent.preventDefault(); + this.setCurrentError(e); + }} + /> + ))} </Autoscroll> </NoContent> </BottomBlock.Content> diff --git a/frontend/app/components/Session_/Fetch/Fetch.js b/frontend/app/components/Session_/Fetch/Fetch.js index ab847f3fe..d8078e89a 100644 --- a/frontend/app/components/Session_/Fetch/Fetch.js +++ b/frontend/app/components/Session_/Fetch/Fetch.js @@ -9,166 +9,183 @@ import FetchDetails from './FetchDetails'; import { renderName, renderDuration } from '../Network'; import { connect } from 'react-redux'; import { setTimelinePointer } from 'Duck/sessions'; +import { renderStart } from 'Components/Session_/Network/NetworkContent'; @connectPlayer((state) => ({ - list: state.fetchList, - listNow: state.fetchListNow, - livePlay: state.livePlay, + list: state.fetchList, + listNow: state.fetchListNow, + livePlay: state.livePlay, })) @connect( - (state) => ({ - timelinePointer: state.getIn(['sessions', 'timelinePointer']), - }), - { setTimelinePointer } + (state) => ({ + timelinePointer: state.getIn(['sessions', 'timelinePointer']), + }), + { setTimelinePointer } ) export default class Fetch extends React.PureComponent { - state = { - filter: '', - filteredList: this.props.list, - current: null, - currentIndex: 0, - showFetchDetails: false, - hasNextError: false, - hasPreviousError: false, - }; + state = { + filter: '', + filteredList: this.props.list, + current: null, + currentIndex: 0, + showFetchDetails: false, + hasNextError: false, + hasPreviousError: false, + }; - onFilterChange = ({ target: { value } }) => { - const { list } = this.props; - const filterRE = getRE(value, 'i'); - const filtered = list.filter((r) => filterRE.test(r.name) || filterRE.test(r.url) || filterRE.test(r.method) || filterRE.test(r.status)); - this.setState({ filter: value, filteredList: value ? filtered : list, currentIndex: 0 }); - }; + onFilterChange = (e, { value }) => { + const { list } = this.props; + const filterRE = getRE(value, 'i'); + const filtered = list.filter( + (r) => + filterRE.test(r.name) || + filterRE.test(r.url) || + filterRE.test(r.method) || + filterRE.test(r.status) + ); + this.setState({ filter: value, filteredList: value ? filtered : list, currentIndex: 0 }); + }; - setCurrent = (item, index) => { - if (!this.props.livePlay) { - pause(); - jump(item.time); - } - this.setState({ current: item, currentIndex: index }); - }; - - onRowClick = (item, index) => { - if (!this.props.livePlay) { - pause(); - } - this.setState({ current: item, currentIndex: index, showFetchDetails: true }); - this.props.setTimelinePointer(null); - }; - - closeModal = () => this.setState({ current: null, showFetchDetails: false }); - - nextClickHander = () => { - // const { list } = this.props; - const { currentIndex, filteredList } = this.state; - - if (currentIndex === filteredList.length - 1) return; - const newIndex = currentIndex + 1; - this.setCurrent(filteredList[newIndex], newIndex); - this.setState({ showFetchDetails: true }); - }; - - prevClickHander = () => { - // const { list } = this.props; - const { currentIndex, filteredList } = this.state; - - if (currentIndex === 0) return; - const newIndex = currentIndex - 1; - this.setCurrent(filteredList[newIndex], newIndex); - this.setState({ showFetchDetails: true }); - }; - - render() { - const { listNow } = this.props; - const { current, currentIndex, showFetchDetails, filteredList } = this.state; - const hasErrors = filteredList.some((r) => r.status >= 400); - return ( - <React.Fragment> - <SlideModal - right - size="middle" - title={ - <div className="flex justify-between"> - <h1>Fetch Request</h1> - <div className="flex items-center"> - <div className="flex items-center"> - <span className="mr-2 color-gray-medium uppercase text-base">Status</span> - <Label data-red={current && current.status >= 400} data-green={current && current.status < 400}> - <div className="uppercase w-16 justify-center code-font text-lg">{current && current.status}</div> - </Label> - </div> - <CloseButton onClick={this.closeModal} size="18" className="ml-2" /> - </div> - </div> - } - isDisplayed={current != null && showFetchDetails} - content={ - current && - showFetchDetails && ( - <FetchDetails - resource={current} - nextClick={this.nextClickHander} - prevClick={this.prevClickHander} - first={currentIndex === 0} - last={currentIndex === filteredList.length - 1} - /> - ) - } - onClose={this.closeModal} - /> - <BottomBlock> - <BottomBlock.Header> - <span className="font-semibold color-gray-medium mr-4">Fetch</span> - <div className="flex items-center"> - <Input - className="input-small" - placeholder="Filter" - icon="search" - iconPosition="left" - name="filter" - onChange={this.onFilterChange} - /> - </div> - </BottomBlock.Header> - <BottomBlock.Content> - <NoContent - title={ - <div className="capitalize flex items-center mt-16"> - <Icon name="info-circle" className="mr-2" size="18" /> - No Data - </div> - } - // size="small" - show={filteredList.length === 0} - > - {/* <NoContent size="small" show={filteredList.length === 0}> */} - <TimeTable rows={filteredList} onRowClick={this.onRowClick} hoverable navigation={hasErrors} activeIndex={listNow.length - 1}> - {[ - { - label: 'Status', - dataKey: 'status', - width: 70, - }, - { - label: 'Method', - dataKey: 'method', - width: 60, - }, - { - label: 'Name', - width: 240, - render: renderName, - }, - { - label: 'Time', - width: 80, - render: renderDuration, - }, - ]} - </TimeTable> - </NoContent> - </BottomBlock.Content> - </BottomBlock> - </React.Fragment> - ); + setCurrent = (item, index) => { + if (!this.props.livePlay) { + pause(); + jump(item.time); } + this.setState({ current: item, currentIndex: index }); + }; + + onRowClick = (item, index) => { + if (!this.props.livePlay) { + pause(); + } + this.setState({ current: item, currentIndex: index, showFetchDetails: true }); + this.props.setTimelinePointer(null); + }; + + closeModal = () => this.setState({ current: null, showFetchDetails: false }); + + nextClickHander = () => { + // const { list } = this.props; + const { currentIndex, filteredList } = this.state; + + if (currentIndex === filteredList.length - 1) return; + const newIndex = currentIndex + 1; + this.setCurrent(filteredList[newIndex], newIndex); + this.setState({ showFetchDetails: true }); + }; + + prevClickHander = () => { + // const { list } = this.props; + const { currentIndex, filteredList } = this.state; + + if (currentIndex === 0) return; + const newIndex = currentIndex - 1; + this.setCurrent(filteredList[newIndex], newIndex); + this.setState({ showFetchDetails: true }); + }; + + render() { + const { listNow } = this.props; + const { current, currentIndex, showFetchDetails, filteredList } = this.state; + const hasErrors = filteredList.some((r) => r.status >= 400); + return ( + <React.Fragment> + <SlideModal + right + size="middle" + title={ + <div className="flex justify-between"> + <h1>Fetch Request</h1> + <div className="flex items-center"> + <div className="flex items-center"> + <span className="mr-2 color-gray-medium uppercase text-base">Status</span> + <Label + data-red={current && current.status >= 400} + data-green={current && current.status < 400} + > + <div className="uppercase w-16 justify-center code-font text-lg"> + {current && current.status} + </div> + </Label> + </div> + <CloseButton onClick={this.closeModal} size="18" className="ml-2" /> + </div> + </div> + } + isDisplayed={current != null && showFetchDetails} + content={ + current && + showFetchDetails && ( + <FetchDetails + resource={current} + nextClick={this.nextClickHander} + prevClick={this.prevClickHander} + first={currentIndex === 0} + last={currentIndex === filteredList.length - 1} + /> + ) + } + onClose={this.closeModal} + /> + <BottomBlock> + <BottomBlock.Header> + <span className="font-semibold color-gray-medium mr-4">Fetch</span> + <div className="flex items-center"> + <Input + className="input-small" + placeholder="Filter" + icon="search" + iconPosition="left" + name="filter" + onChange={this.onFilterChange} + /> + </div> + </BottomBlock.Header> + <BottomBlock.Content> + <NoContent title={ + <div className="capitalize flex items-center mt-16"> + <Icon name="info-circle" className="mr-2" size="18" /> + No Data + </div> + } show={filteredList.length === 0}> + <TimeTable + rows={filteredList} + onRowClick={this.onRowClick} + hoverable + activeIndex={listNow.length - 1} + > + {[ + { + label: 'Start', + width: 90, + render: renderStart, + }, + { + label: 'Status', + dataKey: 'status', + width: 70, + }, + { + label: 'Method', + dataKey: 'method', + width: 60, + }, + { + label: 'Name', + width: 240, + render: renderName, + }, + { + label: 'Time', + width: 80, + render: renderDuration, + }, + ]} + </TimeTable> + </NoContent> + </BottomBlock.Content> + </BottomBlock> + </React.Fragment> + ); + } } diff --git a/frontend/app/components/Session_/GraphQL/GQLDetails.js b/frontend/app/components/Session_/GraphQL/GQLDetails.js index 47ec43239..4caba50a7 100644 --- a/frontend/app/components/Session_/GraphQL/GQLDetails.js +++ b/frontend/app/components/Session_/GraphQL/GQLDetails.js @@ -1,80 +1,72 @@ import React from 'react'; -import { JSONTree, Button } from 'UI' +import { JSONTree, Button } from 'UI'; import cn from 'classnames'; export default class GQLDetails extends React.PureComponent { - render() { - const { - gql: { - variables, - response, - duration, - operationKind, - operationName, - }, - nextClick, - prevClick, - first = false, - last = false, - } = this.props; + render() { + const { + gql: { variables, response, duration, operationKind, operationName }, + nextClick, + prevClick, + first = false, + last = false, + } = this.props; - let jsonVars = undefined; - let jsonResponse = undefined; - try { - jsonVars = JSON.parse(payload); - } catch (e) {} - try { - jsonResponse = JSON.parse(response); - } catch (e) {} - return ( - <div className="px-4 pb-16"> - <h5 className="mb-2">{ 'Operation Name'}</h5> - <div className={ cn('p-2 bg-gray-lightest rounded color-gray-darkest')}>{ operationName }</div> + let jsonVars = undefined; + let jsonResponse = undefined; + try { + jsonVars = JSON.parse(variables); + } catch (e) {} + try { + jsonResponse = JSON.parse(response); + } catch (e) {} + const dataClass = cn('p-2 bg-gray-lightest rounded color-gray-darkest'); + return ( + <div className="px-4 pb-16"> + <h5 className="mb-2">{'Operation Name'}</h5> + <div className={dataClass}>{operationName}</div> - <div className="flex items-center mt-4"> - <div className="w-4/12"> - <div className="font-medium mb-2">Operation Kind</div> - <div>{operationKind}</div> - </div> - <div className="w-4/12"> - <div className="font-medium mb-2">Duration</div> - <div>{parseInt(duration)} ms</div> - </div> - </div> + <div className="flex items-center gap-4 mt-4"> + <div className="w-6/12"> + <div className="mb-2">Operation Kind</div> + <div className={dataClass}>{operationKind}</div> + </div> + <div className="w-6/12"> + <div className="mb-2">Duration</div> + <div className={dataClass}>{duration ? parseInt(duration) : '???'} ms</div> + </div> + </div> - <div className="flex justify-between items-start mt-6"> - <h5 className="mt-1 mr-1">{ 'Response' }</h5> - </div> - <div style={{ height: 'calc(100vh - 314px)', overflowY: 'auto' }}> - { variables && variables !== "{}" && - <div> - <div className="mt-2"> - <h5>{ 'Variables'}</h5> - { jsonVars === undefined - ? <div className="ml-3"> { variables } </div> - : <JSONTree src={ jsonVars } /> - } - </div> - <div className="divider"/> - </div> - } - <div className="mt-3"> - { jsonResponse === undefined - ? <div className="ml-3"> { response } </div> - : <JSONTree src={ jsonResponse } /> - } - </div> - </div> + <div style={{ height: 'calc(100vh - 314px)', overflowY: 'auto' }}> + <div> + <div className="flex justify-between items-start mt-6 mb-2"> + <h5 className="mt-1 mr-1">{'Variables'}</h5> + </div> + <div className={dataClass}> + {jsonVars === undefined ? variables : <JSONTree src={jsonVars} />} + </div> + <div className="divider" /> + </div> - <div className="flex justify-between absolute bottom-0 left-0 right-0 p-3 border-t bg-white"> - <Button variant="outline" onClick={prevClick} disabled={first}> - Prev - </Button> - <Button variant="outline" onClick={nextClick} disabled={last}> - Next - </Button> - </div> - </div> - ); - } -} \ No newline at end of file + <div> + <div className="flex justify-between items-start mt-6 mb-2"> + <h5 className="mt-1 mr-1">{'Response'}</h5> + </div> + <div className={dataClass}> + {jsonResponse === undefined ? response : <JSONTree src={jsonResponse} />} + </div> + </div> + </div> + + <div className="flex justify-between absolute bottom-0 left-0 right-0 p-3 border-t bg-white"> + <Button variant="outline" onClick={prevClick} disabled={first}> + Prev + </Button> + <Button variant="outline" onClick={nextClick} disabled={last}> + Next + </Button> + </div> + </div> + ); + } +} diff --git a/frontend/app/components/Session_/GraphQL/GraphQL.js b/frontend/app/components/Session_/GraphQL/GraphQL.js index 4b29a497b..4c613454b 100644 --- a/frontend/app/components/Session_/GraphQL/GraphQL.js +++ b/frontend/app/components/Session_/GraphQL/GraphQL.js @@ -1,87 +1,136 @@ import React from 'react'; -import { NoContent, Input, SlideModal, CloseButton } from 'UI'; +import { NoContent, Input, SlideModal, CloseButton, Popup, Button } from 'UI'; import { getRE } from 'App/utils'; import { connectPlayer, pause, jump } from 'Player'; import BottomBlock from '../BottomBlock'; import TimeTable from '../TimeTable'; import GQLDetails from './GQLDetails'; +import { renderStart } from 'Components/Session_/Network/NetworkContent'; +import stl from 'Components/Session_/Network/network.module.css'; function renderDefaultStatus() { - return "2xx-3xx"; + return '2xx-3xx'; } -@connectPlayer(state => ({ - list: state.graphqlListNow, + +export function renderName(r) { + return ( + <div className="flex justify-between items-center grow-0 w-full"> + <div>{r.operationName}</div> + <Button + variant="text" + className="right-0 text-xs uppercase p-2 color-gray-500 hover:color-teal" + onClick={(e) => { + e.stopPropagation(); + jump(r.time); + }} + > + Jump + </Button> + </div> + ); +} + +@connectPlayer((state) => ({ + list: state.graphqlList, + listNow: state.graphqlListNow, + time: state.time, livePlay: state.livePlay, })) export default class GraphQL extends React.PureComponent { - state = { - filter: "", + state = { + filter: '', filteredList: this.props.list, - current: null, + filteredListNow: this.props.listNow, + current: null, currentIndex: 0, showFetchDetails: false, hasNextError: false, hasPreviousError: false, - } + lastActiveItem: 0, + }; + + static filterList(list, value) { + const filterRE = getRE(value, 'i'); + + return value + ? list.filter( + (r) => + filterRE.test(r.operationKind) || + filterRE.test(r.operationName) || + filterRE.test(r.variables) + ) + : list; + } onFilterChange = ({ target: { value } }) => { const { list } = this.props; - const filterRE = getRE(value, 'i'); - const filtered = list - .filter((r) => filterRE.test(r.name) || filterRE.test(r.url) || filterRE.test(r.method) || filterRE.test(r.status)); - this.setState({ filter: value, filteredList: value ? filtered : list, currentIndex: 0 }); - } + const filtered = GraphQL.filterList(list, value); + this.setState({ filter: value, filteredList: filtered, currentIndex: 0 }); + }; setCurrent = (item, index) => { if (!this.props.livePlay) { pause(); - jump(item.time) + jump(item.time); } this.setState({ current: item, currentIndex: index }); - } + }; closeModal = () => this.setState({ current: null, showFetchDetails: false }); static getDerivedStateFromProps(nextProps, prevState) { - const { filteredList } = prevState; - if (nextProps.timelinePointer) { - let activeItem = filteredList.find((r) => r.time >= nextProps.timelinePointer.time); - activeItem = activeItem || filteredList[filteredList.length - 1]; + const { list } = nextProps; + if (nextProps.time) { + const filtered = GraphQL.filterList(list, prevState.filter); + console.log({ + list, + filtered, + time: nextProps.time, + }); + + let i = 0; + filtered.forEach((item, index) => { + if (item.time <= nextProps.time) { + i = index; + } + }); + return { - current: activeItem, - currentIndex: filteredList.indexOf(activeItem), + lastActiveItem: i, }; } } render() { - const { list } = this.props; - const { current, currentIndex, filteredList } = this.state; - + const { list, listNow, timelinePointer } = this.props; + const { current, currentIndex, filteredList, lastActiveItem } = this.state; + return ( <React.Fragment> - <SlideModal + <SlideModal size="middle" right - title = { + title={ <div className="flex justify-between"> <h1>GraphQL</h1> <div className="flex items-center"> - <CloseButton onClick={ this.closeModal } size="18" className="ml-2" /> + <CloseButton onClick={this.closeModal} size="18" className="ml-2" /> </div> </div> } - isDisplayed={ current != null } - content={ current && - <GQLDetails - gql={ current } - nextClick={this.nextClickHander} - prevClick={this.prevClickHander} - first={currentIndex === 0} - last={currentIndex === filteredList.length - 1} - /> + isDisplayed={current != null} + content={ + current && ( + <GQLDetails + gql={current} + nextClick={this.nextClickHander} + prevClick={this.prevClickHander} + first={currentIndex === 0} + last={currentIndex === filteredList.length - 1} + /> + ) } - onClose={ this.closeModal } + onClose={this.closeModal} /> <BottomBlock> <BottomBlock.Header> @@ -93,36 +142,38 @@ export default class GraphQL extends React.PureComponent { icon="search" iconPosition="left" name="filter" - onChange={ this.onFilterChange } + onChange={this.onFilterChange} /> </div> </BottomBlock.Header> <BottomBlock.Content> - <NoContent - size="small" - title="No recordings found" - show={ filteredList.length === 0} - > + <NoContent size="small" title="No recordings found" show={filteredList.length === 0}> <TimeTable - rows={ filteredList } - onRowClick={ this.setCurrent } + rows={filteredList} + onRowClick={this.setCurrent} hoverable - navigation - activeIndex={currentIndex} + activeIndex={lastActiveItem} > {[ { - label: "Status", + label: 'Start', + width: 90, + render: renderStart, + }, + { + label: 'Status', width: 70, render: renderDefaultStatus, - }, { - label: "Type", - dataKey: "operationKind", + }, + { + label: 'Type', + dataKey: 'operationKind', width: 60, - }, { - label: "Name", - width: 130, - dataKey: "operationName", + }, + { + label: 'Name', + width: 240, + render: renderName, }, ]} </TimeTable> diff --git a/frontend/app/components/Session_/Network/Network.js b/frontend/app/components/Session_/Network/Network.js index 887cc1148..096a38a2a 100644 --- a/frontend/app/components/Session_/Network/Network.js +++ b/frontend/app/components/Session_/Network/Network.js @@ -95,10 +95,10 @@ export default class Network extends React.PureComponent { }; onRowClick = (e, index) => { - pause(); - jump(e.time); - this.setState({ currentIndex: index }); - this.props.setTimelinePointer(null); + // pause(); + // jump(e.time); + // this.setState({ currentIndex: index }); + // this.props.setTimelinePointer(null); }; onTabClick = (activeTab) => this.setState({ activeTab }); @@ -108,8 +108,18 @@ export default class Network extends React.PureComponent { const filterRE = getRE(value, 'i'); const filtered = resources.filter(({ type, name }) => filterRE.test(name) && (activeTab === ALL || type === TAB_TO_TYPE_MAP[activeTab])); - this.setState({ filter: value, filteredList: value ? filtered : resources, currentIndex: 0 }); - }; + this.setState({ filter: value, filteredList: value ? filtered : resources, currentIndex: 0 }); + } + + static getDerivedStateFromProps(nextProps, prevState) { + const { filteredList } = prevState; + if (nextProps.timelinePointer) { + const activeItem = filteredList.find((r) => r.time >= nextProps.timelinePointer.time); + return { + currentIndex: activeItem ? filteredList.indexOf(activeItem) : filteredList.length - 1, + }; + } + } render() { const { @@ -137,7 +147,7 @@ export default class Network extends React.PureComponent { resourcesSize={resourcesSize} transferredSize={transferredSize} onRowClick={this.onRowClick} - currentIndex={listNow.length - 0} + currentIndex={listNow.length - 1} /> </React.Fragment> ); diff --git a/frontend/app/components/Session_/Network/NetworkContent.js b/frontend/app/components/Session_/Network/NetworkContent.js index b6c54b5b4..b3243c071 100644 --- a/frontend/app/components/Session_/Network/NetworkContent.js +++ b/frontend/app/components/Session_/Network/NetworkContent.js @@ -1,7 +1,7 @@ import React from 'react'; import cn from 'classnames'; // import { connectPlayer } from 'Player'; -import { QuestionMarkHint, Popup, Tabs, Input, NoContent, Icon } from 'UI'; +import { QuestionMarkHint, Popup, Tabs, Input, NoContent, Icon, Button } from 'UI'; import { getRE } from 'App/utils'; import { TYPES } from 'Types/session/resource'; import { formatBytes } from 'App/utils'; @@ -11,6 +11,8 @@ import TimeTable from '../TimeTable'; import BottomBlock from '../BottomBlock'; import InfoLine from '../BottomBlock/InfoLine'; import stl from './network.module.css'; +import { Duration } from 'luxon'; +import { jump } from 'Player'; const ALL = 'ALL'; const XHR = 'xhr'; @@ -21,261 +23,318 @@ const MEDIA = 'media'; const OTHER = 'other'; const TAB_TO_TYPE_MAP = { - [XHR]: TYPES.XHR, - [JS]: TYPES.JS, - [CSS]: TYPES.CSS, - [IMG]: TYPES.IMG, - [MEDIA]: TYPES.MEDIA, - [OTHER]: TYPES.OTHER, + [XHR]: TYPES.XHR, + [JS]: TYPES.JS, + [CSS]: TYPES.CSS, + [IMG]: TYPES.IMG, + [MEDIA]: TYPES.MEDIA, + [OTHER]: TYPES.OTHER, }; const TABS = [ALL, XHR, JS, CSS, IMG, MEDIA, OTHER].map((tab) => ({ - text: tab, - key: tab, + text: tab, + key: tab, })); const DOM_LOADED_TIME_COLOR = 'teal'; const LOAD_TIME_COLOR = 'red'; export function renderType(r) { - return ( - <Popup style={{ width: '100%' }} content={<div className={stl.popupNameContent}>{r.type}</div>}> - <div className={stl.popupNameTrigger}>{r.type}</div> - </Popup> - ); + return ( + <Popup style={{ width: '100%' }} content={<div className={stl.popupNameContent}>{r.type}</div>}> + <div className={stl.popupNameTrigger}>{r.type}</div> + </Popup> + ); } export function renderName(r) { - return ( - <Popup style={{ width: '100%' }} content={<div className={stl.popupNameContent}>{r.url}</div>}> - <div className={stl.popupNameTrigger}>{r.name}</div> - </Popup> - ); + return ( + <div className="flex justify-between items-center grow-0 w-full"> + <Popup + style={{ width: '100%' }} + content={<div className={stl.popupNameContent}>{r.url}</div>} + > + <div className={stl.popupNameTrigger}>{r.name}</div> + </Popup> + <Button + variant="text" + className="right-0 text-xs uppercase p-2 color-gray-500 hover:color-teal" + onClick={(e) => { + e.stopPropagation(); + jump(r.time); + }} + > + Jump + </Button> + </div> + ); +} + +export function renderStart(r) { + return Duration.fromMillis(r.time).toFormat('mm:ss.SSS'); } const renderXHRText = () => ( - <span className="flex items-center"> - {XHR} - <QuestionMarkHint - onHover={true} - content={ - <> - Use our{' '} - <a className="color-teal underline" target="_blank" href="https://docs.openreplay.com/plugins/fetch"> - Fetch plugin - </a> - {' to capture HTTP requests and responses, including status codes and bodies.'} <br /> - We also provide{' '} - <a className="color-teal underline" target="_blank" href="https://docs.openreplay.com/plugins/graphql"> - support for GraphQL - </a> - {' for easy debugging of your queries.'} - </> - } - className="ml-1" - /> - </span> + <span className="flex items-center"> + {XHR} + <QuestionMarkHint + onHover={true} + content={ + <> + Use our{' '} + <a + className="color-teal underline" + target="_blank" + href="https://docs.openreplay.com/plugins/fetch" + > + Fetch plugin + </a> + {' to capture HTTP requests and responses, including status codes and bodies.'} <br /> + We also provide{' '} + <a + className="color-teal underline" + target="_blank" + href="https://docs.openreplay.com/plugins/graphql" + > + support for GraphQL + </a> + {' for easy debugging of your queries.'} + </> + } + className="ml-1" + /> + </span> ); function renderSize(r) { - if (r.responseBodySize) return formatBytes(r.responseBodySize); - let triggerText; - let content; - if (r.decodedBodySize == null) { - triggerText = 'x'; - content = 'Not captured'; - } else { - const headerSize = r.headerSize || 0; - const encodedSize = r.encodedBodySize || 0; - const transferred = headerSize + encodedSize; - const showTransferred = r.headerSize != null; + if (r.responseBodySize) return formatBytes(r.responseBodySize); + let triggerText; + let content; + if (r.decodedBodySize == null) { + triggerText = 'x'; + content = 'Not captured'; + } else { + const headerSize = r.headerSize || 0; + const encodedSize = r.encodedBodySize || 0; + const transferred = headerSize + encodedSize; + const showTransferred = r.headerSize != null; - triggerText = formatBytes(r.decodedBodySize); - content = ( - <ul> - {showTransferred && <li>{`${formatBytes(r.encodedBodySize + headerSize)} transfered over network`}</li>} - <li>{`Resource size: ${formatBytes(r.decodedBodySize)} `}</li> - </ul> - ); - } - - return ( - <Popup style={{ width: '100%' }} content={content}> - <div>{triggerText}</div> - </Popup> + triggerText = formatBytes(r.decodedBodySize); + content = ( + <ul> + {showTransferred && ( + <li>{`${formatBytes(r.encodedBodySize + headerSize)} transfered over network`}</li> + )} + <li>{`Resource size: ${formatBytes(r.decodedBodySize)} `}</li> + </ul> ); + } + + return ( + <Popup style={{ width: '100%' }} content={content}> + <div>{triggerText}</div> + </Popup> + ); } export function renderDuration(r) { - if (!r.success) return 'x'; + if (!r.success) return 'x'; - const text = `${Math.floor(r.duration)}ms`; - if (!r.isRed() && !r.isYellow()) return text; + const text = `${Math.floor(r.duration)}ms`; + if (!r.isRed() && !r.isYellow()) return text; - let tooltipText; - let className = 'w-full h-full flex items-center '; - if (r.isYellow()) { - tooltipText = 'Slower than average'; - className += 'warn color-orange'; - } else { - tooltipText = 'Much slower than average'; - className += 'error color-red'; - } + let tooltipText; + let className = 'w-full h-full flex items-center '; + if (r.isYellow()) { + tooltipText = 'Slower than average'; + className += 'warn color-orange'; + } else { + tooltipText = 'Much slower than average'; + className += 'error color-red'; + } - return ( - <Popup style={{ width: '100%' }} content={tooltipText}> - <div className={cn(className, stl.duration)}> {text} </div> - </Popup> - ); + return ( + <Popup style={{ width: '100%' }} content={tooltipText}> + <div className={cn(className, stl.duration)}> {text} </div> + </Popup> + ); } export default class NetworkContent extends React.PureComponent { - state = { - filter: '', - activeTab: ALL, - }; + state = { + filter: '', + activeTab: ALL, + }; - onTabClick = (activeTab) => this.setState({ activeTab }); - onFilterChange = ({ target: { value } }) => this.setState({ filter: value }); + onTabClick = (activeTab) => this.setState({ activeTab }); + onFilterChange = ({ target: { value } }) => this.setState({ filter: value }); - render() { - const { - location, - resources, - domContentLoadedTime, - loadTime, - domBuildingTime, - fetchPresented, - onRowClick, - isResult = false, - additionalHeight = 0, - resourcesSize, - transferredSize, - time, - currentIndex, - } = this.props; - const { filter, activeTab } = this.state; - const filterRE = getRE(filter, 'i'); - let filtered = resources.filter(({ type, name }) => filterRE.test(name) && (activeTab === ALL || type === TAB_TO_TYPE_MAP[activeTab])); - const lastIndex = currentIndex || filtered.filter((item) => item.time <= time).length - 1; + render() { + const { + location, + resources, + domContentLoadedTime, + loadTime, + domBuildingTime, + fetchPresented, + onRowClick, + isResult = false, + additionalHeight = 0, + resourcesSize, + transferredSize, + time, + currentIndex, + } = this.props; + const { filter, activeTab } = this.state; + const filterRE = getRE(filter, 'i'); + let filtered = resources.filter( + ({ type, name }) => + filterRE.test(name) && (activeTab === ALL || type === TAB_TO_TYPE_MAP[activeTab]) + ); + const lastIndex = currentIndex || filtered.filter((item) => item.time <= time).length - 1; - const referenceLines = []; - if (domContentLoadedTime != null) { - referenceLines.push({ - time: domContentLoadedTime.time, - color: DOM_LOADED_TIME_COLOR, - }); - } - if (loadTime != null) { - referenceLines.push({ - time: loadTime.time, - color: LOAD_TIME_COLOR, - }); - } - - let tabs = TABS; - if (!fetchPresented) { - tabs = TABS.map((tab) => - !isResult && tab.key === XHR - ? { - text: renderXHRText(), - key: XHR, - } - : tab - ); - } - - // const resourcesSize = filtered.reduce((sum, { decodedBodySize }) => sum + (decodedBodySize || 0), 0); - // const transferredSize = filtered - // .reduce((sum, { headerSize, encodedBodySize }) => sum + (headerSize || 0) + (encodedBodySize || 0), 0); - - return ( - <React.Fragment> - <BottomBlock style={{ height: 300 + additionalHeight + 'px' }} className="border"> - <BottomBlock.Header showClose={!isResult}> - <div className="flex items-center"> - <span className="font-semibold color-gray-medium mr-4">Network</span> - <Tabs className="uppercase" tabs={tabs} active={activeTab} onClick={this.onTabClick} border={false} /> - </div> - <Input - // className="input-small" - placeholder="Filter by Name" - icon="search" - iconPosition="left" - name="filter" - onChange={this.onFilterChange} - /> - </BottomBlock.Header> - <BottomBlock.Content> - <InfoLine> - <InfoLine.Point label={filtered.length} value=" requests" /> - <InfoLine.Point label={formatBytes(transferredSize)} value="transferred" display={transferredSize > 0} /> - <InfoLine.Point label={formatBytes(resourcesSize)} value="resources" display={resourcesSize > 0} /> - <InfoLine.Point label="DOM Building Time" value={formatMs(domBuildingTime)} display={domBuildingTime != null} /> - <InfoLine.Point - label="DOMContentLoaded" - value={domContentLoadedTime && formatMs(domContentLoadedTime.value)} - display={domContentLoadedTime != null} - dotColor={DOM_LOADED_TIME_COLOR} - /> - <InfoLine.Point - label="Load" - value={loadTime && formatMs(loadTime.value)} - display={loadTime != null} - dotColor={LOAD_TIME_COLOR} - /> - </InfoLine> - <NoContent - title={ - <div className="capitalize flex items-center mt-16"> - <Icon name="info-circle" className="mr-2" size="18" /> - No Data - </div> - } - size="small" - show={filtered.length === 0} - > - <TimeTable - rows={filtered} - referenceLines={referenceLines} - renderPopup - // navigation - onRowClick={onRowClick} - additionalHeight={additionalHeight} - activeIndex={lastIndex} - > - {[ - { - label: 'Status', - dataKey: 'status', - width: 70, - }, - { - label: 'Type', - dataKey: 'type', - width: 90, - render: renderType, - }, - { - label: 'Name', - width: 200, - render: renderName, - }, - { - label: 'Size', - width: 60, - render: renderSize, - }, - { - label: 'Time', - width: 80, - render: renderDuration, - }, - ]} - </TimeTable> - </NoContent> - </BottomBlock.Content> - </BottomBlock> - </React.Fragment> - ); + const referenceLines = []; + if (domContentLoadedTime != null) { + referenceLines.push({ + time: domContentLoadedTime.time, + color: DOM_LOADED_TIME_COLOR, + }); } + if (loadTime != null) { + referenceLines.push({ + time: loadTime.time, + color: LOAD_TIME_COLOR, + }); + } + + let tabs = TABS; + if (!fetchPresented) { + tabs = TABS.map((tab) => + !isResult && tab.key === XHR + ? { + text: renderXHRText(), + key: XHR, + } + : tab + ); + } + + // const resourcesSize = filtered.reduce((sum, { decodedBodySize }) => sum + (decodedBodySize || 0), 0); + // const transferredSize = filtered + // .reduce((sum, { headerSize, encodedBodySize }) => sum + (headerSize || 0) + (encodedBodySize || 0), 0); + + return ( + <React.Fragment> + <BottomBlock style={{ height: 300 + additionalHeight + 'px' }} className="border"> + <BottomBlock.Header showClose={!isResult}> + <Tabs + className="uppercase" + tabs={tabs} + active={activeTab} + onClick={this.onTabClick} + border={false} + /> + <Input + className="input-small" + placeholder="Filter by Name" + icon="search" + iconPosition="left" + name="filter" + onChange={this.onFilterChange} + /> + </BottomBlock.Header> + <BottomBlock.Content> + {/* <div className={ stl.location }> */} + {/* <Icon name="window" marginRight="8" /> */} + {/* <div>{ location }</div> */} + {/* <div></div> */} + {/* </div> */} + <InfoLine> + <InfoLine.Point label={filtered.length} value=" requests" /> + <InfoLine.Point + label={formatBytes(transferredSize)} + value="transferred" + display={transferredSize > 0} + /> + <InfoLine.Point + label={formatBytes(resourcesSize)} + value="resources" + display={resourcesSize > 0} + /> + <InfoLine.Point + label={formatMs(domBuildingTime)} + value="DOM Building Time" + display={domBuildingTime != null} + /> + <InfoLine.Point + label={domContentLoadedTime && formatMs(domContentLoadedTime.value)} + value="DOMContentLoaded" + display={domContentLoadedTime != null} + dotColor={DOM_LOADED_TIME_COLOR} + /> + <InfoLine.Point + label={loadTime && formatMs(loadTime.value)} + value="Load" + display={loadTime != null} + dotColor={LOAD_TIME_COLOR} + /> + </InfoLine> + <NoContent + title={ + <div className="capitalize flex items-center mt-16"> + <Icon name="info-circle" className="mr-2" size="18" /> + No Data + </div> + } + size="small" + show={filtered.length === 0} + > + <TimeTable + rows={filtered} + referenceLines={referenceLines} + renderPopup + // navigation + onRowClick={onRowClick} + additionalHeight={additionalHeight} + activeIndex={lastIndex} + > + {[ + { + label: 'Start', + width: 90, + render: renderStart, + }, + { + label: 'Status', + dataKey: 'status', + width: 70, + }, + { + label: 'Type', + dataKey: 'type', + width: 90, + render: renderType, + }, + { + label: 'Name', + width: 240, + render: renderName, + }, + { + label: 'Size', + width: 60, + render: renderSize, + }, + { + label: 'Time', + width: 80, + render: renderDuration, + }, + ]} + </TimeTable> + </NoContent> + </BottomBlock.Content> + </BottomBlock> + </React.Fragment> + ); + } } diff --git a/frontend/app/components/Session_/Player/Overlay/ElementsMarker/Marker.tsx b/frontend/app/components/Session_/Player/Overlay/ElementsMarker/Marker.tsx index d5afa65a2..cc8f3fd1f 100644 --- a/frontend/app/components/Session_/Player/Overlay/ElementsMarker/Marker.tsx +++ b/frontend/app/components/Session_/Player/Overlay/ElementsMarker/Marker.tsx @@ -11,27 +11,29 @@ interface Props { active: boolean; } -export default function Marker({ target, active }: Props) { +export default function Marker({ target, active }: Props) { const style = { - top: `${ target.boundingRect.top }px`, - left: `${ target.boundingRect.left }px`, - width: `${ target.boundingRect.width }px`, - height: `${ target.boundingRect.height }px`, - } + top: `${target.boundingRect.top}px`, + left: `${target.boundingRect.left}px`, + width: `${target.boundingRect.width}px`, + height: `${target.boundingRect.height}px`, + } return ( - <div className={ cn(stl.marker, { [stl.active] : active }) } style={ style } onClick={() => activeTarget(target.index)}> - <div className={stl.index}>{target.index + 1}</div> - <Tooltip - open={active} - arrow - sticky - distance={15} - html={( - <div>{target.count} Clicks</div> - )} - > - <div className="absolute inset-0"></div> - </Tooltip> - </div> - ) + <div className={cn(stl.marker, { [stl.active]: active })} style={style} onClick={() => activeTarget(target.index)}> + <div className={stl.index}>{target.index + 1}</div> + {/* @ts-expect-error Tooltip doesn't have children property */} + <Tooltip + open={active} + arrow + sticky + distance={15} + html={( + <div>{target.count} Clicks</div> + )} + trigger="mouseenter" + > + <div className="absolute inset-0"></div> + </Tooltip> + </div> + ) } \ No newline at end of file diff --git a/frontend/app/components/Session_/StackEvents/StackEvents.js b/frontend/app/components/Session_/StackEvents/StackEvents.js index 8069cb663..f4a9387d6 100644 --- a/frontend/app/components/Session_/StackEvents/StackEvents.js +++ b/frontend/app/components/Session_/StackEvents/StackEvents.js @@ -1,85 +1,176 @@ +import { error as errorRoute } from 'App/routes'; +import JsonViewer from 'Components/Session_/StackEvents/UserEvent/JsonViewer'; +import Sentry from 'Components/Session_/StackEvents/UserEvent/Sentry'; +import { hideHint } from 'Duck/components/player'; +import withEnumToggle from 'HOCs/withEnumToggle'; +import { connectPlayer, jump } from 'Player'; import React from 'react'; import { connect } from 'react-redux'; -import { connectPlayer, jump } from 'Player'; -import { NoContent, Tabs } from 'UI'; -import withEnumToggle from 'HOCs/withEnumToggle'; -import { hideHint } from 'Duck/components/player'; -import { typeList } from 'Types/session/stackEvent'; -import UserEvent from './UserEvent'; +import { DATADOG, SENTRY, STACKDRIVER, typeList } from 'Types/session/stackEvent'; +import { NoContent, SlideModal, Tabs, Link } from 'UI'; import Autoscroll from '../Autoscroll'; import BottomBlock from '../BottomBlock'; +import UserEvent from './UserEvent'; const ALL = 'ALL'; -const TABS = [ ALL, ...typeList ].map(tab =>({ text: tab, key: tab })); +const TABS = [ALL, ...typeList].map((tab) => ({ text: tab, key: tab })); @withEnumToggle('activeTab', 'setActiveTab', ALL) -@connectPlayer(state => ({ +@connectPlayer((state) => ({ stackEvents: state.stackList, + stackEventsNow: state.stackListNow, })) -@connect(state => ({ - hintIsHidden: state.getIn(['components', 'player', 'hiddenHints', 'stack']) || - !state.getIn([ 'site', 'list' ]).some(s => s.stackIntegrations), -}), { - hideHint -}) +@connect( + (state) => ({ + hintIsHidden: + state.getIn(['components', 'player', 'hiddenHints', 'stack']) || + !state.getIn(['site', 'list']).some((s) => s.stackIntegrations), + }), + { + hideHint, + } +) export default class StackEvents extends React.PureComponent { -// onFilterChange = (e, { value }) => this.setState({ filter: value }) + // onFilterChange = (e, { value }) => this.setState({ filter: value }) + + state = { + currentEvent: null, + }; + + onDetailsClick(userEvent) { + this.setState({ currentEvent: userEvent }); + } + + closeModal() { + this.setState({ currentEvent: undefined }); + } + + renderPopupContent(userEvent) { + console.log('event', userEvent); + const { source, payload, name } = userEvent; + switch (source) { + case SENTRY: + return <Sentry event={payload} />; + case DATADOG: + return <JsonViewer title={name} data={payload} icon="integrations/datadog" />; + case STACKDRIVER: + return <JsonViewer title={name} data={payload} icon="integrations/stackdriver" />; + default: + return <JsonViewer title={name} data={payload} icon={`integrations/${source}`} />; + } + } render() { const { stackEvents, activeTab, setActiveTab, hintIsHidden } = this.props; //const filterRE = new RegExp(filter, 'i'); + const { currentEvent } = this.state; - const tabs = TABS.filter(({ key }) => key === ALL || stackEvents.some(({ source }) => key === source)); + const tabs = TABS.filter( + ({ key }) => key === ALL || stackEvents.some(({ source }) => key === source) + ); const filteredStackEvents = stackEvents -// .filter(({ data }) => data.includes(filter)) + // .filter(({ data }) => data.includes(filter)) .filter(({ source }) => activeTab === ALL || activeTab === source); + let lastIndex = -1; + // TODO: Need to do filtering in store, or preferably in a selector + filteredStackEvents.forEach((item, index) => { + if ( + this.props.stackEventsNow.length > 0 && + item.time <= this.props.stackEventsNow[this.props.stackEventsNow.length - 1].time + ) { + lastIndex = index; + } + }); + return ( - <BottomBlock> - <BottomBlock.Header> - <div className="flex items-center"> - <span className="font-semibold color-gray-medium mr-4">Events</span> - <Tabs - className="uppercase" - tabs={ tabs } - active={ activeTab } - onClick={ setActiveTab } - border={ false } - /> - </div> - </BottomBlock.Header> - <BottomBlock.Content> - <NoContent - title="Nothing to display yet." - subtext={ !hintIsHidden - ? - <> - <a className="underline color-teal" href="https://docs.openreplay.com/integrations" target="_blank">Integrations</a> - {' and '} - <a className="underline color-teal" href="https://docs.openreplay.com/api#event" target="_blank">Events</a> - { ' make debugging easier. Sync your backend logs and custom events with session replay.' } - <br/><br/> - <button className="color-teal" onClick={() => this.props.hideHint("stack")}>Got It!</button> - </> - : null - } - size="small" - show={ filteredStackEvents.length === 0 } - > - <Autoscroll> - { filteredStackEvents.map(userEvent => ( - <UserEvent - key={ userEvent.key } - userEvent={ userEvent } - onJump={ () => jump(userEvent.time) } - /> - ))} - </Autoscroll> - </NoContent> - </BottomBlock.Content> - </BottomBlock> + <> + <SlideModal + title={ + currentEvent && ( + <div className="mb-4"> + <div className="text-xl mb-2"> + <Link to={errorRoute(currentEvent.errorId)}> + <span className="font-bold">{currentEvent.name}</span> + </Link> + <span className="ml-2 text-sm color-gray-medium">{currentEvent.function}</span> + </div> + <div>{currentEvent.message}</div> + </div> + ) + } + isDisplayed={currentEvent != null} + content={ + currentEvent && <div className="px-4">{this.renderPopupContent(currentEvent)}</div> + } + onClose={this.closeModal.bind(this)} + /> + <BottomBlock> + <BottomBlock.Header> + <div className="flex items-center"> + <span className="font-semibold color-gray-medium mr-4">Events</span> + <Tabs + className="uppercase" + tabs={tabs} + active={activeTab} + onClick={setActiveTab} + border={false} + /> + </div> + </BottomBlock.Header> + <BottomBlock.Content> + <NoContent + title="Nothing to display yet." + subtext={ + !hintIsHidden ? ( + <> + <a + className="underline color-teal" + href="https://docs.openreplay.com/integrations" + target="_blank" + > + Integrations + </a> + {' and '} + <a + className="underline color-teal" + href="https://docs.openreplay.com/api#event" + target="_blank" + > + Events + </a> + { + ' make debugging easier. Sync your backend logs and custom events with session replay.' + } + <br /> + <br /> + <button className="color-teal" onClick={() => this.props.hideHint('stack')}> + Got It! + </button> + </> + ) : null + } + size="small" + show={filteredStackEvents.length === 0} + > + <Autoscroll autoScrollTo={Math.max(lastIndex, 0)}> + {filteredStackEvents.map((userEvent, index) => ( + <UserEvent + key={userEvent.key} + onDetailsClick={this.onDetailsClick.bind(this)} + inactive={index > lastIndex} + selected={lastIndex === index} + userEvent={userEvent} + onJump={() => jump(userEvent.time)} + /> + ))} + </Autoscroll> + </NoContent> + </BottomBlock.Content> + </BottomBlock> + </> ); } } diff --git a/frontend/app/components/Session_/StackEvents/UserEvent/UserEvent.js b/frontend/app/components/Session_/StackEvents/UserEvent/UserEvent.js index a40da51f8..1c657a2d8 100644 --- a/frontend/app/components/Session_/StackEvents/UserEvent/UserEvent.js +++ b/frontend/app/components/Session_/StackEvents/UserEvent/UserEvent.js @@ -1,122 +1,68 @@ import React from 'react'; import cn from 'classnames'; -import { OPENREPLAY, SENTRY, DATADOG, STACKDRIVER } from 'Types/session/stackEvent'; -import { Icon, SlideModal, IconButton } from 'UI'; +import { OPENREPLAY, SENTRY, DATADOG, STACKDRIVER } from 'Types/session/stackEvent'; +import { Icon, IconButton } from 'UI'; import withToggle from 'HOCs/withToggle'; import Sentry from './Sentry'; import JsonViewer from './JsonViewer'; import stl from './userEvent.module.css'; +import { Duration } from 'luxon'; // const modalSources = [ SENTRY, DATADOG ]; -@withToggle() // +@withToggle() // export default class UserEvent extends React.PureComponent { - getIconProps() { - const { source } = this.props.userEvent; - return { - name: `integrations/${ source }`, - size: 18, - marginRight: source === OPENREPLAY ? 11 : 10 - } - } + getIconProps() { + const { source } = this.props.userEvent; + return { + name: `integrations/${source}`, + size: 18, + marginRight: source === OPENREPLAY ? 11 : 10, + }; + } - getLevelClassname() { - const { userEvent } = this.props; - if (userEvent.isRed()) return "error color-red"; - return ''; - } + getLevelClassname() { + const { userEvent } = this.props; + if (userEvent.isRed()) return 'error color-red'; + return ''; + } - // getEventMessage() { - // const { userEvent } = this.props; - // switch(userEvent.source) { - // case SENTRY: - // case DATADOG: - // return null; - // default: - // return JSON.stringify(userEvent.data); - // } - // } + onClickDetails = (e) => { + e.stopPropagation(); + this.props.onDetailsClick(this.props.userEvent); + }; - renderPopupContent() { - const { userEvent: { source, payload, name} } = this.props; - switch(source) { - case SENTRY: - return <Sentry event={ payload } />; - case DATADOG: - return <JsonViewer title={ name } data={ payload } icon="integrations/datadog" />; - case STACKDRIVER: - return <JsonViewer title={ name } data={ payload } icon="integrations/stackdriver" />; - default: - return <JsonViewer title={ name } data={ payload } icon={ `integrations/${ source }` } />; - } - } - - ifNeedModal() { - return !!this.props.userEvent.payload; - } - - onClickDetails = (e) => { - e.stopPropagation(); - this.props.switchOpen(); - } - - renderContent(modalTrigger) { - const { userEvent } = this.props; - //const message = this.getEventMessage(); - return ( - <div - data-scroll-item={ userEvent.isRed() } - // onClick={ this.props.switchOpen } // - onClick={ this.props.onJump } // - className={ - cn( - "group", - stl.userEvent, - this.getLevelClassname(), - { [ stl.modalTrigger ]: modalTrigger } - ) - } - > - <div className={ stl.infoWrapper }> - <div className={ stl.title } > - <Icon { ...this.getIconProps() } /> - { userEvent.name } - </div> - { /* message && - <div className={ stl.message }> - { message } - </div> */ - } - <div className="invisible self-end ml-auto group-hover:visible"> - <IconButton size="small" plain onClick={this.onClickDetails} label="DETAILS" /> - </div> - </div> - </div> - ); - } - - render() { - const { userEvent } = this.props; - if (this.ifNeedModal()) { - return ( - <React.Fragment> - <SlideModal - //title="Add Custom Field" - size="middle" - isDisplayed={ this.props.open } - content={ this.props.open && this.renderPopupContent() } - onClose={ this.props.switchOpen } - /> - { this.renderContent(true) } - </React.Fragment> - //<Modal - // trigger={ this.renderContent(true) } - // content={ this.renderPopupContent() } - // centered={ false } - // size="small" - // /> - ); - } - return this.renderContent(); - } + render() { + const { userEvent, inactive, selected } = this.props; + //const message = this.getEventMessage(); + return ( + <div + data-scroll-item={userEvent.isRed()} + // onClick={ this.props.switchOpen } // + onClick={this.props.onJump} // + className={cn('group flex py-3 px-4 ', stl.userEvent, this.getLevelClassname(), { + [stl.inactive]: inactive, + [stl.selected]: selected, + })} + > + <div className={'self-start pr-4'}> + {Duration.fromMillis(userEvent.time).toFormat('mm:ss.SSS')} + </div> + <div className={cn('mr-auto', stl.infoWrapper)}> + <div className={stl.title}> + <Icon {...this.getIconProps()} /> + {userEvent.name} + </div> + </div> + <div className="self-center"> + <IconButton + outline={!userEvent.isRed()} + red={userEvent.isRed()} + onClick={this.onClickDetails} + label="DETAILS" + /> + </div> + </div> + ); + } } diff --git a/frontend/app/components/Session_/StackEvents/UserEvent/userEvent.module.css b/frontend/app/components/Session_/StackEvents/UserEvent/userEvent.module.css index 57388ffe5..ce5d27afa 100644 --- a/frontend/app/components/Session_/StackEvents/UserEvent/userEvent.module.css +++ b/frontend/app/components/Session_/StackEvents/UserEvent/userEvent.module.css @@ -2,9 +2,6 @@ .userEvent { border-radius: 3px; background-color: rgba(0, 118, 255, 0.05); - font-family: 'Menlo', 'monaco', 'consolas', monospace; - padding: 8px 10px; - margin: 3px 0; &.modalTrigger { cursor: pointer; @@ -35,4 +32,12 @@ &::-webkit-scrollbar { height: 1px; } +} + +.inactive { + opacity: 0.5; +} + +.selected { + background-color: rgba(54, 108, 217, 0.1); } \ No newline at end of file diff --git a/frontend/app/components/Session_/TimeTable/BarRow.js b/frontend/app/components/Session_/TimeTable/BarRow.js deleted file mode 100644 index b53661403..000000000 --- a/frontend/app/components/Session_/TimeTable/BarRow.js +++ /dev/null @@ -1,85 +0,0 @@ -import React from 'react'; -import { Popup } from 'UI'; -import { percentOf } from 'App/utils'; -import styles from './barRow.module.css' -import tableStyles from './timeTable.module.css'; - -const formatTime = time => time < 1000 ? `${ time.toFixed(2) }ms` : `${ time / 1000 }s`; - -const BarRow = ({ resource: { time, ttfb = 0, duration, key }, popup=false, timestart = 0, timewidth }) => { - const timeOffset = time - timestart; - ttfb = ttfb || 0; - const trigger = ( - <div - className={ styles.barWrapper } - style={ { - left: `${ percentOf(timeOffset, timewidth) }%`, - right: `${ 100 - percentOf(timeOffset + duration, timewidth) }%`, - minWidth: '5px' - } } - > - <div - className={ styles.ttfbBar } - style={ { - width: `${ percentOf(ttfb, duration) }%`, - } } - /> - <div - className={ styles.downloadBar } - style={ { - width: `${ percentOf(duration - ttfb, duration) }%`, - minWidth: '5px' - } } - /> - </div> - ); - if (!popup) return <div key={ key } className={ tableStyles.row } > { trigger } </div>; - - return ( - <div key={ key } className={ tableStyles.row } > - <Popup - basic - style={{ width: '100%' }} - unmountHTMLWhenHide - content={ - <React.Fragment> - { ttfb != null && - <div className={ styles.popupRow }> - <div className={ styles.title }>{ 'Waiting (TTFB)' }</div> - <div className={ styles.popupBarWrapper} > - <div - className={ styles.ttfbBar } - style={{ - left: 0, - width: `${ percentOf(ttfb, duration) }%`, - }} - /> - </div> - <div className={ styles.time } >{ formatTime(ttfb) }</div> - </div> - } - <div className={ styles.popupRow }> - <div className={ styles.title } >{ 'Content Download' }</div> - <div className= { styles.popupBarWrapper }> - <div - className={ styles.downloadBar } - style={{ - left: `${ percentOf(ttfb, duration) }%`, - width: `${ percentOf(duration - ttfb, duration) }%`, - }} - /> - </div> - <div className={ styles.time }>{ formatTime(duration - ttfb) }</div> - </div> - </React.Fragment> - } - > - {trigger} - </Popup> - </div> - ); -} - -BarRow.displayName = "BarRow"; - -export default BarRow; diff --git a/frontend/app/components/Session_/TimeTable/BarRow.tsx b/frontend/app/components/Session_/TimeTable/BarRow.tsx new file mode 100644 index 000000000..9de1a8279 --- /dev/null +++ b/frontend/app/components/Session_/TimeTable/BarRow.tsx @@ -0,0 +1,96 @@ +import { Popup } from 'UI'; +import { percentOf } from 'App/utils'; +import styles from './barRow.module.css' +import tableStyles from './timeTable.module.css'; +import React from 'react'; + +const formatTime = time => time < 1000 ? `${time.toFixed(2)}ms` : `${time / 1000}s`; + +interface Props { + resource: { + time: number + ttfb?: number + duration?: number + key: string + } + popup?: boolean + timestart: number + timewidth: number +} + +// TODO: If request has no duration, set duration to 0.2s. Enforce existence of duration in the future. +const BarRow = ({ resource: { time, ttfb = 0, duration = 200, key }, popup = false, timestart = 0, timewidth }: Props) => { + const timeOffset = time - timestart; + ttfb = ttfb || 0; + const trigger = ( + <div + className={styles.barWrapper} + style={{ + left: `${percentOf(timeOffset, timewidth)}%`, + right: `${100 - percentOf(timeOffset + duration, timewidth)}%`, + minWidth: '5px' + }} + > + <div + className={styles.ttfbBar} + style={{ + width: `${percentOf(ttfb, duration)}%`, + }} + /> + <div + className={styles.downloadBar} + style={{ + width: `${percentOf(duration - ttfb, duration)}%`, + minWidth: '5px' + }} + /> + </div> + ); + if (!popup) return <div key={key} className={tableStyles.row} > {trigger} </div>; + + return ( + <div key={key} className={tableStyles.row} > + <Popup + basic + content={ + <React.Fragment> + {ttfb != null && + <div className={styles.popupRow}> + <div className={styles.title}>{'Waiting (TTFB)'}</div> + <div className={styles.popupBarWrapper} > + <div + className={styles.ttfbBar} + style={{ + left: 0, + width: `${percentOf(ttfb, duration)}%`, + }} + /> + </div> + <div className={styles.time} >{formatTime(ttfb)}</div> + </div> + } + <div className={styles.popupRow}> + <div className={styles.title} >{'Content Download'}</div> + <div className={styles.popupBarWrapper}> + <div + className={styles.downloadBar} + style={{ + left: `${percentOf(ttfb, duration)}%`, + width: `${percentOf(duration - ttfb, duration)}%`, + }} + /> + </div> + <div className={styles.time}>{formatTime(duration - ttfb)}</div> + </div> + </React.Fragment> + } + size="mini" + position="top center" + /> + </div> + ); +} + +BarRow.displayName = "BarRow"; + +export default BarRow; \ No newline at end of file diff --git a/frontend/app/components/Session_/TimeTable/TimeTable.tsx b/frontend/app/components/Session_/TimeTable/TimeTable.tsx index 04cdf39d5..886e3074a 100644 --- a/frontend/app/components/Session_/TimeTable/TimeTable.tsx +++ b/frontend/app/components/Session_/TimeTable/TimeTable.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { List, AutoSizer } from 'react-virtualized'; import cn from 'classnames'; +import { Duration } from "luxon"; import { NoContent, IconButton, Button } from 'UI'; import { percentOf } from 'App/utils'; import { formatMs } from 'App/date'; @@ -11,31 +12,33 @@ import stl from './timeTable.module.css'; import autoscrollStl from '../autoscroll.module.css'; //aaa type Timed = { - time: number; + time: number; }; type Durationed = { - duration: number; + duration: number; }; type CanBeRed = { - //+isRed: boolean, - isRed: () => boolean; + //+isRed: boolean, + isRed: () => boolean; }; -type Row = Timed & Durationed & CanBeRed; +type Row = Timed & Durationed & CanBeRed & { [key: string]: any, key: string }; type Line = { - color: string; // Maybe use typescript? - hint?: string; - onClick?: any; + color: string; // Maybe use typescript? + hint?: string; + onClick?: any; } & Timed; type Column = { - label: string; - width: number; - referenceLines?: Array<Line>; - style?: Object; + label: string; + width: number; + dataKey?: string; + render?: (row: any) => void + referenceLines?: Array<Line>; + style?: Object; } & RenderOrKey; // type RenderOrKey = { // Disjoint? @@ -44,23 +47,31 @@ type Column = { // dataKey: string, // } type RenderOrKey = - | { - render?: (row: Row) => React.ReactNode; - key?: string; - } - | { - dataKey: string; - }; + | { + render?: (row: Row) => React.ReactNode; + key?: string; + } + | { + dataKey: string; + }; type Props = { - className?: string; - rows: Array<Row>; - children: Array<Column>; + className?: string; + rows: Array<Row>; + children: Array<Column>; + tableHeight?: number + activeIndex?: number + renderPopup?: boolean + navigation?: boolean + referenceLines?: any[] + additionalHeight?: number + hoverable?: boolean + onRowClick?: (row: any, index: number) => void }; type TimeLineInfo = { - timestart: number; - timewidth: number; + timestart: number; + timewidth: number; }; type State = TimeLineInfo & typeof initialState; @@ -72,247 +83,235 @@ const ROW_HEIGHT = 32; const TIME_SECTIONS_COUNT = 8; const ZERO_TIMEWIDTH = 1000; -function formatTime(ms) { - if (ms < 0) return ''; - return formatMs(ms); +function formatTime(ms: number) { + if (ms < 0) return ''; + if (ms < 1000) return Duration.fromMillis(ms).toFormat('0.SSS') + return Duration.fromMillis(ms).toFormat('mm:ss'); } -function computeTimeLine(rows: Array<Row>, firstVisibleRowIndex: number, visibleCount): TimeLineInfo { - const visibleRows = rows.slice(firstVisibleRowIndex, firstVisibleRowIndex + visibleCount + _additionalHeight); - let timestart = visibleRows.length > 0 ? Math.min(...visibleRows.map((r) => r.time)) : 0; - const timeend = visibleRows.length > 0 ? Math.max(...visibleRows.map((r) => r.time + r.duration)) : 0; - let timewidth = timeend - timestart; - const offset = timewidth / 70; - if (timestart >= offset) { - timestart -= offset; - } - timewidth *= 1.5; // += offset; - if (timewidth === 0) { - timewidth = ZERO_TIMEWIDTH; - } - return { - timestart, - timewidth, - }; +function computeTimeLine(rows: Array<Row>, firstVisibleRowIndex: number, visibleCount: number): TimeLineInfo { + const visibleRows = rows.slice(firstVisibleRowIndex, firstVisibleRowIndex + visibleCount + _additionalHeight); + let timestart = visibleRows.length > 0 ? Math.min(...visibleRows.map((r) => r.time)) : 0; + // TODO: GraphQL requests do not have a duration, so their timeline is borked. Assume a duration of 0.2s for every GraphQL request + const timeend = visibleRows.length > 0 ? Math.max(...visibleRows.map((r) => r.time + (r.duration ?? 200))) : 0; + let timewidth = timeend - timestart; + const offset = timewidth / 70; + if (timestart >= offset) { + timestart -= offset; + } + timewidth *= 1.5; // += offset; + if (timewidth === 0) { + timewidth = ZERO_TIMEWIDTH; + } + return { + timestart, + timewidth, + }; } const initialState = { - firstVisibleRowIndex: 0, + firstVisibleRowIndex: 0, }; export default class TimeTable extends React.PureComponent<Props, State> { - state = { - ...computeTimeLine(this.props.rows, initialState.firstVisibleRowIndex, this.visibleCount), - ...initialState, - }; + state = { + ...computeTimeLine(this.props.rows, initialState.firstVisibleRowIndex, this.visibleCount), + ...initialState, + }; - get tableHeight() { - return this.props.tableHeight || 195; + get tableHeight() { + return this.props.tableHeight || 195; + } + + get visibleCount() { + return Math.ceil(this.tableHeight / ROW_HEIGHT); + } + + scroller = React.createRef<List>(); + autoScroll = true; + + componentDidMount() { + if (this.scroller.current) { + this.scroller.current.scrollToRow(this.props.activeIndex); + } + } + + componentDidUpdate(prevProps: any, prevState: any) { + if ( + prevState.firstVisibleRowIndex !== this.state.firstVisibleRowIndex || + (this.props.rows.length <= this.visibleCount + _additionalHeight && prevProps.rows.length !== this.props.rows.length) + ) { + this.setState({ + ...computeTimeLine(this.props.rows, this.state.firstVisibleRowIndex, this.visibleCount), + }); + } + if (this.props.activeIndex && this.props.activeIndex >= 0 && prevProps.activeIndex !== this.props.activeIndex && this.scroller.current) { + this.scroller.current.scrollToRow(this.props.activeIndex); + } + } + + onScroll = ({ scrollTop, scrollHeight, clientHeight }: { scrollTop: number; scrollHeight: number; clientHeight: number }): void => { + const firstVisibleRowIndex = Math.floor(scrollTop / ROW_HEIGHT + 0.33); + + if (this.state.firstVisibleRowIndex !== firstVisibleRowIndex) { + this.autoScroll = scrollHeight - clientHeight - scrollTop < ROW_HEIGHT / 2; + this.setState({ firstVisibleRowIndex }); + } + }; + + renderRow = ({ index, key, style: rowStyle }: any) => { + const { activeIndex } = this.props; + const { children: columns, rows, renderPopup, hoverable, onRowClick } = this.props; + const { timestart, timewidth } = this.state; + const row = rows[index]; + return ( + <div + style={rowStyle} + key={key} + className={cn('border-b border-color-gray-light-shade', stl.row, { + [stl.hoverable]: hoverable, + 'error color-red': !!row.isRed && row.isRed(), + 'cursor-pointer': typeof onRowClick === 'function', + [stl.activeRow]: activeIndex === index, + [stl.inactiveRow]: !activeIndex || index > activeIndex, + })} + onClick={typeof onRowClick === 'function' ? () => onRowClick(row, index) : undefined} + id="table-row" + > + {columns.map(({ dataKey, render, width }) => ( + <div className={stl.cell} style={{ width: `${width}px` }}> + {render ? render(row) : row[dataKey || ''] || <i className="color-gray-light">{'empty'}</i>} + </div> + ))} + <div className={cn('relative flex-1 flex', stl.timeBarWrapper)}> + <BarRow resource={row} timestart={timestart} timewidth={timewidth} popup={renderPopup} /> + </div> + </div> + ); + }; + + onPrevClick = () => { + let prevRedIndex = -1; + for (let i = this.state.firstVisibleRowIndex - 1; i >= 0; i--) { + if (this.props.rows[i].isRed()) { + prevRedIndex = i; + break; + } + } + if (this.scroller.current != null) { + this.scroller.current.scrollToRow(prevRedIndex); + } + }; + + onNextClick = () => { + let prevRedIndex = -1; + for (let i = this.state.firstVisibleRowIndex + 1; i < this.props.rows.length; i++) { + if (this.props.rows[i].isRed()) { + prevRedIndex = i; + break; + } + } + if (this.scroller.current != null) { + this.scroller.current.scrollToRow(prevRedIndex); + } + }; + + render() { + const { className, rows, children: columns, navigation = false, referenceLines = [], additionalHeight = 0, activeIndex } = this.props; + const { timewidth, timestart } = this.state; + + _additionalHeight = additionalHeight; + + const sectionDuration = Math.round(timewidth / TIME_SECTIONS_COUNT); + const timeColumns: number[] = []; + if (timewidth > 0) { + for (let i = 0; i < TIME_SECTIONS_COUNT; i++) { + timeColumns.push(timestart + i * sectionDuration); + } } - get visibleCount() { - return Math.ceil(this.tableHeight / ROW_HEIGHT); - } + const visibleRefLines = referenceLines.filter(({ time }) => time > timestart && time < timestart + timewidth); - scroller = React.createRef(); - autoScroll = true; + const columnsSumWidth = columns.reduce((sum, { width }) => sum + width, 0); - componentDidMount() { - if (this.scroller.current) { - this.scroller.current.scrollToRow(this.props.activeIndex); - } - } - - componentDidUpdate(prevProps: any, prevState: any) { - // if (prevProps.rows.length !== this.props.rows.length && - // this.autoScroll && - // this.scroller.current != null) { - // this.scroller.current.scrollToRow(this.props.rows.length); - // } - if ( - prevState.firstVisibleRowIndex !== this.state.firstVisibleRowIndex || - (this.props.rows.length <= this.visibleCount + _additionalHeight && prevProps.rows.length !== this.props.rows.length) - ) { - this.setState({ - ...computeTimeLine(this.props.rows, this.state.firstVisibleRowIndex, this.visibleCount), - }); - } - if (this.props.activeIndex >= 0 && prevProps.activeIndex !== this.props.activeIndex && this.scroller.current) { - this.scroller.current.scrollToRow(this.props.activeIndex); - } - } - - onScroll = ({ scrollTop, scrollHeight, clientHeight }: { scrollTop: number; scrollHeight: number; clientHeight: number }): void => { - const firstVisibleRowIndex = Math.floor(scrollTop / ROW_HEIGHT + 0.33); - - if (this.state.firstVisibleRowIndex !== firstVisibleRowIndex) { - this.autoScroll = scrollHeight - clientHeight - scrollTop < ROW_HEIGHT / 2; - this.setState({ firstVisibleRowIndex }); - } - }; - - renderRow = ({ index, key, style: rowStyle }: any) => { - const { activeIndex } = this.props; - const { children: columns, rows, renderPopup, hoverable, onRowClick } = this.props; - const { timestart, timewidth } = this.state; - const row = rows[index]; - return ( - <div - style={rowStyle} - key={key} - className={cn('border-b border-color-gray-light-shade', stl.row, { - [stl.hoverable]: hoverable, - 'error color-red': !!row.isRed && row.isRed(), - 'cursor-pointer': typeof onRowClick === 'function', - [stl.activeRow]: activeIndex === index, - })} - onClick={typeof onRowClick === 'function' ? () => onRowClick(row, index) : null} - id="table-row" - > - {columns.map(({ dataKey, render, width }) => ( - <div className={stl.cell} style={{ width: `${width}px` }}> - {render ? render(row) : row[dataKey] || <i className="color-gray-light">{'empty'}</i>} - </div> - ))} - <div className={cn('relative flex-1 flex', stl.timeBarWrapper)}> - <BarRow resource={row} timestart={timestart} timewidth={timewidth} popup={renderPopup} /> - </div> - </div> - ); - }; - - onPrevClick = () => { - let prevRedIndex = -1; - for (let i = this.state.firstVisibleRowIndex - 1; i >= 0; i--) { - if (this.props.rows[i].isRed()) { - prevRedIndex = i; - break; - } - } - if (this.scroller.current != null) { - this.scroller.current.scrollToRow(prevRedIndex); - } - }; - - onNextClick = () => { - let prevRedIndex = -1; - for (let i = this.state.firstVisibleRowIndex + 1; i < this.props.rows.length; i++) { - if (this.props.rows[i].isRed()) { - prevRedIndex = i; - break; - } - } - if (this.scroller.current != null) { - this.scroller.current.scrollToRow(prevRedIndex); - } - }; - - render() { - const { className, rows, children: columns, navigation = false, referenceLines = [], additionalHeight = 0, activeIndex } = this.props; - const { timewidth, timestart } = this.state; - - _additionalHeight = additionalHeight; - - const sectionDuration = Math.round(timewidth / TIME_SECTIONS_COUNT); - const timeColumns = []; - if (timewidth > 0) { - for (let i = 0; i < TIME_SECTIONS_COUNT; i++) { - timeColumns.push(timestart + i * sectionDuration); - } - } - - const visibleRefLines = referenceLines.filter(({ time }) => time > timestart && time < timestart + timewidth); - - const columnsSumWidth = columns.reduce((sum, { width }) => sum + width, 0); - - return ( - <div className={cn(className, 'relative')}> - {navigation && ( - <div className={cn(autoscrollStl.navButtons, 'flex items-center')}> - <Button - variant="text-primary" - icon="chevron-up" - tooltip={{ - title: 'Previous Error', - delay: 0, - }} - onClick={this.onPrevClick} - /> - <Button - variant="text-primary" - icon="chevron-down" - tooltip={{ - title: 'Next Error', - delay: 0, - }} - onClick={this.onNextClick} - /> - {/* <IconButton - size="small" - icon="chevron-up" + return ( + <div className={cn(className, 'relative')}> + {navigation && ( + <div className={cn(autoscrollStl.navButtons, 'flex items-center')}> + <Button + variant="text-primary" + icon="chevron-up" + tooltip={{ + title: 'Previous Error', + delay: 0, + }} onClick={this.onPrevClick} - /> */} - {/* <IconButton - size="small" + /> + <Button + variant="text-primary" icon="chevron-down" + tooltip={{ + title: 'Next Error', + delay: 0, + }} onClick={this.onNextClick} - /> */} - </div> - )} - <div className={stl.headers}> - <div className={stl.infoHeaders}> - {columns.map(({ label, width }) => ( - <div className={stl.headerCell} style={{ width: `${width}px` }}> - {label} - </div> - ))} - </div> - <div className={stl.waterfallHeaders}> - {timeColumns.map((time, i) => ( - <div className={stl.timeCell} key={`tc-${i}`}> - {formatTime(time)} - </div> - ))} - </div> - </div> + /> + </div> + )} + <div className={stl.headers}> + <div className={stl.infoHeaders}> + {columns.map(({ label, width }) => ( + <div className={stl.headerCell} style={{ width: `${width}px` }}> + {label} + </div> + ))} + </div> + <div className={stl.waterfallHeaders}> + {timeColumns.map((time, i) => ( + <div className={stl.timeCell} key={`tc-${i}`}> + {formatTime(time)} + </div> + ))} + </div> + </div> - <NoContent size="small" show={rows.length === 0} title="No recordings found"> - <div className="relative"> - <div className={stl.timePart} style={{ left: `${columnsSumWidth}px` }}> - {timeColumns.map((_, index) => ( - <div key={`tc-${index}`} className={stl.timeCell} /> - ))} - {visibleRefLines.map(({ time, color, onClick }) => ( - <div - className={cn(stl.refLine, `bg-${color}`)} - style={{ - left: `${percentOf(time - timestart, timewidth)}%`, - cursor: typeof onClick === 'function' ? 'click' : 'auto', - }} - onClick={onClick} - /> - ))} - </div> - <AutoSizer disableHeight> - {({ width }) => ( - <List - ref={this.scroller} - className={stl.list} - height={this.tableHeight + additionalHeight} - width={width} - overscanRowCount={20} - rowCount={rows.length} - rowHeight={ROW_HEIGHT} - rowRenderer={this.renderRow} - onScroll={this.onScroll} - scrollToAlignment="start" - forceUpdateProp={timestart | timewidth | activeIndex} - /> - )} - </AutoSizer> - </div> - </NoContent> + <NoContent size="small" show={rows.length === 0}> + <div className="relative"> + <div className={stl.timePart} style={{ left: `${columnsSumWidth}px` }}> + {timeColumns.map((_, index) => ( + <div key={`tc-${index}`} className={stl.timeCell} /> + ))} + {visibleRefLines.map(({ time, color, onClick }) => ( + <div + className={cn(stl.refLine, `bg-${color}`)} + style={{ + left: `${percentOf(time - timestart, timewidth)}%`, + cursor: typeof onClick === 'function' ? 'click' : 'auto', + }} + onClick={onClick} + /> + ))} </div> - ); - } -} + <AutoSizer disableHeight> + {({ width }: { width: number }) => ( + <List + ref={this.scroller} + className={stl.list} + height={this.tableHeight + additionalHeight} + width={width} + overscanRowCount={20} + rowCount={rows.length} + rowHeight={ROW_HEIGHT} + rowRenderer={this.renderRow} + onScroll={this.onScroll} + scrollToAlignment="start" + forceUpdateProp={timestart | timewidth | (activeIndex || 0)} + /> + )} + </AutoSizer> + </div> + </NoContent> + </div> + ); + } +} \ No newline at end of file diff --git a/frontend/app/components/Session_/TimeTable/timeTable.module.css b/frontend/app/components/Session_/TimeTable/timeTable.module.css index 643f02012..6a22a9d57 100644 --- a/frontend/app/components/Session_/TimeTable/timeTable.module.css +++ b/frontend/app/components/Session_/TimeTable/timeTable.module.css @@ -101,4 +101,8 @@ $offset: 10px; .activeRow { background-color: rgba(54, 108, 217, 0.1); +} + +.inactiveRow { + opacity: 0.5; } \ No newline at end of file diff --git a/frontend/app/components/Session_/autoscroll.module.css b/frontend/app/components/Session_/autoscroll.module.css index 42c5d980a..209badfb2 100644 --- a/frontend/app/components/Session_/autoscroll.module.css +++ b/frontend/app/components/Session_/autoscroll.module.css @@ -1,19 +1,12 @@ -.wrapper { - & .navButtons { - opacity: 0; - transition: opacity .3s - } - &:hover { - & .navButtons { - opacity: .7; - } - } -} - .navButtons { position: absolute; - right: 260px; - top: -39px; + + background: rgba(255, 255, 255, 0.5); + padding: 4px; + + right: 24px; + top: 8px; + z-index: 1; } diff --git a/frontend/app/components/hocs/withLocationHandlers.js b/frontend/app/components/hocs/withLocationHandlers.js index a202f178e..b386690b8 100644 --- a/frontend/app/components/hocs/withLocationHandlers.js +++ b/frontend/app/components/hocs/withLocationHandlers.js @@ -1,60 +1,55 @@ import React from 'react'; import { withRouter } from 'react-router-dom'; -import { - removeQueryParams, - addQueryParams, - setQueryParams, - parseQuery, -} from 'App/routes'; +import { removeQueryParams, addQueryParams, setQueryParams, parseQuery } from 'App/routes'; /* eslint-disable react/sort-comp */ -const withLocationHandlers = propNames => BaseComponent => +const withLocationHandlers = (propNames) => (BaseComponent) => { @withRouter - class extends React.Component { - getQuery = names => parseQuery(this.props.location, names) - getParam = name => parseQuery(this.props.location)[ name ] + class WrapperClass extends React.Component { + getQuery = (names) => parseQuery(this.props.location, names); + getParam = (name) => parseQuery(this.props.location)[name]; addQuery = (params) => { const { location, history } = this.props; history.push(addQueryParams(location, params)); - } - removeQuery = (names = [], replace=false) => { + }; + removeQuery = (names = [], replace = false) => { const { location, history } = this.props; - const namesArray = Array.isArray(names) ? names : [ names ]; + const namesArray = Array.isArray(names) ? names : [names]; /* to avoid update stack overflow */ const actualNames = Object.keys(this.getQuery(namesArray)); if (actualNames.length > 0) { - history[ replace ? 'replace' : 'push' ](removeQueryParams(location, actualNames)); + history[replace ? 'replace' : 'push'](removeQueryParams(location, actualNames)); } - } - setQuery = (params, replace=false) => { + }; + setQuery = (params, replace = false) => { const { location, history } = this.props; - history[ replace ? 'replace' : 'push' ](setQueryParams(location, params)); - } + history[replace ? 'replace' : 'push'](setQueryParams(location, params)); + }; query = { all: this.getQuery, get: this.getParam, add: this.addQuery, remove: this.removeQuery, - set: this.setQuery, // TODO: use namespaces - } + set: this.setQuery, // TODO: use namespaces + }; - getHash = () => this.props.location.hash.substring(1) + getHash = () => this.props.location.hash.substring(1); setHash = (hash) => { const { location, history } = this.props; - history.push({ ...location, hash: `#${ hash }` }); - } + history.push({ ...location, hash: `#${hash}` }); + }; removeHash = () => { const { location, history } = this.props; history.push({ ...location, hash: '' }); - } + }; hash = { get: this.getHash, set: this.setHash, remove: this.removeHash, - } + }; getQueryProps() { if (Array.isArray(propNames)) return this.getQuery(propNames); @@ -62,7 +57,9 @@ const withLocationHandlers = propNames => BaseComponent => const values = Object.values(propNames); const query = this.getQuery(values); const queryProps = {}; - Object.keys(propNames).map((key) => { queryProps[ key ] = query[ propNames[ key ] ]; }); + Object.keys(propNames).map((key) => { + queryProps[key] = query[propNames[key]]; + }); return queryProps; } return {}; @@ -70,15 +67,9 @@ const withLocationHandlers = propNames => BaseComponent => render() { const queryProps = this.getQueryProps(); - return ( - <BaseComponent - query={ this.query } - hash={ this.hash } - { ...queryProps } - { ...this.props } - /> - ); + return <BaseComponent query={this.query} hash={this.hash} {...queryProps} {...this.props} />; } - }; - + } + return WrapperClass; +}; export default withLocationHandlers; diff --git a/frontend/app/components/hocs/withPermissions.js b/frontend/app/components/hocs/withPermissions.js index 1f4e6ade8..f31730553 100644 --- a/frontend/app/components/hocs/withPermissions.js +++ b/frontend/app/components/hocs/withPermissions.js @@ -2,33 +2,32 @@ import React from "react"; import { connect } from "react-redux"; import { NoPermission, NoSessionPermission } from "UI"; -export default (requiredPermissions, className, isReplay = false) => - (BaseComponent) => - ( - @connect((state, props) => ({ - permissions: - state.getIn(["user", "account", "permissions"]) || [], - isEnterprise: - state.getIn(["user", "account", "edition"]) === "ee", - })) - class extends React.PureComponent { - render() { - const hasPermission = requiredPermissions.every( - (permission) => - this.props.permissions.includes(permission) - ); +export default (requiredPermissions, className, isReplay = false) => (BaseComponent) => { + @connect((state, props) => ({ + permissions: + state.getIn(["user", "account", "permissions"]) || [], + isEnterprise: + state.getIn(["user", "account", "edition"]) === "ee", + })) + class WrapperClass extends React.PureComponent { + render() { + const hasPermission = requiredPermissions.every( + (permission) => + this.props.permissions.includes(permission) + ); - return !this.props.isEnterprise || hasPermission ? ( - <BaseComponent {...this.props} /> - ) : ( - <div className={className}> - {isReplay ? ( - <NoSessionPermission /> - ) : ( - <NoPermission /> - )} - </div> - ); - } - } - ); + return !this.props.isEnterprise || hasPermission ? ( + <BaseComponent {...this.props} /> + ) : ( + <div className={className}> + {isReplay ? ( + <NoSessionPermission /> + ) : ( + <NoPermission /> + )} + </div> + ); + } + } + return WrapperClass +} \ No newline at end of file diff --git a/frontend/app/components/hocs/withSiteIdRouter.js b/frontend/app/components/hocs/withSiteIdRouter.js index 4dbaf623c..ee41610ce 100644 --- a/frontend/app/components/hocs/withSiteIdRouter.js +++ b/frontend/app/components/hocs/withSiteIdRouter.js @@ -1,30 +1,32 @@ import React from 'react'; import { withRouter } from 'react-router-dom'; import { connect } from 'react-redux'; -import { withSiteId } from 'App/routes'; +import { withSiteId } from 'App/routes'; import { setSiteId } from 'Duck/site'; -export default BaseComponent => -@withRouter -@connect((state, props) => ({ - urlSiteId: props.match.params.siteId, - siteId: state.getIn([ 'site', 'siteId' ]), -}), { - setSiteId, -}) -class extends React.PureComponent { - push = (location) => { - const { history, siteId } = this.props; - if (typeof location === 'string') { - history.push(withSiteId(location, siteId)); - } else if (typeof location === 'object'){ - history.push({ ...location, pathname: withSiteId(location.pathname, siteId) }); +export default BaseComponent => { + @withRouter + @connect((state, props) => ({ + urlSiteId: props.match.params.siteId, + siteId: state.getIn(['site', 'siteId']), + }), { + setSiteId, + }) + class WrappedClass extends React.PureComponent { + push = (location) => { + const { history, siteId } = this.props; + if (typeof location === 'string') { + history.push(withSiteId(location, siteId)); + } else if (typeof location === 'object') { + history.push({ ...location, pathname: withSiteId(location.pathname, siteId) }); + } + } + + render() { + const { history, ...other } = this.props + + return <BaseComponent {...other} history={{ ...history, push: this.push }} /> } } - - render() { - const { history, ...other } = this.props - - return <BaseComponent { ...other } history={ { ...history, push: this.push } } /> - } -} \ No newline at end of file + return WrappedClass +} \ No newline at end of file diff --git a/frontend/app/components/hocs/withSiteIdUpdater.js b/frontend/app/components/hocs/withSiteIdUpdater.js index 3abb48c09..1c4e038ae 100644 --- a/frontend/app/components/hocs/withSiteIdUpdater.js +++ b/frontend/app/components/hocs/withSiteIdUpdater.js @@ -2,36 +2,39 @@ import React from 'react'; import { connect } from 'react-redux'; import { setSiteId } from 'Duck/site'; -export default BaseComponent => -@connect((state, props) => ({ - urlSiteId: props.match.params.siteId, - siteId: state.getIn([ 'site', 'siteId' ]), -}), { - setSiteId, -}) -class extends React.PureComponent { - state = { load: false } - constructor(props) { - super(props); - if (props.urlSiteId && props.urlSiteId !== props.siteId) { - props.setSiteId(props.urlSiteId); +export default (BaseComponent) => { + @connect((state, props) => ({ + urlSiteId: props.match.params.siteId, + siteId: state.getIn(['site', 'siteId']), + }), { + setSiteId, + }) + class WrapperClass extends React.PureComponent { + state = { load: false } + constructor(props) { + super(props); + if (props.urlSiteId && props.urlSiteId !== props.siteId) { + props.setSiteId(props.urlSiteId); + } } - } - componentDidUpdate(prevProps) { - const { urlSiteId, siteId, location: { pathname }, history } = this.props; - const shouldUrlUpdate = urlSiteId && urlSiteId !== siteId; - if (shouldUrlUpdate) { - const path = [ '', siteId ].concat(pathname.split('/').slice(2)).join('/'); - history.push(path); + componentDidUpdate(prevProps) { + const { urlSiteId, siteId, location: { pathname }, history } = this.props; + const shouldUrlUpdate = urlSiteId && urlSiteId !== siteId; + if (shouldUrlUpdate) { + const path = ['', siteId].concat(pathname.split('/').slice(2)).join('/'); + history.push(path); + } + const shouldBaseComponentReload = shouldUrlUpdate || siteId !== prevProps.siteId; + if (shouldBaseComponentReload) { + this.setState({ load: true }); + setTimeout(() => this.setState({ load: false }), 0); + } } - const shouldBaseComponentReload = shouldUrlUpdate || siteId !== prevProps.siteId; - if (shouldBaseComponentReload) { - this.setState({ load: true }); - setTimeout(() => this.setState({ load: false }), 0); + + render() { + return this.state.load ? null : <BaseComponent {...this.props} />; } } - render() { - return this.state.load ? null : <BaseComponent { ...this.props } />; - } -} \ No newline at end of file + return WrapperClass +} \ No newline at end of file diff --git a/frontend/app/components/shared/CustomMetrics/FilterSeries/SeriesName/SeriesName.tsx b/frontend/app/components/shared/CustomMetrics/FilterSeries/SeriesName/SeriesName.tsx index 5d25e9de9..d6a69c73d 100644 --- a/frontend/app/components/shared/CustomMetrics/FilterSeries/SeriesName/SeriesName.tsx +++ b/frontend/app/components/shared/CustomMetrics/FilterSeries/SeriesName/SeriesName.tsx @@ -46,7 +46,7 @@ function SeriesName(props: Props) { onFocus={() => setEditing(true)} /> ) : ( - <div className="text-base h-8 flex items-center border-transparent">{name.trim() === '' ? 'Seriess ' + (seriesIndex + 1) : name }</div> + <div className="text-base h-8 flex items-center border-transparent">{name && name.trim() === '' ? 'Series ' + (seriesIndex + 1) : name }</div> )} <div className="ml-3 cursor-pointer" onClick={() => setEditing(true)}><Icon name="pencil" size="14" /></div> diff --git a/frontend/app/components/ui/ErrorItem/ErrorItem.js b/frontend/app/components/ui/ErrorItem/ErrorItem.js index f1145ac71..3eab87fe2 100644 --- a/frontend/app/components/ui/ErrorItem/ErrorItem.js +++ b/frontend/app/components/ui/ErrorItem/ErrorItem.js @@ -2,19 +2,24 @@ import React from 'react' import cn from 'classnames' import { IconButton } from 'UI' import stl from './errorItem.module.css'; +import {Duration} from "luxon"; -function ErrorItem({ error = {}, onErrorClick, onJump }) { +function ErrorItem({ error = {}, onErrorClick, onJump, inactive, selected }) { return ( - <div className={ cn(stl.wrapper, 'py-3 px-4 flex cursor-pointer') } onClick={onJump}> - <div className="mr-auto"> + <div className={ cn(stl.wrapper, 'py-3 px-4 flex cursor-pointer', { + [stl.inactive]: inactive, + [stl.selected]: selected + }) } onClick={onJump}> + <div className={"self-start pr-4 color-red"}>{Duration.fromMillis(error.time).toFormat('mm:ss.SSS')}</div> + <div className="mr-auto overflow-hidden"> <div className="color-red mb-1 cursor-pointer code-font"> {error.name} <span className="color-gray-darkest ml-2">{ error.stack0InfoString }</span> </div> <div className="text-sm color-gray-medium">{error.message}</div> </div> - <div className="self-end"> - <IconButton plain onClick={onErrorClick} label="DETAILS" /> + <div className="self-center"> + <IconButton red onClick={onErrorClick} label="DETAILS" /> </div> </div> ) diff --git a/frontend/app/components/ui/ErrorItem/errorItem.module.css b/frontend/app/components/ui/ErrorItem/errorItem.module.css index 5a185ed5c..45b2a12c1 100644 --- a/frontend/app/components/ui/ErrorItem/errorItem.module.css +++ b/frontend/app/components/ui/ErrorItem/errorItem.module.css @@ -1,3 +1,11 @@ .wrapper { border-bottom: solid thin $gray-light-shade; +} + +.inactive { + opacity: 0.5; +} + +.selected { + background-color: rgba(54, 108, 217, 0.1); } \ No newline at end of file diff --git a/frontend/app/components/ui/LinkStyledInput/LinkStyledInput.js b/frontend/app/components/ui/LinkStyledInput/LinkStyledInput.js index eb579507e..110d0e6d7 100644 --- a/frontend/app/components/ui/LinkStyledInput/LinkStyledInput.js +++ b/frontend/app/components/ui/LinkStyledInput/LinkStyledInput.js @@ -46,7 +46,7 @@ export default class LinkStyledInput extends React.PureComponent { document.removeEventListener('click', this.onEndChange, false); this.setState({ changing: false, - value: this.state.value.trim(), + value: this.state.value ? this.state.value.trim() : undefined, }); } diff --git a/frontend/app/components/ui/NoContent/NoContent.js b/frontend/app/components/ui/NoContent/NoContent.js new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/app/components/ui/Pagination/Pagination.tsx b/frontend/app/components/ui/Pagination/Pagination.tsx index b36d2b397..6d131fa96 100644 --- a/frontend/app/components/ui/Pagination/Pagination.tsx +++ b/frontend/app/components/ui/Pagination/Pagination.tsx @@ -5,21 +5,21 @@ import cn from 'classnames' import { debounce } from 'App/utils'; import { numberWithCommas } from 'App/utils'; interface Props { - page: number - totalPages: number - onPageChange: (page: number) => void - limit?: number - debounceRequest?: number + page: number + totalPages: number + onPageChange: (page: number) => void + limit?: number + debounceRequest?: number } export default function Pagination(props: Props) { - const { page, totalPages, onPageChange, limit = 5, debounceRequest = 0 } = props; - const [currentPage, setCurrentPage] = React.useState(page); - React.useMemo( - () => setCurrentPage(page), - [page], - ); + const { page, totalPages, onPageChange, limit = 5, debounceRequest = 0 } = props; + const [currentPage, setCurrentPage] = React.useState(page); + React.useMemo( + () => setCurrentPage(page), + [page], + ); - const debounceChange = React.useCallback(debounce(onPageChange, debounceRequest), []); + const debounceChange = React.useCallback(debounce(onPageChange, debounceRequest), []); const changePage = (page: number) => { if (page > 0 && page <= totalPages) { @@ -33,7 +33,7 @@ export default function Pagination(props: Props) { return ( <div className="flex items-center"> <Popup - content="Previous Page" + content="Previous Page" // hideOnClick={true} animation="none" delay={1500} diff --git a/frontend/app/components/ui/Tooltip/Tooltip.js b/frontend/app/components/ui/Tooltip/Tooltip.js deleted file mode 100644 index 4e891f511..000000000 --- a/frontend/app/components/ui/Tooltip/Tooltip.js +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react'; -import { Popup } from 'UI'; - -export default class Tooltip extends React.PureComponent { - static defaultProps = { - timeout: 500, - } - state = { - open: false, - } - mouseOver = false - onMouseEnter = () => { - this.mouseOver = true; - setTimeout(() => { - if (this.mouseOver) this.setState({ open: true }); - }, this.props.timeout) - } - onMouseLeave = () => { - this.mouseOver = false; - this.setState({ - open: false, - }); - } - - render() { - const { trigger, tooltip, position } = this.props; - const { open } = this.state; - return ( - <Popup - open={ open } - content={ tooltip } - disabled={ !tooltip } - position={position} - > - <span //TODO: no wrap component around - onMouseEnter={ this.onMouseEnter } - onMouseLeave={ this.onMouseLeave } - > - { trigger } - </span> - </Popup> - ); - } -} \ No newline at end of file diff --git a/frontend/app/components/ui/Tooltip/Tooltip.tsx b/frontend/app/components/ui/Tooltip/Tooltip.tsx new file mode 100644 index 000000000..6a14ce3f7 --- /dev/null +++ b/frontend/app/components/ui/Tooltip/Tooltip.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { Popup } from 'UI'; + +interface Props { + timeout: number + position: string + tooltip: string + trigger: React.ReactNode +} + +export default class Tooltip extends React.PureComponent<Props> { + static defaultProps = { + timeout: 500, + } + state = { + open: false, + } + mouseOver = false + onMouseEnter = () => { + this.mouseOver = true; + setTimeout(() => { + if (this.mouseOver) this.setState({ open: true }); + }, this.props.timeout) + } + onMouseLeave = () => { + this.mouseOver = false; + this.setState({ + open: false, + }); + } + + render() { + const { trigger, tooltip, position } = this.props; + const { open } = this.state; + return ( + <Popup + open={open} + content={tooltip} + disabled={!tooltip} + position={position} + > + <span //TODO: no wrap component around + onMouseEnter={ this.onMouseEnter } + onMouseLeave={ this.onMouseLeave } + > + { trigger } + </span> + </Popup> + ); + } +} \ No newline at end of file diff --git a/frontend/app/components/ui/Tooltip/index.js b/frontend/app/components/ui/Tooltip/index.ts similarity index 100% rename from frontend/app/components/ui/Tooltip/index.js rename to frontend/app/components/ui/Tooltip/index.ts diff --git a/frontend/app/declaration.d.ts b/frontend/app/declaration.d.ts new file mode 100644 index 000000000..0463954c3 --- /dev/null +++ b/frontend/app/declaration.d.ts @@ -0,0 +1,9 @@ +declare module '*.scss' { + const content: Record<string, string>; + export default content; +} + +declare module '*.css' { + const content: Record<string, string>; + export default content; +} \ No newline at end of file diff --git a/frontend/app/player/MessageDistributor/StatedScreen/Screen/Marker.js b/frontend/app/player/MessageDistributor/StatedScreen/Screen/Marker.js index 5461422cf..daf5a67b4 100644 --- a/frontend/app/player/MessageDistributor/StatedScreen/Screen/Marker.js +++ b/frontend/app/player/MessageDistributor/StatedScreen/Screen/Marker.js @@ -7,15 +7,14 @@ export default class Marker { constructor(overlay, screen) { this.screen = screen; - - this._tooltip = document.createElement('div') - this._tooltip.className = styles.tooltip; - this._tooltip.appendChild(document.createElement('div')) - - const htmlStr = document.createElement('div') - htmlStr.innerHTML = "<b>Right-click \> Inspect</b> for more details." - this._tooltip.appendChild(htmlStr) + this._tooltip = document.createElement('div'); + this._tooltip.className = styles.tooltip; + this._tooltip.appendChild(document.createElement('div')); + + const htmlStr = document.createElement('div'); + htmlStr.innerHTML = '<b>Right-click > Inspect</b> for more details.'; + this._tooltip.appendChild(htmlStr); const marker = document.createElement('div'); marker.className = styles.marker; @@ -31,8 +30,8 @@ export default class Marker { marker.appendChild(markerR); marker.appendChild(markerT); marker.appendChild(markerB); - - marker.appendChild(this._tooltip) + + marker.appendChild(this._tooltip); overlay.appendChild(marker); this._marker = marker; @@ -55,14 +54,15 @@ export default class Marker { this.mark(null); } - _autodefineTarget() { // TODO: put to Screen + _autodefineTarget() { + // TODO: put to Screen if (this._selector) { try { const fitTargets = this.screen.document.querySelectorAll(this._selector); if (fitTargets.length === 0) { this._target = null; } else { - this._target = fitTargets[ 0 ]; + this._target = fitTargets[0]; const cursorTarget = this.screen.getCursorTarget(); fitTargets.forEach((target) => { if (target.contains(cursorTarget)) { @@ -70,7 +70,7 @@ export default class Marker { } }); } - } catch(e) { + } catch (e) { console.info(e); } } else { @@ -85,18 +85,18 @@ export default class Marker { } getTagString(tag) { - const attrs = tag.attributes - let str = `<span style="color:#9BBBDC">${tag.tagName.toLowerCase()}</span>` + const attrs = tag.attributes; + let str = `<span style="color:#9BBBDC">${tag.tagName.toLowerCase()}</span>`; for (let i = 0; i < attrs.length; i++) { - let k = attrs[i] - const attribute = k.name + let k = attrs[i]; + const attribute = k.name; if (attribute === 'class') { - str += `<span style="color:#F29766">${'.' + k.value.split(' ').join('.')}</span>` + str += `<span style="color:#F29766">${'.' + k.value.split(' ').join('.')}</span>`; } if (attribute === 'id') { - str += `<span style="color:#F29766">${'#' + k.value.split(' ').join('#')}</span>` + str += `<span style="color:#F29766">${'#' + k.value.split(' ').join('#')}</span>`; } } @@ -117,8 +117,7 @@ export default class Marker { this._marker.style.top = rect.top + 'px'; this._marker.style.width = rect.width + 'px'; this._marker.style.height = rect.height + 'px'; - + this._tooltip.firstChild.innerHTML = this.getTagString(this._target); } - -} \ No newline at end of file +} diff --git a/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts b/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts index 378021203..db850842f 100644 --- a/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts +++ b/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts @@ -103,6 +103,7 @@ export default class DOMManager extends ListWalker<Message> { pNode.sheet && pNode.sheet.cssRules && pNode.sheet.cssRules.length > 0 && + pNode.innerText && pNode.innerText.trim().length === 0 ) { logger.log("Trying to insert child to a style tag with virtual rules: ", parent, child); diff --git a/frontend/app/player/MessageDistributor/messages/urlResolve.ts b/frontend/app/player/MessageDistributor/messages/urlResolve.ts index 44298ec08..07a7381fb 100644 --- a/frontend/app/player/MessageDistributor/messages/urlResolve.ts +++ b/frontend/app/player/MessageDistributor/messages/urlResolve.ts @@ -29,7 +29,7 @@ function cssUrlsIndex(css: string): Array<[number, number]> { return idxs; } function unquote(str: string): [string, string] { - str = str.trim(); + str = str ? str.trim() : ''; if (str.length <= 2) { return [str, ""] } diff --git a/frontend/app/styles/colors-autogen.css b/frontend/app/styles/colors-autogen.css index d1fd5a0a9..42ae94dab 100644 --- a/frontend/app/styles/colors-autogen.css +++ b/frontend/app/styles/colors-autogen.css @@ -35,6 +35,7 @@ .fill-light-blue-bg { fill: $light-blue-bg } .fill-white { fill: $white } .fill-borderColor { fill: $borderColor } +.fill-figmaColors { fill: $figmaColors } /* color */ .color-main { color: $main } @@ -71,6 +72,7 @@ .color-light-blue-bg { color: $light-blue-bg } .color-white { color: $white } .color-borderColor { color: $borderColor } +.color-figmaColors { color: $figmaColors } /* hover color */ .hover-main:hover { color: $main } @@ -107,6 +109,7 @@ .hover-light-blue-bg:hover { color: $light-blue-bg } .hover-white:hover { color: $white } .hover-borderColor:hover { color: $borderColor } +.hover-figmaColors:hover { color: $figmaColors } .border-main { border-color: $main } .border-gray-light-shade { border-color: $gray-light-shade } @@ -142,3 +145,4 @@ .border-light-blue-bg { border-color: $light-blue-bg } .border-white { border-color: $white } .border-borderColor { border-color: $borderColor } +.border-figmaColors { border-color: $figmaColors } diff --git a/frontend/app/utils.ts b/frontend/app/utils.ts index 03202fff0..52bf7c6ad 100644 --- a/frontend/app/utils.ts +++ b/frontend/app/utils.ts @@ -158,11 +158,15 @@ export function percentOf(part: number, whole: number): number { return whole > 0 ? (part * 100) / whole : 0; } -export function fileType(url) { - return url.split(/[#?]/)[0].split('.').pop().trim(); +export function fileType(url: string) { + const filename = url.split(/[#?]/) + if (!filename || filename.length == 0) return '' + const parts = filename[0].split('.') + if (!parts || parts.length == 0) return '' + return parts.pop().trim(); } -export function fileName(url) { +export function fileName(url: string) { if (url) { var m = url.toString().match(/.*\/(.+?)\./); if (m && m.length > 1) { diff --git a/frontend/app/validate.js b/frontend/app/validate.js index 687091003..76d588ac9 100644 --- a/frontend/app/validate.js +++ b/frontend/app/validate.js @@ -36,7 +36,7 @@ export function validateName(value, options) { } = Object.assign({}, defaultOptions, options); if (typeof value !== 'string') return false; // throw Error? - if (!empty && value.trim() === '') return false; + if (!empty && value && value.trim() === '') return false; const charsRegex = admissibleChars ? `|${ admissibleChars.split('').map(escapeRegexp).join('|') }` diff --git a/frontend/package.json b/frontend/package.json index 957c65c3a..3bc21e1f9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,7 @@ "upload:minio": "node ./scripts/upload-minio.js", "deploy:minio": "yarn build:minio && yarn upload:minio", "lint": "eslint --fix app; exit 0", + "tsc": "tsc --noEmit --w --incremental false", "gen:constants": "node ./scripts/constants.js", "gen:icons": "node ./scripts/icons.ts", "gen:colors": "node ./scripts/colors.js", diff --git a/frontend/webpack.config.ts b/frontend/webpack.config.ts index 4972b3213..404531b55 100644 --- a/frontend/webpack.config.ts +++ b/frontend/webpack.config.ts @@ -55,30 +55,30 @@ const config: Configuration = { test: /\.css$/i, exclude: /node_modules/, use: [ - stylesHandler, - { - loader: "css-loader", - options: { - modules: { - mode: "local", - auto: true, - localIdentName: "[name]__[local]--[hash:base64:5]", - } - // url: { - // filter: (url: string) => { - // // Semantic-UI-CSS has an extra semi colon in one of the URL due to which CSS loader along - // // with webpack 5 fails to generate a build. - // // Below if condition is a hack. After Semantic-UI-CSS fixes this, one can replace use clause with just - // // use: ['style-loader', 'css-loader'] - // if (url.includes('charset=utf-8;;')) { - // return false; - // } - // return true; - // }, - // } - }, + stylesHandler, + { + loader: "css-loader", + options: { + modules: { + mode: "local", + auto: true, + localIdentName: "[name]__[local]--[hash:base64:5]", + } + // url: { + // filter: (url: string) => { + // // Semantic-UI-CSS has an extra semi colon in one of the URL due to which CSS loader along + // // with webpack 5 fails to generate a build. + // // Below if condition is a hack. After Semantic-UI-CSS fixes this, one can replace use clause with just + // // use: ['style-loader', 'css-loader'] + // if (url.includes('charset=utf-8;;')) { + // return false; + // } + // return true; + // }, + // } }, - 'postcss-loader' + }, + 'postcss-loader' ], }, // { @@ -116,7 +116,7 @@ const config: Configuration = { 'window.env.PRODUCTION': isDevelopment ? false : true, }), new HtmlWebpackPlugin({ - template: 'app/assets/index.html' + template: 'app/assets/index.html' }), new CopyWebpackPlugin({ patterns: [