From e9e3e21a10b8e81fe52f8f0f6dc4d2ed588b0acb Mon Sep 17 00:00:00 2001 From: Delirium Date: Wed, 21 Jun 2023 12:35:40 +0200 Subject: [PATCH] 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 --- frontend/app/Router.js | 9 +- frontend/app/api_client.js | 1 + .../components/DashboardList/Header.tsx | 15 -- .../components/FilterSeries/FilterSeries.tsx | 1 + .../components/FFlags/FFlagItem/FFlagItem.tsx | 51 ++++ .../app/components/FFlags/FFlagItem/index.ts | 1 + frontend/app/components/FFlags/FFlagsList.tsx | 98 ++++++++ .../components/FFlags/FFlagsListHeader.tsx | 36 +++ .../app/components/FFlags/FFlagsSearch.tsx | 42 ++++ .../components/FFlags/NewFFlag/Conditions.tsx | 90 +++++++ .../FFlags/NewFFlag/Description.tsx | 60 +++++ .../app/components/FFlags/NewFFlag/Header.tsx | 25 ++ .../components/FFlags/NewFFlag/Helpers.tsx | 20 ++ .../app/components/FFlags/NewFFlag/HowTo.tsx | 38 +++ .../FFlags/NewFFlag/Multivariant.tsx | 113 +++++++++ .../components/FFlags/NewFFlag/NewFFlag.tsx | 236 ++++++++++++++++++ .../app/components/FFlags/NewFFlag/index.ts | 1 + frontend/app/components/FFlags/index.ts | 1 + frontend/app/components/Overview/Overview.tsx | 55 ++-- .../ReplayPlayer/EventsBlock/NoteEvent.tsx} | 0 .../Session_/EventsBlock/NoteEvent.tsx | 2 +- .../Player/Controls/components/CreateNote.tsx | 2 +- .../Player/Controls/components/ReadNote.tsx | 2 +- .../shared/AnimatedSVG/AnimatedSVG.tsx | 2 + .../shared/Filters/FilterItem/FilterItem.tsx | 1 - .../shared/Filters/FilterList/FilterList.tsx | 11 +- .../shared/OverviewMenu/OverviewMenu.tsx | 61 ++++- .../components/Notes/index.ts | 2 - .../shared/SessionSearch/SessionSearch.tsx | 4 +- .../SessionsTabOverview.tsx} | 4 +- .../LatestSessionsMessage.tsx | 0 .../components/LatestSessionsMessage/index.ts | 0 .../components/NoContentMessage.tsx | 0 .../components/Notes/NoteItem.tsx | 0 .../components/Notes/NoteList.tsx | 0 .../components/Notes/NoteTags.tsx | 0 .../components/Notes/TeamBadge.tsx | 2 +- .../components/Notes/index.ts | 2 + .../SessionHeader/SessionHeader.tsx | 0 .../components/SessionHeader/index.ts | 0 .../SessionList/SessionDateRange.tsx | 2 +- .../components/SessionList/SessionList.tsx | 0 .../components/SessionList/index.ts | 0 .../SessionSettingButton.tsx | 0 .../components/SessionSettingButton/index.ts | 0 .../components/SessionSort/SessionSort.tsx | 0 .../components/SessionSort/index.ts | 0 .../SessionSort/sortDropdown.module.css | 0 .../components/SessionTags/SessionTags.tsx | 0 .../components/SessionTags/index.ts | 0 .../shared/SessionsTabOverview/index.ts | 0 frontend/app/components/ui/SVG.tsx | 6 +- .../{SideMenuitem.js => SideMenuitem.tsx} | 21 +- frontend/app/duck/customField.js | 3 +- frontend/app/duck/search.js | 2 +- frontend/app/mstore/featureFlagsStore.ts | 165 ++++++++++++ frontend/app/mstore/index.tsx | 3 + frontend/app/mstore/types/FeatureFlag.ts | 179 +++++++++++++ frontend/app/mstore/types/filter.ts | 5 +- frontend/app/mstore/types/filterItem.ts | 8 +- frontend/app/routes.js | 16 +- frontend/app/services/FFlagsService.ts | 84 +++++++ frontend/app/services/index.ts | 4 + frontend/app/svg/icons/fflag-multi.svg | 9 + frontend/app/svg/icons/fflag-single.svg | 6 + frontend/app/svg/icons/toggles.svg | 10 + frontend/app/svg/no-fflags.svg | 89 +++++++ frontend/app/types/filter/newFilter.js | 49 +++- frontend/package.json | 4 - tracker/tracker/src/main/app/index.ts | 27 +- tracker/tracker/src/main/app/session.ts | 14 ++ tracker/tracker/src/main/index.ts | 29 +++ .../tracker/src/main/modules/featureFlags.ts | 106 ++++++++ .../src/tests/featureFlags.unit.test.ts | 136 ++++++++++ 74 files changed, 1887 insertions(+), 78 deletions(-) create mode 100644 frontend/app/components/FFlags/FFlagItem/FFlagItem.tsx create mode 100644 frontend/app/components/FFlags/FFlagItem/index.ts create mode 100644 frontend/app/components/FFlags/FFlagsList.tsx create mode 100644 frontend/app/components/FFlags/FFlagsListHeader.tsx create mode 100644 frontend/app/components/FFlags/FFlagsSearch.tsx create mode 100644 frontend/app/components/FFlags/NewFFlag/Conditions.tsx create mode 100644 frontend/app/components/FFlags/NewFFlag/Description.tsx create mode 100644 frontend/app/components/FFlags/NewFFlag/Header.tsx create mode 100644 frontend/app/components/FFlags/NewFFlag/Helpers.tsx create mode 100644 frontend/app/components/FFlags/NewFFlag/HowTo.tsx create mode 100644 frontend/app/components/FFlags/NewFFlag/Multivariant.tsx create mode 100644 frontend/app/components/FFlags/NewFFlag/NewFFlag.tsx create mode 100644 frontend/app/components/FFlags/NewFFlag/index.ts create mode 100644 frontend/app/components/FFlags/index.ts rename frontend/app/components/{shared/SessionListContainer/index.ts => Session/Player/ReplayPlayer/EventsBlock/NoteEvent.tsx} (100%) delete mode 100644 frontend/app/components/shared/SessionListContainer/components/Notes/index.ts rename frontend/app/components/shared/{SessionListContainer/SessionListContainer.tsx => SessionsTabOverview/SessionsTabOverview.tsx} (93%) rename frontend/app/components/shared/{SessionListContainer => SessionsTabOverview}/components/LatestSessionsMessage/LatestSessionsMessage.tsx (100%) rename frontend/app/components/shared/{SessionListContainer => SessionsTabOverview}/components/LatestSessionsMessage/index.ts (100%) rename frontend/app/components/shared/{SessionListContainer => SessionsTabOverview}/components/NoContentMessage.tsx (100%) rename frontend/app/components/shared/{SessionListContainer => SessionsTabOverview}/components/Notes/NoteItem.tsx (100%) rename frontend/app/components/shared/{SessionListContainer => SessionsTabOverview}/components/Notes/NoteList.tsx (100%) rename frontend/app/components/shared/{SessionListContainer => SessionsTabOverview}/components/Notes/NoteTags.tsx (100%) rename frontend/app/components/shared/{SessionListContainer => SessionsTabOverview}/components/Notes/TeamBadge.tsx (99%) create mode 100644 frontend/app/components/shared/SessionsTabOverview/components/Notes/index.ts rename frontend/app/components/shared/{SessionListContainer => SessionsTabOverview}/components/SessionHeader/SessionHeader.tsx (100%) rename frontend/app/components/shared/{SessionListContainer => SessionsTabOverview}/components/SessionHeader/index.ts (100%) rename frontend/app/components/shared/{SessionListContainer => SessionsTabOverview}/components/SessionList/SessionDateRange.tsx (97%) rename frontend/app/components/shared/{SessionListContainer => SessionsTabOverview}/components/SessionList/SessionList.tsx (100%) rename frontend/app/components/shared/{SessionListContainer => SessionsTabOverview}/components/SessionList/index.ts (100%) rename frontend/app/components/shared/{SessionListContainer => SessionsTabOverview}/components/SessionSettingButton/SessionSettingButton.tsx (100%) rename frontend/app/components/shared/{SessionListContainer => SessionsTabOverview}/components/SessionSettingButton/index.ts (100%) rename frontend/app/components/shared/{SessionListContainer => SessionsTabOverview}/components/SessionSort/SessionSort.tsx (100%) rename frontend/app/components/shared/{SessionListContainer => SessionsTabOverview}/components/SessionSort/index.ts (100%) rename frontend/app/components/shared/{SessionListContainer => SessionsTabOverview}/components/SessionSort/sortDropdown.module.css (100%) rename frontend/app/components/shared/{SessionListContainer => SessionsTabOverview}/components/SessionTags/SessionTags.tsx (100%) rename frontend/app/components/shared/{SessionListContainer => SessionsTabOverview}/components/SessionTags/index.ts (100%) create mode 100644 frontend/app/components/shared/SessionsTabOverview/index.ts rename frontend/app/components/ui/SideMenuitem/{SideMenuitem.js => SideMenuitem.tsx} (80%) create mode 100644 frontend/app/mstore/featureFlagsStore.ts create mode 100644 frontend/app/mstore/types/FeatureFlag.ts create mode 100644 frontend/app/services/FFlagsService.ts create mode 100644 frontend/app/svg/icons/fflag-multi.svg create mode 100644 frontend/app/svg/icons/fflag-single.svg create mode 100644 frontend/app/svg/icons/toggles.svg create mode 100644 frontend/app/svg/no-fflags.svg create mode 100644 tracker/tracker/src/main/modules/featureFlags.ts create mode 100644 tracker/tracker/src/tests/featureFlags.unit.test.ts diff --git a/frontend/app/Router.js b/frontend/app/Router.js index 10aac6cf8..50bfc9566 100644 --- a/frontend/app/Router.js +++ b/frontend/app/Router.js @@ -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 { + + + + } /> diff --git a/frontend/app/api_client.js b/frontend/app/api_client.js index c9a041cc9..41d6fd89f 100644 --- a/frontend/app/api_client.js +++ b/frontend/app/api_client.js @@ -28,6 +28,7 @@ const siteIdRequiredPaths = [ '/cards', '/unprocessed', '/notes', + '/feature-flags', // '/custom_metrics/sessions', ]; diff --git a/frontend/app/components/Dashboard/components/DashboardList/Header.tsx b/frontend/app/components/Dashboard/components/DashboardList/Header.tsx index 00fc42349..9290ff799 100644 --- a/frontend/app/components/Dashboard/components/DashboardList/Header.tsx +++ b/frontend/app/components/Dashboard/components/DashboardList/Header.tsx @@ -47,21 +47,6 @@ function Header({ history, siteId }: { history: any; siteId: string }) { }) } /> - {/*
diff --git a/frontend/app/components/FFlags/FFlagItem/FFlagItem.tsx b/frontend/app/components/FFlags/FFlagItem/FFlagItem.tsx new file mode 100644 index 000000000..09886902a --- /dev/null +++ b/frontend/app/components/FFlags/FFlagItem/FFlagItem.tsx @@ -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 ( +
+
+ +
+ + {flag.flagKey} +
+ +
{flag.isSingleOption ? 'Single Option' : 'Multivariant'}
+
{resentOrDate(flag.updatedAt || flag.createdAt)}
+
+ + {user} +
+
+ +
+
+ {flag.description ?
{flag.description}
: null} +
+ ); +} + +export default observer(FFlagItem); diff --git a/frontend/app/components/FFlags/FFlagItem/index.ts b/frontend/app/components/FFlags/FFlagItem/index.ts new file mode 100644 index 000000000..c8d990b98 --- /dev/null +++ b/frontend/app/components/FFlags/FFlagItem/index.ts @@ -0,0 +1 @@ +export { default } from './FFlagItem' \ No newline at end of file diff --git a/frontend/app/components/FFlags/FFlagsList.tsx b/frontend/app/components/FFlags/FFlagsList.tsx new file mode 100644 index 000000000..eb1812648 --- /dev/null +++ b/frontend/app/components/FFlags/FFlagsList.tsx @@ -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 ( +
+ + +
+ + +
+ You haven't created any feature flags yet. +
+
+ } + subtext={ +
+ Use feature flags to deploy and rollback new functionality with ease. +
+ } + > +
+
+
+ Status: + { + featureFlagsStore.setSort({ query: '', order: value.value }) + void featureFlagsStore.fetchFlags(); + }} + /> +
+
+
+
Key
+
Type
+
Last modified
+
Last modified by
+
Status
+
+ + {featureFlagsStore.flags.map((flag) => ( + + + + ))} +
+ +
+ +
+ ); +} + +export default observer(FFlagsList); diff --git a/frontend/app/components/FFlags/FFlagsListHeader.tsx b/frontend/app/components/FFlags/FFlagsListHeader.tsx new file mode 100644 index 000000000..49c28c888 --- /dev/null +++ b/frontend/app/components/FFlags/FFlagsListHeader.tsx @@ -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 ( +
+
+ + +
+
+ +
+
+ +
+
+
+ ) +} + +export default observer(FFlagsListHeader); \ No newline at end of file diff --git a/frontend/app/components/FFlags/FFlagsSearch.tsx b/frontend/app/components/FFlags/FFlagsSearch.tsx new file mode 100644 index 000000000..4ec13d3f5 --- /dev/null +++ b/frontend/app/components/FFlags/FFlagsSearch.tsx @@ -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) => { + setQuery(value); + debounceUpdate(value); + }; + + return ( +
+ + +
+ ); +} + +export default observer(FFlagsSearch); diff --git a/frontend/app/components/FFlags/NewFFlag/Conditions.tsx b/frontend/app/components/FFlags/NewFFlag/Conditions.tsx new file mode 100644 index 000000000..e8f5cc6c3 --- /dev/null +++ b/frontend/app/components/FFlags/NewFFlag/Conditions.tsx @@ -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) => { + 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 ( +
+
+
Condition
+
Set {set}
+
removeCondition(index)} + > + +
+
+
+
0 ? 'p-2 border-b mb-2' : ''}> + +
+ +
+
+ Rollout to + %
} + /> + of sessions +
+ + ); +} + +export default observer(RolloutCondition); diff --git a/frontend/app/components/FFlags/NewFFlag/Description.tsx b/frontend/app/components/FFlags/NewFFlag/Description.tsx new file mode 100644 index 000000000..cfcbab77d --- /dev/null +++ b/frontend/app/components/FFlags/NewFFlag/Description.tsx @@ -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 ( + <> + + {isDescrEditing ? ( +