feat(ui/tracker): feature flags (#1097)

* fix(player): fix initial visual offset jump check

* change(ui): add empty feature flags page

* change(ui): add empty feature flags page

* fix(ui): some more fixes

* change(ui): add subrouting for sessions tab

* change(ui): more fixes for routing

* change(ui): add flag creation page, flags list table, flag store/type

* change(tracker): flags in tracker

* change(tracker): return all flags

* feat(ui): add API and types connector

* feat(ui): split components to prevent rerendering

* feat(ui): add icon, fix redirect.path crashlooping

* feat(ui): add conditions and stuff, add flags class to tracker to handle stuff

* feat(ui): add condition state and filters

* feat(ui): fix flag creation with api change

* feat(ui): fix flag editing (api changes); simplify new/edit flag component

* feat(ui): add filters, make table pretty :insert_magic_emoji:

* feat(ui): remove rollout percentage from list, remove console logs

* feat(ui): multivar toggler

* feat(tracker): add more methods to tracker

* feat(tracker): more type coverage

* feat(tracker): add tests

* fix(ui): some fixes for multivar

* feat(ui): multivar api support

* fix(ui):start adding tests for fflags

* fix(ui): rm not working file..

* fix(ui): rm unused packages

* fix(ui): remove name field, fix some api and type names

* fix(ui): fix crash

* fix(tracker/ui): keep flags in sessionStorage, support API errors in feature flags storage

* fix(tracker/ui): clear unused things, fix url handling, fix icons rendering etc
This commit is contained in:
Delirium 2023-06-21 12:35:40 +02:00 committed by GitHub
parent 0a0f5170e5
commit e9e3e21a10
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
74 changed files with 1887 additions and 78 deletions

View file

@ -63,8 +63,11 @@ const DASHBOARD_SELECT_PATH = routes.dashboardSelected();
const DASHBOARD_METRIC_CREATE_PATH = routes.dashboardMetricCreate();
const DASHBOARD_METRIC_DETAILS_PATH = routes.dashboardMetricDetails();
// const WIDGET_PATAH = routes.dashboardMetric();
const SESSIONS_PATH = routes.sessions();
const FFLAGS_PATH = routes.fflags();
const FFLAG_PATH = routes.fflag();
const FFLAG_CREATE_PATH = routes.newFFlag();
const NOTES_PATH = routes.notes();
const ASSIST_PATH = routes.assist();
const RECORDINGS_PATH = routes.recordings();
// const ERRORS_PATH = routes.errors();
@ -232,6 +235,10 @@ class Router extends React.Component {
<Route exact strict path={withSiteId(FUNNEL_CREATE_PATH, siteIdList)} component={FunnelsDetails} />
<Route exact strict path={withSiteId(FUNNEL_ISSUE_PATH, siteIdList)} component={FunnelIssue} />
<Route exact strict path={withSiteId(SESSIONS_PATH, siteIdList)} component={SessionsOverview} />
<Route exact strict path={withSiteId(FFLAGS_PATH, siteIdList)} component={SessionsOverview} />
<Route exact strict path={withSiteId(FFLAG_PATH, siteIdList)} component={SessionsOverview} />
<Route exact strict path={withSiteId(FFLAG_CREATE_PATH, siteIdList)} component={SessionsOverview} />
<Route exact strict path={withSiteId(NOTES_PATH, siteIdList)} component={SessionsOverview} />
<Route exact strict path={withSiteId(SESSION_PATH, siteIdList)} component={Session} />
<Route exact strict path={withSiteId(LIVE_SESSION_PATH, siteIdList)} component={LiveSession} />
<Route exact strict path={withSiteId(LIVE_SESSION_PATH, siteIdList)} render={(props) => <Session {...props} live />} />

View file

@ -28,6 +28,7 @@ const siteIdRequiredPaths = [
'/cards',
'/unprocessed',
'/notes',
'/feature-flags',
// '/custom_metrics/sessions',
];

View file

@ -47,21 +47,6 @@ function Header({ history, siteId }: { history: any; siteId: string }) {
})
}
/>
{/* <Select
options={[
{ label: 'Visibility - All', value: 'all' },
{ label: 'Visibility - Private', value: 'private' },
{ label: 'Visibility - Team', value: 'team' },
]}
defaultValue={'all'}
plain
onChange={({ value }) =>
dashboardStore.updateKey('filter', {
...dashboardStore.filter,
visibility: value.value,
})
}
/> */}
<Select
options={[

View file

@ -51,6 +51,7 @@ function FilterSeries(props: Props) {
observeChanges()
}
console.log(series.filter)
return (
<div className="border rounded bg-white">
<div className={cn("border-b px-5 h-12 flex items-center relative", { 'hidden': hideHeader })}>

View file

@ -0,0 +1,51 @@
import React from 'react'
import FeatureFlag from 'App/mstore/types/FeatureFlag'
import { Icon, Toggler, Link } from 'UI'
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
import { resentOrDate } from 'App/date';
import { toast } from 'react-toastify';
function FFlagItem({ flag }: { flag: FeatureFlag }) {
const { featureFlagsStore, userStore } = useStore();
const toggleActivity = () => {
flag.setIsEnabled(!flag.isActive);
featureFlagsStore.updateFlag(flag, true).then(() => {
toast.success('Feature flag updated.');
})
}
const flagIcon = flag.isSingleOption ? 'fflag-single' : 'fflag-multi' as const
const flagOwner = flag.updatedBy || flag.createdBy
const user = userStore.list.length > 0 ? userStore.list.find(u => parseInt(u.userId) === flagOwner!)?.name : flagOwner;
return (
<div className={'w-full py-2 border-b'}>
<div className={'flex items-center'}>
<Link style={{ flex: 1 }} to={`feature-flags/${flag.featureFlagId}`}>
<div className={'flex items-center gap-2 link'}>
<Icon name={flagIcon} size={32} />
{flag.flagKey}
</div>
</Link>
<div style={{ flex: 1 }}>{flag.isSingleOption ? 'Single Option' : 'Multivariant'}</div>
<div style={{ flex: 1 }}>{resentOrDate(flag.updatedAt || flag.createdAt)}</div>
<div style={{ flex: 1 }} className={'flex items-center gap-2'}>
<Icon name={'person-fill'} />
{user}
</div>
<div style={{ marginLeft: 'auto', width: 115 }}>
<Toggler
checked={flag.isActive}
name={'persist-flag'}
label={flag.isActive ? 'Enabled' : 'Disabled'}
onChange={toggleActivity}
/>
</div>
</div>
{flag.description ? <div className={'text-disabled-text pt-2'}>{flag.description}</div> : null}
</div>
);
}
export default observer(FFlagItem);

View file

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

View file

@ -0,0 +1,98 @@
import React from 'react';
import FFlagsListHeader from 'Components/FFlags/FFlagsListHeader';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import { Loader, NoContent } from 'UI';
import FFlagItem from './FFlagItem';
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
import Select from 'Shared/Select';
function FFlagsList({ siteId }: { siteId: string }) {
const { featureFlagsStore, userStore } = useStore();
React.useEffect(() => {
void featureFlagsStore.fetchFlags();
void userStore.fetchUsers();
}, []);
return (
<div
className={'mb-5 w-full mx-auto bg-white rounded px-4 pb-10 pt-4 widget-wrapper'}
style={{ maxWidth: '1300px' }}
>
<FFlagsListHeader siteId={siteId} />
<Loader loading={featureFlagsStore.isLoading}>
<div className="w-full h-full">
<NoContent
show={featureFlagsStore.flags.length === 0}
title={
<div className={'flex flex-col items-center justify-center'}>
<AnimatedSVG name={ICONS.NO_FFLAGS} size={285} />
<div className="text-center text-gray-600 mt-4">
You haven't created any feature flags yet.
</div>
</div>
}
subtext={
<div className="text-center flex justify-center items-center flex-col">
Use feature flags to deploy and rollback new functionality with ease.
</div>
}
>
<div>
<div className={'border-y px-3 py-2 mt-2 flex items-center w-full justify-end gap-4'}>
<div className={'flex items-center gap-2'}>
Status:
<Select
options={[
{ label: 'All', value: '0' as const },
{ label: 'Only active', value: '1' as const },
{ label: 'Only inactive', value: '2' as const },
]}
defaultValue={featureFlagsStore.activity}
plain
onChange={
({ value }) => {
featureFlagsStore.setActivity(value.value);
void featureFlagsStore.fetchFlags();
}}
/>
</div>
<div>
<Select
options={[
{ label: 'Newest', value: 'DESC' },
{ label: 'Oldest', value: 'ASC' },
]}
defaultValue={featureFlagsStore.sort.order}
plain
onChange={
({ value }) => {
featureFlagsStore.setSort({ query: '', order: value.value })
void featureFlagsStore.fetchFlags();
}}
/>
</div>
</div>
<div className={'flex items-center font-semibold border-b py-2'}>
<div style={{ flex: 1 }}>Key</div>
<div style={{ flex: 1 }}>Type</div>
<div style={{ flex: 1 }}>Last modified</div>
<div style={{ flex: 1 }}>Last modified by</div>
<div style={{ marginLeft: 'auto', width: 115 }}>Status</div>
</div>
{featureFlagsStore.flags.map((flag) => (
<React.Fragment key={flag.featureFlagId}>
<FFlagItem flag={flag} />
</React.Fragment>
))}
</div>
</NoContent>
</div>
</Loader>
</div>
);
}
export default observer(FFlagsList);

View file

@ -0,0 +1,36 @@
import React from 'react'
import { Button, PageTitle } from 'UI'
import FFlagsSearch from "Components/FFlags/FFlagsSearch";
import { useHistory } from "react-router";
import { newFFlag, withSiteId } from 'App/routes';
import ReloadButton from "Shared/ReloadButton";
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
function FFlagsListHeader({ siteId }: { siteId: string }) {
const history = useHistory();
const { featureFlagsStore } = useStore();
const onReload = () => {
void featureFlagsStore.fetchFlags();
}
return (
<div className="flex items-center justify-between px-6">
<div className="flex items-center mr-3 gap-2">
<PageTitle title="Feature Flags" />
<ReloadButton onClick={onReload} loading={featureFlagsStore.isLoading} />
</div>
<div className="ml-auto flex items-center">
<Button variant="primary" onClick={() => history.push(withSiteId(newFFlag(), siteId))}>
Create Feature Flag
</Button>
<div className="mx-2"></div>
<div className="w-1/4" style={{ minWidth: 300 }}>
<FFlagsSearch />
</div>
</div>
</div>
)
}
export default observer(FFlagsListHeader);

View file

@ -0,0 +1,42 @@
import React, { useEffect, useState } from 'react';
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
import { Icon } from 'UI';
import { debounce } from 'App/utils';
let debounceUpdate: any = () => {};
function FFlagsSearch() {
const { featureFlagsStore } = useStore();
const [query, setQuery] = useState(featureFlagsStore.flagsSearch);
useEffect(() => {
debounceUpdate = debounce(
(value: string) => {
featureFlagsStore.setSort({ order: featureFlagsStore.sort.order, query: value })
void featureFlagsStore.fetchFlags()
},
250
);
}, []);
const write = ({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setQuery(value);
debounceUpdate(value);
};
return (
<div className="relative">
<Icon name="search" className="absolute top-0 bottom-0 ml-2 m-auto" size="16" />
<input
value={query}
name="flagsSearch"
className="bg-white p-2 border border-borderColor-gray-light-shade rounded w-full pl-10"
placeholder="Search by key"
onChange={write}
/>
</div>
);
}
export default observer(FFlagsSearch);

View file

@ -0,0 +1,90 @@
import React from 'react';
import { Icon, Input, Button } from 'UI';
import cn from 'classnames';
import FilterList from 'Shared/Filters/FilterList';
import { nonFlagFilters } from 'Types/filter/newFilter';
import { observer } from 'mobx-react-lite';
import { Conditions } from "App/mstore/types/FeatureFlag";
interface Props {
set: number;
conditions: Conditions;
removeCondition: (ind: number) => void;
index: number
}
function RolloutCondition({ set, conditions, removeCondition, index }: Props) {
const [forceRender, forceRerender] = React.useState(false);
const onAddFilter = () => {
conditions.filter.addFilter({});
forceRerender(!forceRender);
};
const onUpdateFilter = (filterIndex: number, filter: any) => {
conditions.filter.updateFilter(filterIndex, filter);
forceRerender(!forceRender);
};
const onChangeEventsOrder = (_: any, { name, value }: any) => {
conditions.filter.updateKey(name, value);
forceRerender(!forceRender);
};
const onRemoveFilter = (filterIndex: number) => {
conditions.filter.removeFilter(filterIndex);
forceRerender(!forceRender);
};
const onPercentChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value || '0';
if (value.length > 3) return;
if (parseInt(value, 10) > 100) return conditions.setRollout(100);
conditions.setRollout(parseInt(value, 10));
};
return (
<div className={'border bg-white rounded'}>
<div className={'flex items-center border-b px-4 py-2 gap-2'}>
<div>Condition</div>
<div className={'p-2 rounded bg-gray-lightest'}>Set {set}</div>
<div
className={cn(
'p-2 cursor-pointer rounded ml-auto',
'hover:bg-teal-light'
)}
onClick={() => removeCondition(index)}
>
<Icon name={'trash'} color={'main'} />
</div>
</div>
<div className={'p-2 border-b'}>
<div className={conditions.filter.filters.length > 0 ? 'p-2 border-b mb-2' : ''}>
<FilterList
filter={conditions.filter}
onUpdateFilter={onUpdateFilter}
onRemoveFilter={onRemoveFilter}
onChangeEventsOrder={onChangeEventsOrder}
supportsEmpty
hideEventsOrder
excludeFilterKeys={nonFlagFilters}
/>
</div>
<Button variant={'text-primary'} onClick={() => onAddFilter()}>
+ Add Condition
</Button>
</div>
<div className={'px-4 py-2 flex items-center gap-2'}>
<span>Rollout to</span>
<Input
type="text"
width={60}
value={conditions.rolloutPercentage}
onChange={onPercentChange}
leadingButton={<div className={'p-2 text-disabled-text'}>%</div>}
/>
<span>of sessions</span>
</div>
</div>
);
}
export default observer(RolloutCondition);

View file

@ -0,0 +1,60 @@
import React from 'react';
import { observer } from 'mobx-react-lite';
import { Button } from 'UI';
import cn from 'classnames';
import FeatureFlag from 'MOBX/types/FeatureFlag';
function Description({
isDescrEditing,
current,
setEditing,
showDescription,
}: {
showDescription: boolean;
isDescrEditing: boolean;
current: FeatureFlag;
setEditing: ({ isDescrEditing }: { isDescrEditing: boolean }) => void;
}) {
return (
<>
<label>
<span className={'font-semibold'}>Description </span> <span className={"text-disabled-text text-sm"}>(Optional)</span>
</label>
{isDescrEditing ? (
<textarea
name="flag-description"
placeholder="Description..."
rows={3}
autoFocus
className="rounded fluid border px-2 py-1 w-full"
value={current.description}
onChange={(e) => {
if (current) current.setDescription(e.target.value);
}}
onBlur={() => setEditing({ isDescrEditing: false })}
onFocus={() => setEditing({ isDescrEditing: true })}
/>
) : showDescription ? (
<div
onClick={() => setEditing({ isDescrEditing: true })}
className={cn(
'cursor-pointer border-b w-fit',
'border-b-borderColor-transparent hover:border-dotted hover:border-gray-medium'
)}
>
{current.description}
</div>
) : (
<Button
variant={'text-primary'}
icon={'edit'}
onClick={() => setEditing({ isDescrEditing: true })}
>
Add
</Button>
)}
</>
);
}
export default observer(Description);

View file

@ -0,0 +1,25 @@
import React from 'react';
import { Button } from 'UI';
import { observer } from 'mobx-react-lite';
import cn from "classnames";
function Header({ current, onCancel, onSave, isNew }: any) {
return (
<>
<div>
<h1 className={cn('text-2xl')}>{isNew ? 'New Feature Flag' : current.flagKey}</h1>
</div>
<div className={'flex items-center gap-2'}>
<Button variant="text-primary" onClick={onCancel}>
{isNew ? "Cancel" : "Back"}
</Button>
<Button variant="primary" onClick={onSave}>
Save
</Button>
</div>
</>
);
}
export default observer(Header);

View file

@ -0,0 +1,20 @@
import React from 'react';
import { QuestionMarkHint } from 'UI';
function Rollout() {
return (
<div className={'flex items-center gap-2'}>
Rollout <QuestionMarkHint delay={150} content={"Must add up to 100% across all variants"} />
</div>
);
}
function Payload() {
return (
<div className={'flex items-center gap-2'}>
Payload <QuestionMarkHint delay={150} content={"Will be sent as an additional string"} /> <span className={"text-disabled-text text-sm"}>(Optional)</span>
</div>
)
}
export { Payload, Rollout };

View file

@ -0,0 +1,38 @@
import React from 'react'
// @ts-ignore
import Highlight from 'react-highlight'
import { PageTitle } from 'UI'
function HowTo() {
return (
<div className={'w-full h-screen p-4'}>
<PageTitle title={'Implement feature flags'} />
<div className={'my-2'}>
<Highlight className={'js'}>
{
`
type FeatureFlag = {
key: string;
is_persist: boolean;
value: string | boolean;
payload: string
}
tracker.onFlagsLoad((flags: FeatureFlag) => {
/* run code */
})
// or
if (openreplay.isFlagEnabled('my_flag')) {
// run your activation code here
}`}
</Highlight>
</div>
<a className={'link'}>Documentation</a>
</div>
)
}
export default HowTo;

View file

@ -0,0 +1,113 @@
import React from 'react';
import { Rollout, Payload } from './Helpers';
import { Input, Button, Icon } from 'UI';
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
import cn from 'classnames';
function Multivariant() {
const { featureFlagsStore } = useStore();
const avg = React.useMemo(() => {
return Math.floor(100 / featureFlagsStore.currentFflag!.variants.length);
}, [featureFlagsStore.currentFflag!.variants.length]);
return (
<div>
<div className={'flex items-center gap-2 font-semibold mt-4'}>
<div style={{ flex: 1 }}>Variant</div>
<div style={{ flex: 3 }}>Key</div>
<div style={{ flex: 3 }}>Description</div>
<div style={{ flex: 3 }}>
<Payload />
</div>
<div style={{ flex: 3 }} className={'flex items-center gap-2'}>
<Rollout />{' '}
<Button
variant={'text-primary'}
onClick={featureFlagsStore.currentFflag!.redistributeVariants}
>
Distribute Equally
</Button>
</div>
</div>
<div>
{featureFlagsStore.currentFflag!.variants.map((variant, ind) => {
console.log(variant, featureFlagsStore.currentFflag)
return (
<div className={'flex items-center gap-2 my-2 '} key={variant.index}>
<div style={{ flex: 1 }}>
<div className={'p-2 text-center bg-gray-lightest rounded-full w-10 h-10'}>
{ind + 1}
</div>
</div>
<div style={{ flex: 4 }}>
<Input
placeholder={'buy-btn-variant-1'}
value={variant.value}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
variant.setKey(e.target.value)
}
/>
</div>
<div style={{ flex: 4 }}>
<Input
placeholder={'Very red button'}
value={variant.description}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
variant.setDescription(e.target.value)
}
/>
</div>
<div style={{ flex: 4 }}>
<Input
placeholder={"Example: very important button, {'buttonColor': 'red'}"}
value={variant.payload}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
variant.setPayload(e.target.value)
}
/>
</div>
<div style={{ flex: 4 }} className={'flex items-center gap-2'}>
<Input
className={
featureFlagsStore.currentFflag!.isRedDistribution
? '!border-red !text-red !w-full'
: '!w-full'
}
type={'tel'}
placeholder={avg}
value={variant.rolloutPercentage}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
variant.setRollout(parseInt(e.target.value.replace(/\D/g, ''), 10))
}
/>
<div
className={cn(
'p-2 cursor-pointer rounded ml-auto',
featureFlagsStore.currentFflag!.variants.length === 1
? 'cursor-not-allowed'
: 'hover:bg-teal-light'
)}
onClick={() =>
featureFlagsStore.currentFflag!.variants.length === 1
? null
: featureFlagsStore.currentFflag!.removeVariant(variant.index)
}
>
<Icon
name={'trash'}
color={featureFlagsStore.currentFflag!.variants.length === 1 ? '' : 'main'}
/>
</div>
</div>
</div>)})}
</div>
<Button variant={'text-primary'} onClick={featureFlagsStore.currentFflag!.addVariant}>
+ Add Variant
</Button>
</div>
);
}
export default observer(Multivariant);

View file

@ -0,0 +1,236 @@
import React from 'react';
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
import { Input, SegmentSelection, Toggler, Loader, Button, NoContent } from 'UI';
import Breadcrumb from 'Shared/Breadcrumb';
import { useModal } from 'App/components/Modal';
import HowTo from 'Components/FFlags/NewFFlag/HowTo';
import { useHistory } from 'react-router';
import { withSiteId, fflags } from 'App/routes';
import Description from './Description';
import Header from './Header';
import RolloutCondition from './Conditions';
import Multivariant from './Multivariant';
import { Payload } from './Helpers'
import { toast } from 'react-toastify';
function NewFFlag({ siteId, fflagId }: { siteId: string; fflagId?: string }) {
const { featureFlagsStore } = useStore();
React.useEffect(() => {
if (fflagId) {
void featureFlagsStore.fetchFlag(parseInt(fflagId, 10));
} else {
featureFlagsStore.initNewFlag();
}
}, [fflagId]);
const current = featureFlagsStore.currentFflag;
const { showModal } = useModal();
const history = useHistory();
if (featureFlagsStore.isLoading) return <Loader loading={true} />;
if (!current) return (
<div className={'w-full mx-auto mb-4'} style={{ maxWidth: 1300 }}>
<Breadcrumb
items={[
{ label: 'Feature Flags', to: withSiteId(fflags(), siteId) },
{ label: fflagId },
]}
/>
<NoContent show title={'Feature flag not found'} />
</div>
)
const onImplementClick = () => {
showModal(<HowTo />, { right: true, width: 450 });
};
const onCancel = () => {
featureFlagsStore.setCurrentFlag(null);
history.push(withSiteId(fflags(), siteId));
};
const onError = (e: string) => toast.error(`Failed to update flag: ${e}`)
const onSave = () => {
const possibleError = featureFlagsStore.checkFlagForm();
if (possibleError) return toast.error(possibleError);
if (fflagId) {
featureFlagsStore.updateFlag().then(() => {
toast.success('Feature flag updated.');
})
.catch((e) => {
e.json().then((body: Record<string, any>) => onError(body.errors.join(',')))
})
} else {
featureFlagsStore.createFlag().then(() => {
toast.success('Feature flag created.');
history.push(withSiteId(fflags(), siteId));
}).catch(() => {
toast.error('Failed to create flag.');
})
}
};
const showDescription = Boolean(current.description?.length);
return (
<div className={'w-full mx-auto mb-4'} style={{ maxWidth: 1300 }}>
<Breadcrumb
items={[
{ label: 'Feature Flags', to: withSiteId(fflags(), siteId) },
{ label: fflagId ? current.flagKey : 'New Feature Flag' },
]}
/>
<div className={'w-full bg-white rounded p-4 widget-wrapper'}>
<div className="flex justify-between items-center">
<Header
current={current}
onCancel={onCancel}
onSave={onSave}
isNew={!fflagId}
/>
</div>
<div className={'w-full border-b border-light-gray my-2'} />
<label className={'font-semibold'}>Key</label>
<Input
type="text"
placeholder={'new-unique-key'}
value={current.flagKey}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
current.setFlagKey(e.target.value.replace(/\s/g, '-'));
}}
/>
<div className={'text-sm text-disabled-text mt-1 flex items-center gap-1'}>
Feature flag keys must be unique.
<div className={'link'} onClick={onImplementClick}>
Learn how to implement feature flags
</div>
in your code.
</div>
<div className={'mt-4'}>
<Description
current={current}
isDescrEditing={featureFlagsStore.isDescrEditing}
setEditing={featureFlagsStore.setEditing}
showDescription={showDescription}
/>
</div>
<div className={'mt-4'}>
<label className={'font-semibold'}>Feature Type</label>
<div style={{ width: 340 }}>
<SegmentSelection
outline
name={'feature-type'}
size={'small'}
onSelect={(_: any, { value }: any) => {
current.setIsSingleOption(value === 'single');
}}
value={{ value: current.isSingleOption ? 'single' : 'multi' }}
list={[
{ name: 'Single Variant (Boolean)', value: 'single' },
{ name: 'Multi-Variant (String)', value: 'multi' },
]}
/>
</div>
{current.isSingleOption ? (
<>
<div className={'text-sm text-disabled-text mt-1 flex items-center gap-1'}>
Users will be served
<code className={'p-1 text-red rounded bg-gray-lightest'}>true</code> if they match
one or more rollout conditions.
</div>
<div>
<Payload />
<Input placeholder={"Example: very important button, {'buttonColor': 'red'}"} className={'mt-2'} />
</div>
</>
) : (
<Multivariant />
)}
</div>
<div className={'mt-4'}>
<label className={'font-semibold'}>Persist flag across authentication</label>
<Toggler
checked={current.isPersist}
name={'persist-flag'}
onChange={() => {
current.setIsPersist(!current.isPersist);
}}
label={current.isPersist ? 'Yes' : 'No'}
/>
<div className={'text-sm text-disabled-text flex items-center gap-1'}>
Persist flag to not reset this feature flag status after a user is identified.
</div>
</div>
<div className={'mt-4'}>
<label className={'font-semibold'}>Enable this feature flag (Status)?</label>
<Toggler
checked={current.isActive}
name={'persist-flag'}
onChange={() => {
current.setIsEnabled(!current.isActive);
}}
label={current.isActive ? 'Enabled' : 'Disabled'}
/>
</div>
<div className={'mt-4 p-4 rounded bg-gray-lightest'}>
<label className={'font-semibold'}>Rollout Conditions</label>
{current.conditions.length === 0 ? null
: (
<div className={'text-sm text-disabled-text'}>
Indicate the users for whom you intend to make this flag available. Keep in mind that
each set of conditions will be deployed separately from one another.
</div>
)
}
<NoContent
show={current.conditions.length === 0}
title={'100% of sessions will get this feature flag'}
subtext={
<div className={"flex flex-col items-center"}>
<div className={'text-sm mb-1'}>
Indicate the users for whom you intend to make this flag available.
</div>
<Button onClick={() => current!.addCondition()} variant={'text-primary'}>
+ Create Condition Set
</Button>
</div>
}
>
<>
{current.conditions.map((condition, index) => (
<React.Fragment key={index}>
<RolloutCondition
set={index + 1}
index={index}
conditions={condition}
removeCondition={current.removeCondition}
/>
<div className={'my-2 w-full text-center'}>OR</div>
</React.Fragment>
))}
<div
onClick={() => current!.addCondition()}
className={
'flex items-center justify-center w-full bg-white rounded border mt-2 p-2'
}
>
<Button variant={'text-primary'}>+ Create Condition Set</Button>
</div>
</>
</NoContent>
</div>
</div>
</div>
);
}
export default observer(NewFFlag);

View file

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

View file

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

View file

