feat: tag and watch (UI/Tracker) (#1822)
* feat(ui/tracker): start tag n watch * fix(tracker): test coverage, fix some watcher api * fix(tracker): add intersectionobserver, adjust tests * feat(tracker): relay + apollo plugins * feat(ui): tags search * feat(ui): tags name edit * feat(ui): tags search icon * feat(ui): icons for tabs in player * feat(ui): save and find button * feat(tracker): save tags in session storage (just in case) * feat(ui): improve loading * feat(ui): fix icon names gen * feat(ui): fix typo
This commit is contained in:
parent
0c2dd6f9f1
commit
309a9fd970
62 changed files with 2087 additions and 1178 deletions
|
|
@ -86,6 +86,7 @@ const (
|
|||
MsgTabChange = 117
|
||||
MsgTabData = 118
|
||||
MsgCanvasNode = 119
|
||||
MsgTagTrigger = 120
|
||||
MsgIssueEvent = 125
|
||||
MsgSessionEnd = 126
|
||||
MsgSessionSearch = 127
|
||||
|
|
@ -2301,6 +2302,27 @@ func (msg *CanvasNode) TypeID() int {
|
|||
return 119
|
||||
}
|
||||
|
||||
type TagTrigger struct {
|
||||
message
|
||||
TagId int64
|
||||
}
|
||||
|
||||
func (msg *TagTrigger) Encode() []byte {
|
||||
buf := make([]byte, 11)
|
||||
buf[0] = 120
|
||||
p := 1
|
||||
p = WriteInt(msg.TagId, buf, p)
|
||||
return buf[:p]
|
||||
}
|
||||
|
||||
func (msg *TagTrigger) Decode() Message {
|
||||
return msg
|
||||
}
|
||||
|
||||
func (msg *TagTrigger) TypeID() int {
|
||||
return 120
|
||||
}
|
||||
|
||||
type IssueEvent struct {
|
||||
message
|
||||
MessageID uint64
|
||||
|
|
|
|||
|
|
@ -1401,6 +1401,15 @@ func DecodeCanvasNode(reader BytesReader) (Message, error) {
|
|||
return msg, err
|
||||
}
|
||||
|
||||
func DecodeTagTrigger(reader BytesReader) (Message, error) {
|
||||
var err error = nil
|
||||
msg := &TagTrigger{}
|
||||
if msg.TagId, err = reader.ReadInt(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, err
|
||||
}
|
||||
|
||||
func DecodeIssueEvent(reader BytesReader) (Message, error) {
|
||||
var err error = nil
|
||||
msg := &IssueEvent{}
|
||||
|
|
@ -2033,6 +2042,8 @@ func ReadMessage(t uint64, reader BytesReader) (Message, error) {
|
|||
return DecodeTabData(reader)
|
||||
case 119:
|
||||
return DecodeCanvasNode(reader)
|
||||
case 120:
|
||||
return DecodeTagTrigger(reader)
|
||||
case 125:
|
||||
return DecodeIssueEvent(reader)
|
||||
case 126:
|
||||
|
|
|
|||
|
|
@ -808,6 +808,13 @@ class CanvasNode(Message):
|
|||
self.timestamp = timestamp
|
||||
|
||||
|
||||
class TagTrigger(Message):
|
||||
__id__ = 120
|
||||
|
||||
def __init__(self, tag_id):
|
||||
self.tag_id = tag_id
|
||||
|
||||
|
||||
class IssueEvent(Message):
|
||||
__id__ = 125
|
||||
|
||||
|
|
|
|||
|
|
@ -1194,6 +1194,15 @@ cdef class CanvasNode(PyMessage):
|
|||
self.timestamp = timestamp
|
||||
|
||||
|
||||
cdef class TagTrigger(PyMessage):
|
||||
cdef public int __id__
|
||||
cdef public long tag_id
|
||||
|
||||
def __init__(self, long tag_id):
|
||||
self.__id__ = 120
|
||||
self.tag_id = tag_id
|
||||
|
||||
|
||||
cdef class IssueEvent(PyMessage):
|
||||
cdef public int __id__
|
||||
cdef public unsigned long message_id
|
||||
|
|
|
|||
|
|
@ -727,6 +727,11 @@ class MessageCodec(Codec):
|
|||
timestamp=self.read_uint(reader)
|
||||
)
|
||||
|
||||
if message_id == 120:
|
||||
return TagTrigger(
|
||||
tag_id=self.read_int(reader)
|
||||
)
|
||||
|
||||
if message_id == 125:
|
||||
return IssueEvent(
|
||||
message_id=self.read_uint(reader),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -30,7 +30,8 @@ const siteIdRequiredPaths: string[] = [
|
|||
'/notes',
|
||||
'/feature-flags',
|
||||
'/check-recording-status',
|
||||
'/usability-tests'
|
||||
'/usability-tests',
|
||||
'/tags'
|
||||
];
|
||||
|
||||
export const clean = (obj: any, forbiddenValues: any[] = [undefined, '']): any => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { Button, PageTitle } from 'UI'
|
|||
import FFlagsSearch from "Components/FFlags/FFlagsSearch";
|
||||
import { useHistory } from "react-router";
|
||||
import { newFFlag, withSiteId } from 'App/routes';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
function FFlagsListHeader({ siteId }: { siteId: string }) {
|
||||
const history = useHistory();
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ function Player() {
|
|||
>
|
||||
<div className={cn("relative flex-1", 'overflow-visible')}>
|
||||
<Overlay isClickmap />
|
||||
<div className={cn(stl.screenWrapper, '!overflow-y-scroll')} style={{ maxHeight: 800 }} ref={screenWrapper} />
|
||||
<div className={cn(stl.screenWrapper, stl.checkers, '!overflow-y-scroll')} style={{ maxHeight: 800 }} ref={screenWrapper} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ function Player(props: IProps) {
|
|||
>
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<Overlay closedLive={closedLive} />
|
||||
<div className={cn(stl.screenWrapper)} ref={screenWrapper} />
|
||||
<div className={cn(stl.screenWrapper, stl.checkers)} ref={screenWrapper} />
|
||||
</div>
|
||||
{bottomBlock === CONSOLE ? (
|
||||
<div style={{ maxWidth, width: '100%' }}>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import ConsolePanel from 'Shared/DevTools/ConsolePanel';
|
|||
import ProfilerPanel from 'Shared/DevTools/ProfilerPanel';
|
||||
import { PlayerContext } from 'App/components/Session/playerContext';
|
||||
import { debounce } from 'App/utils';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
interface IProps {
|
||||
fullView: boolean;
|
||||
|
|
@ -109,6 +110,7 @@ function Player(props: IProps) {
|
|||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
const isInspMode = playerContext.store.get().inspectorMode;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -118,7 +120,7 @@ function Player(props: IProps) {
|
|||
{fullscreen && <EscapeButton onClose={fullscreenOff} />}
|
||||
<div className={cn('relative flex-1', 'overflow-hidden')}>
|
||||
<Overlay nextId={nextId} />
|
||||
<div className={cn(stl.screenWrapper)} ref={screenWrapper} />
|
||||
<div className={cn(stl.screenWrapper, isInspMode ? stl.solidBg : stl.checkers)} ref={screenWrapper} />
|
||||
</div>
|
||||
{!fullscreen && !!bottomBlock && (
|
||||
<div
|
||||
|
|
@ -143,7 +145,6 @@ function Player(props: IProps) {
|
|||
{bottomBlock === PERFORMANCE && <ConnectedPerformance />}
|
||||
{bottomBlock === GRAPHQL && <GraphQL />}
|
||||
{bottomBlock === EXCEPTIONS && <Exceptions />}
|
||||
{bottomBlock === INSPECTOR && <Inspector />}
|
||||
</div>
|
||||
)}
|
||||
{!fullView ? (
|
||||
|
|
@ -168,4 +169,4 @@ export default connect(
|
|||
fullscreenOff,
|
||||
updateLastPlayedSession,
|
||||
}
|
||||
)(Player);
|
||||
)(observer(Player));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import React from 'react';
|
||||
import { Button, Checkbox, Input } from 'antd';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { withSiteId, sessions } from 'App/routes';
|
||||
import store from 'App/store';
|
||||
|
||||
interface Props {
|
||||
onSave: (name: string, ignoreClRage: boolean, ignoreDeadCl: boolean) => Promise<any>;
|
||||
hideModal: () => void;
|
||||
}
|
||||
|
||||
function SaveModal({ onSave, hideModal }: Props) {
|
||||
const history = useHistory();
|
||||
const [name, setName] = React.useState('');
|
||||
const [ignoreClRage, setIgnoreClRage] = React.useState(false);
|
||||
const [ignoreDeadCl, setIgnoreDeadCl] = React.useState(false);
|
||||
|
||||
const save = () => {
|
||||
void onSave(name, ignoreClRage, ignoreDeadCl);
|
||||
hideModal();
|
||||
};
|
||||
const saveAndOpen = () => {
|
||||
onSave(name, ignoreClRage, ignoreDeadCl).then((tagId) => {
|
||||
hideModal();
|
||||
const siteId = store.getState().getIn(['site', 'siteId']);
|
||||
history.push(withSiteId(sessions({ tnw: `is|${tagId}`, range: 'LAST_24_HOURS' }), siteId));
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className={'h-screen bg-white p-4 flex flex-col gap-4'}>
|
||||
<div className={'font-semibold text-xl'}>Tag Element</div>
|
||||
<div className={'w-full border border-b-light-gray'} />
|
||||
<div>
|
||||
<div className={'font-semibold'}>Name</div>
|
||||
<Input
|
||||
placeholder={'E.g Buy Now Button'}
|
||||
className={'w-full'}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className={'font-semibold'}>Ignore following actions on this element</div>
|
||||
<div className={'flex gap-2'}>
|
||||
<Checkbox checked={ignoreClRage} onChange={(e) => setIgnoreClRage(e.target.checked)}>
|
||||
Click Rage
|
||||
</Checkbox>
|
||||
<Checkbox checked={ignoreDeadCl} onChange={(e) => setIgnoreDeadCl(e.target.checked)}>
|
||||
Dead Click
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'w-full border border-b-light-gray'} />
|
||||
<div className={'flex gap-2'}>
|
||||
<Button type={'primary'} disabled={name === ''} onClick={save}>
|
||||
Tag
|
||||
</Button>
|
||||
<Button type={'default'} disabled={name === ''} onClick={saveAndOpen}>
|
||||
Tag & Find Element
|
||||
</Button>
|
||||
<Button type={'primary'} ghost onClick={hideModal}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SaveModal;
|
||||
93
frontend/app/components/Session/Player/TagWatch/TagWatch.tsx
Normal file
93
frontend/app/components/Session/Player/TagWatch/TagWatch.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { useStore } from 'App/mstore';
|
||||
import SaveModal from 'Components/Session/Player/TagWatch/SaveModal';
|
||||
import React from 'react';
|
||||
import { PlayerContext } from 'Components/Session/playerContext';
|
||||
import { Button, Input } from 'antd';
|
||||
import { CopyButton } from 'UI';
|
||||
import { SearchOutlined, ZoomInOutlined } from '@ant-design/icons';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useModal } from 'App/components/Modal';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
function TagWatch() {
|
||||
const { tagWatchStore } = useStore();
|
||||
const [selector, setSelector] = React.useState('');
|
||||
const { store, player } = React.useContext(PlayerContext);
|
||||
const { showModal, hideModal } = useModal();
|
||||
|
||||
const tagSelector = store.get().tagSelector;
|
||||
|
||||
React.useEffect(() => {
|
||||
player.pause();
|
||||
player.toggleInspectorMode(true);
|
||||
player.scale();
|
||||
|
||||
return () => {
|
||||
player.toggleInspectorMode(false);
|
||||
player.scale();
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (tagSelector !== '' && tagSelector !== selector) {
|
||||
setSelector(tagSelector);
|
||||
}
|
||||
}, [tagSelector]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selector !== tagSelector) {
|
||||
player.markBySelector(selector);
|
||||
}
|
||||
}, [selector]);
|
||||
|
||||
const onSave = async (name: string, ignoreClRage: boolean, ignoreDeadCl: boolean) => {
|
||||
try {
|
||||
const tag = await tagWatchStore.createTag({
|
||||
name,
|
||||
selector,
|
||||
ignoreClickRage: ignoreClRage,
|
||||
ignoreDeadClick: ignoreDeadCl,
|
||||
});
|
||||
// @ts-ignore
|
||||
toast.success('Tag created');
|
||||
setSelector('');
|
||||
return tag
|
||||
} catch {
|
||||
// @ts-ignore
|
||||
toast.error('Failed to create tag');
|
||||
}
|
||||
};
|
||||
const openSaveModal = () => {
|
||||
if (selector === '') {
|
||||
return;
|
||||
}
|
||||
showModal(<SaveModal onSave={onSave} hideModal={hideModal} />, { right: true, width: 400 });
|
||||
};
|
||||
return (
|
||||
<div className={'w-full h-full p-2 flex flex-col gap-2'}>
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<div className={'font-semibold text-xl'}>Element Selector</div>
|
||||
<CopyButton content={selector} />
|
||||
</div>
|
||||
<Input.TextArea value={selector} onChange={(e) => setSelector(e.target.value)} />
|
||||
<Button
|
||||
onClick={openSaveModal}
|
||||
type={'primary'}
|
||||
ghost
|
||||
icon={<ZoomInOutlined />}
|
||||
disabled={selector === ''}
|
||||
>
|
||||
Tag Element
|
||||
</Button>
|
||||
<div className={'text-disabled-text text-sm'}>
|
||||
Create and filter sessions by ‘watch elements’ to determine if they rendered or not.
|
||||
</div>
|
||||
<div className={'w-full border border-b-light-gray'} />
|
||||
<Button type={'link'} icon={<SearchOutlined />}>
|
||||
Find session with selector
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(TagWatch);
|
||||
1
frontend/app/components/Session/Player/TagWatch/index.ts
Normal file
1
frontend/app/components/Session/Player/TagWatch/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './TagWatch';
|
||||
|
|
@ -1,30 +1,40 @@
|
|||
import React from 'react'
|
||||
import React from 'react';
|
||||
import EventsBlock from '../Session_/EventsBlock';
|
||||
import PageInsightsPanel from '../Session_/PageInsightsPanel/PageInsightsPanel'
|
||||
import PageInsightsPanel from '../Session_/PageInsightsPanel/PageInsightsPanel';
|
||||
import TagWatch from "Components/Session/Player/TagWatch";
|
||||
|
||||
import cn from 'classnames';
|
||||
import stl from './rightblock.module.css';
|
||||
|
||||
function RightBlock(props: any) {
|
||||
const { activeTab } = props;
|
||||
|
||||
if (activeTab === 'EVENTS') {
|
||||
function RightBlock({
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
}: {
|
||||
activeTab: string;
|
||||
setActiveTab: (tab: string) => void;
|
||||
}) {
|
||||
switch (activeTab) {
|
||||
case 'EVENTS':
|
||||
return (
|
||||
<div className={cn("flex flex-col bg-white border-l", stl.panel)}>
|
||||
<EventsBlock
|
||||
setActiveTab={props.setActiveTab}
|
||||
/>
|
||||
<div className={cn('flex flex-col bg-white border-l', stl.panel)}>
|
||||
<EventsBlock setActiveTab={setActiveTab} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (activeTab === 'CLICKMAP') {
|
||||
);
|
||||
case 'CLICKMAP':
|
||||
return (
|
||||
<div className={cn("flex flex-col bg-white border-l", stl.panel)}>
|
||||
<PageInsightsPanel setActiveTab={props.setActiveTab} />
|
||||
<div className={cn('flex flex-col bg-white border-l', stl.panel)}>
|
||||
<PageInsightsPanel setActiveTab={setActiveTab} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
case 'INSPECTOR':
|
||||
return (
|
||||
<div className={cn('bg-white border-l', stl.panel)}>
|
||||
<TagWatch />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default RightBlock
|
||||
export default RightBlock;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import stl from './tabs.module.css';
|
||||
import { Segmented } from 'antd';
|
||||
import { Icon } from 'UI'
|
||||
|
||||
interface Props {
|
||||
tabs: Array<any>;
|
||||
|
|
@ -10,22 +12,37 @@ interface Props {
|
|||
className?: string;
|
||||
}
|
||||
|
||||
const Tabs = ({ tabs, active, onClick, border = true, className }: Props) => (
|
||||
<div className={ cn(stl.tabs, className, { [ stl.bordered ]: border }) } role="tablist" >
|
||||
{ tabs.map(({ key, text, hidden = false, disabled = false }) => (
|
||||
const iconMap = {
|
||||
"INSPECTOR": 'filters/tag-element',
|
||||
"CLICKMAP": 'mouse-pointer-click',
|
||||
'EVENTS': 'user-switch'
|
||||
} as const
|
||||
|
||||
const Tabs = ({ tabs, active, onClick, border = true, className }: Props) => {
|
||||
console.log(tabs)
|
||||
return (
|
||||
<div className={cn(stl.tabs, className, { [stl.bordered]: border })} role="tablist">
|
||||
<Segmented
|
||||
value={active}
|
||||
options={tabs.map(({ key, text, hidden = false, disabled = false }) => ({
|
||||
label: (
|
||||
<div
|
||||
key={ key }
|
||||
className={ cn(stl.tab, { [ stl.active ]: active === key, [ stl.disabled ]: disabled }) }
|
||||
data-hidden={ hidden }
|
||||
onClick={ onClick && (() => onClick(key)) }
|
||||
role="tab"
|
||||
data-openreplay-label={text}
|
||||
onClick={() => {
|
||||
onClick(key);
|
||||
}}
|
||||
className={'font-semibold flex gap-1 items-center'}
|
||||
>
|
||||
{ text }
|
||||
<Icon size={16} color={'black'} name={iconMap[key as keyof typeof iconMap]} />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
))}
|
||||
),
|
||||
value: key,
|
||||
disabled: disabled,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
Tabs.displayName = 'Tabs';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
.tabs {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
&.bordered {
|
||||
border-bottom: solid thin $gray-light;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 14px 15px;
|
||||
.tab {
|
||||
padding: 14px 0;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
flex: 1;
|
||||
|
|
@ -26,9 +29,9 @@
|
|||
color: $teal;
|
||||
border-bottom: solid thin $teal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.disabled {
|
||||
.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ import { useParams } from 'react-router-dom';
|
|||
import { toast } from 'react-toastify';
|
||||
|
||||
const TABS = {
|
||||
EVENTS: 'User Events',
|
||||
EVENTS: 'Activity',
|
||||
CLICKMAP: 'Click Map',
|
||||
INSPECTOR: 'Tag',
|
||||
};
|
||||
const UXTTABS = {
|
||||
EVENTS: TABS.EVENTS
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useStore } from "App/mstore";
|
|||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import { connect } from 'react-redux';
|
||||
import {MarkedTarget, selectStorageType, STORAGE_TYPES, StorageType} from 'Player';
|
||||
import { selectStorageType, STORAGE_TYPES, StorageType } from 'Player';
|
||||
import { PlayButton, PlayingState, FullScreenButton } from 'App/player-ui'
|
||||
|
||||
import { Tooltip } from 'UI';
|
||||
|
|
@ -131,13 +131,8 @@ function Controls(props: any) {
|
|||
};
|
||||
|
||||
const toggleBottomTools = (blockName: number) => {
|
||||
if (blockName === INSPECTOR) {
|
||||
// player.toggleInspectorMode(false);
|
||||
bottomBlock && toggleBottomBlock();
|
||||
} else {
|
||||
// player.toggleInspectorMode(false);
|
||||
player.toggleInspectorMode(false);
|
||||
toggleBottomBlock(blockName);
|
||||
}
|
||||
};
|
||||
|
||||
const state = completed ? PlayingState.Completed : playing ? PlayingState.Playing : PlayingState.Paused
|
||||
|
|
|
|||
|
|
@ -15,9 +15,15 @@
|
|||
/* border: solid thin $gray-light; */
|
||||
/* border-radius: 3px; */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.checkers {
|
||||
background: repeating-conic-gradient($gray-lightest 0% 25%, transparent 0% 50%)
|
||||
50% / 10px 10px;
|
||||
}
|
||||
.solidBg {
|
||||
background: $gray-lightest;
|
||||
}
|
||||
|
||||
.mobileScreenWrapper {
|
||||
width: 100%;
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@ import SavedSearch from 'Shared/SavedSearch';
|
|||
import { Button } from 'UI';
|
||||
import { connect } from 'react-redux';
|
||||
import { clearSearch } from 'Duck/search';
|
||||
import TagList from './components/TagList';
|
||||
|
||||
interface Props {
|
||||
clearSearch: () => void;
|
||||
appliedFilter: any;
|
||||
savedSearch: any;
|
||||
}
|
||||
|
||||
const MainSearchBar = (props: Props) => {
|
||||
const { appliedFilter } = props;
|
||||
const hasFilters = appliedFilter && appliedFilter.filters && appliedFilter.filters.size > 0;
|
||||
|
|
@ -20,7 +22,8 @@ const MainSearchBar = (props: Props) => {
|
|||
<div style={{ width: '60%', marginRight: '10px' }}>
|
||||
<SessionSearchField />
|
||||
</div>
|
||||
<div className="flex items-center" style={{ width: '40%' }}>
|
||||
<div className="flex items-center gap-2" style={{ width: '40%' }}>
|
||||
<TagList />
|
||||
<SavedSearch />
|
||||
<Button
|
||||
variant={hasSearch ? 'text-primary' : 'text'}
|
||||
|
|
@ -37,7 +40,7 @@ const MainSearchBar = (props: Props) => {
|
|||
export default connect(
|
||||
(state: any) => ({
|
||||
appliedFilter: state.getIn(['search', 'instance']),
|
||||
savedSearch: state.getIn([ 'search', 'savedSearch' ])
|
||||
savedSearch: state.getIn(['search', 'savedSearch']),
|
||||
}),
|
||||
{
|
||||
clearSearch,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
import { Tag } from 'App/services/TagWatchService';
|
||||
import { useModal } from 'Components/Modal';
|
||||
import { refreshFilterOptions, addFilterByKeyAndValue } from 'Duck/search';
|
||||
import { connect } from 'react-redux';
|
||||
import React from 'react';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FilterKey } from 'Types/filter/filterType';
|
||||
import { addOptionsToFilter } from 'Types/filter/newFilter';
|
||||
import { Button, Icon, confirm } from 'UI';
|
||||
import { Typography } from 'antd';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
function TagList(props: {
|
||||
refreshFilterOptions: typeof refreshFilterOptions;
|
||||
addFilterByKeyAndValue: typeof addFilterByKeyAndValue;
|
||||
}) {
|
||||
const { refreshFilterOptions, addFilterByKeyAndValue } = props;
|
||||
const { tagWatchStore } = useStore();
|
||||
const { showModal, hideModal } = useModal();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!tagWatchStore.isLoading) {
|
||||
tagWatchStore.getTags().then((tags) => {
|
||||
if (tags) {
|
||||
addOptionsToFilter(
|
||||
FilterKey.TAGGED_ELEMENT,
|
||||
tags.map((tag) => ({ label: tag.name, value: tag.tagId.toString() }))
|
||||
);
|
||||
refreshFilterOptions();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const addTag = (tagId: number) => {
|
||||
addFilterByKeyAndValue(FilterKey.TAGGED_ELEMENT, tagId.toString());
|
||||
hideModal();
|
||||
};
|
||||
const openModal = () => {
|
||||
showModal(<TagListModal onTagClick={addTag} />, {
|
||||
right: true,
|
||||
width: 400,
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Button variant={'outline'} disabled={!tagWatchStore.tags.length} onClick={openModal}>
|
||||
<span>Tags</span>
|
||||
<span className={'font-bold ml-1'}>{tagWatchStore.tags.length}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const TagListModal = observer(({ onTagClick }: { onTagClick: (tagId: number) => void }) => {
|
||||
const { tagWatchStore } = useStore();
|
||||
|
||||
const updateTagName = (id: number, name: string) => {
|
||||
void tagWatchStore.updateTagName(id, name);
|
||||
// very annoying
|
||||
// @ts-ignore
|
||||
toast.success('Tag name updated');
|
||||
};
|
||||
const onRemove = async (id: number) => {
|
||||
if (
|
||||
await confirm({
|
||||
header: 'Remove Tag',
|
||||
confirmButton: 'Remove',
|
||||
confirmation: 'Are you sure you want to remove this tag?',
|
||||
})
|
||||
) {
|
||||
void tagWatchStore.deleteTag(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={'h-screen flex flex-col gap-2 p-4'}>
|
||||
<div className={'text-2xl font-semibold'}>Tagged Elements</div>
|
||||
{tagWatchStore.tags.map((tag) => (
|
||||
<TagRow
|
||||
key={tag.tagId}
|
||||
tag={tag}
|
||||
onEdit={updateTagName}
|
||||
onDelete={onRemove}
|
||||
onTagClick={onTagClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const TagRow = (props: {
|
||||
tag: Tag;
|
||||
onEdit: (id: number, name: string) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onTagClick: (tagId: number) => void;
|
||||
}) => {
|
||||
const { tag, onEdit, onDelete, onTagClick } = props;
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [name, setName] = React.useState(tag.name);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'w-full border-b border-b-gray-light p-2 hover:bg-active-blue flex items-center gap-2 cursor-pointer'
|
||||
}
|
||||
onClick={() => onTagClick(tag.tagId)}
|
||||
key={tag.tagId}
|
||||
>
|
||||
<Icon name={'search'} />
|
||||
<Typography.Text
|
||||
editable={{
|
||||
onChange: (e) => {
|
||||
if (e !== tag.name) {
|
||||
onEdit(tag.tagId, e);
|
||||
setName(e);
|
||||
}
|
||||
setIsEditing(false);
|
||||
},
|
||||
text: name,
|
||||
editing: isEditing,
|
||||
onCancel: () => {
|
||||
setIsEditing(false);
|
||||
setName(tag.name);
|
||||
},
|
||||
triggerType: [],
|
||||
maxLength: 90,
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
</Typography.Text>
|
||||
|
||||
<div
|
||||
className={'cursor-pointer ml-auto p-2 hover:bg-gray-light rounded'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}}
|
||||
>
|
||||
<Icon name={'edit'} />
|
||||
</div>
|
||||
<div
|
||||
className={'cursor-pointer p-2 hover:bg-gray-light rounded'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void onDelete(tag.tagId);
|
||||
}}
|
||||
>
|
||||
<Icon name={'trash'} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default connect(() => ({}), { refreshFilterOptions, addFilterByKeyAndValue })(
|
||||
observer(TagList)
|
||||
);
|
||||
|
|
@ -3,33 +3,57 @@ import FilterList from 'Shared/Filters/FilterList';
|
|||
import FilterSelection from 'Shared/Filters/FilterSelection';
|
||||
import SaveFilterButton from 'Shared/SaveFilterButton';
|
||||
import { connect } from 'react-redux';
|
||||
import { FilterKey } from 'Types/filter/filterType';
|
||||
import { addOptionsToFilter } from 'Types/filter/newFilter';
|
||||
import { Button } from 'UI';
|
||||
import { edit, addFilter, fetchSessions, updateFilter } from 'Duck/search';
|
||||
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { debounce } from 'App/utils';
|
||||
import useSessionSearchQueryHandler from 'App/hooks/useSessionSearchQueryHandler';
|
||||
import { refreshFilterOptions } from 'Duck/search';
|
||||
|
||||
let debounceFetch: any = () => {}
|
||||
let debounceFetch: any = () => {};
|
||||
|
||||
interface Props {
|
||||
appliedFilter: any;
|
||||
edit: typeof edit;
|
||||
addFilter: typeof addFilter;
|
||||
saveRequestPayloads: boolean;
|
||||
metaLoading?: boolean
|
||||
metaLoading?: boolean;
|
||||
fetchSessions: typeof fetchSessions;
|
||||
updateFilter: typeof updateFilter;
|
||||
refreshFilterOptions: typeof refreshFilterOptions;
|
||||
}
|
||||
|
||||
function SessionSearch(props: Props) {
|
||||
const { tagWatchStore } = useStore();
|
||||
const { appliedFilter, saveRequestPayloads = false, metaLoading = false } = props;
|
||||
const hasEvents = appliedFilter.filters.filter((i: any) => i.isEvent).size > 0;
|
||||
const hasFilters = appliedFilter.filters.filter((i: any) => !i.isEvent).size > 0;
|
||||
|
||||
useSessionSearchQueryHandler({ appliedFilter, applyFilter: props.updateFilter, loading: metaLoading });
|
||||
useSessionSearchQueryHandler({
|
||||
appliedFilter,
|
||||
applyFilter: props.updateFilter,
|
||||
loading: metaLoading,
|
||||
onBeforeLoad: async () => {
|
||||
const tags = await tagWatchStore.getTags();
|
||||
if (tags) {
|
||||
addOptionsToFilter(
|
||||
FilterKey.TAGGED_ELEMENT,
|
||||
tags.map((tag) => ({
|
||||
label: tag.name,
|
||||
value: tag.tagId.toString()
|
||||
}))
|
||||
);
|
||||
props.refreshFilterOptions();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
debounceFetch = debounce(() => props.fetchSessions(), 500);
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
const onAddFilter = (filter: any) => {
|
||||
props.addFilter(filter);
|
||||
|
|
@ -49,7 +73,7 @@ function SessionSearch(props: Props) {
|
|||
filters: newFilters,
|
||||
});
|
||||
|
||||
debounceFetch()
|
||||
debounceFetch();
|
||||
};
|
||||
|
||||
const onRemoveFilter = (filterIndex: any) => {
|
||||
|
|
@ -61,7 +85,7 @@ function SessionSearch(props: Props) {
|
|||
filters: newFilters,
|
||||
});
|
||||
|
||||
debounceFetch()
|
||||
debounceFetch();
|
||||
};
|
||||
|
||||
const onChangeEventsOrder = (e: any, { value }: any) => {
|
||||
|
|
@ -69,7 +93,7 @@ function SessionSearch(props: Props) {
|
|||
eventsOrder: value,
|
||||
});
|
||||
|
||||
debounceFetch()
|
||||
debounceFetch();
|
||||
};
|
||||
|
||||
return !metaLoading ? (
|
||||
|
|
@ -89,11 +113,7 @@ function SessionSearch(props: Props) {
|
|||
<div className="border-t px-5 py-1 flex items-center -mx-2">
|
||||
<div>
|
||||
<FilterSelection filter={undefined} onFilterClick={onAddFilter}>
|
||||
<Button
|
||||
variant="text-primary"
|
||||
className="mr-2"
|
||||
icon="plus"
|
||||
>
|
||||
<Button variant="text-primary" className="mr-2" icon="plus">
|
||||
ADD STEP
|
||||
</Button>
|
||||
</FilterSelection>
|
||||
|
|
@ -114,7 +134,7 @@ export default connect(
|
|||
(state: any) => ({
|
||||
saveRequestPayloads: state.getIn(['site', 'instance', 'saveRequestPayloads']),
|
||||
appliedFilter: state.getIn(['search', 'instance']),
|
||||
metaLoading: state.getIn(['customFields', 'fetchRequestActive', 'loading'])
|
||||
metaLoading: state.getIn(['customFields', 'fetchRequestActive', 'loading']),
|
||||
}),
|
||||
{ edit, addFilter, fetchSessions, updateFilter }
|
||||
)(SessionSearch);
|
||||
{ edit, addFilter, fetchSessions, updateFilter, refreshFilterOptions }
|
||||
)(observer(SessionSearch));
|
||||
|
|
|
|||
19
frontend/app/components/ui/Icons/filters_tag_element.tsx
Normal file
19
frontend/app/components/ui/Icons/filters_tag_element.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
|
||||
/* Auto-generated, do not edit */
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
fill?: string;
|
||||
}
|
||||
|
||||
function Filters_tag_element(props: Props) {
|
||||
const { size = 14, width = size, height = size, fill = '' } = props;
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><g><path clipRule="evenodd" d="M1.958 6.664a3.908 3.908 0 0 0 .785 2.318c.046.036.088.08.123.13.29.408.837.959 1.531 1.404.695.446 1.484.752 2.263.752a.625.625 0 1 1 0 1.25c-1.096 0-2.119-.424-2.937-.95-.82-.525-1.487-1.184-1.876-1.734l-.007-.01a.626.626 0 0 1-.115-.118A5.158 5.158 0 0 1 .708 6.672v-.005C.708 3.252 4.112.708 8 .708c3.887 0 7.291 2.544 7.291 5.959v.013a5.416 5.416 0 0 1-.248 1.508.625.625 0 1 1-1.193-.376c.118-.373.182-.761.191-1.152C14.037 4.184 11.47 1.958 8 1.958c-3.472 0-6.04 2.229-6.042 4.706Zm8 6.457V8.778l3.01 3.076-1.317-.44a.625.625 0 0 0-.718.246l-.975 1.46Zm.135-5.988a.84.84 0 0 0-.54-.198h-.018a.852.852 0 0 0-.827.852v6.666a.858.858 0 0 0 .589.807c.063.021.13.032.196.032h.06a.858.858 0 0 0 .706-.372l1.449-2.17 2.54.85c.06.02.122.03.185.032a.845.845 0 0 0 .58-1.476l-4.873-4.98a.618.618 0 0 0-.047-.043Z"/></g><defs><clipPath id="a"><path fill="none" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default Filters_tag_element;
|
||||
|
|
@ -225,6 +225,7 @@ export { default as Filters_referrer } from './filters_referrer';
|
|||
export { default as Filters_resize } from './filters_resize';
|
||||
export { default as Filters_rev_id } from './filters_rev_id';
|
||||
export { default as Filters_state_action } from './filters_state_action';
|
||||
export { default as Filters_tag_element } from './filters_tag_element';
|
||||
export { default as Filters_ttfb } from './filters_ttfb';
|
||||
export { default as Filters_user_alt } from './filters_user_alt';
|
||||
export { default as Filters_userid } from './filters_userid';
|
||||
|
|
@ -344,6 +345,7 @@ export { default as Mic } from './mic';
|
|||
export { default as Minus } from './minus';
|
||||
export { default as Mobile } from './mobile';
|
||||
export { default as Mouse_alt } from './mouse_alt';
|
||||
export { default as Mouse_pointer_click } from './mouse_pointer_click';
|
||||
export { default as Network } from './network';
|
||||
export { default as Next1 } from './next1';
|
||||
export { default as No_dashboard } from './no_dashboard';
|
||||
|
|
@ -454,6 +456,7 @@ export { default as Turtle } from './turtle';
|
|||
export { default as User_alt } from './user_alt';
|
||||
export { default as User_circle } from './user_circle';
|
||||
export { default as User_friends } from './user_friends';
|
||||
export { default as User_switch } from './user_switch';
|
||||
export { default as Users } from './users';
|
||||
export { default as Vendors_graphql } from './vendors_graphql';
|
||||
export { default as Vendors_mobx } from './vendors_mobx';
|
||||
|
|
|
|||
19
frontend/app/components/ui/Icons/mouse_pointer_click.tsx
Normal file
19
frontend/app/components/ui/Icons/mouse_pointer_click.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
|
||||
/* Auto-generated, do not edit */
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
fill?: string;
|
||||
}
|
||||
|
||||
function Mouse_pointer_click(props: Props) {
|
||||
const { size = 14, width = size, height = size, fill = '' } = props;
|
||||
return (
|
||||
<svg fill="none" width={ `${ width }px` } height={ `${ height }px` } ><g stroke="#000" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m5.25 5.25 2.917 7L9.2 9.201l3.049-1.034-7-2.917Z" fill="#000" fillOpacity=".85"/><path d="m9.375 9.375 2.475 2.475M4.193 1.306l.453 1.69m-1.65 1.65-1.69-.453m6.832-1.83L6.9 3.6M3.6 6.9 2.363 8.137"/></g></svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default Mouse_pointer_click;
|
||||
19
frontend/app/components/ui/Icons/user_switch.tsx
Normal file
19
frontend/app/components/ui/Icons/user_switch.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
|
||||
/* Auto-generated, do not edit */
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
fill?: string;
|
||||
}
|
||||
|
||||
function User_switch(props: Props) {
|
||||
const { size = 14, width = size, height = size, fill = '' } = props;
|
||||
return (
|
||||
<svg fill="none" width={ `${ width }px` } height={ `${ height }px` } ><path d="M11.526 4.23a3.874 3.874 0 1 0-6.14 3.144l-.015.007A5.822 5.822 0 0 0 3.507 8.64a5.835 5.835 0 0 0-1.256 1.867 5.838 5.838 0 0 0-.46 2.158.125.125 0 0 0 .126.128h.935a.126.126 0 0 0 .125-.122 4.662 4.662 0 0 1 1.37-3.192A4.631 4.631 0 0 1 7.65 8.106a3.874 3.874 0 0 0 3.875-3.875ZM7.65 6.919a2.687 2.687 0 1 1 0-5.375 2.687 2.687 0 0 1 0 5.375Zm1.64 3.453h4.125a.125.125 0 0 0 .125-.125v-.875a.125.125 0 0 0-.125-.125H10.66l.737-.939a.126.126 0 0 0 .027-.076.125.125 0 0 0-.125-.125h-1.135a.255.255 0 0 0-.196.095l-1.07 1.36a.501.501 0 0 0 .395.81Zm3.75 1H8.918a.125.125 0 0 0-.125.125v.875c0 .069.056.125.125.125h2.757l-.737.94a.125.125 0 0 0 .098.201h1.135a.255.255 0 0 0 .197-.096l1.07-1.36a.502.502 0 0 0-.396-.81Z" fill="#000" fillOpacity=".85"/></svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default User_switch;
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,5 +1,5 @@
|
|||
export default {
|
||||
warning: 'Warnings',
|
||||
alert: 'Alerts',
|
||||
all: 'Log Entires',
|
||||
all: 'Log Entries',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,7 +8,12 @@ import { errors as errorsRoute, isRoute } from 'App/routes';
|
|||
import { fetchList as fetchSessionList, fetchAutoplayList } from './sessions';
|
||||
import { fetchList as fetchErrorsList } from './errors';
|
||||
import { FilterCategory, FilterKey } from 'Types/filter/filterType';
|
||||
import { filtersMap, liveFiltersMap, conditionalFiltersMap, generateFilterOptions } from 'Types/filter/newFilter';
|
||||
import {
|
||||
filtersMap,
|
||||
liveFiltersMap,
|
||||
conditionalFiltersMap,
|
||||
generateFilterOptions
|
||||
} from "Types/filter/newFilter";
|
||||
import { DURATION_FILTER } from 'App/constants/storageKeys';
|
||||
import Period, { CUSTOM_RANGE } from 'Types/app/period';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,38 +1,44 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useHistory } from 'react-router';
|
||||
import { createUrlQuery, getFiltersFromQuery } from 'App/utils/search';
|
||||
|
||||
interface Props {
|
||||
onBeforeLoad?: () => Promise<any>;
|
||||
appliedFilter: any;
|
||||
applyFilter: any;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const useSessionSearchQueryHandler = (props: Props) => {
|
||||
const [beforeHookLoaded, setBeforeHookLoaded] = useState(!props.onBeforeLoad);
|
||||
const { appliedFilter, applyFilter, loading } = props;
|
||||
const history = useHistory();
|
||||
|
||||
useEffect(() => {
|
||||
const applyFilterFromQuery = () => {
|
||||
const applyFilterFromQuery = async () => {
|
||||
if (!loading) {
|
||||
if (props.onBeforeLoad) {
|
||||
await props.onBeforeLoad();
|
||||
setBeforeHookLoaded(true);
|
||||
}
|
||||
const filter = getFiltersFromQuery(history.location.search, appliedFilter);
|
||||
applyFilter(filter, true, false);
|
||||
}
|
||||
};
|
||||
|
||||
applyFilterFromQuery();
|
||||
void applyFilterFromQuery();
|
||||
}, [loading]);
|
||||
|
||||
useEffect(() => {
|
||||
const generateUrlQuery = () => {
|
||||
if (!loading) {
|
||||
if (!loading && beforeHookLoaded) {
|
||||
const search: any = createUrlQuery(appliedFilter);
|
||||
history.replace({ search });
|
||||
}
|
||||
};
|
||||
|
||||
generateUrlQuery();
|
||||
}, [appliedFilter, loading]);
|
||||
}, [appliedFilter, loading, beforeHookLoaded]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,22 +5,21 @@ import UserStore from './userStore';
|
|||
import RoleStore from './roleStore';
|
||||
import APIClient from 'App/api_client';
|
||||
import FunnelStore from './funnelStore';
|
||||
import {
|
||||
services
|
||||
} from 'App/services';
|
||||
import { services } from 'App/services';
|
||||
import SettingsStore from './settingsStore';
|
||||
import AuditStore from './auditStore';
|
||||
import NotificationStore from './notificationStore';
|
||||
import ErrorStore from './errorStore';
|
||||
import SessionStore from './sessionStore';
|
||||
import NotesStore from './notesStore';
|
||||
import BugReportStore from './bugReportStore'
|
||||
import RecordingsStore from './recordingsStore'
|
||||
import BugReportStore from './bugReportStore';
|
||||
import RecordingsStore from './recordingsStore';
|
||||
import AssistMultiviewStore from './assistMultiviewStore';
|
||||
import WeeklyReportStore from './weeklyReportConfigStore'
|
||||
import AlertStore from './alertsStore'
|
||||
import FeatureFlagsStore from "./featureFlagsStore";
|
||||
import WeeklyReportStore from './weeklyReportConfigStore';
|
||||
import AlertStore from './alertsStore';
|
||||
import FeatureFlagsStore from './featureFlagsStore';
|
||||
import UxtestingStore from './uxtestingStore';
|
||||
import TagWatchStore from './tagWatchStore';
|
||||
|
||||
export class RootStore {
|
||||
dashboardStore: DashboardStore;
|
||||
|
|
@ -37,10 +36,11 @@ export class RootStore {
|
|||
bugReportStore: BugReportStore;
|
||||
recordingsStore: RecordingsStore;
|
||||
assistMultiviewStore: AssistMultiviewStore;
|
||||
weeklyReportStore: WeeklyReportStore
|
||||
alertsStore: AlertStore
|
||||
featureFlagsStore: FeatureFlagsStore
|
||||
uxtestingStore: UxtestingStore
|
||||
weeklyReportStore: WeeklyReportStore;
|
||||
alertsStore: AlertStore;
|
||||
featureFlagsStore: FeatureFlagsStore;
|
||||
uxtestingStore: UxtestingStore;
|
||||
tagWatchStore: TagWatchStore;
|
||||
|
||||
constructor() {
|
||||
this.dashboardStore = new DashboardStore();
|
||||
|
|
@ -61,13 +61,14 @@ export class RootStore {
|
|||
this.alertsStore = new AlertStore();
|
||||
this.featureFlagsStore = new FeatureFlagsStore();
|
||||
this.uxtestingStore = new UxtestingStore();
|
||||
this.tagWatchStore = new TagWatchStore();
|
||||
}
|
||||
|
||||
initClient() {
|
||||
const client = new APIClient();
|
||||
services.forEach(service => {
|
||||
services.forEach((service) => {
|
||||
service.initClient(client);
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
66
frontend/app/mstore/tagWatchStore.ts
Normal file
66
frontend/app/mstore/tagWatchStore.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { makeAutoObservable } from 'mobx';
|
||||
import { tagWatchService } from 'App/services';
|
||||
import { CreateTag, Tag } from 'App/services/TagWatchService';
|
||||
|
||||
export default class TagWatchStore {
|
||||
tags: Tag[] = [];
|
||||
isLoading = false;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
setTags = (tags: Tag[]) => {
|
||||
this.tags = tags;
|
||||
};
|
||||
|
||||
setLoading = (loading: boolean) => {
|
||||
this.isLoading = loading;
|
||||
};
|
||||
|
||||
getTags = async () => {
|
||||
if (this.isLoading) {
|
||||
return;
|
||||
}
|
||||
this.setLoading(true);
|
||||
try {
|
||||
const tags: Tag[] = await tagWatchService.getTags();
|
||||
this.setTags(tags);
|
||||
return tags;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
createTag = async (data: CreateTag) => {
|
||||
try {
|
||||
const tagId: number = await tagWatchService.createTag(data);
|
||||
return tagId;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
deleteTag = async (id: number) => {
|
||||
try {
|
||||
await tagWatchService.deleteTag(id);
|
||||
this.setTags(this.tags.filter((t) => t.tagId !== id));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
updateTagName = async (id: number, name: string) => {
|
||||
try {
|
||||
await tagWatchService.updateTagName(id, name);
|
||||
const updatedTag = this.tags.find((t) => t.tagId === id)
|
||||
if (updatedTag) {
|
||||
this.setTags(this.tags.map((t) => t.tagId === id ? { ...updatedTag, name } : t));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,19 +1,10 @@
|
|||
import type Screen from './Screen'
|
||||
import type Marker from './Marker'
|
||||
|
||||
//import { select } from 'optimal-select';
|
||||
|
||||
export default class Inspector {
|
||||
// private captureCallbacks = [];
|
||||
// private bubblingCallbacks = [];
|
||||
constructor(private screen: Screen, private marker: Marker) {}
|
||||
|
||||
private onMouseMove = (e: MouseEvent) => {
|
||||
// const { overlay } = this.screen;
|
||||
// if (!overlay.contains(e.target)) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
const target = this.screen.getElementFromPoint(e);
|
||||
|
|
@ -34,30 +25,15 @@ export default class Inspector {
|
|||
return
|
||||
}
|
||||
this.clickCallback && this.clickCallback({ target });
|
||||
// const targets = [ target ];
|
||||
// while (target.parentElement !== null) {
|
||||
// target = target.parentElement;
|
||||
// targets.push(target);
|
||||
// }
|
||||
// for (let i = targets.length - 1; i >= 0; i--) {
|
||||
// for (let j = 0; j < this.captureCallbacks.length; j++) {
|
||||
// this.captureCallbacks[j]({ target: targets[i] });
|
||||
// }
|
||||
// }
|
||||
|
||||
// onTargetClick(select(markedTarget, { root: this.screen.document }));
|
||||
}
|
||||
|
||||
// addClickListener(callback, useCapture = false) {
|
||||
// if (useCapture) {
|
||||
// this.captureCallbacks.push(callback);
|
||||
// } else {
|
||||
// //this.bubblingCallbacks.push(callback);
|
||||
// }
|
||||
// }
|
||||
addClickListener(callback: (el: { target: Element }) => void) {
|
||||
this.clickCallback = callback
|
||||
}
|
||||
|
||||
private clickCallback: (e: { target: Element }) => void | null = null
|
||||
enable(clickCallback?: Inspector['clickCallback']) {
|
||||
private clickCallback: (e: { target: Element }) => void = () => {}
|
||||
|
||||
enable() {
|
||||
this.screen.overlay.addEventListener('mousemove', this.onMouseMove)
|
||||
this.screen.overlay.addEventListener('mouseleave', this.onOverlayLeave)
|
||||
this.screen.overlay.addEventListener('click', this.onMarkClick)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type Screen from './Screen'
|
||||
import type Screen from './Screen';
|
||||
import styles from './marker.module.css';
|
||||
import { finder } from '@medv/finder';
|
||||
|
||||
const metaCharsMap = {
|
||||
'&': '&',
|
||||
|
|
@ -9,7 +10,7 @@ const metaCharsMap = {
|
|||
"'": ''',
|
||||
'/': '/',
|
||||
'`': '`',
|
||||
'=': '='
|
||||
'=': '=',
|
||||
};
|
||||
|
||||
function escapeHtml(str: string) {
|
||||
|
|
@ -19,29 +20,30 @@ function escapeHtml(str: string) {
|
|||
});
|
||||
}
|
||||
|
||||
|
||||
function escapeRegExp(string: string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function safeString(string: string) {
|
||||
return (escapeHtml(escapeRegExp(string)))
|
||||
return escapeHtml(escapeRegExp(string));
|
||||
}
|
||||
|
||||
export default class Marker {
|
||||
private _target: Element | null = null;
|
||||
private selector: string | null = null;
|
||||
private tooltip: HTMLDivElement
|
||||
private marker: HTMLDivElement
|
||||
private readonly tooltip: HTMLDivElement;
|
||||
private readonly tooltipSelector: HTMLDivElement;
|
||||
private readonly tooltipHint: HTMLDivElement;
|
||||
private marker: HTMLDivElement;
|
||||
|
||||
constructor(overlay: HTMLElement, private readonly screen: Screen) {
|
||||
constructor(private readonly overlay: HTMLElement, private readonly 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.tooltipSelector = document.createElement('div');
|
||||
this.tooltipHint = document.createElement('div');
|
||||
this.tooltipHint.innerText = '(click to tag element)'
|
||||
this.tooltipHint.className = styles.tooltipHint;
|
||||
this.tooltip.append(this.tooltipSelector, this.tooltipHint);
|
||||
|
||||
const marker = document.createElement('div');
|
||||
marker.className = styles.marker;
|
||||
|
|
@ -78,19 +80,17 @@ export default class Marker {
|
|||
}
|
||||
|
||||
unmark() {
|
||||
this.mark(null)
|
||||
this.mark(null);
|
||||
}
|
||||
|
||||
private autodefineTarget() {
|
||||
// TODO: put to Screen
|
||||
if (this.selector) {
|
||||
if (this.selector && this.screen.document) {
|
||||
try {
|
||||
const fitTargets = this.screen.document.querySelectorAll(this.selector);
|
||||
if (fitTargets.length === 0) {
|
||||
this._target = null;
|
||||
} else {
|
||||
// TODO: fix getCursorTarget()?
|
||||
// this._target = fitTargets[0];
|
||||
this._target = fitTargets[0];
|
||||
// const cursorTarget = this.screen.getCursorTarget();
|
||||
// fitTargets.forEach((target) => {
|
||||
// if (target.contains(cursorTarget)) {
|
||||
|
|
@ -108,27 +108,23 @@ export default class Marker {
|
|||
|
||||
markBySelector(selector: string) {
|
||||
this.selector = selector;
|
||||
this.lastSelector = selector;
|
||||
this.autodefineTarget();
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
lastSelector = '';
|
||||
private getTagString(el: Element) {
|
||||
const attrs = el.attributes;
|
||||
let str = `<span style="color:#9BBBDC">${el.tagName.toLowerCase()}</span>`;
|
||||
|
||||
for (let i = 0; i < attrs.length; i++) {
|
||||
let k = attrs[i];
|
||||
const attribute = k.name;
|
||||
if (attribute === 'class') {
|
||||
str += `<span style="color:#F29766">${'.' + safeString(k.value).split(' ').join('.')}</span>`;
|
||||
}
|
||||
|
||||
if (attribute === 'id') {
|
||||
str += `<span style="color:#F29766">${'#' + safeString(k.value).split(' ').join('#')}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
return str;
|
||||
if (!this.screen.document) return '';
|
||||
const selector = finder(el, {
|
||||
root: this.screen.document.body,
|
||||
seedMinLength: 3,
|
||||
optimizedMinLength: 2,
|
||||
threshold: 1000,
|
||||
maxNumberOfTries: 10_000,
|
||||
});
|
||||
this.lastSelector = selector;
|
||||
return selector
|
||||
}
|
||||
|
||||
redraw() {
|
||||
|
|
@ -146,6 +142,17 @@ export default class Marker {
|
|||
this.marker.style.width = rect.width + 'px';
|
||||
this.marker.style.height = rect.height + 'px';
|
||||
|
||||
this.tooltip.firstChild.innerHTML = this.getTagString(this._target);
|
||||
const replayScale = this.screen.getScale()
|
||||
if (replayScale < 1) {
|
||||
const upscale = (1 / replayScale).toFixed(3);
|
||||
const yShift = ((1 - replayScale)/2) * 100;
|
||||
this.tooltip.style.transform = `scale(${upscale}) translateY(-${yShift + 0.5}%)`
|
||||
}
|
||||
this.tooltipSelector.textContent = this.getTagString(this._target);
|
||||
}
|
||||
|
||||
|
||||
clean() {
|
||||
this.marker.remove();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,24 +39,21 @@
|
|||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 100%;
|
||||
padding: 15px;
|
||||
padding: 8px;
|
||||
max-width: 600px;
|
||||
box-shadow: 2px 2px 1px rgba(40, 40, 100, .3);
|
||||
z-index: 999;
|
||||
border-radius: 3px;
|
||||
background-color: #202124;
|
||||
min-width: 400px;
|
||||
font-size: 20px !important;
|
||||
|
||||
& div:first-child {
|
||||
max-width: 600px;
|
||||
height: 22px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
& div:last-child {
|
||||
font-size: 18px;
|
||||
margin-top: 10px;
|
||||
color: $tealx;
|
||||
}
|
||||
min-width: 300px;
|
||||
font-size: 16px !important;
|
||||
color:#9BBBDC;
|
||||
}
|
||||
|
||||
.tooltipHint {
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: rgba(138, 170, 201, 0.8);
|
||||
}
|
||||
|
|
@ -16,6 +16,8 @@ export default class WebPlayer extends Player {
|
|||
...TargetMarker.INITIAL_STATE,
|
||||
...MessageManager.INITIAL_STATE,
|
||||
...MessageLoader.INITIAL_STATE,
|
||||
...InspectorController.INITIAL_STATE,
|
||||
|
||||
liveTimeTravel: false,
|
||||
inspectorMode: false,
|
||||
}
|
||||
|
|
@ -65,7 +67,7 @@ export default class WebPlayer extends Player {
|
|||
}
|
||||
|
||||
this.targetMarker = new TargetMarker(this.screen, wpState)
|
||||
this.inspectorController = new InspectorController(screen)
|
||||
this.inspectorController = new InspectorController(screen, wpState)
|
||||
|
||||
|
||||
const endTime = session.duration?.valueOf() || 0
|
||||
|
|
@ -126,7 +128,7 @@ export default class WebPlayer extends Player {
|
|||
this.inspectorController.marker?.mark(e)
|
||||
}
|
||||
|
||||
toggleInspectorMode = (flag: boolean, clickCallback?: Parameters<InspectorController['enableInspector']>[0]) => {
|
||||
toggleInspectorMode = (flag: boolean) => {
|
||||
if (typeof flag !== 'boolean') {
|
||||
const { inspectorMode } = this.wpState.get()
|
||||
flag = !inspectorMode
|
||||
|
|
@ -135,13 +137,17 @@ export default class WebPlayer extends Player {
|
|||
if (flag) {
|
||||
this.pause()
|
||||
this.wpState.update({ inspectorMode: true })
|
||||
return this.inspectorController.enableInspector(clickCallback)
|
||||
return this.inspectorController.enableInspector()
|
||||
} else {
|
||||
this.inspectorController.disableInspector()
|
||||
this.wpState.update({ inspectorMode: false })
|
||||
}
|
||||
}
|
||||
|
||||
markBySelector = (selector: string) => {
|
||||
this.inspectorController.markBySelector(selector)
|
||||
}
|
||||
|
||||
// Target Marker
|
||||
setActiveTarget = (...args: Parameters<TargetMarker['setActiveTarget']>) => {
|
||||
this.targetMarker.setActiveTarget(...args)
|
||||
|
|
|
|||
|
|
@ -1,72 +1,58 @@
|
|||
import Marker from '../Screen/Marker'
|
||||
import Inspector from '../Screen/Inspector'
|
||||
import Screen from '../Screen/Screen'
|
||||
import type { Dimensions } from '../Screen/types'
|
||||
|
||||
import type { Store } from 'Player';
|
||||
import { State } from 'Player/web/addons/TargetMarker';
|
||||
import Marker from '../Screen/Marker';
|
||||
import Inspector from '../Screen/Inspector';
|
||||
import Screen, { ScaleMode } from '../Screen/Screen';
|
||||
import type { Dimensions } from '../Screen/types';
|
||||
|
||||
export default class InspectorController {
|
||||
private substitutor: Screen | null = null
|
||||
private inspector: Inspector | null = null
|
||||
marker: Marker | null = null
|
||||
constructor(private screen: Screen) {
|
||||
screen.overlay.addEventListener('contextmenu', () => {
|
||||
screen.overlay.style.display = 'none'
|
||||
const doc = screen.document
|
||||
if (!doc) { return }
|
||||
const returnOverlay = () => {
|
||||
screen.overlay.style.display = 'block'
|
||||
doc.removeEventListener('mousemove', returnOverlay)
|
||||
doc.removeEventListener('mouseclick', returnOverlay) // TODO: prevent default in case of input selection
|
||||
static INITIAL_STATE = {
|
||||
tagSelector: '',
|
||||
}
|
||||
doc.addEventListener('mousemove', returnOverlay)
|
||||
doc.addEventListener('mouseclick', returnOverlay)
|
||||
})
|
||||
private substitutor: Screen | null = null;
|
||||
private inspector: Inspector | null = null;
|
||||
marker: Marker | null = null;
|
||||
|
||||
constructor(private screen: Screen, private readonly store: Store<{ tagSelector: string }>) {
|
||||
screen.overlay.addEventListener('contextmenu', () => {
|
||||
screen.overlay.style.display = 'none';
|
||||
const doc = screen.document;
|
||||
if (!doc) {
|
||||
return;
|
||||
}
|
||||
const returnOverlay = () => {
|
||||
screen.overlay.style.display = 'block';
|
||||
doc.removeEventListener('mousemove', returnOverlay);
|
||||
doc.removeEventListener('mouseclick', returnOverlay); // TODO: prevent default in case of input selection
|
||||
};
|
||||
doc.addEventListener('mousemove', returnOverlay);
|
||||
doc.addEventListener('mouseclick', returnOverlay);
|
||||
});
|
||||
}
|
||||
|
||||
scale(dims: Dimensions) {
|
||||
if (this.substitutor) {
|
||||
this.substitutor.scale(dims)
|
||||
}
|
||||
this.screen.scale(dims);
|
||||
}
|
||||
|
||||
enableInspector(clickCallback?: (e: { target: Element }) => void): Document | null {
|
||||
const parent = this.screen.getParentElement()
|
||||
if (!parent) return null;
|
||||
if (!this.substitutor) {
|
||||
this.substitutor = new Screen()
|
||||
this.marker = new Marker(this.substitutor.overlay, this.substitutor)
|
||||
this.inspector = new Inspector(this.substitutor, this.marker)
|
||||
//this.inspector.addClickListener(clickCallback, true)
|
||||
this.substitutor.attach(parent)
|
||||
enableInspector(): Document | null {
|
||||
this.marker = new Marker(this.screen.overlay, this.screen);
|
||||
this.inspector = new Inspector(this.screen, this.marker);
|
||||
this.inspector.addClickListener(() => {
|
||||
this.store.update({ tagSelector: this.marker?.lastSelector ?? '' })
|
||||
});
|
||||
|
||||
this.inspector?.enable();
|
||||
return this.screen.document;
|
||||
}
|
||||
|
||||
this.substitutor.display(false)
|
||||
|
||||
const docElement = this.screen.document?.documentElement // this.substitutor.document?.importNode(
|
||||
const doc = this.substitutor.document
|
||||
if (doc && docElement) {
|
||||
doc.open()
|
||||
doc.write(docElement.outerHTML)
|
||||
doc.close()
|
||||
|
||||
// TODO! : copy stylesheets & cssRules?
|
||||
}
|
||||
this.screen.display(false);
|
||||
this.inspector.enable(clickCallback);
|
||||
this.substitutor.display(true);
|
||||
return doc;
|
||||
markBySelector(selector: string) {
|
||||
this.marker?.markBySelector(selector);
|
||||
}
|
||||
|
||||
disableInspector() {
|
||||
if (this.substitutor) {
|
||||
const doc = this.substitutor.document;
|
||||
if (doc) {
|
||||
doc.documentElement.innerHTML = "";
|
||||
this.inspector?.clean();
|
||||
this.inspector = null;
|
||||
this.marker?.clean();
|
||||
this.marker = null;
|
||||
}
|
||||
this.inspector.clean();
|
||||
this.substitutor.display(false);
|
||||
}
|
||||
this.screen.display(true);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -741,6 +741,14 @@ export default class RawMessageReader extends PrimitiveReader {
|
|||
};
|
||||
}
|
||||
|
||||
case 120: {
|
||||
const tagId = this.readInt(); if (tagId === null) { return resetPointer() }
|
||||
return {
|
||||
tp: MType.TagTrigger,
|
||||
tagId,
|
||||
};
|
||||
}
|
||||
|
||||
case 93: {
|
||||
const timestamp = this.readUint(); if (timestamp === null) { return resetPointer() }
|
||||
const length = this.readUint(); if (length === null) { return resetPointer() }
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ import type {
|
|||
RawTabChange,
|
||||
RawTabData,
|
||||
RawCanvasNode,
|
||||
RawTagTrigger,
|
||||
RawIosEvent,
|
||||
RawIosScreenChanges,
|
||||
RawIosClickEvent,
|
||||
|
|
@ -196,6 +197,8 @@ export type TabData = RawTabData & Timed
|
|||
|
||||
export type CanvasNode = RawCanvasNode & Timed
|
||||
|
||||
export type TagTrigger = RawTagTrigger & Timed
|
||||
|
||||
export type IosEvent = RawIosEvent & Timed
|
||||
|
||||
export type IosScreenChanges = RawIosScreenChanges & Timed
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ export const enum MType {
|
|||
TabChange = 117,
|
||||
TabData = 118,
|
||||
CanvasNode = 119,
|
||||
TagTrigger = 120,
|
||||
IosEvent = 93,
|
||||
IosScreenChanges = 96,
|
||||
IosClickEvent = 100,
|
||||
|
|
@ -494,6 +495,11 @@ export interface RawCanvasNode {
|
|||
timestamp: number,
|
||||
}
|
||||
|
||||
export interface RawTagTrigger {
|
||||
tp: MType.TagTrigger,
|
||||
tagId: number,
|
||||
}
|
||||
|
||||
export interface RawIosEvent {
|
||||
tp: MType.IosEvent,
|
||||
timestamp: number,
|
||||
|
|
@ -586,4 +592,4 @@ export interface RawIosIssueEvent {
|
|||
}
|
||||
|
||||
|
||||
export type RawMessage = RawTimestamp | RawSetPageLocation | RawSetViewportSize | RawSetViewportScroll | RawCreateDocument | RawCreateElementNode | RawCreateTextNode | RawMoveNode | RawRemoveNode | RawSetNodeAttribute | RawRemoveNodeAttribute | RawSetNodeData | RawSetCssData | RawSetNodeScroll | RawSetInputValue | RawSetInputChecked | RawMouseMove | RawNetworkRequestDeprecated | RawConsoleLog | RawCssInsertRule | RawCssDeleteRule | RawFetch | RawProfiler | RawOTable | RawRedux | RawVuex | RawMobX | RawNgRx | RawGraphQl | RawPerformanceTrack | RawStringDict | RawSetNodeAttributeDict | RawResourceTimingDeprecated | RawConnectionInformation | RawSetPageVisibility | RawLoadFontFace | RawSetNodeFocus | RawLongTask | RawSetNodeAttributeURLBased | RawSetCssDataURLBased | RawCssInsertRuleURLBased | RawMouseClick | RawCreateIFrameDocument | RawAdoptedSsReplaceURLBased | RawAdoptedSsReplace | RawAdoptedSsInsertRuleURLBased | RawAdoptedSsInsertRule | RawAdoptedSsDeleteRule | RawAdoptedSsAddOwner | RawAdoptedSsRemoveOwner | RawZustand | RawNetworkRequest | RawWsChannel | RawSelectionChange | RawMouseThrashing | RawResourceTiming | RawTabChange | RawTabData | RawCanvasNode | RawIosEvent | RawIosScreenChanges | RawIosClickEvent | RawIosInputEvent | RawIosPerformanceEvent | RawIosLog | RawIosInternalError | RawIosNetworkCall | RawIosSwipeEvent | RawIosIssueEvent;
|
||||
export type RawMessage = RawTimestamp | RawSetPageLocation | RawSetViewportSize | RawSetViewportScroll | RawCreateDocument | RawCreateElementNode | RawCreateTextNode | RawMoveNode | RawRemoveNode | RawSetNodeAttribute | RawRemoveNodeAttribute | RawSetNodeData | RawSetCssData | RawSetNodeScroll | RawSetInputValue | RawSetInputChecked | RawMouseMove | RawNetworkRequestDeprecated | RawConsoleLog | RawCssInsertRule | RawCssDeleteRule | RawFetch | RawProfiler | RawOTable | RawRedux | RawVuex | RawMobX | RawNgRx | RawGraphQl | RawPerformanceTrack | RawStringDict | RawSetNodeAttributeDict | RawResourceTimingDeprecated | RawConnectionInformation | RawSetPageVisibility | RawLoadFontFace | RawSetNodeFocus | RawLongTask | RawSetNodeAttributeURLBased | RawSetCssDataURLBased | RawCssInsertRuleURLBased | RawMouseClick | RawCreateIFrameDocument | RawAdoptedSsReplaceURLBased | RawAdoptedSsReplace | RawAdoptedSsInsertRuleURLBased | RawAdoptedSsInsertRule | RawAdoptedSsDeleteRule | RawAdoptedSsAddOwner | RawAdoptedSsRemoveOwner | RawZustand | RawNetworkRequest | RawWsChannel | RawSelectionChange | RawMouseThrashing | RawResourceTiming | RawTabChange | RawTabData | RawCanvasNode | RawTagTrigger | RawIosEvent | RawIosScreenChanges | RawIosClickEvent | RawIosInputEvent | RawIosPerformanceEvent | RawIosLog | RawIosInternalError | RawIosNetworkCall | RawIosSwipeEvent | RawIosIssueEvent;
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ export const TP_MAP = {
|
|||
117: MType.TabChange,
|
||||
118: MType.TabData,
|
||||
119: MType.CanvasNode,
|
||||
120: MType.TagTrigger,
|
||||
93: MType.IosEvent,
|
||||
96: MType.IosScreenChanges,
|
||||
100: MType.IosClickEvent,
|
||||
|
|
|
|||
|
|
@ -509,8 +509,13 @@ type TrCanvasNode = [
|
|||
timestamp: number,
|
||||
]
|
||||
|
||||
type TrTagTrigger = [
|
||||
type: 120,
|
||||
tagId: number,
|
||||
]
|
||||
|
||||
export type TrackerMessage = TrTimestamp | TrSetPageLocation | TrSetViewportSize | TrSetViewportScroll | TrCreateDocument | TrCreateElementNode | TrCreateTextNode | TrMoveNode | TrRemoveNode | TrSetNodeAttribute | TrRemoveNodeAttribute | TrSetNodeData | TrSetNodeScroll | TrSetInputTarget | TrSetInputValue | TrSetInputChecked | TrMouseMove | TrNetworkRequestDeprecated | TrConsoleLog | TrPageLoadTiming | TrPageRenderTiming | TrCustomEvent | TrUserID | TrUserAnonymousID | TrMetadata | TrCSSInsertRule | TrCSSDeleteRule | TrFetch | TrProfiler | TrOTable | TrStateAction | TrRedux | TrVuex | TrMobX | TrNgRx | TrGraphQL | TrPerformanceTrack | TrStringDict | TrSetNodeAttributeDict | TrResourceTimingDeprecated | TrConnectionInformation | TrSetPageVisibility | TrLoadFontFace | TrSetNodeFocus | TrLongTask | TrSetNodeAttributeURLBased | TrSetCSSDataURLBased | TrTechnicalInfo | TrCustomIssue | TrCSSInsertRuleURLBased | TrMouseClick | TrCreateIFrameDocument | TrAdoptedSSReplaceURLBased | TrAdoptedSSInsertRuleURLBased | TrAdoptedSSDeleteRule | TrAdoptedSSAddOwner | TrAdoptedSSRemoveOwner | TrJSException | TrZustand | TrBatchMetadata | TrPartitionedMessage | TrNetworkRequest | TrWSChannel | TrInputChange | TrSelectionChange | TrMouseThrashing | TrUnbindNodes | TrResourceTiming | TrTabChange | TrTabData | TrCanvasNode
|
||||
|
||||
export type TrackerMessage = TrTimestamp | TrSetPageLocation | TrSetViewportSize | TrSetViewportScroll | TrCreateDocument | TrCreateElementNode | TrCreateTextNode | TrMoveNode | TrRemoveNode | TrSetNodeAttribute | TrRemoveNodeAttribute | TrSetNodeData | TrSetNodeScroll | TrSetInputTarget | TrSetInputValue | TrSetInputChecked | TrMouseMove | TrNetworkRequestDeprecated | TrConsoleLog | TrPageLoadTiming | TrPageRenderTiming | TrCustomEvent | TrUserID | TrUserAnonymousID | TrMetadata | TrCSSInsertRule | TrCSSDeleteRule | TrFetch | TrProfiler | TrOTable | TrStateAction | TrRedux | TrVuex | TrMobX | TrNgRx | TrGraphQL | TrPerformanceTrack | TrStringDict | TrSetNodeAttributeDict | TrResourceTimingDeprecated | TrConnectionInformation | TrSetPageVisibility | TrLoadFontFace | TrSetNodeFocus | TrLongTask | TrSetNodeAttributeURLBased | TrSetCSSDataURLBased | TrTechnicalInfo | TrCustomIssue | TrCSSInsertRuleURLBased | TrMouseClick | TrCreateIFrameDocument | TrAdoptedSSReplaceURLBased | TrAdoptedSSInsertRuleURLBased | TrAdoptedSSDeleteRule | TrAdoptedSSAddOwner | TrAdoptedSSRemoveOwner | TrJSException | TrZustand | TrBatchMetadata | TrPartitionedMessage | TrNetworkRequest | TrWSChannel | TrInputChange | TrSelectionChange | TrMouseThrashing | TrUnbindNodes | TrResourceTiming | TrTabChange | TrTabData | TrCanvasNode | TrTagTrigger
|
||||
|
||||
export default function translate(tMsg: TrackerMessage): RawMessage | null {
|
||||
switch(tMsg[0]) {
|
||||
|
|
@ -1028,6 +1033,13 @@ export default function translate(tMsg: TrackerMessage): RawMessage | null {
|
|||
}
|
||||
}
|
||||
|
||||
case 120: {
|
||||
return {
|
||||
tp: MType.TagTrigger,
|
||||
tagId: tMsg[1],
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
38
frontend/app/services/TagWatchService.ts
Normal file
38
frontend/app/services/TagWatchService.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import BaseService from "App/services/BaseService";
|
||||
|
||||
export interface CreateTag {
|
||||
name: string;
|
||||
selector: string;
|
||||
ignoreClickRage: boolean;
|
||||
ignoreDeadClick: boolean;
|
||||
}
|
||||
|
||||
export interface Tag extends CreateTag {
|
||||
tagId: number;
|
||||
}
|
||||
|
||||
export default class TagWatchService extends BaseService {
|
||||
createTag(data: CreateTag) {
|
||||
return this.client.post('/tags', data)
|
||||
.then(r => r.json())
|
||||
.then((response: { data: any; }) => response.data || {})
|
||||
}
|
||||
|
||||
getTags() {
|
||||
return this.client.get('/tags')
|
||||
.then(r => r.json())
|
||||
.then((response: { data: any; }) => response.data || {})
|
||||
}
|
||||
|
||||
deleteTag(id: number) {
|
||||
return this.client.delete(`/tags/${id}`)
|
||||
.then(r => r.json())
|
||||
.then((response: { data: any; }) => response.data || {})
|
||||
}
|
||||
|
||||
updateTagName(id: number, name: string) {
|
||||
return this.client.put(`/tags/${id}`, { name })
|
||||
.then(r => r.json())
|
||||
.then((response: { data: any; }) => response.data || {})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +1,24 @@
|
|||
import DashboardService from "./DashboardService";
|
||||
import MetricService from "./MetricService";
|
||||
import FunnelService from "./FunnelService";
|
||||
import SessionSerivce from "./SessionService";
|
||||
import UserService from "./UserService";
|
||||
import DashboardService from './DashboardService';
|
||||
import MetricService from './MetricService';
|
||||
import FunnelService from './FunnelService';
|
||||
import SessionService from './SessionService';
|
||||
import UserService from './UserService';
|
||||
import AuditService from './AuditService';
|
||||
import ErrorService from "./ErrorService";
|
||||
import NotesService from "./NotesService";
|
||||
import RecordingsService from "./RecordingsService";
|
||||
import ConfigService from './ConfigService'
|
||||
import AlertsService from './AlertsService'
|
||||
import WebhookService from './WebhookService'
|
||||
import HealthService from "./HealthService";
|
||||
import FFlagsService from "App/services/FFlagsService";
|
||||
import AssistStatsService from './AssistStatsService'
|
||||
import UxtestingService from './UxtestingService'
|
||||
import ErrorService from './ErrorService';
|
||||
import NotesService from './NotesService';
|
||||
import RecordingsService from './RecordingsService';
|
||||
import ConfigService from './ConfigService';
|
||||
import AlertsService from './AlertsService';
|
||||
import WebhookService from './WebhookService';
|
||||
import HealthService from './HealthService';
|
||||
import FFlagsService from 'App/services/FFlagsService';
|
||||
import AssistStatsService from './AssistStatsService';
|
||||
import UxtestingService from './UxtestingService';
|
||||
import TagWatchService from 'App/services/TagWatchService';
|
||||
|
||||
export const dashboardService = new DashboardService();
|
||||
export const metricService = new MetricService();
|
||||
export const sessionService = new SessionSerivce();
|
||||
export const sessionService = new SessionService();
|
||||
export const userService = new UserService();
|
||||
export const funnelService = new FunnelService();
|
||||
export const auditService = new AuditService();
|
||||
|
|
@ -36,6 +37,8 @@ export const assistStatsService = new AssistStatsService();
|
|||
|
||||
export const uxtestingService = new UxtestingService();
|
||||
|
||||
export const tagWatchService = new TagWatchService();
|
||||
|
||||
export const services = [
|
||||
dashboardService,
|
||||
metricService,
|
||||
|
|
@ -52,5 +55,6 @@ export const services = [
|
|||
healthService,
|
||||
fflagsService,
|
||||
assistStatsService,
|
||||
uxtestingService
|
||||
]
|
||||
uxtestingService,
|
||||
tagWatchService,
|
||||
];
|
||||
|
|
|
|||
10
frontend/app/svg/icons/filters/tag-element.svg
Normal file
10
frontend/app/svg/icons/filters/tag-element.svg
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<g clip-path="url(#clip0_322_2510)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.95801 6.66413C1.96504 7.49187 2.2347 8.29603 2.72816 8.96069C2.73328 8.96759 2.73825 8.97457 2.74304 8.98161C2.78908 9.01786 2.83075 9.06126 2.86636 9.1115C3.15601 9.52018 3.70303 10.0709 4.39747 10.5164C5.09214 10.9619 5.88124 11.2684 6.65956 11.2684C7.00474 11.2684 7.28456 11.5482 7.28456 11.8934C7.28456 12.2386 7.00474 12.5184 6.65956 12.5184C5.56421 12.5184 4.54129 12.0937 3.72259 11.5685C2.90365 11.0432 2.23594 10.3837 1.84653 9.8343L1.83975 9.82454C1.79721 9.79103 1.75836 9.75139 1.72452 9.70581C1.07259 8.82771 0.716628 7.76512 0.708027 6.6715L0.708008 6.66659C0.708008 3.25163 4.11207 0.708252 7.99967 0.708252C11.8873 0.708252 15.2913 3.25163 15.2913 6.66659V6.6804L15.2912 6.68039C15.2799 7.19201 15.1962 7.69945 15.0425 8.18758C14.9389 8.51683 14.5879 8.69972 14.2587 8.59608C13.9294 8.49244 13.7465 8.14151 13.8502 7.81226C13.9677 7.43895 14.032 7.05096 14.0413 6.65972C14.0366 4.18409 11.4689 1.95825 7.99967 1.95825C4.52843 1.95825 1.9597 4.18674 1.95801 6.66413ZM9.95801 13.1206V8.77774L12.9688 11.8542L11.651 11.4139C11.3837 11.3246 11.0897 11.4253 10.9332 11.6597L9.95801 13.1206ZM10.0931 7.13325C9.95984 7.02219 9.77556 6.93506 9.55301 6.93506L9.53463 6.93533C9.31312 6.94185 9.10288 7.03444 8.94853 7.19346C8.79426 7.35242 8.70798 7.56522 8.70801 7.78673V7.787V14.4534C8.70801 14.4607 8.70814 14.4681 8.7084 14.4754C8.71458 14.6507 8.7743 14.8199 8.87953 14.9602C8.98476 15.1005 9.13044 15.2052 9.29697 15.2602C9.36022 15.2811 9.4264 15.2917 9.49301 15.2917H9.55301C9.68626 15.2917 9.81768 15.2607 9.93687 15.2011C10.056 15.1415 10.1597 15.055 10.2397 14.9484C10.2465 14.9392 10.2531 14.9299 10.2595 14.9204L11.7078 12.7508L14.2483 13.5995C14.308 13.6195 14.3704 13.6303 14.4333 13.6316C14.6107 13.6353 14.7848 13.583 14.9308 13.4823C15.0769 13.3815 15.1875 13.2373 15.247 13.0701C15.3065 12.903 15.3119 12.7213 15.2623 12.5509C15.2179 12.3982 15.1315 12.2615 15.0134 12.1561L10.1397 7.17624C10.1249 7.16113 10.1094 7.14679 10.0931 7.13325Z" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_322_2510">
|
||||
<rect width="16" height="16" fill="none"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
7
frontend/app/svg/icons/mouse-pointer-click.svg
Normal file
7
frontend/app/svg/icons/mouse-pointer-click.svg
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="mouse-pointer-click">
|
||||
<path id="Vector" d="M5.25 5.25L8.16667 12.25L9.2015 9.2015L12.25 8.16667L5.25 5.25Z" fill="black" fill-opacity="0.85" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Vector_2" d="M9.37451 9.37475L11.8496 11.8498" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Vector_3" d="M4.19316 1.30608L4.64641 2.996M2.99616 4.64625L1.30566 4.193M8.13766 2.3625L6.89983 3.60033M3.59991 6.89967L2.36325 8.1375" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 709 B |
5
frontend/app/svg/icons/user-switch.svg
Normal file
5
frontend/app/svg/icons/user-switch.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="UserSwitch">
|
||||
<path id="Vector" d="M11.5259 4.23058C11.5259 2.08995 9.79154 0.355577 7.65092 0.355577C5.51029 0.355577 3.77592 2.08995 3.77592 4.23058C3.77592 5.52433 4.41029 6.6712 5.38529 7.37433C5.37904 7.37745 5.37436 7.37901 5.37123 7.38058C4.67279 7.67589 4.04623 8.09933 3.50717 8.63995C2.97122 9.17493 2.54453 9.80912 2.25092 10.5071C1.96203 11.1907 1.80612 11.923 1.79154 12.665C1.79113 12.6816 1.79405 12.6982 1.80015 12.7137C1.80624 12.7293 1.81538 12.7434 1.82703 12.7554C1.83868 12.7673 1.8526 12.7768 1.86797 12.7833C1.88335 12.7897 1.89986 12.7931 1.91654 12.7931H2.85248C2.91967 12.7931 2.97592 12.7384 2.97748 12.6712C3.00873 11.465 3.49154 10.3353 4.34623 9.47902C5.22904 8.59308 6.40092 8.10558 7.65092 8.10558C9.79154 8.10558 11.5259 6.3712 11.5259 4.23058ZM7.65092 6.91808C6.16654 6.91808 4.96342 5.71495 4.96342 4.23058C4.96342 2.7462 6.16654 1.54308 7.65092 1.54308C9.13529 1.54308 10.3384 2.7462 10.3384 4.23058C10.3384 5.71495 9.13529 6.91808 7.65092 6.91808ZM9.29154 10.3712H13.4165C13.4853 10.3712 13.5415 10.315 13.5415 10.2462V9.3712C13.5415 9.30245 13.4853 9.2462 13.4165 9.2462H10.6587L11.3962 8.30714C11.4132 8.28522 11.4226 8.25832 11.4228 8.23058C11.4228 8.16183 11.3665 8.10558 11.2978 8.10558H10.1634C10.0869 8.10558 10.015 8.14151 9.96654 8.20089L8.89623 9.56183C8.82748 9.64933 8.78998 9.7587 8.78998 9.8712C8.79154 10.1478 9.01498 10.3712 9.29154 10.3712ZM13.0415 11.3712H8.91654C8.84779 11.3712 8.79154 11.4275 8.79154 11.4962V12.3712C8.79154 12.44 8.84779 12.4962 8.91654 12.4962H11.6744L10.9369 13.4353C10.9198 13.4572 10.9105 13.4841 10.9103 13.5118C10.9103 13.5806 10.9665 13.6368 11.0353 13.6368H12.1697C12.2462 13.6368 12.3181 13.6009 12.3665 13.5415L13.4369 12.1806C13.5056 12.0931 13.5431 11.9837 13.5431 11.8712C13.5415 11.5946 13.3181 11.3712 13.0415 11.3712Z" fill="black" fill-opacity="0.85"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
|
@ -64,6 +64,8 @@ export const setQueryParamKeyFromFilterkey = (filterKey: string) => {
|
|||
return 'duration';
|
||||
case FilterKey.FEATURE_FLAG:
|
||||
return 'feature_flag';
|
||||
case FilterKey.TAGGED_ELEMENT:
|
||||
return 'tnw'
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -149,6 +151,8 @@ export const getFilterKeyTypeByKey = (key: string) => {
|
|||
return FilterKey.DURATION;
|
||||
case FilterKey.FEATURE_FLAG:
|
||||
return 'feature_flag';
|
||||
case 'tnw':
|
||||
return FilterKey.TAGGED_ELEMENT
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -306,4 +310,5 @@ export enum FilterKey {
|
|||
|
||||
CLICKMAP_URL = 'clickMapUrl',
|
||||
FEATURE_FLAG = 'featureFlag',
|
||||
TAGGED_ELEMENT = 'tag',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -262,6 +262,17 @@ export const filters = [
|
|||
operatorOptions: filterOptions.getOperatorsByKeys(['is']),
|
||||
icon: 'filters/duration'
|
||||
},
|
||||
{
|
||||
key: FilterKey.TAGGED_ELEMENT,
|
||||
type: FilterType.MULTIPLE_DROPDOWN,
|
||||
category: FilterCategory.RECORDING_ATTRIBUTES,
|
||||
label: 'Tagged Element',
|
||||
operator: 'is',
|
||||
isEvent: true,
|
||||
icon: 'filters/tag-element',
|
||||
operatorOptions: filterOptions.getOperatorsByKeys(['is']),
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: FilterKey.USER_COUNTRY,
|
||||
type: FilterType.MULTIPLE_DROPDOWN,
|
||||
|
|
@ -721,6 +732,15 @@ export const addElementToFiltersMap = (
|
|||
};
|
||||
};
|
||||
|
||||
export const addOptionsToFilter = (
|
||||
key,
|
||||
options,
|
||||
) => {
|
||||
if (filtersMap[key] && filtersMap[key].options) {
|
||||
filtersMap[key].options = options
|
||||
}
|
||||
}
|
||||
|
||||
export const addElementToFlagConditionsMap = (
|
||||
category = FilterCategory.METADATA,
|
||||
key,
|
||||
|
|
@ -829,10 +849,14 @@ export default Record({
|
|||
} else {
|
||||
if (type === FilterKey.METADATA) {
|
||||
_filter = filtersMap[filter.source];
|
||||
} else {
|
||||
if (filtersMap[filter.key]) {
|
||||
_filter = filtersMap[filter.key]
|
||||
} else {
|
||||
_filter = filtersMap[type];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!_filter) {
|
||||
_filter = {
|
||||
|
|
|
|||
|
|
@ -48,18 +48,18 @@ export const getFiltersFromQuery = (search: string, filter: any) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const entires = getQueryObject(search);
|
||||
const period: any = getPeriodFromEntries(entires);
|
||||
const filters = getFiltersFromEntries(entires);
|
||||
const entries = getQueryObject(search);
|
||||
const period: any = getPeriodFromEntries(entries);
|
||||
const filters = getFiltersFromEntries(entries);
|
||||
|
||||
return Filter({ filters, rangeValue: period.rangeName, startDate: period.start, endDate: period.end });
|
||||
};
|
||||
|
||||
const getFiltersFromEntries = (entires: any) => {
|
||||
const getFiltersFromEntries = (entries: any) => {
|
||||
const _filters: any = { ...filtersMap };
|
||||
const filters: any = [];
|
||||
if (entires.length > 0) {
|
||||
entires.forEach((item: any) => {
|
||||
if (entries.length > 0) {
|
||||
entries.forEach((item: any) => {
|
||||
if (!item.key || !item.value) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -72,6 +72,10 @@ const getFiltersFromEntries = (entires: any) => {
|
|||
const sourceArr = tmp[1] ? tmp[1].split('|') : [];
|
||||
const sourceOperator = sourceArr.shift();
|
||||
|
||||
if (filterKey && _filters[filterKey]) {
|
||||
filter = _filters[filterKey];
|
||||
filter.value = valueArr;
|
||||
} else {
|
||||
if (filterKey) {
|
||||
filter.type = filterKey;
|
||||
filter.key = filterKey;
|
||||
|
|
@ -79,7 +83,6 @@ const getFiltersFromEntries = (entires: any) => {
|
|||
filter = _filters[item.key];
|
||||
if (!!filter) {
|
||||
filter.type = filter.key;
|
||||
filter.key = filter.key;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -96,6 +99,7 @@ const getFiltersFromEntries = (entires: any) => {
|
|||
filter.source = sourceArr && sourceArr.length > 0 ? sourceArr : null;
|
||||
filter.sourceOperator = !!sourceOperator ? decodeURI(sourceOperator) : null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!filter.filters || filter.filters.size === 0) {
|
||||
// TODO support subfilters in url
|
||||
|
|
@ -106,15 +110,15 @@ const getFiltersFromEntries = (entires: any) => {
|
|||
return filters;
|
||||
};
|
||||
|
||||
const getPeriodFromEntries = (entires: any) => {
|
||||
const rangeFilter = entires.find(({ key }: any) => key === 'range');
|
||||
const getPeriodFromEntries = (entries: any) => {
|
||||
const rangeFilter = entries.find(({ key }: any) => key === 'range');
|
||||
if (!rangeFilter) {
|
||||
return Period();
|
||||
}
|
||||
|
||||
if (rangeFilter.value === CUSTOM_RANGE) {
|
||||
const start = entires.find(({ key }: any) => key === 'rStart').value;
|
||||
const end = entires.find(({ key }: any) => key === 'rEnd').value;
|
||||
const start = entries.find(({ key }: any) => key === 'rStart').value;
|
||||
const end = entries.find(({ key }: any) => key === 'rEnd').value;
|
||||
return Period({ rangeName: rangeFilter.value, start, end });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
"@ant-design/icons": "^5.2.5",
|
||||
"@babel/plugin-transform-private-methods": "^7.23.3",
|
||||
"@floating-ui/react-dom-interactions": "^0.10.3",
|
||||
"@medv/finder": "^3.1.0",
|
||||
"@sentry/browser": "^5.21.1",
|
||||
"@svg-maps/world": "^1.0.1",
|
||||
"@svgr/webpack": "^6.2.1",
|
||||
|
|
|
|||
|
|
@ -112,11 +112,11 @@ ${iconPaths.map(icon => ` ${titleCase(icon.fileName)}`).join(',\n')}
|
|||
} from './Icons'
|
||||
|
||||
|
||||
// export type IconNames = ${icons.map((icon) => '\'' + icon.slice(0, -4).replaceAll('-', '_') + '\'').join(' | ')};
|
||||
export type OldIconNames = ${icons.map((icon) => '\'' + icon.slice(0, -4) + '\'').join(' | ')};
|
||||
// export type NewIconNames = ${icons.map((icon) => '\'' + icon.slice(0, -4).replaceAll('-', '_') + '\'').join(' | ')};
|
||||
export type IconNames = ${icons.map((icon) => '\'' + icon.slice(0, -4) + '\'').join(' | ')};
|
||||
|
||||
interface Props {
|
||||
name: OldIconNames;
|
||||
name: IconNames;
|
||||
size?: number | string;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
|
|
|
|||
|
|
@ -2762,6 +2762,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@medv/finder@npm:^3.1.0":
|
||||
version: 3.1.0
|
||||
resolution: "@medv/finder@npm:3.1.0"
|
||||
checksum: 43f8f5af50d51e2609a26b7f1306cf0b170967df9aa0169d70319215957d47066c35d888d8d049d47384639e28c1bba96bc9c09558bb7368ea43c8b0b6e03836
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mrmlnc/readdir-enhanced@npm:^2.2.1":
|
||||
version: 2.2.1
|
||||
resolution: "@mrmlnc/readdir-enhanced@npm:2.2.1"
|
||||
|
|
@ -18091,6 +18098,7 @@ __metadata:
|
|||
"@babel/runtime": ^7.23.2
|
||||
"@floating-ui/react-dom-interactions": ^0.10.3
|
||||
"@jest/globals": ^29.7.0
|
||||
"@medv/finder": ^3.1.0
|
||||
"@openreplay/sourcemap-uploader": ^3.0.8
|
||||
"@sentry/browser": ^5.21.1
|
||||
"@storybook/addon-actions": ^6.5.12
|
||||
|
|
|
|||
|
|
@ -518,6 +518,10 @@ message 119, 'CanvasNode' do
|
|||
uint 'Timestamp'
|
||||
end
|
||||
|
||||
message 120, 'TagTrigger', :replayer => :devtools do
|
||||
int 'TagId'
|
||||
end
|
||||
|
||||
## Backend-only
|
||||
message 125, 'IssueEvent', :replayer => false, :tracker => false do
|
||||
uint 'MessageID'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@openreplay/tracker",
|
||||
"description": "The OpenReplay tracker main package",
|
||||
"version": "12.0.0",
|
||||
"version": "12.0.1-2",
|
||||
"keywords": [
|
||||
"logging",
|
||||
"replay"
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export declare const enum Type {
|
|||
TabChange = 117,
|
||||
TabData = 118,
|
||||
CanvasNode = 119,
|
||||
TagTrigger = 120,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -580,6 +581,11 @@ export type CanvasNode = [
|
|||
/*timestamp:*/ number,
|
||||
]
|
||||
|
||||
export type TagTrigger = [
|
||||
/*type:*/ Type.TagTrigger,
|
||||
/*tagId:*/ number,
|
||||
]
|
||||
|
||||
type Message = Timestamp | SetPageLocation | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetNodeScroll | SetInputTarget | SetInputValue | SetInputChecked | MouseMove | NetworkRequestDeprecated | ConsoleLog | PageLoadTiming | PageRenderTiming | CustomEvent | UserID | UserAnonymousID | Metadata | CSSInsertRule | CSSDeleteRule | Fetch | Profiler | OTable | StateAction | Redux | Vuex | MobX | NgRx | GraphQL | PerformanceTrack | StringDict | SetNodeAttributeDict | ResourceTimingDeprecated | ConnectionInformation | SetPageVisibility | LoadFontFace | SetNodeFocus | LongTask | SetNodeAttributeURLBased | SetCSSDataURLBased | TechnicalInfo | CustomIssue | CSSInsertRuleURLBased | MouseClick | CreateIFrameDocument | AdoptedSSReplaceURLBased | AdoptedSSInsertRuleURLBased | AdoptedSSDeleteRule | AdoptedSSAddOwner | AdoptedSSRemoveOwner | JSException | Zustand | BatchMetadata | PartitionedMessage | NetworkRequest | WSChannel | InputChange | SelectionChange | MouseThrashing | UnbindNodes | ResourceTiming | TabChange | TabData | CanvasNode
|
||||
|
||||
type Message = Timestamp | SetPageLocation | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetNodeScroll | SetInputTarget | SetInputValue | SetInputChecked | MouseMove | NetworkRequestDeprecated | ConsoleLog | PageLoadTiming | PageRenderTiming | CustomEvent | UserID | UserAnonymousID | Metadata | CSSInsertRule | CSSDeleteRule | Fetch | Profiler | OTable | StateAction | Redux | Vuex | MobX | NgRx | GraphQL | PerformanceTrack | StringDict | SetNodeAttributeDict | ResourceTimingDeprecated | ConnectionInformation | SetPageVisibility | LoadFontFace | SetNodeFocus | LongTask | SetNodeAttributeURLBased | SetCSSDataURLBased | TechnicalInfo | CustomIssue | CSSInsertRuleURLBased | MouseClick | CreateIFrameDocument | AdoptedSSReplaceURLBased | AdoptedSSInsertRuleURLBased | AdoptedSSDeleteRule | AdoptedSSAddOwner | AdoptedSSRemoveOwner | JSException | Zustand | BatchMetadata | PartitionedMessage | NetworkRequest | WSChannel | InputChange | SelectionChange | MouseThrashing | UnbindNodes | ResourceTiming | TabChange | TabData | CanvasNode | TagTrigger
|
||||
export default Message
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import ConditionsManager from '../modules/conditionsManager.js'
|
||||
import FeatureFlags from '../modules/featureFlags.js'
|
||||
import type Message from './messages.gen.js'
|
||||
import Message, { TagTrigger } from './messages.gen.js'
|
||||
import {
|
||||
Timestamp,
|
||||
Metadata,
|
||||
|
|
@ -34,6 +34,7 @@ import type { Options as SessOptions } from './session.js'
|
|||
import type { Options as NetworkOptions } from '../modules/network.js'
|
||||
import CanvasRecorder from './canvas.js'
|
||||
import UserTestManager from '../modules/userTesting/index.js'
|
||||
import TagWatcher from '../modules/tagWatcher.js'
|
||||
|
||||
import type {
|
||||
Options as WebworkerOptions,
|
||||
|
|
@ -179,6 +180,7 @@ export default class App {
|
|||
private uxtManager: UserTestManager
|
||||
private conditionsManager: ConditionsManager | null = null
|
||||
public featureFlags: FeatureFlags
|
||||
private tagWatcher: TagWatcher
|
||||
|
||||
constructor(
|
||||
projectKey: string,
|
||||
|
|
@ -230,6 +232,9 @@ export default class App {
|
|||
this.session = new Session(this, this.options)
|
||||
this.attributeSender = new AttributeSender(this, Boolean(this.options.disableStringDict))
|
||||
this.featureFlags = new FeatureFlags(this)
|
||||
this.tagWatcher = new TagWatcher(this.sessionStorage, this.debug.error, (tag) => {
|
||||
this.send(TagTrigger(tag) as Message)
|
||||
})
|
||||
this.session.attachUpdateCallback(({ userID, metadata }) => {
|
||||
if (userID != null) {
|
||||
// TODO: nullable userID
|
||||
|
|
@ -687,6 +692,7 @@ export default class App {
|
|||
this.startCallbacks.forEach((cb) => cb(onStartInfo))
|
||||
await this.conditionsManager?.fetchConditions(projectID as string, token as string)
|
||||
await this.featureFlags.reloadFlags(token as string)
|
||||
await this.tagWatcher.fetchTags(this.options.ingestPoint, token as string)
|
||||
this.conditionsManager?.processFlags(this.featureFlags.flags)
|
||||
}
|
||||
const cycle = () => {
|
||||
|
|
@ -896,7 +902,6 @@ export default class App {
|
|||
) {
|
||||
const reason =
|
||||
'OpenReplay: trying to call `start()` on the instance that has been started already.'
|
||||
this.signalError(reason, [])
|
||||
return Promise.resolve(UnsuccessfulStart(reason))
|
||||
}
|
||||
this.activityState = ActivityState.Starting
|
||||
|
|
@ -1049,8 +1054,10 @@ export default class App {
|
|||
this.compressionThreshold = compressionThreshold
|
||||
const onStartInfo = { sessionToken: token, userUUID, sessionID }
|
||||
// TODO: start as early as possible (before receiving the token)
|
||||
/** after start */
|
||||
this.startCallbacks.forEach((cb) => cb(onStartInfo)) // MBTODO: callbacks after DOM "mounted" (observed)
|
||||
void this.featureFlags.reloadFlags()
|
||||
await this.tagWatcher.fetchTags(this.options.ingestPoint, token)
|
||||
this.activityState = ActivityState.Active
|
||||
|
||||
if (canvasEnabled) {
|
||||
|
|
@ -1234,6 +1241,7 @@ export default class App {
|
|||
this.ticker.stop()
|
||||
this.stopCallbacks.forEach((cb) => cb())
|
||||
this.debug.log('OpenReplay tracking stopped.')
|
||||
this.tagWatcher.clear()
|
||||
if (this.worker && stopWorker) {
|
||||
this.worker.postMessage('stop')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -942,3 +942,12 @@ export function CanvasNode(
|
|||
]
|
||||
}
|
||||
|
||||
export function TagTrigger(
|
||||
tagId: number,
|
||||
): Messages.TagTrigger {
|
||||
return [
|
||||
Messages.Type.TagTrigger,
|
||||
tagId,
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
83
tracker/tracker/src/main/modules/tagWatcher.ts
Normal file
83
tracker/tracker/src/main/modules/tagWatcher.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
export const WATCHED_TAGS_KEY = '__or__watched_tags__'
|
||||
|
||||
class TagWatcher {
|
||||
intervals: Record<string, ReturnType<typeof setInterval>> = {}
|
||||
tags: { id: number; selector: string }[] = []
|
||||
observer: IntersectionObserver
|
||||
|
||||
constructor(
|
||||
private readonly sessionStorage: Storage,
|
||||
private readonly errLog: (args: any[]) => void,
|
||||
private readonly onTag: (tag: number) => void,
|
||||
) {
|
||||
const tags: { id: number; selector: string }[] = JSON.parse(
|
||||
sessionStorage.getItem(WATCHED_TAGS_KEY) ?? '[]',
|
||||
)
|
||||
this.setTags(tags)
|
||||
this.observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
if (entry.target) {
|
||||
// @ts-ignore
|
||||
const tag = entry.target.__or_watcher_tagname as number
|
||||
if (tag) {
|
||||
this.onTagRendered(tag)
|
||||
}
|
||||
this.observer.unobserve(entry.target)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async fetchTags(ingest: string, token: string) {
|
||||
return fetch(`${ingest}/v1/web/tags`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then(({ tags }: { tags: { id: number; selector: string }[] }) => {
|
||||
if (tags && tags.length) {
|
||||
this.setTags(tags)
|
||||
this.sessionStorage.setItem(WATCHED_TAGS_KEY, JSON.stringify(tags) || '')
|
||||
}
|
||||
})
|
||||
.catch((e) => this.errLog(e))
|
||||
}
|
||||
|
||||
setTags(tags: { id: number; selector: string }[]) {
|
||||
this.tags = tags
|
||||
this.intervals = {}
|
||||
tags.forEach((tag) => {
|
||||
this.intervals[tag.id] = setInterval(() => {
|
||||
const possibleEls = document.querySelectorAll(tag.selector)
|
||||
if (possibleEls.length > 0) {
|
||||
const el = possibleEls[0]
|
||||
// @ts-ignore
|
||||
el.__or_watcher_tagname = tag.id
|
||||
this.observer.observe(el)
|
||||
}
|
||||
}, 500)
|
||||
})
|
||||
}
|
||||
|
||||
onTagRendered(tagId: number) {
|
||||
if (this.intervals[tagId]) {
|
||||
clearInterval(this.intervals[tagId])
|
||||
}
|
||||
this.onTag(tagId)
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.tags.forEach((tag) => {
|
||||
clearInterval(this.intervals[tag.id])
|
||||
})
|
||||
this.tags = []
|
||||
this.intervals = {}
|
||||
this.observer.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
export default TagWatcher
|
||||
103
tracker/tracker/src/tests/tagWatcher.test.ts
Normal file
103
tracker/tracker/src/tests/tagWatcher.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import TagWatcher, { WATCHED_TAGS_KEY } from '../main/modules/TagWatcher'
|
||||
import { describe, expect, jest, afterEach, beforeEach, test } from '@jest/globals'
|
||||
|
||||
describe('TagWatcher', () => {
|
||||
let sessionStorageMock: Storage
|
||||
let errLogMock: (args: any[]) => void
|
||||
const onTag = jest.fn()
|
||||
let mockObserve: Function
|
||||
let mockUnobserve: Function
|
||||
let mockDisconnect: Function
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorageMock = {
|
||||
// @ts-ignore
|
||||
getItem: jest.fn(),
|
||||
setItem: jest.fn(),
|
||||
}
|
||||
errLogMock = jest.fn()
|
||||
mockObserve = jest.fn()
|
||||
mockUnobserve = jest.fn()
|
||||
mockDisconnect = jest.fn()
|
||||
|
||||
// @ts-ignore
|
||||
global.IntersectionObserver = jest.fn((callback) => ({
|
||||
observe: mockObserve,
|
||||
unobserve: mockUnobserve,
|
||||
disconnect: mockDisconnect,
|
||||
callback,
|
||||
}))
|
||||
jest.useFakeTimers()
|
||||
// @ts-ignore
|
||||
global.document.querySelectorAll = jest.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
jest.useRealTimers()
|
||||
})
|
||||
function triggerIntersection(elements: any, isIntersecting: boolean, observer: any) {
|
||||
const entries = elements.map((el: any) => ({
|
||||
isIntersecting,
|
||||
target: el,
|
||||
}))
|
||||
// @ts-ignore
|
||||
observer.callback(entries)
|
||||
}
|
||||
|
||||
test('constructor initializes with tags from sessionStorage', () => {
|
||||
// @ts-ignore
|
||||
sessionStorageMock.getItem.mockReturnValue('div,span')
|
||||
const watcher = new TagWatcher(sessionStorageMock, errLogMock, onTag)
|
||||
expect(watcher.tags).toEqual(['div', 'span'])
|
||||
expect(watcher.intervals).toHaveProperty('div')
|
||||
expect(watcher.intervals).toHaveProperty('span')
|
||||
})
|
||||
|
||||
test('fetchTags sets tags and updates sessionStorage', async () => {
|
||||
// @ts-ignore
|
||||
global.fetch = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
json: () => Promise.resolve(['div', 'span', 'p']),
|
||||
}),
|
||||
)
|
||||
const watcher = new TagWatcher(sessionStorageMock, errLogMock, onTag)
|
||||
await watcher.fetchTags('https://localhost.com', '123')
|
||||
expect(watcher.tags).toEqual(['div', 'span', 'p'])
|
||||
expect(sessionStorageMock.setItem).toHaveBeenCalledWith(WATCHED_TAGS_KEY, 'div,span,p')
|
||||
})
|
||||
|
||||
test('setTags sets intervals for each tag', () => {
|
||||
const watcher = new TagWatcher(sessionStorageMock, errLogMock, onTag)
|
||||
watcher.setTags([
|
||||
{ id: 1, selector: 'div' },
|
||||
{ id: 2, selector: 'p' },
|
||||
])
|
||||
expect(watcher.intervals).toHaveProperty('div')
|
||||
expect(watcher.intervals).toHaveProperty('p')
|
||||
expect(mockObserve).not.toHaveBeenCalled() // No elements to observe initially
|
||||
})
|
||||
|
||||
test('onTagRendered sends messages', () => {
|
||||
const watcher = new TagWatcher(sessionStorageMock, errLogMock, onTag)
|
||||
watcher.setTags([{ id: 1, selector: 'div' }])
|
||||
// @ts-ignore
|
||||
document.querySelectorAll.mockReturnValue([{ __or_watcher_tagname: 'div' }]) // Mock a found element
|
||||
jest.advanceTimersByTime(1000)
|
||||
triggerIntersection([{ __or_watcher_tagname: 'div' }], true, watcher.observer)
|
||||
expect(onTag).toHaveBeenCalled()
|
||||
expect(watcher.observer.unobserve).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('clear method clears all intervals and resets tags', () => {
|
||||
const watcher = new TagWatcher(sessionStorageMock, errLogMock, onTag)
|
||||
watcher.setTags([
|
||||
{ id: 1, selector: 'div' },
|
||||
{ id: 2, selector: 'p' },
|
||||
])
|
||||
watcher.clear()
|
||||
expect(watcher.tags).toEqual([])
|
||||
expect(watcher.intervals).toEqual({})
|
||||
expect(watcher.observer.disconnect).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -294,6 +294,10 @@ export default class MessageEncoder extends PrimitiveEncoder {
|
|||
return this.string(msg[1]) && this.uint(msg[2])
|
||||
break
|
||||
|
||||
case Messages.Type.TagTrigger:
|
||||
return this.int(msg[1])
|
||||
break
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue