ui: use default filter for sessions, move around saved search actions, remove tags modal

This commit is contained in:
nick-delirium 2024-12-27 10:53:22 +01:00
parent 15fe4e5994
commit 592ae5ac4d
No known key found for this signature in database
GPG key ID: 93ABD695DF5FDBA0
11 changed files with 138 additions and 359 deletions

View file

@ -1,6 +1,5 @@
import React from 'react';
import SessionFilters from 'Shared/SessionFilters';
import AiSessionSearchField from 'Shared/SessionFilters/AiSessionSearchField';
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
@ -9,10 +8,6 @@ const MainSearchBar = () => {
const projectId = projectsStore.siteId;
const currSite = React.useRef(projectId);
// @ts-ignore
const originStr = window.env.ORIGIN || window.location.origin;
const isSaas = /app\.openreplay\.com/.test(originStr);
React.useEffect(() => {
if (projectId !== currSite.current && currSite.current !== undefined) {
console.debug('clearing filters due to project change');
@ -22,7 +17,6 @@ const MainSearchBar = () => {
}, [projectId]);
return (
<div className={'flex flex-col gap-2 w-full'}>
{isSaas ? <AiSessionSearchField /> : null}
<SessionFilters />
</div>
);

View file

@ -1,49 +1,22 @@
import React, { useEffect } from 'react';
import { Icon } from 'UI';
import { Button } from 'antd';
import cn from 'classnames';
import stl from './SavedSearch.module.css';
import { useModal } from 'App/components/Modal';
import SavedSearchModal from './components/SavedSearchModal';
import React from 'react';
import { Dropdown } from 'antd';
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
function SavedSearch() {
const { showModal } = useModal();
const { searchStore, customFieldStore } = useStore();
const { searchStore } = useStore();
const savedSearch = searchStore.savedSearch;
const list = searchStore.list;
const fetchedMeta = customFieldStore.fetchedMetadata;
// useEffect(() => {
// if (list.size === 0 && !fetchedMeta) {
// void searchStore.fetchSavedSearchList();
// }
// }, [fetchedMeta]);
const options = searchStore.list.map((item) => ({
key: item.searchId,
label: item.name,
onClick: () => searchStore.applySavedSearch(item)
}))
return (
<div className={cn('flex items-center', { [stl.disabled]: list.size === 0 })}>
<Button
// variant="outline"
type="primary"
ghost
onClick={() => showModal(<SavedSearchModal />, { right: true, width: 450 })}
className="flex gap-1"
>
<span className="mr-1">Saved Search</span>
<span className="font-meidum">{list.size}</span>
<Icon name="ellipsis-v" color="teal" size="14" />
</Button>
{savedSearch.exists() && (
<div className="flex items-center ml-2">
<Icon name="search" size="14" />
<span className="color-gray-medium px-1">Viewing:</span>
<span className="font-medium" style={{ whiteSpace: 'nowrap', width: '30%' }}>
{savedSearch.name.length > 15 ? `${savedSearch.name.slice(0, 15)}...` : savedSearch.name}
</span>
</div>
)}
</div>
<Dropdown.Button menu={{ items: options }} className={'w-fit'}>
{savedSearch.exists() ? 'Update' : 'Save'} Search
</Dropdown.Button>
);
}

View file

@ -67,10 +67,10 @@ function SavedSearchModal(props: Props) {
<div className="bg-white box-shadow h-screen">
<div className="p-6">
<h1 className="text-2xl">
Saved Search <span className="color-gray-medium">{searchStore.list.size}</span>
Saved Search <span className="color-gray-medium">{searchStore.list.length}</span>
</h1>
</div>
{searchStore.list.size > 1 && (
{searchStore.list.length > 1 && (
<div className="mb-6 w-full px-4">
<Input
icon="search"

View file

@ -1,15 +1,16 @@
import React from 'react';
import React, { useMemo } from "react";
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import SaveFilterButton from 'Shared/SaveFilterButton';
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
import TagList from '../MainSearchBar/components/TagList';
import SavedSearch from '../SavedSearch/SavedSearch';
import { Button } from 'antd';
import AiSessionSearchField from 'Shared/SessionFilters/AiSessionSearchField';
function SearchActions() {
const { aiFiltersStore, searchStore, customFieldStore } = useStore();
const { aiFiltersStore, searchStore, customFieldStore, userStore } = useStore();
const appliedFilter = searchStore.instance;
const activeTab = searchStore.activeTab;
const isEnterprise = userStore.isEnterprise;
const metaLoading = customFieldStore.isLoading;
const hasEvents =
appliedFilter.filters.filter((i: any) => i.isEvent).length > 0;
@ -19,12 +20,23 @@ function SearchActions() {
const hasSavedSearch = savedSearch && savedSearch.exists();
const hasSearch = hasFilters || hasSavedSearch;
const title = useMemo(() => {
if (activeTab && activeTab.type === 'bookmarks') {
return isEnterprise ? 'Vault' : 'Bookmarks';
}
return 'Sessions';
}, [activeTab?.type, isEnterprise]);
// @ts-ignore
const originStr = window.env.ORIGIN || window.location.origin;
const isSaas = /app\.openreplay\.com/.test(originStr);
const showAiField = isSaas && activeTab.type === 'sessions';
const showPanel = hasEvents || hasFilters || aiFiltersStore.isLoading;
return !metaLoading ? (
<div className={'mb-2'}>
<div className={'flex items-center gap-2 w-full'}>
<TagList />
<SavedSearch />
<h2 className="text-2xl capitalize mr-4">{title}</h2>
{isSaas && showAiField ? <AiSessionSearchField /> : null}
<div className={'ml-auto'} />
<Button
type="link"
@ -32,9 +44,9 @@ function SearchActions() {
onClick={() => searchStore.clearSearch()}
className="font-medium"
>
Clear Search
Clear
</Button>
<SaveFilterButton disabled={!hasEvents && !hasFilters} />
<SavedSearch />
</div>
{showPanel ? (
<>

View file

@ -1,87 +1,8 @@
import { CloseOutlined, EnterOutlined } from '@ant-design/icons';
import { Tour } from 'antd';
import { observer } from 'mobx-react-lite';
import React, { useState } from 'react';
import { useStore } from 'App/mstore';
import { assist as assistRoute, isRoute } from 'App/routes';
import { debounce } from 'App/utils';
import { Icon, Input } from 'UI';
import FilterModal from 'Shared/Filters/FilterModal';
import OutsideClickDetectingDiv from '../OutsideClickDetectingDiv';
const ASSIST_ROUTE = assistRoute();
interface Props {
setFocused?: (focused: boolean) => void;
}
function SessionSearchField(props: Props) {
const { searchStore, searchStoreLive } = useStore();
const isLive =
isRoute(ASSIST_ROUTE, window.location.pathname) ||
window.location.pathname.includes('multiview');
const debounceFetchFilterSearch = React.useCallback(
debounce(
isLive ? searchStoreLive.fetchFilterSearch : searchStore.fetchFilterSearch,
1000
),
[]
);
const [showModal, setShowModal] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const onSearchChange = ({ target: { value } }: any) => {
setSearchQuery(value);
debounceFetchFilterSearch({ q: value });
};
const onAddFilter = (filter: any) => {
isLive
? searchStoreLive.addFilterByKeyAndValue(filter.key, filter.value)
: searchStore.addFilterByKeyAndValue(filter.key, filter.value);
};
const onFocus = () => {
setShowModal(true);
props.setFocused?.(true);
};
const onBlur = () => {
setTimeout(() => {
setShowModal(false);
props.setFocused?.(false);
}, 200);
};
return (
<div className="relative w-full">
<Input
onFocus={onFocus}
onBlur={onBlur}
onChange={onSearchChange}
placeholder={
'Search sessions using any captured event (click, input, page, error...)'
}
style={{ minWidth: 360, height: 30 }}
id="search"
type="search"
autoComplete="off"
className="px-2 py-1 text-lg placeholder-lg !border-0 rounded-r-full nofocus"
/>
{showModal && (
<div className="absolute left-0 shadow-sm rounded-lg bg-white z-50">
<FilterModal
isMainSearch={true}
onFilterClick={onAddFilter}
isLive={isLive}
/>
</div>
)}
</div>
);
}
import { Input } from 'UI';
const AiSearchField = observer(() => {
const { searchStore } = useStore();
@ -129,7 +50,7 @@ const AiSearchField = observer(() => {
value={searchQuery}
style={{ minWidth: 360, height: 30 }}
autoComplete="off"
className="px-2 py-1 pe-9 text-lg placeholder-lg !border-0 rounded-e-full nofocus"
className="px-4 py-1 text-lg placeholder-lg !border-0 nofocus"
leadingButton={
searchQuery !== '' ? (
<div
@ -148,154 +69,21 @@ const AiSearchField = observer(() => {
}
);
function AiSessionSearchField(props: Props) {
const askTourKey = '__or__ask-tour';
const tabKey = '__or__tab';
function AiSessionSearchField() {
const { aiFiltersStore } = useStore();
const isTourShown = localStorage.getItem(askTourKey) !== null;
const [tab, setTab] = useState(localStorage.getItem(tabKey) || 'search');
const [touring, setTouring] = useState(!isTourShown);
const [isFocused, setFocused] = React.useState(false);
const askAiRef = React.useRef(null);
const closeTour = () => {
setTouring(false);
localStorage.setItem(askTourKey, 'true');
};
const changeValue = (v?: string) => {
const newTab = v ? v : tab !== 'ask' ? 'ask' : 'search';
setTab(newTab);
localStorage.setItem(tabKey, newTab);
};
const boxStyle = tab === 'ask'
? gradientBox
: isFocused ? regularBoxFocused : regularBoxUnfocused;
return (
<div className={'bg-white rounded-full shadow-sm'}>
<div className={'bg-white rounded-full shadow-sm w-full'}>
<div
className={aiFiltersStore.isLoading ? 'animate-bg-spin' : ''}
style={boxStyle}
style={gradientBox}
>
<div ref={askAiRef} className={'px-2'}>
<AskAiSwitchToggle
enabled={tab === 'ask'}
setEnabled={changeValue}
loading={aiFiltersStore.isLoading}
/>
</div>
{tab === 'ask' ? (
<AiSearchField {...props} />
) : (
<SessionSearchField {...props} setFocused={setFocused} />
)}
<Tour
open={touring}
onClose={closeTour}
steps={[
{
title: (
<div
className={'text-xl font-semibold flex items-center gap-2'}
>
<span>Introducing</span>
<Icon name={'sparkles'} size={18} />
<span>Ask AI</span>
</div>
),
target: () => askAiRef.current,
description:
'Easily find sessions with our AI search. Just enable Ask AI, type in your query naturally, and the AI will swiftly and precisely display relevant sessions.',
nextButtonProps: {
children: (
<OutsideClickDetectingDiv
onClickOutside={closeTour}
className={
'w-full h-full text-white flex items-center gap-2'
}
>
<span>Ask AI</span>
<Icon
name={'arrow-right-short'}
size={16}
color={'white'}
/>
</OutsideClickDetectingDiv>
),
onClick: () => {
changeValue('ask');
closeTour();
}
}
}
]}
/>
<AiSearchField />
</div>
</div>
);
}
export const AskAiSwitchToggle = ({
enabled,
setEnabled,
loading
}: {
enabled: boolean;
loading: boolean;
setEnabled: () => void;
}) => {
return (
<div
role="switch"
aria-checked={enabled}
onClick={() => setEnabled()}
className={loading ? 'animate-bg-spin' : ''}
style={{
position: 'relative',
display: 'inline-block',
height: 24,
background: enabled
? 'linear-gradient(-25deg, #394eff, #3EAAAf, #3ccf65)'
: 'rgb(170 170 170)',
backgroundSize: loading ? '200% 200%' : 'unset',
borderRadius: 100,
cursor: 'pointer',
transition: 'all 0.2s ease-in-out',
border: 0,
verticalAlign: 'middle'
}}
>
<div
style={{
display: 'inline-block',
insetInlineStart: enabled ? 'calc(100% - 21px)' : '3px',
position: 'absolute',
top: 3,
width: 18,
height: 18,
transition: 'all 0.2s ease-in-out',
background: '#fff',
borderRadius: 100,
verticalAlign: 'middle'
}}
/>
<div
style={{
display: 'inline-block',
overflow: 'hidden',
borderRadius: 100,
height: '100%',
transition: 'all 0.2s ease-in-out',
paddingInline: !enabled ? '30px 0px' : '10px 24px',
width: 88
}}
>
<div style={{ color: 'white', fontSize: 16 }}>Ask AI</div>
</div>
</div>
);
};
export const gradientBox = {
border: 'double 1.5px transparent',
borderRadius: '100px',
@ -307,27 +95,8 @@ export const gradientBox = {
display: 'flex',
gap: '0.25rem',
alignItems: 'center',
width: '100%'
};
const regularBoxUnfocused = {
borderRadius: '100px',
border: 'solid 1.5px #BFBFBF',
background: '#fffff',
display: 'flex',
gap: '0.25rem',
alignItems: 'center',
width: '100%'
};
const regularBoxFocused = {
borderRadius: '100px',
border: 'solid 1.5px #394EFF',
background: '#fffff',
display: 'flex',
gap: '0.25rem',
alignItems: 'center',
width: '100%'
width: '100%',
overflow: 'hidden',
};
export default observer(AiSessionSearchField);

View file

@ -22,6 +22,12 @@ function SessionFilters() {
const saveRequestPayloads =
projectsStore.instance?.saveRequestPayloads ?? false;
useEffect(() => {
if (searchStore.instance.filters.length === 0) {
searchStore.addFilterByKeyAndValue(FilterKey.LOCATION, '', 'isAny')
}
}, [])
useSessionSearchQueryHandler({
appliedFilter,
loading: metaLoading,

View file

@ -14,6 +14,7 @@ function SessionsTabOverview() {
const [query, setQuery] = React.useState('');
const { aiFiltersStore, searchStore } = useStore();
const appliedFilter = searchStore.instance;
const activeTab = searchStore.activeTab;
const handleKeyDown = (event: any) => {
if (event.key === 'Enter') {
@ -41,7 +42,7 @@ function SessionsTabOverview() {
placeholder={'ask session ai'}
/>
) : null}
<SessionHeader />
{activeTab.type !== 'bookmarks' && <SessionHeader />}
<div className="border-b" />
<LatestSessionsMessage />
<SessionList />

View file

@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React from 'react';
import Period from 'Types/app/period';
import SelectDateRange from 'Shared/SelectDateRange';
import SessionTags from '../SessionTags';
@ -8,20 +8,11 @@ import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
function SessionHeader() {
const { searchStore, userStore } = useStore();
const isEnterprise = userStore.isEnterprise;
const activeTab = searchStore.activeTab;
const { searchStore } = useStore();
const { startDate, endDate, rangeValue } = searchStore.instance;
const period = Period({ start: startDate, end: endDate, rangeName: rangeValue });
const title = useMemo(() => {
if (activeTab && activeTab.type === 'bookmarks') {
return isEnterprise ? 'Vault' : 'Bookmarks';
}
return 'Sessions';
}, [activeTab?.type, isEnterprise]);
const onDateChange = (e: any) => {
const dateValues = e.toJSON();
searchStore.edit(dateValues);
@ -30,13 +21,11 @@ function SessionHeader() {
return (
<div className="flex items-center px-4 py-1 justify-between w-full">
<h2 className="text-2xl capitalize mr-4">{title}</h2>
<div className="flex items-center w-full justify-end">
{activeTab.type !== 'bookmarks' && <SessionTags />}
<SessionTags />
<div className="mr-auto" />
<Space>
{activeTab.type !== 'bookmarks' &&
<SelectDateRange isAnt period={period} onChange={onDateChange} right={true} />}
<SelectDateRange isAnt period={period} onChange={onDateChange} right={true} />
<SessionSort />
</Space>
</div>

View file

@ -29,8 +29,9 @@ const useSessionSearchQueryHandler = ({ onBeforeLoad, appliedFilter, loading }:
const converter = JsonUrlConverter.urlParamsToJson(history.location.search);
const json = getFilterFromJson(converter.toJSON());
const filter = new Search(json);
searchStore.applyFilter(filter, true);
searchStore.setUrlParsed();
if (filter.filters.length === 0) return;
searchStore.applyFilter(filter, true);
} catch (error) {
console.error('Error applying filter from query:', error);
}

View file

@ -5,7 +5,7 @@ import {
filtersMap,
generateFilterOptions,
liveFiltersMap,
mobileConditionalFiltersMap
mobileConditionalFiltersMap,
} from 'Types/filter/newFilter';
import { List } from 'immutable';
import { makeAutoObservable, runInAction } from 'mobx';
@ -28,18 +28,18 @@ export const checkValues = (key: any, value: any) => {
};
export const filterMap = ({
category,
value,
key,
operator,
sourceOperator,
source,
custom,
isEvent,
filters,
sort,
order
}: any) => ({
category,
value,
key,
operator,
sourceOperator,
source,
custom,
isEvent,
filters,
sort,
order,
}: any) => ({
value: checkValues(key, value),
custom,
type: category === FilterCategory.METADATA ? FilterKey.METADATA : key,
@ -47,7 +47,7 @@ export const filterMap = ({
source: category === FilterCategory.METADATA ? key.replace(/^_/, '') : source,
sourceOperator,
isEvent,
filters: filters ? filters.map(filterMap) : []
filters: filters ? filters.map(filterMap) : [],
});
export const TAB_MAP: any = {
@ -55,11 +55,11 @@ export const TAB_MAP: any = {
sessions: { name: 'Sessions', type: 'sessions' },
bookmarks: { name: 'Bookmarks', type: 'bookmarks' },
notes: { name: 'Notes', type: 'notes' },
recommendations: { name: 'Recommendations', type: 'recommendations' }
recommendations: { name: 'Recommendations', type: 'recommendations' },
};
class SearchStore {
list = List();
list: SavedSearch[] = [];
latestRequestTime: number | null = null;
latestList = List();
alertMetricId: number | null = null;
@ -107,13 +107,19 @@ class SearchStore {
applySavedSearch(savedSearch: ISavedSearch) {
this.savedSearch = savedSearch;
this.edit({ filters: savedSearch.filter ? savedSearch.filter.filters.map((i: FilterItem) => new FilterItem().fromJson(i)) : [] });
this.edit({
filters: savedSearch.filter
? savedSearch.filter.filters.map((i: FilterItem) =>
new FilterItem().fromJson(i)
)
: [],
});
this.currentPage = 1;
}
async fetchSavedSearchList() {
const response = await searchService.fetchSavedSearch();
this.list = List(response.map((item: any) => new SavedSearch(item)));
this.list = response.map((item: any) => new SavedSearch(item));
}
edit(instance: Partial<Search>) {
@ -122,7 +128,9 @@ class SearchStore {
}
editSavedSearch(instance: Partial<SavedSearch>) {
this.savedSearch = new SavedSearch(Object.assign(this.savedSearch.toData(), instance));
this.savedSearch = new SavedSearch(
Object.assign(this.savedSearch.toData(), instance)
);
}
apply(filter: any, fromUrl: boolean) {
@ -145,7 +153,10 @@ class SearchStore {
.fetchFilterSearch(params)
.then((response: any[]) => {
this.filterSearchList = response.reduce(
(acc: Record<string, { projectId: number; value: string }[]>, item: any) => {
(
acc: Record<string, { projectId: number; value: string }[]>,
item: any
) => {
const { projectId, type, value } = item;
if (!acc[type]) acc[type] = [];
acc[type].push({ projectId, value });
@ -206,17 +217,19 @@ class SearchStore {
}
clearList() {
this.list = List();
this.list = [];
}
clearSearch() {
const instance = this.instance;
this.edit(new Search({
rangeValue: instance.rangeValue,
startDate: instance.startDate,
endDate: instance.endDate,
filters: []
}));
this.edit(
new Search({
rangeValue: instance.rangeValue,
startDate: instance.startDate,
endDate: instance.endDate,
filters: [],
})
);
this.savedSearch = new SavedSearch({});
sessionStore.clearList();
@ -226,7 +239,11 @@ class SearchStore {
checkForLatestSessions() {
const filter = this.instance.toSearch();
if (this.latestRequestTime) {
const period = Period({ rangeName: CUSTOM_RANGE, start: this.latestRequestTime, end: Date.now() });
const period = Period({
rangeName: CUSTOM_RANGE,
start: this.latestRequestTime,
end: Date.now(),
});
const newTimestamps: any = period.toJSON();
filter.startDate = newTimestamps.startDate;
filter.endDate = newTimestamps.endDate;
@ -242,29 +259,32 @@ class SearchStore {
}
addFilter(filter: any) {
const index = filter.isEvent ? -1 : this.instance.filters.findIndex((i: FilterItem) => i.key === filter.key);
const index = filter.isEvent
? -1
: this.instance.filters.findIndex(
(i: FilterItem) => i.key === filter.key
);
console.log(filter)
filter.value = checkFilterValue(filter.value);
filter.filters = filter.filters
? filter.filters.map((subFilter: any) => ({
...subFilter,
value: checkFilterValue(subFilter.value)
}))
...subFilter,
value: checkFilterValue(subFilter.value),
}))
: null;
if (index > -1) {
const oldFilter = new FilterItem(this.instance.filters[index]);
const updatedFilter = {
...oldFilter,
value: oldFilter.value.concat(filter.value)
value: oldFilter.value.concat(filter.value),
};
oldFilter.merge(updatedFilter);
this.updateFilter(index, updatedFilter);
} else {
this.instance.filters.push(filter);
this.instance = new Search({
...this.instance.toData()
...this.instance.toData(),
});
}
@ -275,7 +295,13 @@ class SearchStore {
}
}
addFilterByKeyAndValue(key: any, value: any, operator?: string, sourceOperator?: string, source?: string) {
addFilterByKeyAndValue(
key: any,
value: any,
operator?: string,
sourceOperator?: string,
source?: string
) {
let defaultFilter = { ...filtersMap[key] };
defaultFilter.value = value;
@ -305,7 +331,7 @@ class SearchStore {
this.instance = new Search({
...this.instance.toData(),
filters: newFilters
filters: newFilters,
});
};
@ -316,7 +342,7 @@ class SearchStore {
this.instance = new Search({
...this.instance.toData(),
filters: newFilters
filters: newFilters,
});
};
@ -328,12 +354,17 @@ class SearchStore {
// TODO
}
async fetchSessions(force: boolean = false, bookmarked: boolean = false): Promise<void> {
async fetchSessions(
force: boolean = false,
bookmarked: boolean = false
): Promise<void> {
const filter = this.instance.toSearch();
if (this.activeTags[0] && this.activeTags[0] !== 'all') {
const tagFilter = filtersMap[FilterKey.ISSUE];
tagFilter.value = [issues_types.find((i: any) => i.type === this.activeTags[0])?.type];
tagFilter.value = [
issues_types.find((i: any) => i.type === this.activeTags[0])?.type,
];
delete tagFilter.operatorOptions;
delete tagFilter.options;
delete tagFilter.placeholder;
@ -345,14 +376,17 @@ class SearchStore {
this.latestRequestTime = Date.now();
this.latestList = List();
await sessionStore.fetchSessions({
...filter,
page: this.currentPage,
perPage: this.pageSize,
tab: this.activeTab.type,
bookmarked: bookmarked ? true : undefined
}, force);
};
await sessionStore.fetchSessions(
{
...filter,
page: this.currentPage,
perPage: this.pageSize,
tab: this.activeTab.type,
bookmarked: bookmarked ? true : undefined,
},
force
);
}
}
export default SearchStore;

View file

@ -4,7 +4,7 @@ import {
filtersMap,
mobileConditionalFiltersMap,
} from 'Types/filter/newFilter';
import { action, makeAutoObservable, observable } from 'mobx';
import { makeAutoObservable } from 'mobx';
import { pageUrlOperators } from '../../constants/filterOptions';