@ -3,30 +3,57 @@ import withPageTitle from 'HOCs/withPageTitle';
import NoSessionsMessage from 'Shared/NoSessionsMessage';
import MainSearchBar from 'Shared/MainSearchBar';
import SessionSearch from 'Shared/SessionSearch';
import SessionListContainer from 'Shared/SessionListContainer/SessionListContainer';
import SessionsTabOverview from 'Shared/SessionsTabOverview/SessionsTabOverview';
import cn from 'classnames';
import OverviewMenu from 'Shared/OverviewMenu';
import FFlagsList from 'Components/FFlags';
import NewFFlag from 'Components/FFlags/NewFFlag';
import { Switch, Route } from 'react-router';
import { sessions, fflags, withSiteId, newFFlag, fflag, notes } from 'App/routes';
import { withRouter, RouteComponentProps } from 'react-router-dom';
// @ts-ignore
interface IProps extends RouteComponentProps {
match: {
params: {
siteId: string;
fflagId?: string;
};
};
}
function Overview({ match: { params } }: IProps) {
const { siteId, fflagId } = params;
function Overview() {
return (
<div className="page-margin container-90 flex relative">
<div className={cn('side-menu')}>
<OverviewMenu />
</div>
<div
className={cn("side-menu-margined w-full")}
>
<div className="mb-5 w-full mx-auto" style={{ maxWidth: '1300px'}}>
<NoSessionsMessage />
<MainSearchBar />
<SessionSearch />
<div className="my-4" />
<SessionListContainer />
</div>
<div className={cn('side-menu-margined w-full')}>
<Switch>
<Route exact strict path={[withSiteId(sessions(), siteId), withSiteId(notes(), siteId)]}>
<div className="mb-5 w-full mx-auto" style={{ maxWidth: '1300px' }}>
<NoSessionsMessage />
<MainSearchBar />
<SessionSearch />
<div className="my-4" />
<SessionsTabOverview />
</div>
</Route>
<Route exact strict path={withSiteId(fflags(), siteId)}>
<FFlagsList siteId={siteId} />
</Route>
<Route exact strict path={withSiteId(newFFlag(), siteId)}>
<NewFFlag siteId={siteId} />
</Route>
<Route exact strict path={withSiteId(fflag(), siteId)}>
<NewFFlag siteId={siteId} fflagId={fflagId} />
</Route>
</Switch>
</div>
</div>
);
}
export default withPageTitle('Sessions - OpenReplay')(Overview);
export default withPageTitle('Sessions - OpenReplay')(withRouter(Overview));

View file

@ -9,7 +9,7 @@ import copy from 'copy-to-clipboard';
import { toast } from 'react-toastify';
import { session } from 'App/routes';
import { confirm } from 'UI';
import { TeamBadge } from 'Shared/SessionListContainer/components/Notes';
import { TeamBadge } from 'Shared/SessionsTabOverview/components/Notes';
interface Props {
note: Note;

View file

@ -11,7 +11,7 @@ import { fetchList as fetchSlack } from 'Duck/integrations/slack';
import { fetchList as fetchTeams } from 'Duck/integrations/teams';
import Select from 'Shared/Select';
import { TeamBadge } from 'Shared/SessionListContainer/components/Notes';
import { TeamBadge } from 'Shared/SessionsTabOverview/components/Notes';
import { List } from 'immutable';
interface Props {

View file

@ -4,7 +4,7 @@ import { tagProps, Note } from 'App/services/NotesService';
import { formatTimeOrDate } from 'App/date';
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
import { TeamBadge } from 'Shared/SessionListContainer/components/Notes';
import { TeamBadge } from 'Shared/SessionsTabOverview/components/Notes';
interface Props {
note?: Note;

View file

@ -24,6 +24,7 @@ export enum ICONS {
NO_SEARCH_RESULTS = 'ca-no-search-results',
NO_DASHBOARDS = 'ca-no-dashboards',
NO_PROJECTS = 'ca-no-projects',
NO_FFLAGS = 'no-fflags',
}
const ICONS_SVGS = {
@ -50,6 +51,7 @@ const ICONS_SVGS = {
[ICONS.NO_SEARCH_RESULTS]: require('../../../svg/ca-no-search-results.svg').default,
[ICONS.NO_DASHBOARDS]: require('../../../svg/ca-no-dashboards.svg').default,
[ICONS.NO_PROJECTS]: require('../../../svg/ca-no-projects.svg').default,
[ICONS.NO_FFLAGS]: require('../../../svg/no-fflags.svg').default,
};
interface Props {

View file

@ -21,7 +21,6 @@ function FilterItem(props: Props) {
const { isFilter = false, filterIndex, filter, saveRequestPayloads, disableDelete = false, excludeFilterKeys = [] } = props;
const canShowValues = !(filter.operator === 'isAny' || filter.operator === 'onAny' || filter.operator === 'isUndefined');
const isSubFilter = filter.type === FilterType.SUB_FILTERS;
const replaceFilter = (filter: any) => {
props.onUpdate({
...filter,

View file

@ -16,11 +16,20 @@ interface Props {
excludeFilterKeys?: Array<string>
}
function FilterList(props: Props) {
const { observeChanges = () => {}, filter, hideEventsOrder = false, saveRequestPayloads, supportsEmpty = true, excludeFilterKeys = [] } = props;
const {
observeChanges = () => {},
filter,
hideEventsOrder = false,
saveRequestPayloads,
supportsEmpty = true,
excludeFilterKeys = []
} = props;
const filters = List(filter.filters);
const eventsOrderSupport = filter.eventsOrderSupport;
const hasEvents = filters.filter((i: any) => i.isEvent).size > 0;
const hasFilters = filters.filter((i: any) => !i.isEvent).size > 0;
let rowIndex = 0;
const cannotDeleteFilter = hasEvents && !supportsEmpty;

View file

@ -2,44 +2,81 @@ import React from 'react';
import { SideMenuitem } from 'UI';
import { connect } from 'react-redux';
import { setActiveTab } from 'Duck/search';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import { sessions, fflags, withSiteId, notes } from "App/routes";
interface Props {
setActiveTab: (tab: any) => void;
activeTab: string;
isEnterprise: boolean;
}
function OverviewMenu(props: Props) {
const { activeTab, isEnterprise } = props;
const TabToUrlMap = {
all: sessions() as '/sessions',
bookmark: sessions() as '/sessions',
notes: notes() as '/notes',
flags: fflags() as '/feature-flags',
}
function OverviewMenu(props: Props & RouteComponentProps) {
// @ts-ignore
const { activeTab, isEnterprise, history, match: { params: { siteId } }, location } = props;
React.useEffect(() => {
const currentLocation = location.pathname;
const tab = Object.keys(TabToUrlMap).find((tab: keyof typeof TabToUrlMap) => currentLocation.includes(TabToUrlMap[tab]));
if (tab && tab !== activeTab) {
props.setActiveTab({ type: tab })
}
}, [location.pathname])
return (
<div>
<div className={"flex flex-col gap-2 w-full"}>
<div className="w-full">
<SideMenuitem
active={activeTab === 'all'}
id="menu-manage-alerts"
id="menu-sessions"
title="Sessions"
iconName="play-circle-bold"
onClick={() => props.setActiveTab({ type: 'all' })}
onClick={() => {
props.setActiveTab({ type: 'all' })
!location.pathname.includes(sessions()) && history.push(withSiteId(sessions(), siteId))
}}
/>
</div>
<div className="w-full my-2" />
<div className="w-full">
<SideMenuitem
active={activeTab === 'bookmark'}
id="menu-manage-alerts"
id="menu-bookmarks"
title={`${isEnterprise ? 'Vault' : 'Bookmarks'}`}
iconName={ isEnterprise ? "safe" : "star" }
onClick={() => props.setActiveTab({ type: 'bookmark' })}
onClick={() => {
props.setActiveTab({ type: 'bookmark' })
!location.pathname.includes(sessions()) && history.push(withSiteId(sessions(), siteId))
}}
/>
</div>
<div className="w-full my-2" />
<div className="w-full">
<SideMenuitem
active={activeTab === 'notes'}
id="menu-manage-alerts"
id="menu-notes"
title="Notes"
iconName="stickies"
onClick={() => props.setActiveTab({ type: 'notes' })}
onClick={() => {
props.setActiveTab({ type: 'notes' })
!location.pathname.includes(notes()) && history.push(withSiteId(notes(), siteId))
}}
/>
</div>
<div className="w-full">
<SideMenuitem
active={activeTab === 'flags'}
id="menu-flags"
title="Feature Flags"
iconName="toggles"
onClick={() => {
props.setActiveTab({ type: 'flags' })
!location.pathname.includes(fflags()) && history.push(withSiteId(fflags(), siteId))
}}
/>
</div>
</div>
@ -49,4 +86,4 @@ function OverviewMenu(props: Props) {
export default connect((state: any) => ({
activeTab: state.getIn(['search', 'activeTab', 'type']),
isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee',
}), { setActiveTab })(OverviewMenu);
}), { setActiveTab })(withRouter(OverviewMenu));

View file

@ -1,2 +0,0 @@
export { default } from './NoteList'
export { default as TeamBadge } from './TeamBadge'

View file

@ -72,7 +72,7 @@ function SessionSearch(props: Props) {
debounceFetch()
};
return !metaLoading && (
return !metaLoading ? (
<>
{hasEvents || hasFilters ? (
<div className="border bg-white rounded mt-4">
@ -107,7 +107,7 @@ function SessionSearch(props: Props) {
<></>
)}
</>
);
) : null;
}
export default connect(

View file

@ -5,7 +5,7 @@ import NotesList from './components/Notes/NoteList';
import { connect } from 'react-redux';
import LatestSessionsMessage from './components/LatestSessionsMessage';
function SessionListContainer({
function SessionsTabOverview({
activeTab,
members,
}: {
@ -29,4 +29,4 @@ export default connect(
// @ts-ignore
members: state.getIn(['members', 'list']),
}),
)(SessionListContainer);
)(SessionsTabOverview);

View file

@ -8,4 +8,4 @@ export default function TeamBadge() {
<span className="text-disabled-text text-sm">Team</span>
</div>
)
}
}

View file

@ -0,0 +1,2 @@
export { default } from './NoteList'
export { default as TeamBadge } from './TeamBadge'

View file

@ -31,4 +31,4 @@ export default connect(
filter: state.getIn(['search', 'instance']),
}),
{ applyFilter }
)(SessionDateRange);
)(SessionDateRange);

File diff suppressed because one or more lines are too long

View file

@ -2,22 +2,37 @@ import React from 'react';
import { Icon, Tooltip } from 'UI';
import cn from 'classnames';
import stl from './sideMenuItem.module.css';
import { IconNames } from 'UI/SVG';
function SideMenuitem({
iconBg = false,
iconColor = "gray-dark",
iconSize = 18,
className = '',
iconName = null,
iconName,
title,
active = false,
disabled = false,
tooltipTitle = '',
onClick,
deleteHandler = null,
deleteHandler,
leading = null,
...props
}) {
}: {
title: string;
iconName?: IconNames;
iconBg?: boolean;
iconColor?: string;
iconSize?: number;
className?: string;
active?: boolean;
disabled?: boolean;
tooltipTitle?: string;
onClick?: () => void;
deleteHandler?: () => void;
leading?: React.ReactNode;
id?: string;
}) {
return (
<Tooltip
disabled={ !disabled }

View file

@ -4,7 +4,7 @@ import { fetchListType, saveType, editType, initType, removeType } from './funcT
import { createItemInListUpdater, mergeReducers, success, array } from './funcTools/tools';
import { createEdit, createInit } from './funcTools/crud';
import { createRequestReducer } from './funcTools/request';
import { addElementToFiltersMap, addElementToLiveFiltersMap, clearMetaFilters } from 'Types/filter/newFilter';
import { addElementToFiltersMap, addElementToFlagConditionsMap, addElementToLiveFiltersMap, clearMetaFilters } from 'Types/filter/newFilter';
import { FilterCategory } from '../types/filter/filterType';
import { refreshFilterOptions } from './search';
@ -45,6 +45,7 @@ const reducer = (state = initialState, action = {}) => {
action.data.forEach((item) => {
addElementToFiltersMap(FilterCategory.METADATA, item.key);
addElementToLiveFiltersMap(FilterCategory.METADATA, item.key);
addElementToFlagConditionsMap(FilterCategory.METADATA, item.key)
});
return state.set('list', List(action.data).map(CustomField))

View file

@ -192,7 +192,7 @@ export const reduceThenFetchResource =
(dispatch, getState) => {
dispatch(actionCreator(...args));
const activeTab = getState().getIn(['search', 'activeTab']);
if (activeTab.type === 'notes') return;
if (['notes', 'flags'].includes(activeTab.type)) return;
const filter = getFilters(getState());
filter.limit = PER_PAGE;

View file

@ -0,0 +1,165 @@
import { makeAutoObservable } from 'mobx';
import FeatureFlag from './types/FeatureFlag';
import { fflagsService } from 'App/services';
type All = '0'
type Active = '1'
type Inactive = '2'
export type Activity = All | Active | Inactive
export default class FeatureFlagsStore {
currentFflag: FeatureFlag | null = null;
isDescrEditing: boolean = false;
isTitleEditing: boolean = false;
flags: FeatureFlag[] = [];
isLoading: boolean = false;
flagsSearch: string = '';
activity: Activity = '0';
sort = { order: 'DESC', query: '' };
page: number = 1;
readonly pageSize: number = 10;
constructor() {
makeAutoObservable(this);
}
setFlagsSearch = (search: string) => {
this.flagsSearch = search;
};
setPage = (page: number) => {
this.page = page;
};
setEditing = ({ isDescrEditing = false, isTitleEditing = false }) => {
this.isDescrEditing = isDescrEditing;
this.isTitleEditing = isTitleEditing;
};
setList = (flags: FeatureFlag[]) => {
this.flags = flags;
};
removeFromList = (id: FeatureFlag['featureFlagId']) => {
this.flags = this.flags.filter((f) => f.featureFlagId !== id);
};
addFlag = (flag: FeatureFlag) => {
this.flags.push(flag);
};
getFlagById = (id: string) => {
return this.flags.find((f) => f.featureFlagId === parseInt(id, 10));
};
setCurrentFlag = (flag: FeatureFlag | null) => {
this.currentFflag = flag;
};
initNewFlag = () => {
this.currentFflag = new FeatureFlag();
};
setLoading = (isLoading: boolean) => {
this.isLoading = isLoading;
};
setActivity = (activity: Activity) => {
this.activity = activity;
}
setSort = (sort: { order: string, query: string }) => {
this.sort = sort;
}
fetchFlags = async () => {
this.setLoading(true);
try {
const filters = {
limit: this.pageSize,
page: this.page,
order: this.sort.order,
query: this.sort.query,
isActive: this.activity === '0' ? undefined : this.activity === '1',
// userId: 3,
}
const { list } = await fflagsService.fetchFlags(filters);
const flags = list.map((record) => new FeatureFlag(record));
this.setList(flags);
} catch (e) {
console.error(e);
} finally {
this.setLoading(false);
}
};
checkFlagForm = () => {
if (!this.currentFflag) return 'Feature flag not initialized'
if (this.currentFflag.flagKey === '') {
return 'Feature flag must have a key'
}
if (this.currentFflag?.variants.findIndex((v) => v.value === '') !== -1) {
return 'Variants must include key'
}
return null;
}
createFlag = async () => {
if (this.currentFflag) {
this.setLoading(true);
try {
// @ts-ignore
const result = await fflagsService.createFlag(this.currentFflag.toJS());
this.addFlag(new FeatureFlag(result));
} catch (e) {
console.error(e);
throw e.response;
} finally {
this.setLoading(false);
}
}
};
updateFlag = async (flag?: FeatureFlag, skipLoader?: boolean) => {
const usedFlag = flag || this.currentFflag;
if (usedFlag) {
if (!skipLoader) {
this.setLoading(true);
}
try {
// @ts-ignore
const result = await fflagsService.updateFlag(usedFlag.toJS());
if (!flag) this.setCurrentFlag(new FeatureFlag(result));
} catch (e) {
console.error('getting api error', e);
throw e.response;
} finally {
this.setLoading(false);
}
}
};
deleteFlag = async (id: FeatureFlag['featureFlagId']) => {
this.setLoading(true);
try {
await fflagsService.deleteFlag(id);
this.removeFromList(id);
} catch (e) {
console.error(e);
} finally {
this.setLoading(false);
}
};
fetchFlag = async (id: FeatureFlag['featureFlagId']) => {
this.setLoading(true);
try {
const result = await fflagsService.getFlag(id);
this.setCurrentFlag(new FeatureFlag(result));
} catch (e) {
console.error(e);
} finally {
this.setLoading(false);
}
};
}

View file

@ -19,6 +19,7 @@ import RecordingsStore from './recordingsStore'
import AssistMultiviewStore from './assistMultiviewStore';
import WeeklyReportStore from './weeklyReportConfigStore'
import AlertStore from './alertsStore'
import FeatureFlagsStore from "./featureFlagsStore";
export class RootStore {
dashboardStore: DashboardStore;
@ -37,6 +38,7 @@ export class RootStore {
assistMultiviewStore: AssistMultiviewStore;
weeklyReportStore: WeeklyReportStore
alertsStore: AlertStore
featureFlagsStore: FeatureFlagsStore
constructor() {
this.dashboardStore = new DashboardStore();
@ -55,6 +57,7 @@ export class RootStore {
this.assistMultiviewStore = new AssistMultiviewStore();
this.weeklyReportStore = new WeeklyReportStore();
this.alertsStore = new AlertStore();
this.featureFlagsStore = new FeatureFlagsStore();
}
initClient() {

View file

@ -0,0 +1,179 @@
import { makeAutoObservable } from "mobx";
import { SingleFFlag } from 'App/services/FFlagsService';
import Filter from "App/mstore/types/filter";
export class Conditions {
rolloutPercentage = 100;
filter = new Filter().fromJson({ name: 'Rollout conditions', filters: [] })
constructor(data?: Record<string, any>) {
makeAutoObservable(this)
if (data) {
this.rolloutPercentage = data.rolloutPercentage
this.filter = new Filter().fromJson(data)
}
}
setRollout = (value: number) => {
this.rolloutPercentage = value
}
toJS() {
return {
name: this.filter.name,
rolloutPercentage: this.rolloutPercentage,
filters: this.filter.filters.map(f => f.toJson()),
}
}
}
const initData = {
flagKey: '',
isActive: false,
isPersist: false,
isSingleOption: true,
conditions: [],
description: '',
featureFlagId: 0,
createdAt: 0,
updatedAt: 0,
createdBy: 0,
updatedBy: 0,
}
export class Variant {
index: number;
value: string = '';
description: string = '';
payload: string = '';
rolloutPercentage: number = 100;
constructor(index: number, data?: Record<string, any>) {
Object.assign(this, data)
this.index = index;
makeAutoObservable(this)
}
setIndex = (index: number) => {
this.index = index;
}
setKey = (key: string) => {
this.value = key.replace(/\s/g, '-');
}
setDescription = (description: string) => {
this.description = description;
}
setPayload = (payload: string) => {
this.payload = payload;
}
setRollout = (rollout: number) => {
if (rollout <= 100) {
this.rolloutPercentage = rollout;
}
}
}
export default class FeatureFlag {
flagKey: SingleFFlag['flagKey']
conditions: Conditions[]
createdBy?: SingleFFlag['createdBy']
createdAt?: SingleFFlag['createdAt']
updatedAt?: SingleFFlag['updatedAt']
updatedBy?: SingleFFlag['updatedBy']
isActive: SingleFFlag['isActive']
description: SingleFFlag['description']
isPersist: SingleFFlag['isPersist']
isSingleOption: boolean
featureFlagId: SingleFFlag['featureFlagId']
payload: SingleFFlag['payload']
flagType: string;
variants: Variant[] = [];
constructor(data?: SingleFFlag) {
Object.assign(
this,
initData,
{
...data,
isSingleOption: data ? data.flagType === 'single' : true,
conditions: data?.conditions?.map(c => new Conditions(c)) || [new Conditions()],
variants: data?.flagType === 'multi' ? data?.variants?.map((v, i) => new Variant(i, v)) : [new Variant(1)],
});
makeAutoObservable(this);
}
setPayload = (payload: string) => {
this.payload = payload;
}
addVariant = () => {
this.variants.push(new Variant(this.variants.length + 1))
this.redistributeVariants()
}
removeVariant = (index: number) => {
this.variants = this.variants.filter(v => v.index !== index)
}
get isRedDistribution() {
const totalRollout = this.variants.reduce((acc, v) => acc + v.rolloutPercentage, 0)
return Math.floor(
totalRollout/this.variants.length) !== Math.floor(100 / this.variants.length)
}
redistributeVariants = () => {
const newRolloutDistribution = Math.floor(100 / this.variants.length)
this.variants.forEach(v => v.setRollout(newRolloutDistribution))
}
toJS() {
return {
flagKey: this.flagKey,
conditions: this.conditions.map(c => c.toJS()),
createdBy: this.createdBy,
updatedBy: this.createdBy,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
isActive: this.isActive,
description: this.description,
isPersist: this.isPersist,
flagType: this.isSingleOption ? 'single' as const : 'multi' as const,
featureFlagId: this.featureFlagId,
variants: this.isSingleOption ? undefined : this.variants.map(v => ({ value: v.value, description: v.description, payload: v.payload, rolloutPercentage: v.rolloutPercentage })),
}
}
addCondition = () => {
this.conditions.push(new Conditions())
}
removeCondition = (index: number) => {
this.conditions.splice(index, 1)
}
setFlagKey = (flagKey: string) => {
this.flagKey = flagKey;
}
setDescription = (description: string) => {
this.description = description;
}
setIsPersist = (isPersist: boolean) => {
this.isPersist = isPersist;
}
setIsSingleOption = (isSingleOption: boolean) => {
this.isSingleOption = isSingleOption;
}
setIsEnabled = (isEnabled: boolean) => {
this.isActive = isEnabled;
}
}

View file

@ -34,7 +34,7 @@ export default class Filter {
addFilter(filter: any) {
filter.value = [""]
if (Array.isArray(filter.filters)) {
filter.filters = filter.filters.map(i => {
filter.filters = filter.filters.map((i: Record<string, any>) => {
i.value = [""]
return new FilterItem(i)
})
@ -47,6 +47,7 @@ export default class Filter {
}
updateKey(key: string, value: any) {
// @ts-ignore fix later
this[key] = value
}
@ -56,7 +57,7 @@ export default class Filter {
fromJson(json: any) {
this.name = json.name
this.filters = json.filters.map(i => new FilterItem().fromJson(i))
this.filters = json.filters.map((i: Record<string, any>) => new FilterItem().fromJson(i))
this.eventsOrder = json.eventsOrder
return this
}

View file

@ -37,7 +37,7 @@ export default class FilterItem {
});
if (Array.isArray(data.filters)) {
data.filters = data.filters.map(function (i) {
data.filters = data.filters.map(function (i: Record<string, any>) {
return new FilterItem(i);
});
}
@ -46,11 +46,13 @@ export default class FilterItem {
}
updateKey(key: string, value: any) {
// @ts-ignore
this[key] = value;
}
merge(data: any) {
Object.keys(data).forEach((key) => {
// @ts-ignore
this[key] = data[key];
});
}
@ -63,8 +65,10 @@ export default class FilterItem {
const mainFilter = filtersMap[mainFilterKey];
const subFilterMap = {};
mainFilter.filters.forEach((option: any) => {
// @ts-ignore
subFilterMap[option.key] = option;
});
// @ts-ignore
_filter = subFilterMap[json.type];
}
this.type = _filter.type;
@ -77,7 +81,7 @@ export default class FilterItem {
this.options = _filter.options;
this.isEvent = _filter.isEvent;
(this.value = json.value.length === 0 || !json.value ? [''] : json.value);
(this.value = !json.value || json.value.length === 0 ? [''] : json.value);
(this.operator = json.operator);
this.source = json.source;
this.sourceOperator = json.sourceOperator;

View file

@ -83,6 +83,10 @@ const routerOBTabString = `:activeTab(${ Object.values(OB_TABS).join('|') })`;
export const onboarding = (tab = routerOBTabString) => `/onboarding/${ tab }`;
export const sessions = params => queried('/sessions', params);
export const fflags = params => queried('/feature-flags', params);
export const newFFlag = () => '/feature-flags/create';
export const fflag = (id = ':fflagId', hash) => hashed(`/feature-flags/${ id }`, hash);
export const notes = params => queried('/notes', params);
export const assist = params => queried('/assist', params);
export const recordings = params => queried("/recordings", params);
export const multiviewIndex = params => queried('/multiview', params);
@ -97,13 +101,7 @@ export const funnels = params => queried('/funnels', params)
export const funnelsCreate = () => `/funnels/create`;
export const funnel = (id = ':funnelId', hash) => hashed(`/funnels/${ id }`, hash);
export const funnelIssue = (id = ':funnelId', issueId = ':issueId', hash) => hashed(`/funnels/${ id }/${ issueId}`, hash);
export const tests = () => '/tests';
export const testBuilderNew = () => '/test-sessions';
export const testBuilder = (testId = ':testId') => `/test-builder/${ testId }`;
export const dashboard = () => '/dashboard';
export const dashboardMetrics = () => '/dashboard/metrics';
export const dashboardSelected = (id = ':dashboardId', hash) => hashed(`/dashboard/${ id }`, hash);
@ -123,6 +121,10 @@ const REQUIRED_SITE_ID_ROUTES = [
liveSession(''),
session(''),
sessions(),
newFFlag(),
fflag(),
notes(),
fflags(),
assist(),
recordings(),
multiview(),
@ -174,6 +176,8 @@ export function isRoute(route, path){
const SITE_CHANGE_AVALIABLE_ROUTES = [
sessions(),
notes(),
fflags(),
funnels(),
assist(),
recordings(),

View file

@ -0,0 +1,84 @@
import BaseService from 'App/services/BaseService';
type FFlagType = 'single' | 'multi';
type FFlagCondition = {
name: string;
rolloutPercentage: number;
filters: [];
};
export interface SimpleFlag {
name: string;
flagKey: string;
description: string;
flagType: FFlagType;
isPersist: boolean;
conditions: FFlagCondition[];
payload?: string;
}
type Variant = {
variantId?: number;
value: string;
description?: string;
payload: string;
rolloutPercentage: number;
}
export interface FFlag extends SimpleFlag {
featureFlagId: number;
isActive: boolean;
createdAt: number;
updatedAt: number;
createdBy: number;
updatedBy: number;
conditions: never;
variants: Variant[]
}
export interface SingleFFlag extends SimpleFlag {
createdAt: number;
updatedAt: number;
createdBy: number;
updatedBy: number;
featureFlagId: number;
isActive: boolean;
variants: Variant[]
}
export default class FFlagsService extends BaseService {
fetchFlags(filters: Record<string, any>): Promise<{ list: FFlag[]; total: number }> {
return this.client
.post('/feature-flags/search', filters)
.then((r) => r.json())
.then((j) => j.data || []);
}
createFlag(flag: SimpleFlag): Promise<FFlag> {
return this.client
.post('/feature-flags', flag)
.then((r) => r.json())
.then((j) => j.data || {});
}
updateFlag(flag: FFlag): Promise<FFlag> {
return this.client
.put(`/feature-flags/${flag.featureFlagId}`, flag)
.then((r) => r.json())
.then((j) => j.data || {});
}
deleteFlag(id: number): Promise<void> {
return this.client
.delete(`/feature-flags/${id}`)
.then((r) => r.json())
.then((j) => j.data || {});
}
getFlag(id: number): Promise<SingleFFlag> {
return this.client
.get(`/feature-flags/${id}`)
.then((r) => r.json())
.then((j) => j.data || {});
}
}

View file

@ -11,6 +11,7 @@ import ConfigService from './ConfigService'
import AlertsService from './AlertsService'
import WebhookService from './WebhookService'
import HealthService from "./HealthService";
import FFlagsService from "App/services/FFlagsService";
export const dashboardService = new DashboardService();
export const metricService = new MetricService();
@ -27,6 +28,8 @@ export const webhookService = new WebhookService();
export const healthService = new HealthService();
export const fflagsService = new FFlagsService();
export const services = [
dashboardService,
metricService,
@ -41,4 +44,5 @@ export const services = [
alertsService,
webhookService,
healthService,
fflagsService
]

View file

@ -0,0 +1,9 @@
<svg viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg">
<rect width="44" height="44" rx="22" fill="#3EAAAF" fill-opacity="0.08"/>
<g>
<path d="M22 32.5C19.2152 32.5 16.5445 31.3938 14.5754 29.4246C12.6062 27.4555 11.5 24.7848 11.5 22C11.5 19.2152 12.6062 16.5445 14.5754 14.5754C16.5445 12.6062 19.2152 11.5 22 11.5C24.7848 11.5 27.4555 12.6062 29.4246 14.5754C31.3938 16.5445 32.5 19.2152 32.5 22C32.5 24.7848 31.3938 27.4555 29.4246 29.4246C27.4555 31.3938 24.7848 32.5 22 32.5ZM22 34C25.1826 34 28.2348 32.7357 30.4853 30.4853C32.7357 28.2348 34 25.1826 34 22C34 18.8174 32.7357 15.7652 30.4853 13.5147C28.2348 11.2643 25.1826 10 22 10C18.8174 10 15.7652 11.2643 13.5147 13.5147C11.2643 15.7652 10 18.8174 10 22C10 25.1826 11.2643 28.2348 13.5147 30.4853C15.7652 32.7357 18.8174 34 22 34Z" fill="#24959A"/>
<path d="M22 29.5C20.0109 29.5 18.1032 28.7098 16.6967 27.3033C15.2902 25.8968 14.5 23.9891 14.5 22C14.5 20.0109 15.2902 18.1032 16.6967 16.6967C18.1032 15.2902 20.0109 14.5 22 14.5C23.9891 14.5 25.8968 15.2902 27.3033 16.6967C28.7098 18.1032 29.5 20.0109 29.5 22C29.5 23.9891 28.7098 25.8968 27.3033 27.3033C25.8968 28.7098 23.9891 29.5 22 29.5ZM22 31C23.1819 31 24.3522 30.7672 25.4442 30.3149C26.5361 29.8626 27.5282 29.1997 28.364 28.364C29.1997 27.5282 29.8626 26.5361 30.3149 25.4442C30.7672 24.3522 31 23.1819 31 22C31 20.8181 30.7672 19.6478 30.3149 18.5558C29.8626 17.4639 29.1997 16.4718 28.364 15.636C27.5282 14.8003 26.5361 14.1374 25.4442 13.6851C24.3522 13.2328 23.1819 13 22 13C19.6131 13 17.3239 13.9482 15.636 15.636C13.9482 17.3239 13 19.6131 13 22C13 24.3869 13.9482 26.6761 15.636 28.364C17.3239 30.0518 19.6131 31 22 31Z" fill="#24959A"/>
<path d="M22 26.5C20.8065 26.5 19.6619 26.0259 18.818 25.182C17.9741 24.3381 17.5 23.1935 17.5 22C17.5 20.8065 17.9741 19.6619 18.818 18.818C19.6619 17.9741 20.8065 17.5 22 17.5C23.1935 17.5 24.3381 17.9741 25.182 18.818C26.0259 19.6619 26.5 20.8065 26.5 22C26.5 23.1935 26.0259 24.3381 25.182 25.182C24.3381 26.0259 23.1935 26.5 22 26.5ZM22 28C23.5913 28 25.1174 27.3679 26.2426 26.2426C27.3679 25.1174 28 23.5913 28 22C28 20.4087 27.3679 18.8826 26.2426 17.7574C25.1174 16.6321 23.5913 16 22 16C20.4087 16 18.8826 16.6321 17.7574 17.7574C16.6321 18.8826 16 20.4087 16 22C16 23.5913 16.6321 25.1174 17.7574 26.2426C18.8826 27.3679 20.4087 28 22 28Z" fill="#24959A"/>
<path d="M24.25 22C24.25 22.5967 24.0129 23.169 23.591 23.591C23.169 24.0129 22.5967 24.25 22 24.25C21.4033 24.25 20.831 24.0129 20.409 23.591C19.9871 23.169 19.75 22.5967 19.75 22C19.75 21.4033 19.9871 20.831 20.409 20.409C20.831 19.9871 21.4033 19.75 22 19.75C22.5967 19.75 23.169 19.9871 23.591 20.409C24.0129 20.831 24.25 21.4033 24.25 22Z" fill="#24959A"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

@ -0,0 +1,6 @@
<svg viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg">
<rect width="44" height="44" rx="22" fill="#3EAAAF" fill-opacity="0.08"/>
<g>
<path d="M22 32.5C24.7848 32.5 27.4555 31.3938 29.4246 29.4246C31.3938 27.4555 32.5 24.7848 32.5 22C32.5 19.2152 31.3938 16.5445 29.4246 14.5754C27.4555 12.6062 24.7848 11.5 22 11.5V32.5ZM22 34C18.8174 34 15.7652 32.7357 13.5147 30.4853C11.2643 28.2348 10 25.1826 10 22C10 18.8174 11.2643 15.7652 13.5147 13.5147C15.7652 11.2643 18.8174 10 22 10C25.1826 10 28.2348 11.2643 30.4853 13.5147C32.7357 15.7652 34 18.8174 34 22C34 25.1826 32.7357 28.2348 30.4853 30.4853C28.2348 32.7357 25.1826 34 22 34Z" fill="#24959A"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 668 B

View file

@ -0,0 +1,10 @@
<svg viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2766_21989)">
<path d="M5.0625 10.375C4.01821 10.375 3.01669 10.7898 2.27827 11.5283C1.53984 12.2667 1.125 13.2682 1.125 14.3125C1.125 15.3568 1.53984 16.3583 2.27827 17.0967C3.01669 17.8352 4.01821 18.25 5.0625 18.25H12.9375C13.9818 18.25 14.9833 17.8352 15.7217 17.0967C16.4602 16.3583 16.875 15.3568 16.875 14.3125C16.875 13.2682 16.4602 12.2667 15.7217 11.5283C14.9833 10.7898 13.9818 10.375 12.9375 10.375H5.0625ZM12.9375 17.125C12.1916 17.125 11.4762 16.8287 10.9488 16.3012C10.4213 15.7738 10.125 15.0584 10.125 14.3125C10.125 13.5666 10.4213 12.8512 10.9488 12.3238C11.4762 11.7963 12.1916 11.5 12.9375 11.5C13.6834 11.5 14.3988 11.7963 14.9262 12.3238C15.4537 12.8512 15.75 13.5666 15.75 14.3125C15.75 15.0584 15.4537 15.7738 14.9262 16.3012C14.3988 16.8287 13.6834 17.125 12.9375 17.125ZM5.0625 1.375C4.31658 1.375 3.60121 1.67132 3.07376 2.19876C2.54632 2.72621 2.25 3.44158 2.25 4.1875C2.25 4.93342 2.54632 5.64879 3.07376 6.17624C3.60121 6.70368 4.31658 7 5.0625 7C5.80842 7 6.52379 6.70368 7.05124 6.17624C7.57868 5.64879 7.875 4.93342 7.875 4.1875C7.875 3.44158 7.57868 2.72621 7.05124 2.19876C6.52379 1.67132 5.80842 1.375 5.0625 1.375ZM7.81875 1.375C8.19333 1.74128 8.49082 2.17882 8.69369 2.66184C8.89656 3.14486 9.00071 3.6636 9 4.1875C9.00071 4.7114 8.89656 5.23014 8.69369 5.71316C8.49082 6.19618 8.19333 6.63372 7.81875 7H12.9375C13.6834 7 14.3988 6.70368 14.9262 6.17624C15.4537 5.64879 15.75 4.93342 15.75 4.1875C15.75 3.44158 15.4537 2.72621 14.9262 2.19876C14.3988 1.67132 13.6834 1.375 12.9375 1.375H7.81875ZM5.0625 0.25H12.9375C13.9818 0.25 14.9833 0.664843 15.7217 1.40327C16.4602 2.14169 16.875 3.14321 16.875 4.1875C16.875 5.23179 16.4602 6.23331 15.7217 6.97173C14.9833 7.71016 13.9818 8.125 12.9375 8.125H5.0625C4.01821 8.125 3.01669 7.71016 2.27827 6.97173C1.53984 6.23331 1.125 5.23179 1.125 4.1875C1.125 3.14321 1.53984 2.14169 2.27827 1.40327C3.01669 0.664843 4.01821 0.25 5.0625 0.25V0.25Z" />
</g>
<defs>
<clipPath id="clip0_2766_21989">
<rect width="18" height="18" fill="white" transform="translate(0 0.25)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,89 @@
<svg width="285" height="212" viewBox="0 0 285 212" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2781_22311)">
<path d="M44.4979 2.28539L291.077 163.661H44.4979V2.28539Z" fill="url(#paint0_linear_2781_22311)"/>
<path d="M33.5185 -8.78019e-06L0.361023 205.148L38.2259 168.829L33.5185 -8.78019e-06Z" fill="url(#paint1_linear_2781_22311)"/>
<line opacity="0.8" x1="44.759" y1="18.2562" x2="44.759" y2="163.437" stroke="url(#paint2_linear_2781_22311)" stroke-width="0.522084"/>
<path opacity="0.8" d="M2.45581 205.524L43.2267 163.264" stroke="url(#paint3_linear_2781_22311)" stroke-width="0.522084"/>
<line x1="284.639" y1="163.698" x2="34.5221" y2="163.698" stroke="url(#paint4_linear_2781_22311)" stroke-width="0.522084"/>
<path d="M116.226 189.646C107.514 191.086 69.0109 191.797 51.313 191.499C45.5422 191.402 39.3372 191.564 33.883 193.452C28.3296 195.374 24.0652 197.857 35.6781 198.687C63.6539 200.686 73.8654 186.689 51.3453 189.133C28.8251 191.577 35.4953 137.418 34.776 133.257" stroke="black" stroke-width="1.04417"/>
<path d="M174.352 172.078C174.352 172.078 154.978 179.784 152.106 189.171C149.235 198.558 187.78 193.661 187.78 193.661C187.78 193.661 193.194 192.121 190.96 174.161L178.743 172.846C167.93 173.107 167.821 160.253 167.821 160.253C167.821 160.253 181.745 165.563 184.486 164.602L190.239 173.091L191.503 174.621" fill="white"/>
<path d="M174.545 172.564C167.638 175.942 157.624 181.173 153.803 187.955C153.38 188.926 152.602 190.189 153.474 191.124C158.413 195.734 180.951 193.348 187.535 192.805C187.837 192.659 188.281 192.252 188.532 191.897C191.549 187.422 190.824 179.461 190.443 174.224L190.907 174.678L178.691 173.363C171.303 173.655 167.549 167.16 167.299 160.253L167.273 159.486C170.207 160.598 173.177 161.642 176.174 162.561C178.226 163.177 180.293 163.782 182.403 164.096C183.274 164.226 184.011 164.247 184.705 163.991L184.919 164.304L190.672 172.794C190.526 172.6 191.936 174.328 191.904 174.287L191.1 174.95L189.837 173.42C189.832 173.378 184.068 164.936 184.052 164.889L184.658 165.088C182.982 165.458 181.64 165.009 180.09 164.712C175.84 163.688 171.731 162.247 167.638 160.733L168.348 160.243C168.578 166.388 171.789 172.632 178.738 172.318C178.795 172.303 190.954 173.639 191.022 173.634L191.44 173.681L191.482 174.088C192.171 179.273 192.735 184.598 191.403 189.756C190.907 191.343 190.223 192.988 188.871 194.048C188.579 194.262 188.292 194.465 187.9 194.538C180.272 195.468 157.008 198.193 151.793 192.581C150.247 190.858 150.895 188.769 151.855 186.926C155.051 181.34 160.856 177.905 166.364 174.981C168.912 173.707 171.481 172.548 174.169 171.588L174.556 172.559L174.545 172.564Z" fill="#231F20"/>
<path d="M193.398 175.754L184.486 188.179C184.486 188.179 178.048 197.593 208.893 194.935C239.738 192.283 208.878 171.802 208.878 171.802" fill="white"/>
<path d="M193.821 176.057C191.544 179.696 189.158 183.361 186.793 186.942C186.574 187.281 185.937 188.226 185.718 188.555C183.729 191.573 188.271 192.8 190.709 193.212C195.669 193.98 200.754 193.734 205.792 193.385C209.118 193.134 212.485 192.873 215.644 191.954C221.094 190.414 222.332 187.971 219.084 183.11C216.239 178.98 212.407 175.561 208.596 172.235L209.175 171.363C210.736 172.193 212.214 173.122 213.66 174.125C217.816 177.132 223.036 181.236 224.201 186.582C225.475 195.791 212.255 196.507 206.027 196.929C200.754 197.185 195.366 197.311 190.114 196.219C189.237 196.032 188.271 195.765 187.42 195.447C184.956 194.648 182.131 192.46 182.685 189.443C182.826 188.592 183.17 187.809 183.713 187.151C184.418 186.227 185.77 184.493 186.501 183.564C188.626 180.834 190.787 178.129 192.975 175.446L193.826 176.057H193.821Z" fill="#231F20"/>
<path d="M184.486 164.602C184.486 164.602 189.936 176.386 194.964 175.999C194.964 175.999 209.389 177.717 208.877 171.796L206.445 165.166" fill="white"/>
<path d="M184.486 164.602C184.486 164.602 189.936 176.386 194.964 175.999C194.964 175.999 209.389 177.717 208.877 171.796L206.445 165.166" stroke="#231F20" stroke-width="1.04417" stroke-miterlimit="10"/>
<path d="M119.914 106.938C119.914 106.938 111.921 97.5926 103.777 105.836C95.6322 114.08 106.956 128.26 116.26 125.994" fill="white"/>
<path d="M119.914 106.938C119.914 106.938 111.921 97.5926 103.777 105.836C95.6322 114.08 106.956 128.26 116.26 125.994" stroke="#231F20" stroke-width="1.04417" stroke-miterlimit="10"/>
<path d="M234.475 83.0943C230.8 74.7514 223.376 65.7089 208.371 64.08C208.371 64.08 177.485 57.7837 164.798 83.8618C164.798 83.8618 156.178 111.736 155.458 145.003C155.458 145.003 152.587 163.438 194.003 165.458C194.003 165.458 220.217 168.821 230.633 152.14C232.585 149.013 233.682 145.478 234.016 141.871L237.603 102.944C238.224 96.1934 237.227 89.3697 234.47 83.1047L234.475 83.0943Z" fill="white"/>
<path d="M165.79 115.928C164.119 113.005 166.486 97.6552 167.878 90.3461C181.035 79.2779 202.423 85.7343 211.472 90.3461L206.251 118.8C191.006 111.282 172.925 113.753 165.79 115.928Z" fill="#5BC3C8"/>
<path d="M232.168 84.1124C229.166 76.8032 223.449 70.6217 215.931 67.933C212.464 66.6173 208.711 66.0482 205.009 65.5523C188.976 63.7197 173.209 69.2173 165.707 84.3003C162.183 95.7809 159.379 109.392 157.75 121.89C156.8 129.565 156.184 137.281 155.996 145.013C155.824 146.883 156.325 148.856 157.16 150.542C163.242 162.138 182.278 164.064 194.056 164.602C202.968 165.317 212.59 164.195 220.458 159.82C226.174 156.63 230.649 151.148 231.886 144.664C232.288 142.842 232.413 139.876 232.565 137.965C232.967 132.823 234.841 107.862 235.201 103.283C235.791 96.7207 234.679 90.1685 232.168 84.1176V84.1124ZM236.788 82.0762C239.9 88.7746 240.782 96.4388 239.921 103.711C239.435 108.133 236.726 133.308 236.172 138.289C235.917 140.377 235.708 143.343 235.17 145.389C233.515 152.636 228.242 158.755 221.695 162.08C213.211 166.45 203.036 167.249 193.972 166.309C182.507 165.406 168.792 163.835 159.87 155.92C156.878 153.137 154.576 149.127 154.946 144.914C155.114 140.116 155.322 134.52 155.818 129.471C156.727 119.149 158.236 108.885 160.277 98.7255C161.3 93.6143 162.464 88.5553 163.864 83.4911C164.85 81.4967 165.889 79.5128 167.195 77.6855C176.206 64.1687 193.972 59.8772 208.596 62.1169C221.439 63.2655 231.453 70.3084 236.794 82.071L236.788 82.0762Z" fill="#231F20"/>
<path d="M161.958 93.6091C169.533 85.6629 180.983 81.9405 191.821 82.6923C202.759 83.3553 212.872 88.3517 221.528 94.7994C224.624 97.0861 237.456 105.962 237.383 109.308L236.344 109.219C236.125 108.567 235.52 108.024 235.018 107.523C233.113 105.763 230.972 104.265 228.847 102.756C224.556 99.7697 220.061 96.9452 215.654 94.1625C214.318 93.4002 212.49 92.33 211.123 91.6878C198.749 85.2348 184.71 84.0706 171.742 89.4846C168.583 90.7637 165.492 92.3926 162.684 94.3609L161.963 93.6038L161.958 93.6091Z" fill="#231F20"/>
<path d="M157.995 119.88C157.995 119.88 174.034 108.483 200.248 116.382C204.252 117.588 208.074 119.332 211.66 121.494L234.935 135.522" fill="white"/>
<path d="M157.693 119.457C163.764 114.816 171.429 112.801 178.936 112.247C180.241 112.216 181.651 112.127 182.946 112.159C183.912 112.211 186.021 112.279 186.945 112.362C187.77 112.462 190.057 112.728 190.918 112.832C192.662 113.151 194.416 113.448 196.144 113.85C197.648 114.289 199.793 114.8 201.271 115.281C205.489 116.633 209.541 118.669 213.247 121.05C220.567 125.628 228.002 130.348 235.212 135.078L234.674 135.971C231.625 134.306 228.602 132.588 225.574 130.881C222.541 129.179 219.528 127.456 216.521 125.717C213.853 124.219 210.214 121.948 207.515 120.669C195.262 114.952 180.941 113.532 167.837 116.831C164.568 117.656 161.295 118.784 158.298 120.314L157.693 119.463V119.457Z" fill="#231F20"/>
<path d="M211.316 90.0641L206.45 118.742" stroke="#231F20" stroke-width="1.04417" stroke-miterlimit="10"/>
<path d="M167.821 90.0641C167.821 90.0641 164.03 108.092 165.252 116.403L167.821 90.0641Z" fill="white"/>
<path d="M167.821 90.0641C167.821 90.0641 164.03 108.092 165.252 116.403" stroke="#231F20" stroke-width="1.04417" stroke-miterlimit="10"/>
<path d="M178.748 68.7422C178.748 68.7422 204.116 61.0832 232.465 78.8759" stroke="#231F20" stroke-width="1.04417" stroke-miterlimit="10"/>
<path d="M210.527 64.3202C210.527 64.3202 246.609 39.777 262.172 62.6965C277.735 85.616 231.045 60.7752 224.582 62.6965C224.582 62.6965 214.396 63.8451 213.383 65.1973" fill="white"/>
<path d="M210.235 63.8869C223.183 54.5729 245.037 44.883 259.243 56.5307C260.616 57.6375 262.015 59.115 263.054 60.5403C264.965 63.1716 266.693 65.5993 267.315 68.9981C267.884 75.3048 260.95 74.9133 256.768 73.9735C249.987 72.4908 243.555 69.9065 237.138 67.5153C233.99 66.3563 230.868 65.119 227.662 64.2471C226.743 64.033 225.543 63.7146 224.718 63.9025C221.888 64.1113 219.006 64.3567 216.208 64.8266C215.409 64.978 214.48 65.1294 213.801 65.5105L212.966 64.884C213.42 64.3202 213.874 64.1897 214.433 63.9234C215.816 63.3491 217.262 62.9836 218.698 62.6339C220.604 62.1744 222.504 61.8194 224.441 61.4905L224.232 61.5323C225.104 61.2817 225.757 61.3182 226.404 61.3443C230.487 61.762 234.381 63.1142 238.255 64.2889C243.252 65.6672 259.342 71.9113 263.237 70.0422C263.404 69.1495 262.877 68.0218 262.37 66.9515C261.452 65.1294 260.219 63.2917 258.888 61.715C253.239 54.8496 244.024 53.6905 235.713 55.2307C226.984 56.7604 218.552 60.3993 210.82 64.7431L210.235 63.8816V63.8869Z" fill="#231F20"/>
<path d="M229.009 177.853C229.009 177.853 229.082 186.728 240.636 185.282C240.636 185.282 256.194 179.899 246.891 167.025" fill="white"/>
<path d="M229.009 177.853C229.009 177.853 229.082 186.728 240.636 185.282C240.636 185.282 256.194 179.899 246.891 167.025" stroke="#231F20" stroke-width="1.04417" stroke-miterlimit="10"/>
<path d="M235.906 175.629C235.906 175.629 221.303 184.556 217.952 166.941C217.952 166.941 214.119 136.775 221.543 130.019C228.968 123.264 247.642 159.219 247.642 159.219C247.642 159.219 252.43 167.907 235.911 175.629H235.906Z" fill="white"/>
<path d="M236.637 176.981C227.266 181.972 219.126 178.829 217.283 167.98C217.132 167.427 217.074 166.539 217.022 165.939C216.913 164.686 216.73 161.292 216.657 159.992C216.406 153.727 215.049 128.396 224.07 128.578C232.215 129.539 244.667 151.555 248.697 158.77C251.073 163.939 247.684 169.588 243.596 172.747C241.555 174.438 239.007 175.832 236.637 176.976V176.981ZM235.186 174.276C240.036 171.828 247.235 167.933 247.079 161.757C247.053 161.094 246.906 160.394 246.651 159.794C246.52 159.543 246.134 158.77 245.993 158.509C241.79 150.73 233.071 134.483 225.626 130.327C222.859 128.86 221.382 130.583 220.17 132.88C216.761 140.419 217.335 151.691 217.957 159.903C218.108 161.793 218.385 163.876 218.635 165.761C218.703 166.304 218.834 167.077 219.006 167.62C221.246 176.631 227.292 178.688 235.191 174.271L235.186 174.276Z" fill="#231F20"/>
<path d="M157.906 114.581L131.693 104.301C131.693 104.301 122.441 99.7698 118.609 109.444C114.772 119.113 113.42 133.81 126.054 132.745C138.689 131.685 155.562 131.685 155.562 131.685" fill="white"/>
<path d="M157.718 115.067C148.916 111.809 140.14 108.478 131.374 105.121C128.189 103.675 123.898 103.701 121.486 106.515C119.987 108.186 119.303 110.29 118.593 112.462C117.68 115.396 117.069 118.445 116.97 121.468C116.844 128.604 119.585 132.572 127.135 131.627C133.452 131.173 139.769 131.053 146.092 131.016C149.25 131.037 152.404 131.048 155.567 131.163V132.207C152.425 132.337 149.271 132.437 146.128 132.593C139.848 132.839 133.556 133.173 127.297 133.679C118.588 134.859 114.631 129.685 114.928 121.405C115.189 116.482 116.364 111.694 118.541 107.298C121.345 102.056 127.218 101.263 132.016 103.477C140.73 106.969 149.428 110.493 158.1 114.091L157.718 115.062V115.067Z" fill="#231F20"/>
<path d="M190.965 174.161L184.486 164.602" stroke="#231F20" stroke-width="1.04417" stroke-miterlimit="10"/>
<path d="M54.9985 63.2342H38.4693C36.2895 63.2342 34.5223 65.0013 34.5223 67.1812V164.581C34.5223 166.761 36.2895 168.528 38.4693 168.528H54.9985C57.1783 168.528 58.9454 166.761 58.9454 164.581V67.1812C58.9454 65.0013 57.1783 63.2342 54.9985 63.2342Z" fill="white"/>
<path d="M54.9984 63.772C50.7434 63.8033 43.0792 63.8607 38.8973 63.8921C36.9708 63.7093 35.201 65.2338 35.2323 67.176V67.4631L35.2636 72.0627L35.3315 81.2618C35.5769 111.637 35.8379 131.351 35.8588 161.579V163.882V164.456C35.8484 164.894 35.9215 165.213 36.0729 165.604C36.4644 166.544 37.4407 167.197 38.464 167.192L47.6631 167.213C49.1772 167.213 53.098 167.223 54.5651 167.228C56.361 167.369 57.7393 166.132 57.6506 164.305C57.6506 161.49 57.6819 153.45 57.6871 150.506C57.8333 123.953 58.1257 105.836 58.3136 79.3875C58.3136 78.1345 58.3972 69.0816 58.3972 67.886C58.6739 65.782 57.1912 63.7354 54.988 63.7772L54.9984 63.772ZM54.9984 62.6965C57.2173 62.6808 59.2168 64.4716 59.4466 66.6748C59.5197 67.4161 59.4831 69.4053 59.504 70.1832C59.7285 100.354 60.0156 120.188 60.1984 150.501C60.1879 153.79 60.2715 161.725 60.2192 164.973C60.0731 167.63 57.6715 169.865 55.0141 169.828H54.8679H54.5807H52.2783C49.3599 169.839 41.4973 169.849 38.4797 169.859C35.7335 169.906 33.2432 167.557 33.1962 164.79C33.1701 164.258 33.1962 162.159 33.1962 161.574C33.2066 127.769 33.5303 105.935 33.7809 72.0574L33.8122 67.4579V67.1707L33.8331 66.8314C33.8488 66.6069 33.8905 66.3824 33.9375 66.1631C34.3291 64.3933 35.8379 62.9471 37.6287 62.6495C38.5058 62.4824 40.3122 62.6286 41.1945 62.5921C44.113 62.6077 51.9755 62.6756 54.9932 62.6913L54.9984 62.6965Z" fill="#231F20"/>
<path d="M65.9569 89.897H61.9891C60.308 89.897 58.9453 90.6932 58.9453 91.6754V132.141C58.9453 133.123 60.308 133.919 61.9891 133.919H65.9569C67.6379 133.919 69.0007 133.123 69.0007 132.141V91.6754C69.0007 90.6932 67.6379 89.897 65.9569 89.897Z" fill="white"/>
<path d="M65.957 134.452L63.3674 134.441C62.5164 134.431 61.4148 134.551 60.6004 134.18C59.1699 133.611 58.2719 132.092 58.3711 130.567L58.3606 129.273C58.2406 110.305 58.0265 117.474 57.9952 98.5062L57.9847 93.3324C57.9221 92.2517 58.2353 91.1083 58.9767 90.2991C59.7181 89.4428 60.8458 88.926 61.9839 88.9312H64.5734H65.8682C68.1602 88.8372 70.1597 90.9569 69.9561 93.2488C69.9561 100.553 69.8674 102.417 69.8204 109.924C69.7734 116.737 69.6481 118.711 69.6011 125.31L69.5646 130.484C69.7786 132.489 68.0453 134.54 65.957 134.452ZM65.957 133.392C67.4867 133.376 68.6091 132.008 68.4316 130.484L68.3951 125.31C68.3481 118.789 68.2228 116.685 68.1758 109.924C68.1288 102.359 68.0401 100.631 68.0401 93.2488C68.1601 91.9749 67.1995 90.8107 65.863 90.8681H64.5682L61.9787 90.8629C60.8301 90.842 59.8381 91.8653 59.8903 93.0139C59.8695 114.545 59.645 109.026 59.504 130.573C59.3578 132.066 60.5221 133.439 62.0674 133.392L63.3622 133.402L65.9517 133.392H65.957Z" fill="#231F20"/>
<path d="M97.2559 193.332V195.405H136.84V193.332C136.84 193.332 118.964 162.368 97.2559 193.332Z" fill="#B9CCCD"/>
<path d="M97.7518 193.489C97.7675 194.131 97.7779 194.768 97.7779 195.405L97.2558 194.883H136.84L136.318 195.405V193.332C136.318 193.332 136.391 193.593 136.386 193.593C129.782 185.151 119.758 177.059 108.987 183.549C104.602 186.013 101.156 189.834 97.7518 193.489ZM96.7546 193.176C102.059 183.46 112.146 174.454 123.788 179.361C129.85 182.034 134.423 187.219 137.289 193.071L137.357 193.191V193.332C137.357 193.687 137.357 195.603 137.357 195.927H136.835H96.7338C96.7338 195.013 96.7338 194.084 96.7599 193.171L96.7546 193.176Z" fill="#231F20"/>
<path d="M69.4287 113.196L89.4872 123.419C90.902 124.139 92.5727 123.111 92.5727 121.529C92.5727 120.72 92.1133 119.978 91.3824 119.623L69.4287 108.874V113.202V113.196Z" fill="white"/>
<path d="M68.9797 113.468C72.5143 115.384 77.3488 118.062 80.8519 119.921L86.8925 123.121L88.4065 123.92C89.0017 124.244 89.6699 124.614 90.3904 124.604C93.7735 124.667 94.7812 120.031 91.7113 118.647C89.3828 117.483 84.3342 114.982 81.9483 113.802C78.0901 111.97 73.0833 109.109 68.9067 108.2C68.9014 109.949 68.9275 111.729 68.985 113.468H68.9797ZM69.8725 112.925C69.9195 111.562 69.9508 110.221 69.9508 108.874C69.9508 108.874 69.1938 109.344 69.199 109.344C70.9898 110.732 72.958 111.808 74.9733 112.805C77.9805 114.272 84.0314 117.211 87.0386 118.668C87.7487 119.018 90.4061 120.271 91.0639 120.615C92.1916 121.372 91.231 123.137 89.978 122.61C89.8475 122.568 89.4454 122.349 89.2836 122.276C83.2065 119.263 76.0174 115.828 69.8673 112.93L69.8725 112.925Z" fill="#231F20"/>
<path d="M96.3372 117.577C100.884 117.577 104.57 121.263 104.57 125.81C104.57 130.357 100.884 134.043 96.3372 134.043C91.79 134.043 88.1039 130.357 88.1039 125.81C88.1039 121.263 91.79 117.577 96.3372 117.577Z" fill="#FCD1D3"/>
<path d="M102.9 125.81C102.372 131.501 95.8412 134.701 91.273 130.874C86.1095 126.473 89.5657 117.859 96.3372 118.276C100.206 118.454 103.223 122.124 102.9 125.81ZM106.241 125.81C105.85 117.566 95.8882 113.938 90.1453 119.618C84.4493 125.298 88.224 135.119 96.3372 135.578C101.814 135.907 106.627 131.365 106.241 125.81Z" fill="#231F20"/>
<path d="M113.054 174.297C113.054 174.297 113.368 167.562 110.35 164.748C109.755 164.19 109.196 163.594 108.778 162.89C107.572 160.843 105.881 156.739 108.262 152.505C109.739 149.885 112.391 148.141 115.357 147.619C117.487 147.243 120.134 147.3 122.389 148.919C122.389 148.919 131.729 155.382 124.305 164.002C124.305 164.002 120.071 168.215 121.073 174.297H113.054Z" fill="#F8F3DE"/>
<path d="M112.678 174.657C112.616 174.574 112.558 174.454 112.543 174.334C112.527 174.172 112.543 174.193 112.537 174.172V174.062C112.522 171.854 112.261 169.536 111.425 167.5C111.102 166.743 110.705 166.017 110.131 165.432C107.123 162.717 105.62 158.16 106.794 154.213C108.554 147.921 116.829 144.658 122.405 148.078C126.66 150.897 129.411 156.484 126.874 161.318C125.981 163.349 124.31 164.675 123.188 166.56C121.804 168.847 121.188 171.567 121.585 174.219L121.648 174.824C118.661 174.84 115.618 174.767 112.673 174.663L112.678 174.657ZM113.435 173.937C116.025 173.843 118.531 173.77 121.079 173.775L120.562 174.381C120.426 173.42 120.327 172.439 120.421 171.462C120.588 168.58 121.992 165.563 123.83 163.584C125.302 161.84 126.43 159.762 126.482 157.564C126.623 154.27 124.321 151.075 121.653 149.274C118.818 147.53 114.986 147.927 112.161 149.54C106.298 153.048 106.57 160.592 111.378 164.941C113.289 167.568 113.608 170.945 113.587 174.083V174.198C113.587 174.214 113.592 174.271 113.576 174.151C113.561 174.067 113.503 173.984 113.435 173.937Z" fill="#231F20"/>
<path d="M113.305 175.054H112.997C111.655 175.054 111.65 177.143 112.997 177.143H121.512C122.854 177.143 122.859 175.054 121.512 175.054H112.997C111.655 175.054 111.65 177.143 112.997 177.143H113.305C114.647 177.143 114.652 175.054 113.305 175.054Z" fill="#231F20"/>
<path d="M117.795 143.682V140.273C117.795 139.725 117.314 139.203 116.751 139.229C116.187 139.255 115.706 139.688 115.706 140.273V143.682C115.706 144.23 116.187 144.752 116.751 144.726C117.314 144.7 117.795 144.267 117.795 143.682Z" fill="#231F20"/>
<path d="M130.664 158.64H133.504C134.052 158.64 134.575 158.16 134.548 157.596C134.522 157.032 134.089 156.552 133.504 156.552H130.664C130.116 156.552 129.594 157.032 129.62 157.596C129.646 158.16 130.079 158.64 130.664 158.64Z" fill="#231F20"/>
<path d="M104.544 157.403H100.853C100.305 157.403 99.7828 157.883 99.8089 158.447C99.835 159.011 100.268 159.491 100.853 159.491H104.544C105.092 159.491 105.614 159.011 105.588 158.447C105.562 157.883 105.129 157.403 104.544 157.403Z" fill="#231F20"/>
<path d="M126.404 149.556C127.746 149.556 127.751 147.467 126.404 147.467C125.057 147.467 125.057 149.556 126.404 149.556Z" fill="#231F20"/>
<path d="M126.973 148.987C128.315 148.987 128.32 146.898 126.973 146.898C125.626 146.898 125.626 148.987 126.973 148.987Z" fill="#231F20"/>
<path d="M129.641 143.797L125.95 147.488C125.563 147.875 125.532 148.585 125.95 148.966C126.367 149.347 127.015 149.378 127.427 148.966L131.118 145.275C131.505 144.888 131.536 144.178 131.118 143.797C130.701 143.416 130.053 143.385 129.641 143.797Z" fill="#231F20"/>
<path d="M101.422 146.434C102.764 146.434 102.769 144.345 101.422 144.345C100.075 144.345 100.075 146.434 101.422 146.434Z" fill="#231F20"/>
<path d="M100.963 146.413C102.477 147.927 103.991 149.441 105.505 150.955C105.891 151.341 106.601 151.372 106.982 150.955C107.363 150.537 107.395 149.89 106.982 149.477C105.468 147.963 103.954 146.449 102.44 144.935C102.054 144.549 101.344 144.518 100.963 144.935C100.582 145.353 100.55 146 100.963 146.413Z" fill="#231F20"/>
<path d="M113.141 180.204L112.627 178.708C112.394 178.03 112.897 177.325 113.614 177.325H120.671C121.363 177.325 121.863 177.985 121.676 178.652L121.239 180.204H113.141Z" fill="black"/>
<path d="M133.95 185.076C133.06 184.137 132.306 182.984 131.699 181.932C131.557 181.686 131.197 181.695 131.072 181.949C131.005 182.085 131.034 182.248 131.141 182.355C132.254 183.462 132.8 184.173 133.564 185.415C133.597 185.467 133.649 185.506 133.709 185.52C133.947 185.575 134.117 185.253 133.95 185.076Z" fill="#CC0000"/>
<path d="M130.371 182.501L129.957 183.105C129.827 183.293 129.836 183.544 129.977 183.723L132.682 187.154C132.958 187.504 133.517 187.37 133.604 186.934L133.81 185.906C133.848 185.717 133.778 185.524 133.637 185.393C132.863 184.672 131.893 183.408 131.244 182.498C131.03 182.198 130.579 182.197 130.371 182.501Z" fill="#6C6969" stroke="#231F20" stroke-width="0.522084"/>
<path d="M115.552 161.956L114.75 158.131C115.332 157.919 116.506 157.461 116.544 157.329C116.581 157.196 118.102 157.273 118.857 157.329C119.254 157.177 120.282 156.746 120.746 156.549L121.454 160.303L120.085 160.964C119.802 161.323 118.188 161.192 117.417 161.082C116.926 161.026 115.969 161.641 115.552 161.956Z" fill="#F7E081"/>
<path d="M120.829 156.167C120.868 156.183 120.903 156.209 120.931 156.242C120.958 156.276 120.977 156.315 120.985 156.357L121.754 160.196C121.764 160.247 121.758 160.3 121.738 160.348C121.718 160.397 121.684 160.437 121.64 160.466L121.639 160.467L121.636 160.469L121.625 160.476C121.563 160.516 121.501 160.555 121.437 160.593C121.312 160.669 121.137 160.772 120.937 160.88C120.545 161.092 120.029 161.34 119.604 161.425C119.17 161.512 118.782 161.441 118.445 161.378L118.43 161.375C118.079 161.31 117.781 161.258 117.453 161.323C117.095 161.395 116.638 161.609 116.255 161.817C116.083 161.911 115.914 162.01 115.748 162.113L116.379 165.265C116.393 165.333 116.379 165.404 116.34 165.461C116.302 165.519 116.242 165.559 116.174 165.573C116.106 165.586 116.036 165.572 115.978 165.534C115.921 165.495 115.881 165.436 115.867 165.368L114.33 157.689C114.316 157.621 114.33 157.551 114.369 157.493C114.407 157.435 114.467 157.395 114.535 157.382C114.603 157.368 114.673 157.382 114.731 157.42C114.788 157.459 114.828 157.519 114.842 157.586L114.871 157.731C114.978 157.667 115.107 157.593 115.249 157.517C115.641 157.305 116.157 157.057 116.582 156.972C117.012 156.886 117.391 156.957 117.721 157.02L117.745 157.025C118.089 157.09 118.389 157.143 118.733 157.074C119.091 157.002 119.548 156.788 119.931 156.58C120.15 156.461 120.364 156.333 120.573 156.198L120.582 156.193L120.584 156.191L120.584 156.191M120.547 156.828C120.442 156.891 120.318 156.963 120.181 157.037C119.792 157.249 119.276 157.497 118.835 157.585C118.381 157.676 117.991 157.602 117.652 157.538L117.648 157.537C117.301 157.472 117.012 157.418 116.685 157.484C116.342 157.552 115.886 157.766 115.498 157.976C115.322 158.071 115.15 158.171 114.98 158.275L115.639 161.568C115.744 161.506 115.867 161.434 116.005 161.359C116.394 161.147 116.91 160.9 117.351 160.811C117.785 160.725 118.172 160.796 118.509 160.859L118.525 160.861C118.875 160.927 119.173 160.979 119.501 160.913C119.843 160.845 120.3 160.632 120.688 160.421C120.864 160.326 121.036 160.226 121.206 160.122L120.547 156.829L120.547 156.828Z" fill="black"/>
<path d="M115.827 163.765L117.715 174.101" stroke="black" stroke-width="0.522084"/>
</g>
<defs>
<linearGradient id="paint0_linear_2781_22311" x1="44.4979" y1="169.569" x2="159.709" y2="82.973" gradientUnits="userSpaceOnUse">
<stop stop-color="#EAF0EC"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint1_linear_2781_22311" x1="34.498" y1="169.569" x2="10.5803" y2="163.868" gradientUnits="userSpaceOnUse">
<stop offset="0.0832371" stop-color="#EAF0EC"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint2_linear_2781_22311" x1="44.4979" y1="90.8467" x2="44.4979" y2="14.497" gradientUnits="userSpaceOnUse">
<stop/>
<stop offset="1" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint3_linear_2781_22311" x1="39.8484" y1="160.895" x2="-5.51108" y2="197.797" gradientUnits="userSpaceOnUse">
<stop/>
<stop offset="1" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint4_linear_2781_22311" x1="191.555" y1="163.437" x2="284.639" y2="163.437" gradientUnits="userSpaceOnUse">
<stop/>
<stop offset="1" stop-opacity="0"/>
</linearGradient>
<clipPath id="clip0_2781_22311">
<rect width="284.278" height="211.352" fill="white" transform="translate(0.361084)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -1,3 +1,4 @@
import { KEYS } from 'Types/filter/customFilter';
import Record from 'Types/Record';
import { FilterType, FilterKey, FilterCategory } from './filterType'
import filterOptions, { countries, platformOptions } from 'App/constants';
@ -55,13 +56,32 @@ export const filters = [
{ key: FilterKey.ISSUE, type: FilterType.ISSUE, category: FilterCategory.JAVASCRIPT, label: 'Issue', placeholder: 'Select an issue', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']), icon: 'filters/click', options: filterOptions.issueOptions },
];
export const flagConditionFilters = [
{ key: FilterKey.USER_OS, type: FilterType.MULTIPLE, category: FilterCategory.GEAR, label: 'User OS', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/os' },
{ key: FilterKey.USER_BROWSER, type: FilterType.MULTIPLE, category: FilterCategory.GEAR, label: 'User Browser', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/browser' },
{ key: FilterKey.USER_DEVICE, type: FilterType.MULTIPLE, category: FilterCategory.GEAR, label: 'User Device', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/device' },
{ key: FilterKey.REFERRER, type: FilterType.MULTIPLE, category: FilterCategory.USER, label: 'Referrer', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/arrow-return-right' },
{ key: FilterKey.USER_COUNTRY, type: FilterType.MULTIPLE_DROPDOWN, category: FilterCategory.USER, label: 'User Country', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']), icon: 'filters/country', options: countryOptions },
{ key: FilterKey.USER_CITY, type: FilterType.MULTIPLE, category: FilterCategory.USER, label: 'User City', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']), icon: 'filters/country', options: countryOptions },
{ key: FilterKey.USER_STATE, type: FilterType.MULTIPLE, category: FilterCategory.USER, label: 'User State', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']), icon: 'filters/country', options: countryOptions },
{ key: FilterKey.USERID, type: FilterType.MULTIPLE, category: FilterCategory.USER, label: 'User Id', operator: 'isUndefined', operatorOptions: [{ label: 'is undefined', value: 'isUndefined'}, { key: 'isAny', label: 'is any', value: 'isAny' }], icon: 'filters/userid' },
]
export const eventKeys = filters.filter((i) => i.isEvent).map(i => i.key);
export const nonFlagFilters = filters.filter(i => {
return flagConditionFilters.findIndex(f => f.key === i.key) === -1
}).map(i => i.key);
export const clickmapFilter = {
key: FilterKey.LOCATION,
type: FilterType.MULTIPLE,
category: FilterCategory.INTERACTIONS,
label: 'Visited URL', placeholder: 'Enter URL or path', operator: filterOptions.pageUrlOperators[0].value, operatorOptions: filterOptions.pageUrlOperators, icon: 'filters/location', isEvent: true }
label: 'Visited URL', placeholder: 'Enter URL or path',
operator: filterOptions.pageUrlOperators[0].value,
operatorOptions: filterOptions.pageUrlOperators,
icon: 'filters/location',
isEvent: true,
}
const mapFilters = (list) => {
return list.reduce((acc, filter) => {
@ -99,6 +119,7 @@ export const filterLabelMap = filters.reduce((acc, filter) => {
export let filtersMap = mapFilters(filters)
export let liveFiltersMap = mapLiveFilters(filters)
export let fflagsConditionsMap = mapFilters(flagConditionFilters)
export const clearMetaFilters = () => {
filtersMap = mapFilters(filters);
@ -125,6 +146,17 @@ export const addElementToFiltersMap = (
filtersMap[key] = { key, type, category, label: capitalize(key), operator: operator, operatorOptions, icon, isLive: true }
}
export const addElementToFlagConditionsMap = (
category = FilterCategory.METADATA,
key,
type = FilterType.MULTIPLE,
operator = 'is',
operatorOptions = filterOptions.stringOperators,
icon = 'filters/metadata'
) => {
fflagsConditionsMap[key] = { key, type, category, label: capitalize(key), operator: operator, operatorOptions, icon, isLive: true }
}
export const addElementToLiveFiltersMap = (
category = FilterCategory.METADATA,
key,
@ -233,6 +265,21 @@ export const generateFilterOptions = (map) => {
return filterSection;
}
export const generateFlagConditionOptions = (map) => {
const filterSection = {};
Object.keys(map).forEach(key => {
const filter = map[key];
if (filterSection.hasOwnProperty(filter.category)) {
filterSection[filter.category].push(filter);
} else {
filterSection[filter.category] = [filter];
}
});
return filterSection;
}
export const generateLiveFilterOptions = (map) => {
const filterSection = {};

View file

@ -93,7 +93,6 @@
"@babel/preset-typescript": "^7.17.12",
"@babel/runtime": "^7.17.9",
"@jest/globals": "^29.3.1",
"@mdx-js/react": "^1.6.22",
"@openreplay/sourcemap-uploader": "^3.0.0",
"@storybook/addon-actions": "^6.5.12",
"@storybook/addon-docs": "^6.5.12",
@ -124,13 +123,10 @@
"cssnano": "^5.0.12",
"cypress": "^12.3.0",
"cypress-image-snapshot": "^4.0.1",
"deasync-promise": "^1.0.1",
"deploy-aws-s3-cloudfront": "^3.6.0",
"dotenv": "^6.2.0",
"eslint": "^8.15.0",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.5.0",
"faker": "^5.5.3",
"file-loader": "^6.2.0",
"flow-bin": "^0.115.0",
"html-webpack-plugin": "^5.5.0",

View file

@ -109,12 +109,14 @@ export default class App {
private readonly startCallbacks: Array<StartCallback> = []
private readonly stopCallbacks: Array<() => any> = []
private readonly commitCallbacks: Array<CommitCallback> = []
private readonly options: AppOptions
public readonly options: AppOptions
public readonly networkOptions?: NetworkOptions
private readonly revID: string
private activityState: ActivityState = ActivityState.NotActive
private readonly version = 'TRACKER_VERSION' // TODO: version compatability check inside each plugin.
private readonly worker?: TypedWorker
private featureFlags: string[] = []
private compressionThreshold = 24 * 1000
private restartAttempts = 0
private readonly bc: BroadcastChannel = new BroadcastChannel('rick')
@ -449,6 +451,14 @@ export default class App {
return this.activityState === ActivityState.Active
}
isFeatureActive(feature: string): boolean {
return this.featureFlags.includes(feature)
}
getFeatureFlags(): string[] {
return this.featureFlags
}
resetNextPageSession(flag: boolean) {
if (flag) {
this.sessionStorage.setItem(this.options.session_reset_key, 't')
@ -547,7 +557,14 @@ export default class App {
delay, // derived from token
sessionID, // derived from token
startTimestamp, // real startTS (server time), derived from sessionID
userBrowser,
userCity,
userCountry,
userDevice,
userOS,
userState,
} = r
// TODO: insert feature flags here
if (
typeof token !== 'string' ||
typeof userUUID !== 'string' ||
@ -560,6 +577,14 @@ export default class App {
}
this.delay = delay
this.session.setSessionToken(token)
this.session.setUserInfo({
userBrowser,
userCity,
userCountry,
userDevice,
userOS,
userState,
})
this.session.assign({
sessionID,
timestamp: startTimestamp || timestamp,

View file

@ -1,6 +1,15 @@
import type App from './index.js'
import { generateRandomId } from '../utils.js'
interface UserInfo {
userBrowser: string
userCity: string
userCountry: string
userDevice: string
userOS: string
userState: string
}
interface SessionInfo {
sessionID: string | undefined
metadata: Record<string, string>
@ -24,6 +33,7 @@ export default class Session {
private timestamp = 0
private projectID: string | undefined
private tabId: string
public userInfo: UserInfo
constructor(private readonly app: App, private readonly options: Options) {
this.createTabId()
@ -72,6 +82,10 @@ export default class Session {
this.handleUpdate({ userID })
}
setUserInfo(userInfo: UserInfo) {
this.userInfo = userInfo
}
private getPageNumber(): number | undefined {
const pageNoStr = this.app.sessionStorage.getItem(this.options.session_pageno_key)
if (pageNoStr == null) {

View file

@ -27,6 +27,7 @@ import ConstructedStyleSheets from './modules/constructedStyleSheets.js'
import Selection from './modules/selection.js'
import Tabs from './modules/tabs.js'
import { IN_BROWSER, deprecationWarn, DOCS_HOST } from './utils.js'
import FeatureFlags, { IFeatureFlag } from './modules/featureFlags.js'
import type { Options as AppOptions } from './app/index.js'
import type { Options as ConsoleOptions } from './modules/console.js'
@ -51,6 +52,9 @@ export type Options = Partial<
autoResetOnWindowOpen?: boolean
network?: NetworkOptions
mouse?: MouseHandlerOptions
flags?: {
onFlagsLoad?: (flags: IFeatureFlag[]) => void
}
// dev only
__DISABLE_SECURE_MODE?: boolean
}
@ -88,6 +92,7 @@ function processOptions(obj: any): obj is Options {
}
export default class API {
public featureFlags: FeatureFlags
private readonly app: App | null = null
constructor(private readonly options: Options) {
if (!IN_BROWSER || !processOptions(options)) {
@ -138,8 +143,15 @@ export default class API {
Network(app, options.network)
Selection(app)
Tabs(app)
this.featureFlags = new FeatureFlags(app)
;(window as any).__OPENREPLAY__ = this
app.attachStartCallback(() => {
if (options.flags?.onFlagsLoad) {
this.featureFlags.onFlagsLoad(options.flags.onFlagsLoad)
}
void this.featureFlags.reloadFlags()
})
if (options.autoResetOnWindowOpen) {
const wOpen = window.open
app.attachStartCallback(() => {
@ -174,6 +186,22 @@ export default class API {
}
}
isFlagEnabled(flagName: string): boolean {
return this.featureFlags.isFlagEnabled(flagName)
}
onFlagsLoad(callback: (flags: IFeatureFlag[]) => void): void {
this.featureFlags.onFlagsLoad(callback)
}
clearPersistFlags() {
this.featureFlags.clearPersistFlags()
}
reloadFlags() {
return this.featureFlags.reloadFlags()
}
use<T>(fn: (app: App | null, options?: Options) => T): T {
return fn(this.app, this.options)
}
@ -198,6 +226,7 @@ export default class API {
// TODO: check argument type
return this.app.start(startOpts)
}
stop(): string | undefined {
if (this.app === null) {
return

View file

@ -0,0 +1,106 @@
import App from '../app/index.js'
export interface IFeatureFlag {
key: string
is_persist: boolean
value: string | boolean
payload: string
}
export interface FetchPersistFlagsData {
key: string
value: string | boolean
}
export default class FeatureFlags {
flags: IFeatureFlag[]
storageKey = '__openreplay_flags'
onFlagsCb: (flags: IFeatureFlag[]) => void
constructor(private readonly app: App) {
const persistFlags = this.app.sessionStorage.getItem(this.storageKey)
if (persistFlags) {
const persistFlagsStrArr = persistFlags.split(';').filter(Boolean)
this.flags = persistFlagsStrArr.map((flag) => JSON.parse(flag))
}
}
isFlagEnabled(flagName: string): boolean {
return this.flags.findIndex((flag) => flag.key === flagName) !== -1
}
onFlagsLoad(cb: (flags: IFeatureFlag[]) => void) {
this.onFlagsCb = cb
}
async reloadFlags() {
const persistFlagsStr = this.app.sessionStorage.getItem(this.storageKey)
const persistFlags: Record<string, FetchPersistFlagsData> = {}
if (persistFlagsStr) {
const persistArray = persistFlagsStr.split(';').filter(Boolean)
persistArray.forEach((flag) => {
const flagObj = JSON.parse(flag)
persistFlags[flagObj.key] = { key: flagObj.key, value: flagObj.value }
})
}
const sessionInfo = this.app.session.getInfo()
const userInfo = this.app.session.userInfo
const requestObject = {
projectID: sessionInfo.projectID,
userID: sessionInfo.userID,
metadata: sessionInfo.metadata,
referrer: document.referrer,
// todo: get from backend
os: userInfo.userOS,
device: userInfo.userDevice,
country: userInfo.userCountry,
state: userInfo.userState,
city: userInfo.userCity,
browser: userInfo.userBrowser,
persistFlags: persistFlags,
}
const resp = await fetch(this.app.options.ingestPoint + '/v1/web/feature-flags', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.app.session.getSessionToken() as string}`,
},
body: JSON.stringify(requestObject),
})
if (resp.status === 200) {
const data: { flags: IFeatureFlag[] } = await resp.json()
return this.handleFlags(data.flags)
}
}
handleFlags(flags: IFeatureFlag[]) {
const persistFlags: IFeatureFlag[] = []
flags.forEach((flag) => {
if (flag.is_persist) persistFlags.push(flag)
})
let str = ''
const uniquePersistFlags = this.diffPersist(persistFlags)
uniquePersistFlags.forEach((flag) => {
str += `${JSON.stringify(flag)};`
})
this.app.sessionStorage.setItem(this.storageKey, str)
this.flags = flags
return this.onFlagsCb?.(flags)
}
clearPersistFlags() {
this.app.sessionStorage.removeItem(this.storageKey)
}
diffPersist(flags: IFeatureFlag[]) {
const persistFlags = this.app.sessionStorage.getItem(this.storageKey)
if (!persistFlags) return flags
const persistFlagsStrArr = persistFlags.split(';').filter(Boolean)
const persistFlagsArr = persistFlagsStrArr.map((flag) => JSON.parse(flag))
return flags.filter((flag) => persistFlagsArr.findIndex((pf) => pf.key === flag.key) === -1)
}
}

View file

@ -0,0 +1,136 @@
import FeatureFlags, { FetchPersistFlagsData, IFeatureFlag } from '../main/modules/FeatureFlags'
import { describe, expect, jest, afterEach, beforeEach, test } from '@jest/globals'
jest.mock('../main/app/index.js')
const sessionInfo = {
projectID: 'project1',
userID: 'user1',
metadata: {},
}
const userInfo = {
userOS: 'test',
userDevice: 'test',
userCountry: 'test',
userState: 'test',
userCity: 'test',
userBrowser: 'test',
}
describe('FeatureFlags', () => {
// @ts-ignore
let featureFlags: FeatureFlags
let appMock = {
sessionStorage: { setItem: jest.fn(), getItem: jest.fn(), removeItem: jest.fn() },
options: {
ingestPoint: 'test',
},
session: {
getInfo: () => sessionInfo,
getSessionToken: () => '123',
userInfo: userInfo,
},
}
beforeEach(() => {
// @ts-ignore
featureFlags = new FeatureFlags(appMock)
})
afterEach(() => {
jest.restoreAllMocks()
})
test('should check if a flag is enabled', () => {
const flagName = 'flag1'
featureFlags.flags = [
{ payload: '', is_persist: false, key: 'flag1', value: '' },
{ payload: '', is_persist: false, key: 'flag2', value: '' },
]
const result = featureFlags.isFlagEnabled(flagName)
expect(result).toBe(true)
})
test('should invoke the callback function when flags are loaded', () => {
const flags = [{ key: 'flag1', is_persist: false, value: true, payload: 'payload1' }]
const callback = jest.fn()
featureFlags.onFlagsLoad(callback)
featureFlags.handleFlags(flags)
expect(callback).toHaveBeenCalledWith(flags)
})
test('should reload flags and handle the response', async () => {
const flags = [
{ key: 'flag1', is_persist: true, value: true, payload: 'payload1' },
{ key: 'flag2', is_persist: false, value: false, payload: 'payload2' },
]
const expectedRequestObject = {
projectID: sessionInfo.projectID,
userID: sessionInfo.userID,
metadata: sessionInfo.metadata,
referrer: '',
featureFlags: featureFlags.flags,
os: 'test',
device: 'test',
country: 'test',
state: 'test',
city: 'test',
browser: 'test',
persistFlags: {},
}
const spyOnHandle = jest.spyOn(featureFlags, 'handleFlags')
const expectedResponse = { flags }
// @ts-ignore
global.fetch = jest.fn().mockResolvedValue({
status: 200,
// @ts-ignore
json: jest.fn().mockResolvedValue(expectedResponse),
})
await featureFlags.reloadFlags()
expect(fetch).toHaveBeenCalledWith(
`${appMock.options.ingestPoint}/v1/web/feature-flags`,
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
Authorization: `Bearer ${appMock.session.getSessionToken()}`,
}),
body: JSON.stringify(expectedRequestObject),
}),
)
expect(spyOnHandle).toHaveBeenCalledWith(flags)
})
test('should clear persisted flags', () => {
featureFlags.clearPersistFlags()
expect(appMock.sessionStorage.removeItem).toHaveBeenCalledWith(featureFlags.storageKey)
})
test('should calculate the diff of persisted flags', () => {
const flags: IFeatureFlag[] = [
{ key: 'flag1', value: true, payload: '', is_persist: true },
{ key: 'flag2', value: false, payload: '123', is_persist: true },
{ key: 'flag3', value: false, payload: '123', is_persist: true },
]
const existingFlags: IFeatureFlag[] = [
{ key: 'flag1', value: true, payload: '', is_persist: true },
{ key: 'flag2', value: false, payload: '123', is_persist: true },
]
let str = ''
existingFlags.forEach((flag) => {
str += `${JSON.stringify(flag)};`
})
// @ts-ignore
appMock.sessionStorage.getItem = jest.fn().mockReturnValue(str)
const result = featureFlags.diffPersist(flags)
expect(result).toEqual([flags[2]])
})
})