From af4160eb20009819d4df20285cc451733312ec56 Mon Sep 17 00:00:00 2001 From: Bart Riepe Date: Tue, 16 Aug 2022 15:07:08 +0900 Subject: [PATCH] feat(ui): add consistent timestamps to (almost) all items in the player ui This also tries to make the autoscroll functionality a bit more consistent, where all items are always shown in the list, but items which have not yet occurred will be partially transparent until they happen. Due to that change, autoscroll behavior which previously always went all the way to the bottom of a list didn't make sense anymore, so now it scrolls to the current item. --- .../BugFinder/AutoComplete/AutoComplete.js | 4 +- .../Dashboard/Widgets/common/widgetHOC.js | 223 ++++---- .../FilterSeries/SeriesName/SeriesName.tsx | 2 +- .../components/WidgetName/WidgetName.tsx | 2 +- .../FunnelSaveModal/FunnelSaveModal.js | 80 +-- .../components/Session/IOSPlayer/Network.js | 141 ++--- .../Session/IOSPlayer/StackEvents.js | 9 +- .../app/components/Session_/Autoscroll.js | 85 --- .../app/components/Session_/Autoscroll.tsx | 128 +++++ .../components/Session_/BottomBlock/Header.js | 2 +- .../Session_/BottomBlock/InfoLine.js | 2 +- .../BottomBlock/bottomBlock.module.css | 2 - .../Session_/BottomBlock/infoLine.module.css | 4 +- .../components/Session_/Console/Console.js | 2 +- .../Session_/Console/ConsoleContent.js | 186 ++++--- .../Session_/Console/console.module.css | 11 +- .../Session_/Exceptions/Exceptions.js | 181 ++++--- .../app/components/Session_/Fetch/Fetch.js | 323 +++++------ .../components/Session_/GraphQL/GQLDetails.js | 136 +++-- .../components/Session_/GraphQL/GraphQL.js | 161 ++++-- .../components/Session_/Network/Network.js | 24 +- .../Session_/Network/NetworkContent.js | 511 ++++++++++-------- .../Player/Overlay/ElementsMarker/Marker.tsx | 44 +- .../Session_/StackEvents/StackEvents.js | 211 ++++++-- .../StackEvents/UserEvent/UserEvent.js | 162 ++---- .../UserEvent/userEvent.module.css | 11 +- .../components/Session_/TimeTable/BarRow.js | 85 --- .../components/Session_/TimeTable/BarRow.tsx | 96 ++++ .../Session_/TimeTable/TimeTable.tsx | 499 +++++++++-------- .../Session_/TimeTable/timeTable.module.css | 4 + .../components/Session_/autoscroll.module.css | 21 +- .../components/hocs/withLocationHandlers.js | 63 +-- .../app/components/hocs/withPermissions.js | 57 +- .../app/components/hocs/withSiteIdRouter.js | 48 +- .../app/components/hocs/withSiteIdUpdater.js | 59 +- .../FilterSeries/SeriesName/SeriesName.tsx | 2 +- .../app/components/ui/ErrorItem/ErrorItem.js | 15 +- .../ui/ErrorItem/errorItem.module.css | 8 + .../ui/LinkStyledInput/LinkStyledInput.js | 2 +- .../app/components/ui/NoContent/NoContent.js | 0 .../components/ui/Pagination/Pagination.tsx | 26 +- frontend/app/components/ui/Tooltip/Tooltip.js | 44 -- .../app/components/ui/Tooltip/Tooltip.tsx | 51 ++ .../ui/Tooltip/{index.js => index.ts} | 0 frontend/app/declaration.d.ts | 9 + .../StatedScreen/Screen/Marker.js | 43 +- .../managers/DOM/DOMManager.ts | 1 + .../MessageDistributor/messages/urlResolve.ts | 2 +- frontend/app/styles/colors-autogen.css | 4 + frontend/app/utils.ts | 10 +- frontend/app/validate.js | 2 +- frontend/package.json | 1 + frontend/webpack.config.ts | 48 +- 53 files changed, 2097 insertions(+), 1750 deletions(-) delete mode 100644 frontend/app/components/Session_/Autoscroll.js create mode 100644 frontend/app/components/Session_/Autoscroll.tsx delete mode 100644 frontend/app/components/Session_/TimeTable/BarRow.js create mode 100644 frontend/app/components/Session_/TimeTable/BarRow.tsx create mode 100644 frontend/app/components/ui/NoContent/NoContent.js delete mode 100644 frontend/app/components/ui/Tooltip/Tooltip.js create mode 100644 frontend/app/components/ui/Tooltip/Tooltip.tsx rename frontend/app/components/ui/Tooltip/{index.js => index.ts} (100%) create mode 100644 frontend/app/declaration.d.ts 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: [