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:
Delirium 2024-01-19 11:11:27 +01:00 committed by GitHub
parent 0c2dd6f9f1
commit 309a9fd970
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 2087 additions and 1178 deletions

View file

@ -86,6 +86,7 @@ const (
MsgTabChange = 117 MsgTabChange = 117
MsgTabData = 118 MsgTabData = 118
MsgCanvasNode = 119 MsgCanvasNode = 119
MsgTagTrigger = 120
MsgIssueEvent = 125 MsgIssueEvent = 125
MsgSessionEnd = 126 MsgSessionEnd = 126
MsgSessionSearch = 127 MsgSessionSearch = 127
@ -2301,6 +2302,27 @@ func (msg *CanvasNode) TypeID() int {
return 119 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 { type IssueEvent struct {
message message
MessageID uint64 MessageID uint64

View file

@ -1401,6 +1401,15 @@ func DecodeCanvasNode(reader BytesReader) (Message, error) {
return msg, err 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) { func DecodeIssueEvent(reader BytesReader) (Message, error) {
var err error = nil var err error = nil
msg := &IssueEvent{} msg := &IssueEvent{}
@ -2033,6 +2042,8 @@ func ReadMessage(t uint64, reader BytesReader) (Message, error) {
return DecodeTabData(reader) return DecodeTabData(reader)
case 119: case 119:
return DecodeCanvasNode(reader) return DecodeCanvasNode(reader)
case 120:
return DecodeTagTrigger(reader)
case 125: case 125:
return DecodeIssueEvent(reader) return DecodeIssueEvent(reader)
case 126: case 126:

View file

@ -808,6 +808,13 @@ class CanvasNode(Message):
self.timestamp = timestamp self.timestamp = timestamp
class TagTrigger(Message):
__id__ = 120
def __init__(self, tag_id):
self.tag_id = tag_id
class IssueEvent(Message): class IssueEvent(Message):
__id__ = 125 __id__ = 125

View file

@ -1194,6 +1194,15 @@ cdef class CanvasNode(PyMessage):
self.timestamp = timestamp 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 class IssueEvent(PyMessage):
cdef public int __id__ cdef public int __id__
cdef public unsigned long message_id cdef public unsigned long message_id

View file

@ -727,6 +727,11 @@ class MessageCodec(Codec):
timestamp=self.read_uint(reader) timestamp=self.read_uint(reader)
) )
if message_id == 120:
return TagTrigger(
tag_id=self.read_int(reader)
)
if message_id == 125: if message_id == 125:
return IssueEvent( return IssueEvent(
message_id=self.read_uint(reader), message_id=self.read_uint(reader),

File diff suppressed because it is too large Load diff

View file

@ -30,7 +30,8 @@ const siteIdRequiredPaths: string[] = [
'/notes', '/notes',
'/feature-flags', '/feature-flags',
'/check-recording-status', '/check-recording-status',
'/usability-tests' '/usability-tests',
'/tags'
]; ];
export const clean = (obj: any, forbiddenValues: any[] = [undefined, '']): any => { export const clean = (obj: any, forbiddenValues: any[] = [undefined, '']): any => {

View file

@ -3,7 +3,6 @@ import { Button, PageTitle } from 'UI'
import FFlagsSearch from "Components/FFlags/FFlagsSearch"; import FFlagsSearch from "Components/FFlags/FFlagsSearch";
import { useHistory } from "react-router"; import { useHistory } from "react-router";
import { newFFlag, withSiteId } from 'App/routes'; import { newFFlag, withSiteId } from 'App/routes';
import { observer } from 'mobx-react-lite';
function FFlagsListHeader({ siteId }: { siteId: string }) { function FFlagsListHeader({ siteId }: { siteId: string }) {
const history = useHistory(); const history = useHistory();

View file

@ -24,7 +24,7 @@ function Player() {
> >
<div className={cn("relative flex-1", 'overflow-visible')}> <div className={cn("relative flex-1", 'overflow-visible')}>
<Overlay isClickmap /> <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>
</div> </div>
); );

View file

@ -52,7 +52,7 @@ function Player(props: IProps) {
> >
<div className="relative flex-1 overflow-hidden"> <div className="relative flex-1 overflow-hidden">
<Overlay closedLive={closedLive} /> <Overlay closedLive={closedLive} />
<div className={cn(stl.screenWrapper)} ref={screenWrapper} /> <div className={cn(stl.screenWrapper, stl.checkers)} ref={screenWrapper} />
</div> </div>
{bottomBlock === CONSOLE ? ( {bottomBlock === CONSOLE ? (
<div style={{ maxWidth, width: '100%' }}> <div style={{ maxWidth, width: '100%' }}>

View file

@ -33,6 +33,7 @@ import ConsolePanel from 'Shared/DevTools/ConsolePanel';
import ProfilerPanel from 'Shared/DevTools/ProfilerPanel'; import ProfilerPanel from 'Shared/DevTools/ProfilerPanel';
import { PlayerContext } from 'App/components/Session/playerContext'; import { PlayerContext } from 'App/components/Session/playerContext';
import { debounce } from 'App/utils'; import { debounce } from 'App/utils';
import { observer } from 'mobx-react-lite';
interface IProps { interface IProps {
fullView: boolean; fullView: boolean;
@ -109,6 +110,7 @@ function Player(props: IProps) {
document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp); document.addEventListener('mouseup', handleMouseUp);
}; };
const isInspMode = playerContext.store.get().inspectorMode;
return ( return (
<div <div
@ -118,7 +120,7 @@ function Player(props: IProps) {
{fullscreen && <EscapeButton onClose={fullscreenOff} />} {fullscreen && <EscapeButton onClose={fullscreenOff} />}
<div className={cn('relative flex-1', 'overflow-hidden')}> <div className={cn('relative flex-1', 'overflow-hidden')}>
<Overlay nextId={nextId} /> <Overlay nextId={nextId} />
<div className={cn(stl.screenWrapper)} ref={screenWrapper} /> <div className={cn(stl.screenWrapper, isInspMode ? stl.solidBg : stl.checkers)} ref={screenWrapper} />
</div> </div>
{!fullscreen && !!bottomBlock && ( {!fullscreen && !!bottomBlock && (
<div <div
@ -143,7 +145,6 @@ function Player(props: IProps) {
{bottomBlock === PERFORMANCE && <ConnectedPerformance />} {bottomBlock === PERFORMANCE && <ConnectedPerformance />}
{bottomBlock === GRAPHQL && <GraphQL />} {bottomBlock === GRAPHQL && <GraphQL />}
{bottomBlock === EXCEPTIONS && <Exceptions />} {bottomBlock === EXCEPTIONS && <Exceptions />}
{bottomBlock === INSPECTOR && <Inspector />}
</div> </div>
)} )}
{!fullView ? ( {!fullView ? (
@ -168,4 +169,4 @@ export default connect(
fullscreenOff, fullscreenOff,
updateLastPlayedSession, updateLastPlayedSession,
} }
)(Player); )(observer(Player));

View file

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

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

View file

@ -0,0 +1 @@
export { default } from './TagWatch';

View file

@ -1,30 +1,40 @@
import React from 'react' import React from 'react';
import EventsBlock from '../Session_/EventsBlock'; 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 cn from 'classnames';
import stl from './rightblock.module.css'; import stl from './rightblock.module.css';
function RightBlock(props: any) { function RightBlock({
const { activeTab } = props; activeTab,
setActiveTab,
if (activeTab === 'EVENTS') { }: {
return ( activeTab: string;
<div className={cn("flex flex-col bg-white border-l", stl.panel)}> setActiveTab: (tab: string) => void;
<EventsBlock }) {
setActiveTab={props.setActiveTab} switch (activeTab) {
/> case 'EVENTS':
</div> return (
) <div className={cn('flex flex-col bg-white border-l', stl.panel)}>
<EventsBlock setActiveTab={setActiveTab} />
</div>
);
case 'CLICKMAP':
return (
<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;
} }
if (activeTab === 'CLICKMAP') {
return (
<div className={cn("flex flex-col bg-white border-l", stl.panel)}>
<PageInsightsPanel setActiveTab={props.setActiveTab} />
</div>
)
}
return null
} }
export default RightBlock export default RightBlock;

View file

@ -1,31 +1,48 @@
import React from 'react'; import React from 'react';
import cn from 'classnames'; import cn from 'classnames';
import stl from './tabs.module.css'; import stl from './tabs.module.css';
import { Segmented } from 'antd';
import { Icon } from 'UI'
interface Props { interface Props {
tabs: Array<any>; tabs: Array<any>;
active: string; active: string;
onClick: (key: any) => void; onClick: (key: any) => void;
border?: boolean; border?: boolean;
className?: string; className?: string;
} }
const Tabs = ({ tabs, active, onClick, border = true, className }: Props) => ( const iconMap = {
<div className={ cn(stl.tabs, className, { [ stl.bordered ]: border }) } role="tablist" > "INSPECTOR": 'filters/tag-element',
{ tabs.map(({ key, text, hidden = false, disabled = false }) => ( "CLICKMAP": 'mouse-pointer-click',
<div 'EVENTS': 'user-switch'
key={ key } } as const
className={ cn(stl.tab, { [ stl.active ]: active === key, [ stl.disabled ]: disabled }) }
data-hidden={ hidden } const Tabs = ({ tabs, active, onClick, border = true, className }: Props) => {
onClick={ onClick && (() => onClick(key)) } console.log(tabs)
role="tab" return (
data-openreplay-label={text} <div className={cn(stl.tabs, className, { [stl.bordered]: border })} role="tablist">
> <Segmented
{ text } value={active}
</div> options={tabs.map(({ key, text, hidden = false, disabled = false }) => ({
))} label: (
</div> <div
); onClick={() => {
onClick(key);
}}
className={'font-semibold flex gap-1 items-center'}
>
<Icon size={16} color={'black'} name={iconMap[key as keyof typeof iconMap]} />
<span>{text}</span>
</div>
),
value: key,
disabled: disabled,
}))}
/>
</div>
);
};
Tabs.displayName = 'Tabs'; Tabs.displayName = 'Tabs';

View file

@ -1,14 +1,17 @@
.tabs { .tabs {
display: flex; display: flex;
height: 100%;
width: 100%;
justify-content: space-around; justify-content: space-around;
align-items: center; align-items: center;
font-size: 16px;
&.bordered { &.bordered {
border-bottom: solid thin $gray-light; border-bottom: solid thin $gray-light;
} }
} }
.tab { .tab {
padding: 14px 15px; padding: 14px 0;
text-align: center; text-align: center;
text-transform: uppercase; text-transform: uppercase;
flex: 1; flex: 1;
@ -19,16 +22,16 @@
white-space: nowrap; white-space: nowrap;
&:hover { &:hover {
color: $teal; color: $teal;
} }
&.active { &.active {
color: $teal; color: $teal;
border-bottom: solid thin $teal; border-bottom: solid thin $teal;
} }
} }
.disabled { .disabled {
pointer-events: none; pointer-events: none;
opacity: 0.5; opacity: 0.5;
} }

View file

@ -17,8 +17,9 @@ import { useParams } from 'react-router-dom';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
const TABS = { const TABS = {
EVENTS: 'User Events', EVENTS: 'Activity',
CLICKMAP: 'Click Map', CLICKMAP: 'Click Map',
INSPECTOR: 'Tag',
}; };
const UXTTABS = { const UXTTABS = {
EVENTS: TABS.EVENTS EVENTS: TABS.EVENTS

View file

@ -2,7 +2,7 @@ import { useStore } from "App/mstore";
import React from 'react'; import React from 'react';
import cn from 'classnames'; import cn from 'classnames';
import { connect } from 'react-redux'; 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 { PlayButton, PlayingState, FullScreenButton } from 'App/player-ui'
import { Tooltip } from 'UI'; import { Tooltip } from 'UI';
@ -131,13 +131,8 @@ function Controls(props: any) {
}; };
const toggleBottomTools = (blockName: number) => { const toggleBottomTools = (blockName: number) => {
if (blockName === INSPECTOR) { player.toggleInspectorMode(false);
// player.toggleInspectorMode(false);
bottomBlock && toggleBottomBlock();
} else {
// player.toggleInspectorMode(false);
toggleBottomBlock(blockName); toggleBottomBlock(blockName);
}
}; };
const state = completed ? PlayingState.Completed : playing ? PlayingState.Playing : PlayingState.Paused const state = completed ? PlayingState.Completed : playing ? PlayingState.Playing : PlayingState.Paused

View file

@ -15,8 +15,14 @@
/* border: solid thin $gray-light; */ /* border: solid thin $gray-light; */
/* border-radius: 3px; */ /* border-radius: 3px; */
overflow: hidden; overflow: hidden;
background: repeating-conic-gradient($gray-lightest 0% 25%, transparent 0% 50%) }
50% / 10px 10px;
.checkers {
background: repeating-conic-gradient($gray-lightest 0% 25%, transparent 0% 50%)
50% / 10px 10px;
}
.solidBg {
background: $gray-lightest;
} }
.mobileScreenWrapper { .mobileScreenWrapper {

View file

@ -4,12 +4,14 @@ import SavedSearch from 'Shared/SavedSearch';
import { Button } from 'UI'; import { Button } from 'UI';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { clearSearch } from 'Duck/search'; import { clearSearch } from 'Duck/search';
import TagList from './components/TagList';
interface Props { interface Props {
clearSearch: () => void; clearSearch: () => void;
appliedFilter: any; appliedFilter: any;
savedSearch: any; savedSearch: any;
} }
const MainSearchBar = (props: Props) => { const MainSearchBar = (props: Props) => {
const { appliedFilter } = props; const { appliedFilter } = props;
const hasFilters = appliedFilter && appliedFilter.filters && appliedFilter.filters.size > 0; const hasFilters = appliedFilter && appliedFilter.filters && appliedFilter.filters.size > 0;
@ -20,7 +22,8 @@ const MainSearchBar = (props: Props) => {
<div style={{ width: '60%', marginRight: '10px' }}> <div style={{ width: '60%', marginRight: '10px' }}>
<SessionSearchField /> <SessionSearchField />
</div> </div>
<div className="flex items-center" style={{ width: '40%' }}> <div className="flex items-center gap-2" style={{ width: '40%' }}>
<TagList />
<SavedSearch /> <SavedSearch />
<Button <Button
variant={hasSearch ? 'text-primary' : 'text'} variant={hasSearch ? 'text-primary' : 'text'}
@ -37,7 +40,7 @@ const MainSearchBar = (props: Props) => {
export default connect( export default connect(
(state: any) => ({ (state: any) => ({
appliedFilter: state.getIn(['search', 'instance']), appliedFilter: state.getIn(['search', 'instance']),
savedSearch: state.getIn([ 'search', 'savedSearch' ]) savedSearch: state.getIn(['search', 'savedSearch']),
}), }),
{ {
clearSearch, clearSearch,

View file

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

View file

@ -3,33 +3,57 @@ import FilterList from 'Shared/Filters/FilterList';
import FilterSelection from 'Shared/Filters/FilterSelection'; import FilterSelection from 'Shared/Filters/FilterSelection';
import SaveFilterButton from 'Shared/SaveFilterButton'; import SaveFilterButton from 'Shared/SaveFilterButton';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { FilterKey } from 'Types/filter/filterType';
import { addOptionsToFilter } from 'Types/filter/newFilter';
import { Button } from 'UI'; import { Button } from 'UI';
import { edit, addFilter, fetchSessions, updateFilter } from 'Duck/search'; 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 { debounce } from 'App/utils';
import useSessionSearchQueryHandler from 'App/hooks/useSessionSearchQueryHandler'; import useSessionSearchQueryHandler from 'App/hooks/useSessionSearchQueryHandler';
import { refreshFilterOptions } from 'Duck/search';
let debounceFetch: any = () => {} let debounceFetch: any = () => {};
interface Props { interface Props {
appliedFilter: any; appliedFilter: any;
edit: typeof edit; edit: typeof edit;
addFilter: typeof addFilter; addFilter: typeof addFilter;
saveRequestPayloads: boolean; saveRequestPayloads: boolean;
metaLoading?: boolean metaLoading?: boolean;
fetchSessions: typeof fetchSessions; fetchSessions: typeof fetchSessions;
updateFilter: typeof updateFilter; updateFilter: typeof updateFilter;
refreshFilterOptions: typeof refreshFilterOptions;
} }
function SessionSearch(props: Props) { function SessionSearch(props: Props) {
const { tagWatchStore } = useStore();
const { appliedFilter, saveRequestPayloads = false, metaLoading = false } = props; const { appliedFilter, saveRequestPayloads = false, metaLoading = false } = props;
const hasEvents = appliedFilter.filters.filter((i: any) => i.isEvent).size > 0; const hasEvents = appliedFilter.filters.filter((i: any) => i.isEvent).size > 0;
const hasFilters = 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(() => { useEffect(() => {
debounceFetch = debounce(() => props.fetchSessions(), 500); debounceFetch = debounce(() => props.fetchSessions(), 500);
}, []) }, []);
const onAddFilter = (filter: any) => { const onAddFilter = (filter: any) => {
props.addFilter(filter); props.addFilter(filter);
@ -49,7 +73,7 @@ function SessionSearch(props: Props) {
filters: newFilters, filters: newFilters,
}); });
debounceFetch() debounceFetch();
}; };
const onRemoveFilter = (filterIndex: any) => { const onRemoveFilter = (filterIndex: any) => {
@ -61,7 +85,7 @@ function SessionSearch(props: Props) {
filters: newFilters, filters: newFilters,
}); });
debounceFetch() debounceFetch();
}; };
const onChangeEventsOrder = (e: any, { value }: any) => { const onChangeEventsOrder = (e: any, { value }: any) => {
@ -69,7 +93,7 @@ function SessionSearch(props: Props) {
eventsOrder: value, eventsOrder: value,
}); });
debounceFetch() debounceFetch();
}; };
return !metaLoading ? ( return !metaLoading ? (
@ -89,11 +113,7 @@ function SessionSearch(props: Props) {
<div className="border-t px-5 py-1 flex items-center -mx-2"> <div className="border-t px-5 py-1 flex items-center -mx-2">
<div> <div>
<FilterSelection filter={undefined} onFilterClick={onAddFilter}> <FilterSelection filter={undefined} onFilterClick={onAddFilter}>
<Button <Button variant="text-primary" className="mr-2" icon="plus">
variant="text-primary"
className="mr-2"
icon="plus"
>
ADD STEP ADD STEP
</Button> </Button>
</FilterSelection> </FilterSelection>
@ -114,7 +134,7 @@ export default connect(
(state: any) => ({ (state: any) => ({
saveRequestPayloads: state.getIn(['site', 'instance', 'saveRequestPayloads']), saveRequestPayloads: state.getIn(['site', 'instance', 'saveRequestPayloads']),
appliedFilter: state.getIn(['search', 'instance']), appliedFilter: state.getIn(['search', 'instance']),
metaLoading: state.getIn(['customFields', 'fetchRequestActive', 'loading']) metaLoading: state.getIn(['customFields', 'fetchRequestActive', 'loading']),
}), }),
{ edit, addFilter, fetchSessions, updateFilter } { edit, addFilter, fetchSessions, updateFilter, refreshFilterOptions }
)(SessionSearch); )(observer(SessionSearch));

View 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;

View file

@ -225,6 +225,7 @@ export { default as Filters_referrer } from './filters_referrer';
export { default as Filters_resize } from './filters_resize'; export { default as Filters_resize } from './filters_resize';
export { default as Filters_rev_id } from './filters_rev_id'; export { default as Filters_rev_id } from './filters_rev_id';
export { default as Filters_state_action } from './filters_state_action'; 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_ttfb } from './filters_ttfb';
export { default as Filters_user_alt } from './filters_user_alt'; export { default as Filters_user_alt } from './filters_user_alt';
export { default as Filters_userid } from './filters_userid'; 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 Minus } from './minus';
export { default as Mobile } from './mobile'; export { default as Mobile } from './mobile';
export { default as Mouse_alt } from './mouse_alt'; 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 Network } from './network';
export { default as Next1 } from './next1'; export { default as Next1 } from './next1';
export { default as No_dashboard } from './no_dashboard'; 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_alt } from './user_alt';
export { default as User_circle } from './user_circle'; export { default as User_circle } from './user_circle';
export { default as User_friends } from './user_friends'; 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 Users } from './users';
export { default as Vendors_graphql } from './vendors_graphql'; export { default as Vendors_graphql } from './vendors_graphql';
export { default as Vendors_mobx } from './vendors_mobx'; export { default as Vendors_mobx } from './vendors_mobx';

View 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;

View 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

View file

@ -1,5 +1,5 @@
export default { export default {
warning: 'Warnings', warning: 'Warnings',
alert: 'Alerts', alert: 'Alerts',
all: 'Log Entires', all: 'Log Entries',
}; };

View file

@ -8,7 +8,12 @@ import { errors as errorsRoute, isRoute } from 'App/routes';
import { fetchList as fetchSessionList, fetchAutoplayList } from './sessions'; import { fetchList as fetchSessionList, fetchAutoplayList } from './sessions';
import { fetchList as fetchErrorsList } from './errors'; import { fetchList as fetchErrorsList } from './errors';
import { FilterCategory, FilterKey } from 'Types/filter/filterType'; 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 { DURATION_FILTER } from 'App/constants/storageKeys';
import Period, { CUSTOM_RANGE } from 'Types/app/period'; import Period, { CUSTOM_RANGE } from 'Types/app/period';

View file

@ -1,38 +1,44 @@
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import { createUrlQuery, getFiltersFromQuery } from 'App/utils/search'; import { createUrlQuery, getFiltersFromQuery } from 'App/utils/search';
interface Props { interface Props {
onBeforeLoad?: () => Promise<any>;
appliedFilter: any; appliedFilter: any;
applyFilter: any; applyFilter: any;
loading: boolean; loading: boolean;
} }
const useSessionSearchQueryHandler = (props: Props) => { const useSessionSearchQueryHandler = (props: Props) => {
const [beforeHookLoaded, setBeforeHookLoaded] = useState(!props.onBeforeLoad);
const { appliedFilter, applyFilter, loading } = props; const { appliedFilter, applyFilter, loading } = props;
const history = useHistory(); const history = useHistory();
useEffect(() => { useEffect(() => {
const applyFilterFromQuery = () => { const applyFilterFromQuery = async () => {
if (!loading) { if (!loading) {
if (props.onBeforeLoad) {
await props.onBeforeLoad();
setBeforeHookLoaded(true);
}
const filter = getFiltersFromQuery(history.location.search, appliedFilter); const filter = getFiltersFromQuery(history.location.search, appliedFilter);
applyFilter(filter, true, false); applyFilter(filter, true, false);
} }
}; };
applyFilterFromQuery(); void applyFilterFromQuery();
}, [loading]); }, [loading]);
useEffect(() => { useEffect(() => {
const generateUrlQuery = () => { const generateUrlQuery = () => {
if (!loading) { if (!loading && beforeHookLoaded) {
const search: any = createUrlQuery(appliedFilter); const search: any = createUrlQuery(appliedFilter);
history.replace({ search }); history.replace({ search });
} }
}; };
generateUrlQuery(); generateUrlQuery();
}, [appliedFilter, loading]); }, [appliedFilter, loading, beforeHookLoaded]);
return null; return null;
}; };

View file

@ -5,22 +5,21 @@ import UserStore from './userStore';
import RoleStore from './roleStore'; import RoleStore from './roleStore';
import APIClient from 'App/api_client'; import APIClient from 'App/api_client';
import FunnelStore from './funnelStore'; import FunnelStore from './funnelStore';
import { import { services } from 'App/services';
services
} from 'App/services';
import SettingsStore from './settingsStore'; import SettingsStore from './settingsStore';
import AuditStore from './auditStore'; import AuditStore from './auditStore';
import NotificationStore from './notificationStore'; import NotificationStore from './notificationStore';
import ErrorStore from './errorStore'; import ErrorStore from './errorStore';
import SessionStore from './sessionStore'; import SessionStore from './sessionStore';
import NotesStore from './notesStore'; import NotesStore from './notesStore';
import BugReportStore from './bugReportStore' import BugReportStore from './bugReportStore';
import RecordingsStore from './recordingsStore' import RecordingsStore from './recordingsStore';
import AssistMultiviewStore from './assistMultiviewStore'; import AssistMultiviewStore from './assistMultiviewStore';
import WeeklyReportStore from './weeklyReportConfigStore' import WeeklyReportStore from './weeklyReportConfigStore';
import AlertStore from './alertsStore' import AlertStore from './alertsStore';
import FeatureFlagsStore from "./featureFlagsStore"; import FeatureFlagsStore from './featureFlagsStore';
import UxtestingStore from './uxtestingStore'; import UxtestingStore from './uxtestingStore';
import TagWatchStore from './tagWatchStore';
export class RootStore { export class RootStore {
dashboardStore: DashboardStore; dashboardStore: DashboardStore;
@ -37,10 +36,11 @@ export class RootStore {
bugReportStore: BugReportStore; bugReportStore: BugReportStore;
recordingsStore: RecordingsStore; recordingsStore: RecordingsStore;
assistMultiviewStore: AssistMultiviewStore; assistMultiviewStore: AssistMultiviewStore;
weeklyReportStore: WeeklyReportStore weeklyReportStore: WeeklyReportStore;
alertsStore: AlertStore alertsStore: AlertStore;
featureFlagsStore: FeatureFlagsStore featureFlagsStore: FeatureFlagsStore;
uxtestingStore: UxtestingStore uxtestingStore: UxtestingStore;
tagWatchStore: TagWatchStore;
constructor() { constructor() {
this.dashboardStore = new DashboardStore(); this.dashboardStore = new DashboardStore();
@ -61,13 +61,14 @@ export class RootStore {
this.alertsStore = new AlertStore(); this.alertsStore = new AlertStore();
this.featureFlagsStore = new FeatureFlagsStore(); this.featureFlagsStore = new FeatureFlagsStore();
this.uxtestingStore = new UxtestingStore(); this.uxtestingStore = new UxtestingStore();
this.tagWatchStore = new TagWatchStore();
} }
initClient() { initClient() {
const client = new APIClient(); const client = new APIClient();
services.forEach(service => { services.forEach((service) => {
service.initClient(client); service.initClient(client);
}) });
} }
} }

View 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);
}
};
}

View file

@ -1,19 +1,10 @@
import type Screen from './Screen' import type Screen from './Screen'
import type Marker from './Marker' import type Marker from './Marker'
//import { select } from 'optimal-select';
export default class Inspector { export default class Inspector {
// private captureCallbacks = [];
// private bubblingCallbacks = [];
constructor(private screen: Screen, private marker: Marker) {} constructor(private screen: Screen, private marker: Marker) {}
private onMouseMove = (e: MouseEvent) => { private onMouseMove = (e: MouseEvent) => {
// const { overlay } = this.screen;
// if (!overlay.contains(e.target)) {
// return;
// }
e.stopPropagation(); e.stopPropagation();
const target = this.screen.getElementFromPoint(e); const target = this.screen.getElementFromPoint(e);
@ -34,30 +25,15 @@ export default class Inspector {
return return
} }
this.clickCallback && this.clickCallback({ target }); 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) { addClickListener(callback: (el: { target: Element }) => void) {
// if (useCapture) { this.clickCallback = callback
// this.captureCallbacks.push(callback); }
// } else {
// //this.bubblingCallbacks.push(callback);
// }
// }
private clickCallback: (e: { target: Element }) => void | null = null private clickCallback: (e: { target: Element }) => void = () => {}
enable(clickCallback?: Inspector['clickCallback']) {
enable() {
this.screen.overlay.addEventListener('mousemove', this.onMouseMove) this.screen.overlay.addEventListener('mousemove', this.onMouseMove)
this.screen.overlay.addEventListener('mouseleave', this.onOverlayLeave) this.screen.overlay.addEventListener('mouseleave', this.onOverlayLeave)
this.screen.overlay.addEventListener('click', this.onMarkClick) this.screen.overlay.addEventListener('click', this.onMarkClick)

View file

@ -1,5 +1,6 @@
import type Screen from './Screen' import type Screen from './Screen';
import styles from './marker.module.css'; import styles from './marker.module.css';
import { finder } from '@medv/finder';
const metaCharsMap = { const metaCharsMap = {
'&': '&amp;', '&': '&amp;',
@ -9,7 +10,7 @@ const metaCharsMap = {
"'": '&#39;', "'": '&#39;',
'/': '&#x2F;', '/': '&#x2F;',
'`': '&#x60;', '`': '&#x60;',
'=': '&#x3D;' '=': '&#x3D;',
}; };
function escapeHtml(str: string) { function escapeHtml(str: string) {
@ -19,29 +20,30 @@ function escapeHtml(str: string) {
}); });
} }
function escapeRegExp(string: string) { function escapeRegExp(string: string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
} }
function safeString(string: string) { function safeString(string: string) {
return (escapeHtml(escapeRegExp(string))) return escapeHtml(escapeRegExp(string));
} }
export default class Marker { export default class Marker {
private _target: Element | null = null; private _target: Element | null = null;
private selector: string | null = null; private selector: string | null = null;
private tooltip: HTMLDivElement private readonly tooltip: HTMLDivElement;
private marker: 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 = document.createElement('div');
this.tooltip.className = styles.tooltip; this.tooltip.className = styles.tooltip;
this.tooltip.appendChild(document.createElement('div')); this.tooltipSelector = document.createElement('div');
this.tooltipHint = document.createElement('div');
const htmlStr = document.createElement('div'); this.tooltipHint.innerText = '(click to tag element)'
htmlStr.innerHTML = '<b>Right-click > Inspect</b> for more details.'; this.tooltipHint.className = styles.tooltipHint;
this.tooltip.appendChild(htmlStr); this.tooltip.append(this.tooltipSelector, this.tooltipHint);
const marker = document.createElement('div'); const marker = document.createElement('div');
marker.className = styles.marker; marker.className = styles.marker;
@ -78,19 +80,17 @@ export default class Marker {
} }
unmark() { unmark() {
this.mark(null) this.mark(null);
} }
private autodefineTarget() { private autodefineTarget() {
// TODO: put to Screen if (this.selector && this.screen.document) {
if (this.selector) {
try { try {
const fitTargets = this.screen.document.querySelectorAll(this.selector); const fitTargets = this.screen.document.querySelectorAll(this.selector);
if (fitTargets.length === 0) { if (fitTargets.length === 0) {
this._target = null; this._target = null;
} else { } else {
// TODO: fix getCursorTarget()? this._target = fitTargets[0];
// this._target = fitTargets[0];
// const cursorTarget = this.screen.getCursorTarget(); // const cursorTarget = this.screen.getCursorTarget();
// fitTargets.forEach((target) => { // fitTargets.forEach((target) => {
// if (target.contains(cursorTarget)) { // if (target.contains(cursorTarget)) {
@ -108,27 +108,23 @@ export default class Marker {
markBySelector(selector: string) { markBySelector(selector: string) {
this.selector = selector; this.selector = selector;
this.lastSelector = selector;
this.autodefineTarget(); this.autodefineTarget();
this.redraw(); this.redraw();
} }
lastSelector = '';
private getTagString(el: Element) { private getTagString(el: Element) {
const attrs = el.attributes; if (!this.screen.document) return '';
let str = `<span style="color:#9BBBDC">${el.tagName.toLowerCase()}</span>`; const selector = finder(el, {
root: this.screen.document.body,
for (let i = 0; i < attrs.length; i++) { seedMinLength: 3,
let k = attrs[i]; optimizedMinLength: 2,
const attribute = k.name; threshold: 1000,
if (attribute === 'class') { maxNumberOfTries: 10_000,
str += `<span style="color:#F29766">${'.' + safeString(k.value).split(' ').join('.')}</span>`; });
} this.lastSelector = selector;
return selector
if (attribute === 'id') {
str += `<span style="color:#F29766">${'#' + safeString(k.value).split(' ').join('#')}</span>`;
}
}
return str;
} }
redraw() { redraw() {
@ -146,6 +142,17 @@ export default class Marker {
this.marker.style.width = rect.width + 'px'; this.marker.style.width = rect.width + 'px';
this.marker.style.height = rect.height + '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();
} }
} }

View file

@ -39,24 +39,21 @@
position: absolute; position: absolute;
left: 0; left: 0;
bottom: 100%; bottom: 100%;
padding: 15px; padding: 8px;
max-width: 600px;
box-shadow: 2px 2px 1px rgba(40, 40, 100, .3); box-shadow: 2px 2px 1px rgba(40, 40, 100, .3);
z-index: 999; z-index: 999;
border-radius: 3px; border-radius: 3px;
background-color: #202124; background-color: #202124;
min-width: 400px; min-width: 300px;
font-size: 20px !important; font-size: 16px !important;
color:#9BBBDC;
}
& div:first-child { .tooltipHint {
max-width: 600px; margin-top: 4px;
height: 22px; font-size: 14px;
white-space: nowrap; width: 100%;
overflow: hidden; text-align: center;
text-overflow: ellipsis; color: rgba(138, 170, 201, 0.8);
}
& div:last-child {
font-size: 18px;
margin-top: 10px;
color: $tealx;
}
} }

View file

@ -16,6 +16,8 @@ export default class WebPlayer extends Player {
...TargetMarker.INITIAL_STATE, ...TargetMarker.INITIAL_STATE,
...MessageManager.INITIAL_STATE, ...MessageManager.INITIAL_STATE,
...MessageLoader.INITIAL_STATE, ...MessageLoader.INITIAL_STATE,
...InspectorController.INITIAL_STATE,
liveTimeTravel: false, liveTimeTravel: false,
inspectorMode: false, inspectorMode: false,
} }
@ -65,7 +67,7 @@ export default class WebPlayer extends Player {
} }
this.targetMarker = new TargetMarker(this.screen, wpState) this.targetMarker = new TargetMarker(this.screen, wpState)
this.inspectorController = new InspectorController(screen) this.inspectorController = new InspectorController(screen, wpState)
const endTime = session.duration?.valueOf() || 0 const endTime = session.duration?.valueOf() || 0
@ -126,7 +128,7 @@ export default class WebPlayer extends Player {
this.inspectorController.marker?.mark(e) this.inspectorController.marker?.mark(e)
} }
toggleInspectorMode = (flag: boolean, clickCallback?: Parameters<InspectorController['enableInspector']>[0]) => { toggleInspectorMode = (flag: boolean) => {
if (typeof flag !== 'boolean') { if (typeof flag !== 'boolean') {
const { inspectorMode } = this.wpState.get() const { inspectorMode } = this.wpState.get()
flag = !inspectorMode flag = !inspectorMode
@ -135,13 +137,17 @@ export default class WebPlayer extends Player {
if (flag) { if (flag) {
this.pause() this.pause()
this.wpState.update({ inspectorMode: true }) this.wpState.update({ inspectorMode: true })
return this.inspectorController.enableInspector(clickCallback) return this.inspectorController.enableInspector()
} else { } else {
this.inspectorController.disableInspector() this.inspectorController.disableInspector()
this.wpState.update({ inspectorMode: false }) this.wpState.update({ inspectorMode: false })
} }
} }
markBySelector = (selector: string) => {
this.inspectorController.markBySelector(selector)
}
// Target Marker // Target Marker
setActiveTarget = (...args: Parameters<TargetMarker['setActiveTarget']>) => { setActiveTarget = (...args: Parameters<TargetMarker['setActiveTarget']>) => {
this.targetMarker.setActiveTarget(...args) this.targetMarker.setActiveTarget(...args)

View file

@ -1,72 +1,58 @@
import Marker from '../Screen/Marker' import type { Store } from 'Player';
import Inspector from '../Screen/Inspector' import { State } from 'Player/web/addons/TargetMarker';
import Screen from '../Screen/Screen' import Marker from '../Screen/Marker';
import type { Dimensions } from '../Screen/types' import Inspector from '../Screen/Inspector';
import Screen, { ScaleMode } from '../Screen/Screen';
import type { Dimensions } from '../Screen/types';
export default class InspectorController { export default class InspectorController {
private substitutor: Screen | null = null static INITIAL_STATE = {
private inspector: Inspector | null = null tagSelector: '',
marker: Marker | null = null }
constructor(private screen: Screen) { private substitutor: Screen | null = null;
screen.overlay.addEventListener('contextmenu', () => { private inspector: Inspector | null = null;
screen.overlay.style.display = 'none' marker: Marker | null = null;
const doc = screen.document
if (!doc) { return } constructor(private screen: Screen, private readonly store: Store<{ tagSelector: string }>) {
const returnOverlay = () => { screen.overlay.addEventListener('contextmenu', () => {
screen.overlay.style.display = 'block' screen.overlay.style.display = 'none';
doc.removeEventListener('mousemove', returnOverlay) const doc = screen.document;
doc.removeEventListener('mouseclick', returnOverlay) // TODO: prevent default in case of input selection if (!doc) {
} return;
doc.addEventListener('mousemove', returnOverlay) }
doc.addEventListener('mouseclick', returnOverlay) 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) { scale(dims: Dimensions) {
if (this.substitutor) { this.screen.scale(dims);
this.substitutor.scale(dims)
}
} }
enableInspector(clickCallback?: (e: { target: Element }) => void): Document | null { enableInspector(): Document | null {
const parent = this.screen.getParentElement() this.marker = new Marker(this.screen.overlay, this.screen);
if (!parent) return null; this.inspector = new Inspector(this.screen, this.marker);
if (!this.substitutor) { this.inspector.addClickListener(() => {
this.substitutor = new Screen() this.store.update({ tagSelector: this.marker?.lastSelector ?? '' })
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)
}
this.substitutor.display(false) this.inspector?.enable();
return this.screen.document;
}
const docElement = this.screen.document?.documentElement // this.substitutor.document?.importNode( markBySelector(selector: string) {
const doc = this.substitutor.document this.marker?.markBySelector(selector);
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;
} }
disableInspector() { disableInspector() {
if (this.substitutor) { this.inspector?.clean();
const doc = this.substitutor.document; this.inspector = null;
if (doc) { this.marker?.clean();
doc.documentElement.innerHTML = ""; this.marker = null;
}
this.inspector.clean();
this.substitutor.display(false);
}
this.screen.display(true);
} }
} }

View file

@ -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: { case 93: {
const timestamp = this.readUint(); if (timestamp === null) { return resetPointer() } const timestamp = this.readUint(); if (timestamp === null) { return resetPointer() }
const length = this.readUint(); if (length === null) { return resetPointer() } const length = this.readUint(); if (length === null) { return resetPointer() }

View file

@ -63,6 +63,7 @@ import type {
RawTabChange, RawTabChange,
RawTabData, RawTabData,
RawCanvasNode, RawCanvasNode,
RawTagTrigger,
RawIosEvent, RawIosEvent,
RawIosScreenChanges, RawIosScreenChanges,
RawIosClickEvent, RawIosClickEvent,
@ -196,6 +197,8 @@ export type TabData = RawTabData & Timed
export type CanvasNode = RawCanvasNode & Timed export type CanvasNode = RawCanvasNode & Timed
export type TagTrigger = RawTagTrigger & Timed
export type IosEvent = RawIosEvent & Timed export type IosEvent = RawIosEvent & Timed
export type IosScreenChanges = RawIosScreenChanges & Timed export type IosScreenChanges = RawIosScreenChanges & Timed

View file

@ -61,6 +61,7 @@ export const enum MType {
TabChange = 117, TabChange = 117,
TabData = 118, TabData = 118,
CanvasNode = 119, CanvasNode = 119,
TagTrigger = 120,
IosEvent = 93, IosEvent = 93,
IosScreenChanges = 96, IosScreenChanges = 96,
IosClickEvent = 100, IosClickEvent = 100,
@ -494,6 +495,11 @@ export interface RawCanvasNode {
timestamp: number, timestamp: number,
} }
export interface RawTagTrigger {
tp: MType.TagTrigger,
tagId: number,
}
export interface RawIosEvent { export interface RawIosEvent {
tp: MType.IosEvent, tp: MType.IosEvent,
timestamp: number, 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;

View file

@ -62,6 +62,7 @@ export const TP_MAP = {
117: MType.TabChange, 117: MType.TabChange,
118: MType.TabData, 118: MType.TabData,
119: MType.CanvasNode, 119: MType.CanvasNode,
120: MType.TagTrigger,
93: MType.IosEvent, 93: MType.IosEvent,
96: MType.IosScreenChanges, 96: MType.IosScreenChanges,
100: MType.IosClickEvent, 100: MType.IosClickEvent,

View file

@ -509,8 +509,13 @@ type TrCanvasNode = [
timestamp: number, 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 { export default function translate(tMsg: TrackerMessage): RawMessage | null {
switch(tMsg[0]) { 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: default:
return null return null
} }

View 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 || {})
}
}

View file

@ -1,23 +1,24 @@
import DashboardService from "./DashboardService"; import DashboardService from './DashboardService';
import MetricService from "./MetricService"; import MetricService from './MetricService';
import FunnelService from "./FunnelService"; import FunnelService from './FunnelService';
import SessionSerivce from "./SessionService"; import SessionService from './SessionService';
import UserService from "./UserService"; import UserService from './UserService';
import AuditService from './AuditService'; import AuditService from './AuditService';
import ErrorService from "./ErrorService"; import ErrorService from './ErrorService';
import NotesService from "./NotesService"; import NotesService from './NotesService';
import RecordingsService from "./RecordingsService"; import RecordingsService from './RecordingsService';
import ConfigService from './ConfigService' import ConfigService from './ConfigService';
import AlertsService from './AlertsService' import AlertsService from './AlertsService';
import WebhookService from './WebhookService' import WebhookService from './WebhookService';
import HealthService from "./HealthService"; import HealthService from './HealthService';
import FFlagsService from "App/services/FFlagsService"; import FFlagsService from 'App/services/FFlagsService';
import AssistStatsService from './AssistStatsService' import AssistStatsService from './AssistStatsService';
import UxtestingService from './UxtestingService' import UxtestingService from './UxtestingService';
import TagWatchService from 'App/services/TagWatchService';
export const dashboardService = new DashboardService(); export const dashboardService = new DashboardService();
export const metricService = new MetricService(); export const metricService = new MetricService();
export const sessionService = new SessionSerivce(); export const sessionService = new SessionService();
export const userService = new UserService(); export const userService = new UserService();
export const funnelService = new FunnelService(); export const funnelService = new FunnelService();
export const auditService = new AuditService(); export const auditService = new AuditService();
@ -36,6 +37,8 @@ export const assistStatsService = new AssistStatsService();
export const uxtestingService = new UxtestingService(); export const uxtestingService = new UxtestingService();
export const tagWatchService = new TagWatchService();
export const services = [ export const services = [
dashboardService, dashboardService,
metricService, metricService,
@ -52,5 +55,6 @@ export const services = [
healthService, healthService,
fflagsService, fflagsService,
assistStatsService, assistStatsService,
uxtestingService uxtestingService,
] tagWatchService,
];

View 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

View 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

View 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

View file

@ -64,6 +64,8 @@ export const setQueryParamKeyFromFilterkey = (filterKey: string) => {
return 'duration'; return 'duration';
case FilterKey.FEATURE_FLAG: case FilterKey.FEATURE_FLAG:
return 'feature_flag'; return 'feature_flag';
case FilterKey.TAGGED_ELEMENT:
return 'tnw'
} }
}; };
@ -149,6 +151,8 @@ export const getFilterKeyTypeByKey = (key: string) => {
return FilterKey.DURATION; return FilterKey.DURATION;
case FilterKey.FEATURE_FLAG: case FilterKey.FEATURE_FLAG:
return 'feature_flag'; return 'feature_flag';
case 'tnw':
return FilterKey.TAGGED_ELEMENT
} }
}; };
@ -306,4 +310,5 @@ export enum FilterKey {
CLICKMAP_URL = 'clickMapUrl', CLICKMAP_URL = 'clickMapUrl',
FEATURE_FLAG = 'featureFlag', FEATURE_FLAG = 'featureFlag',
TAGGED_ELEMENT = 'tag',
} }

View file

@ -262,6 +262,17 @@ export const filters = [
operatorOptions: filterOptions.getOperatorsByKeys(['is']), operatorOptions: filterOptions.getOperatorsByKeys(['is']),
icon: 'filters/duration' 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, key: FilterKey.USER_COUNTRY,
type: FilterType.MULTIPLE_DROPDOWN, 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 = ( export const addElementToFlagConditionsMap = (
category = FilterCategory.METADATA, category = FilterCategory.METADATA,
key, key,
@ -830,7 +850,11 @@ export default Record({
if (type === FilterKey.METADATA) { if (type === FilterKey.METADATA) {
_filter = filtersMap[filter.source]; _filter = filtersMap[filter.source];
} else { } else {
_filter = filtersMap[type]; if (filtersMap[filter.key]) {
_filter = filtersMap[filter.key]
} else {
_filter = filtersMap[type];
}
} }
} }

View file

@ -48,18 +48,18 @@ export const getFiltersFromQuery = (search: string, filter: any) => {
return; return;
} }
const entires = getQueryObject(search); const entries = getQueryObject(search);
const period: any = getPeriodFromEntries(entires); const period: any = getPeriodFromEntries(entries);
const filters = getFiltersFromEntries(entires); const filters = getFiltersFromEntries(entries);
return Filter({ filters, rangeValue: period.rangeName, startDate: period.start, endDate: period.end }); 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 = { ...filtersMap };
const filters: any = []; const filters: any = [];
if (entires.length > 0) { if (entries.length > 0) {
entires.forEach((item: any) => { entries.forEach((item: any) => {
if (!item.key || !item.value) { if (!item.key || !item.value) {
return; return;
} }
@ -72,29 +72,33 @@ const getFiltersFromEntries = (entires: any) => {
const sourceArr = tmp[1] ? tmp[1].split('|') : []; const sourceArr = tmp[1] ? tmp[1].split('|') : [];
const sourceOperator = sourceArr.shift(); const sourceOperator = sourceArr.shift();
if (filterKey) { if (filterKey && _filters[filterKey]) {
filter.type = filterKey; filter = _filters[filterKey];
filter.key = filterKey; filter.value = valueArr;
} else { } else {
filter = _filters[item.key]; if (filterKey) {
if (!!filter) { filter.type = filterKey;
filter.type = filter.key; filter.key = filterKey;
filter.key = filter.key; } else {
filter = _filters[item.key];
if (!!filter) {
filter.type = filter.key;
}
} }
}
if (!filter) { if (!filter) {
return; return;
} }
filter.value = valueArr; filter.value = valueArr;
filter.operator = operator; filter.operator = operator;
if (filter.icon === 'filters/metadata') { if (filter.icon === 'filters/metadata') {
filter.source = filter.type; filter.source = filter.type;
filter.type = 'MULTIPLE'; filter.type = 'MULTIPLE';
} else { } else {
filter.source = sourceArr && sourceArr.length > 0 ? sourceArr : null; filter.source = sourceArr && sourceArr.length > 0 ? sourceArr : null;
filter.sourceOperator = !!sourceOperator ? decodeURI(sourceOperator) : null; filter.sourceOperator = !!sourceOperator ? decodeURI(sourceOperator) : null;
}
} }
if (!filter.filters || filter.filters.size === 0) { if (!filter.filters || filter.filters.size === 0) {
@ -106,15 +110,15 @@ const getFiltersFromEntries = (entires: any) => {
return filters; return filters;
}; };
const getPeriodFromEntries = (entires: any) => { const getPeriodFromEntries = (entries: any) => {
const rangeFilter = entires.find(({ key }: any) => key === 'range'); const rangeFilter = entries.find(({ key }: any) => key === 'range');
if (!rangeFilter) { if (!rangeFilter) {
return Period(); return Period();
} }
if (rangeFilter.value === CUSTOM_RANGE) { if (rangeFilter.value === CUSTOM_RANGE) {
const start = entires.find(({ key }: any) => key === 'rStart').value; const start = entries.find(({ key }: any) => key === 'rStart').value;
const end = entires.find(({ key }: any) => key === 'rEnd').value; const end = entries.find(({ key }: any) => key === 'rEnd').value;
return Period({ rangeName: rangeFilter.value, start, end }); return Period({ rangeName: rangeFilter.value, start, end });
} }

View file

@ -27,6 +27,7 @@
"@ant-design/icons": "^5.2.5", "@ant-design/icons": "^5.2.5",
"@babel/plugin-transform-private-methods": "^7.23.3", "@babel/plugin-transform-private-methods": "^7.23.3",
"@floating-ui/react-dom-interactions": "^0.10.3", "@floating-ui/react-dom-interactions": "^0.10.3",
"@medv/finder": "^3.1.0",
"@sentry/browser": "^5.21.1", "@sentry/browser": "^5.21.1",
"@svg-maps/world": "^1.0.1", "@svg-maps/world": "^1.0.1",
"@svgr/webpack": "^6.2.1", "@svgr/webpack": "^6.2.1",

View file

@ -112,11 +112,11 @@ ${iconPaths.map(icon => ` ${titleCase(icon.fileName)}`).join(',\n')}
} from './Icons' } from './Icons'
// export type IconNames = ${icons.map((icon) => '\'' + icon.slice(0, -4).replaceAll('-', '_') + '\'').join(' | ')}; // export type NewIconNames = ${icons.map((icon) => '\'' + icon.slice(0, -4).replaceAll('-', '_') + '\'').join(' | ')};
export type OldIconNames = ${icons.map((icon) => '\'' + icon.slice(0, -4) + '\'').join(' | ')}; export type IconNames = ${icons.map((icon) => '\'' + icon.slice(0, -4) + '\'').join(' | ')};
interface Props { interface Props {
name: OldIconNames; name: IconNames;
size?: number | string; size?: number | string;
width?: number | string; width?: number | string;
height?: number | string; height?: number | string;

View file

@ -2762,6 +2762,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@mrmlnc/readdir-enhanced@npm:^2.2.1":
version: 2.2.1 version: 2.2.1
resolution: "@mrmlnc/readdir-enhanced@npm:2.2.1" resolution: "@mrmlnc/readdir-enhanced@npm:2.2.1"
@ -18091,6 +18098,7 @@ __metadata:
"@babel/runtime": ^7.23.2 "@babel/runtime": ^7.23.2
"@floating-ui/react-dom-interactions": ^0.10.3 "@floating-ui/react-dom-interactions": ^0.10.3
"@jest/globals": ^29.7.0 "@jest/globals": ^29.7.0
"@medv/finder": ^3.1.0
"@openreplay/sourcemap-uploader": ^3.0.8 "@openreplay/sourcemap-uploader": ^3.0.8
"@sentry/browser": ^5.21.1 "@sentry/browser": ^5.21.1
"@storybook/addon-actions": ^6.5.12 "@storybook/addon-actions": ^6.5.12

View file

@ -518,6 +518,10 @@ message 119, 'CanvasNode' do
uint 'Timestamp' uint 'Timestamp'
end end
message 120, 'TagTrigger', :replayer => :devtools do
int 'TagId'
end
## Backend-only ## Backend-only
message 125, 'IssueEvent', :replayer => false, :tracker => false do message 125, 'IssueEvent', :replayer => false, :tracker => false do
uint 'MessageID' uint 'MessageID'

View file

@ -1,7 +1,7 @@
{ {
"name": "@openreplay/tracker", "name": "@openreplay/tracker",
"description": "The OpenReplay tracker main package", "description": "The OpenReplay tracker main package",
"version": "12.0.0", "version": "12.0.1-2",
"keywords": [ "keywords": [
"logging", "logging",
"replay" "replay"

View file

@ -73,6 +73,7 @@ export declare const enum Type {
TabChange = 117, TabChange = 117,
TabData = 118, TabData = 118,
CanvasNode = 119, CanvasNode = 119,
TagTrigger = 120,
} }
@ -580,6 +581,11 @@ export type CanvasNode = [
/*timestamp:*/ number, /*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 export default Message

View file

@ -1,6 +1,6 @@
import ConditionsManager from '../modules/conditionsManager.js' import ConditionsManager from '../modules/conditionsManager.js'
import FeatureFlags from '../modules/featureFlags.js' import FeatureFlags from '../modules/featureFlags.js'
import type Message from './messages.gen.js' import Message, { TagTrigger } from './messages.gen.js'
import { import {
Timestamp, Timestamp,
Metadata, Metadata,
@ -34,6 +34,7 @@ import type { Options as SessOptions } from './session.js'
import type { Options as NetworkOptions } from '../modules/network.js' import type { Options as NetworkOptions } from '../modules/network.js'
import CanvasRecorder from './canvas.js' import CanvasRecorder from './canvas.js'
import UserTestManager from '../modules/userTesting/index.js' import UserTestManager from '../modules/userTesting/index.js'
import TagWatcher from '../modules/tagWatcher.js'
import type { import type {
Options as WebworkerOptions, Options as WebworkerOptions,
@ -179,6 +180,7 @@ export default class App {
private uxtManager: UserTestManager private uxtManager: UserTestManager
private conditionsManager: ConditionsManager | null = null private conditionsManager: ConditionsManager | null = null
public featureFlags: FeatureFlags public featureFlags: FeatureFlags
private tagWatcher: TagWatcher
constructor( constructor(
projectKey: string, projectKey: string,
@ -230,6 +232,9 @@ export default class App {
this.session = new Session(this, this.options) this.session = new Session(this, this.options)
this.attributeSender = new AttributeSender(this, Boolean(this.options.disableStringDict)) this.attributeSender = new AttributeSender(this, Boolean(this.options.disableStringDict))
this.featureFlags = new FeatureFlags(this) 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 }) => { this.session.attachUpdateCallback(({ userID, metadata }) => {
if (userID != null) { if (userID != null) {
// TODO: nullable userID // TODO: nullable userID
@ -687,6 +692,7 @@ export default class App {
this.startCallbacks.forEach((cb) => cb(onStartInfo)) this.startCallbacks.forEach((cb) => cb(onStartInfo))
await this.conditionsManager?.fetchConditions(projectID as string, token as string) await this.conditionsManager?.fetchConditions(projectID as string, token as string)
await this.featureFlags.reloadFlags(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) this.conditionsManager?.processFlags(this.featureFlags.flags)
} }
const cycle = () => { const cycle = () => {
@ -896,7 +902,6 @@ export default class App {
) { ) {
const reason = const reason =
'OpenReplay: trying to call `start()` on the instance that has been started already.' 'OpenReplay: trying to call `start()` on the instance that has been started already.'
this.signalError(reason, [])
return Promise.resolve(UnsuccessfulStart(reason)) return Promise.resolve(UnsuccessfulStart(reason))
} }
this.activityState = ActivityState.Starting this.activityState = ActivityState.Starting
@ -1049,8 +1054,10 @@ export default class App {
this.compressionThreshold = compressionThreshold this.compressionThreshold = compressionThreshold
const onStartInfo = { sessionToken: token, userUUID, sessionID } const onStartInfo = { sessionToken: token, userUUID, sessionID }
// TODO: start as early as possible (before receiving the token) // TODO: start as early as possible (before receiving the token)
/** after start */
this.startCallbacks.forEach((cb) => cb(onStartInfo)) // MBTODO: callbacks after DOM "mounted" (observed) this.startCallbacks.forEach((cb) => cb(onStartInfo)) // MBTODO: callbacks after DOM "mounted" (observed)
void this.featureFlags.reloadFlags() void this.featureFlags.reloadFlags()
await this.tagWatcher.fetchTags(this.options.ingestPoint, token)
this.activityState = ActivityState.Active this.activityState = ActivityState.Active
if (canvasEnabled) { if (canvasEnabled) {
@ -1234,6 +1241,7 @@ export default class App {
this.ticker.stop() this.ticker.stop()
this.stopCallbacks.forEach((cb) => cb()) this.stopCallbacks.forEach((cb) => cb())
this.debug.log('OpenReplay tracking stopped.') this.debug.log('OpenReplay tracking stopped.')
this.tagWatcher.clear()
if (this.worker && stopWorker) { if (this.worker && stopWorker) {
this.worker.postMessage('stop') this.worker.postMessage('stop')
} }

View file

@ -942,3 +942,12 @@ export function CanvasNode(
] ]
} }
export function TagTrigger(
tagId: number,
): Messages.TagTrigger {
return [
Messages.Type.TagTrigger,
tagId,
]
}

View 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

View 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()
})
})

View file

@ -294,6 +294,10 @@ export default class MessageEncoder extends PrimitiveEncoder {
return this.string(msg[1]) && this.uint(msg[2]) return this.string(msg[1]) && this.uint(msg[2])
break break
case Messages.Type.TagTrigger:
return this.int(msg[1])
break
} }
} }