From d406420223de7d05b77d6caaecb4148714bb3138 Mon Sep 17 00:00:00 2001 From: David Leek Date: Thu, 17 Apr 2025 11:59:35 +0200 Subject: [PATCH 1/4] chore: menu cleanups (#9792) --- .../component/admin/menu/CenteredNavLink.tsx | 24 --- .../layout/MainLayout/AdminMenu/AdminMenu.tsx | 156 ------------------ .../Header/DrawerMenu/DrawerMenu.module.scss | 14 -- .../Header/NavigationLink/NavigationLink.tsx | 92 ----------- .../Header/NavigationMenu/NavigationMenu.tsx | 122 -------------- 5 files changed, 408 deletions(-) delete mode 100644 frontend/src/component/admin/menu/CenteredNavLink.tsx delete mode 100644 frontend/src/component/layout/MainLayout/AdminMenu/AdminMenu.tsx delete mode 100644 frontend/src/component/menu/Header/NavigationLink/NavigationLink.tsx delete mode 100644 frontend/src/component/menu/Header/NavigationMenu/NavigationMenu.tsx diff --git a/frontend/src/component/admin/menu/CenteredNavLink.tsx b/frontend/src/component/admin/menu/CenteredNavLink.tsx deleted file mode 100644 index 851322680c..0000000000 --- a/frontend/src/component/admin/menu/CenteredNavLink.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { styled } from '@mui/material'; -import type { FC } from 'react'; -import { NavLink } from 'react-router-dom'; - -const StyledNavLink = styled(NavLink)(({ theme }) => ({ - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - width: '100%', - height: '100%', - textDecoration: 'none', - color: 'inherit', - padding: theme.spacing(0, 5), - '&.active': { - fontWeight: 'bold', - }, -})); - -export const CenteredNavLink: FC<{ - to: string; - children?: React.ReactNode; -}> = ({ to, children }) => { - return {children}; -}; diff --git a/frontend/src/component/layout/MainLayout/AdminMenu/AdminMenu.tsx b/frontend/src/component/layout/MainLayout/AdminMenu/AdminMenu.tsx deleted file mode 100644 index 88ed67ff71..0000000000 --- a/frontend/src/component/layout/MainLayout/AdminMenu/AdminMenu.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { Grid, styled, Paper, useMediaQuery, useTheme } from '@mui/material'; -import type { ReactNode } from 'react'; -import { Sticky } from 'component/common/Sticky/Sticky'; -import { AdminMenuNavigation } from './AdminNavigationItems'; -import { useNewAdminMenu } from '../../../../hooks/useNewAdminMenu'; - -const breakpointLgMinusPadding = 1250; -const breakpointLgMinusPaddingAdmin = 1550; -const breakpointXlMinusPadding = 1512; -const breakpointXlAdmin = 1812; -const breakpointXxl = 1856; - -const MainLayoutContent = styled(Grid)(({ theme }) => ({ - minWidth: 0, // this is a fix for overflowing flex - maxWidth: `${breakpointXlMinusPadding}px`, - margin: '0 auto', - paddingLeft: theme.spacing(2), - paddingRight: theme.spacing(2), - [theme.breakpoints.up(breakpointXxl)]: { - width: '100%', - }, - [theme.breakpoints.down(breakpointXxl)]: { - marginLeft: theme.spacing(7), - marginRight: theme.spacing(7), - }, - [theme.breakpoints.down('lg')]: { - maxWidth: `${breakpointLgMinusPadding}px`, - paddingLeft: theme.spacing(1), - paddingRight: theme.spacing(1), - }, - [theme.breakpoints.down(1024)]: { - marginLeft: 0, - marginRight: 0, - }, - [theme.breakpoints.down('sm')]: { - minWidth: '100%', - }, - minHeight: '94vh', -})); - -const AdminMainLayoutContent = styled(Grid)(({ theme }) => ({ - minWidth: 0, // this is a fix for overflowing flex - maxWidth: `${breakpointXlMinusPadding}px`, - margin: '0 auto', - paddingLeft: theme.spacing(2), - paddingRight: theme.spacing(2), - [theme.breakpoints.up(breakpointXxl)]: { - width: '100%', - }, - [theme.breakpoints.down(breakpointXxl)]: { - marginLeft: 0, - marginRight: 0, - }, - [theme.breakpoints.down('lg')]: { - maxWidth: `${breakpointLgMinusPadding}px`, - paddingLeft: theme.spacing(1), - paddingRight: theme.spacing(1), - }, - [theme.breakpoints.down(1024)]: { - marginLeft: 0, - marginRight: 0, - }, - [theme.breakpoints.down('sm')]: { - minWidth: '100%', - }, - minHeight: '94vh', -})); - -const StyledAdminMainGrid = styled(Grid)(({ theme }) => ({ - minWidth: 0, // this is a fix for overflowing flex - maxWidth: `${breakpointXlAdmin}px`, - margin: '0 auto', - paddingLeft: theme.spacing(2), - paddingRight: theme.spacing(2), - [theme.breakpoints.down('lg')]: { - maxWidth: `${breakpointLgMinusPaddingAdmin}px`, - paddingLeft: theme.spacing(1), - paddingRight: theme.spacing(1), - }, - [theme.breakpoints.down(1024)]: { - marginLeft: 0, - marginRight: 0, - }, - [theme.breakpoints.down('sm')]: { - minWidth: '100%', - }, - minHeight: '94vh', -})); - -const StyledMenuPaper = styled(Paper)(({ theme }) => ({ - width: '100%', - minWidth: 320, - padding: theme.spacing(3), - marginTop: theme.spacing(6.5), - borderRadius: `${theme.shape.borderRadiusLarge}px`, - boxShadow: 'none', -})); - -const StickyContainer = styled(Sticky)(({ theme }) => ({ - position: 'sticky', - top: 0, - background: theme.palette.background.application, - transition: 'padding 0.3s ease', -})); - -interface IWrapIfAdminSubpageProps { - children: ReactNode; -} - -export const WrapIfAdminSubpage = ({ children }: IWrapIfAdminSubpageProps) => { - const showOnlyAdminMenu = useNewAdminMenu(); - const theme = useTheme(); - const isSmallScreen = useMediaQuery(theme.breakpoints.down('lg')); - const showAdminMenu = !isSmallScreen && showOnlyAdminMenu; - - if (showAdminMenu) { - return ( - - {children} - - ); - } - - return {children}; -}; - -interface IAdminMenuProps { - children: ReactNode; -} - -export const AdminMenu = ({ children }: IAdminMenuProps) => { - const theme = useTheme(); - const isBreakpoint = useMediaQuery(theme.breakpoints.down(1352)); - const breakpointedSize = isBreakpoint ? 8 : 9; - const onClick = () => { - scrollTo({ - top: 0, - behavior: 'smooth', - }); - }; - - return ( - - - - - - - - - - {children} - - - ); -}; diff --git a/frontend/src/component/menu/Header/DrawerMenu/DrawerMenu.module.scss b/frontend/src/component/menu/Header/DrawerMenu/DrawerMenu.module.scss index 1f1ae94ce3..7c20a68608 100644 --- a/frontend/src/component/menu/Header/DrawerMenu/DrawerMenu.module.scss +++ b/frontend/src/component/menu/Header/DrawerMenu/DrawerMenu.module.scss @@ -39,20 +39,6 @@ color: inherit; } -.navigationLink { - text-decoration: none; - color: var(--drawer-link-inactive); - padding: var(--drawer-padding); - display: flex; - align-items: centre; -} - -.navigationLinkActive { - background-color: var(--drawer-link-active-bg); - color: var(--drawer-link-active); - width: 100%; -} - .navigationIcon { margin-right: 16px; fill: #635dc5; diff --git a/frontend/src/component/menu/Header/NavigationLink/NavigationLink.tsx b/frontend/src/component/menu/Header/NavigationLink/NavigationLink.tsx deleted file mode 100644 index c98d1d6d36..0000000000 --- a/frontend/src/component/menu/Header/NavigationLink/NavigationLink.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { ListItem, Link, styled } from '@mui/material'; -import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; -import { EnterpriseBadge } from 'component/common/EnterpriseBadge/EnterpriseBadge'; -import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; -import type { INavigationMenuItem } from 'interfaces/route'; -import { Link as RouterLink } from 'react-router-dom'; -interface INavigationLinkProps { - path: string; - text: string; - handleClose: () => void; - mode?: INavigationMenuItem['menu']['mode']; -} - -const StyledListItem = styled(ListItem)({ - minWidth: '150px', - height: '100%', - width: '100%', - margin: '0', - padding: '0', -}); - -const StyledLink = styled(RouterLink)(({ theme }) => ({ - textDecoration: 'none', - alignItems: 'center', - display: 'flex', - color: 'inherit', - height: '100%', - width: '100%', - '&&': { - // Override MenuItem's built-in padding. - color: theme.palette.text.primary, - padding: theme.spacing(1, 2), - }, -})); - -const StyledSpan = styled('span')(({ theme }) => ({ - width: '12.5px', - height: '12.5px', - display: 'block', - backgroundColor: theme.palette.primary.main, - marginRight: '1rem', - borderRadius: '2px', -})); - -const StyledBadgeContainer = styled('div')(({ theme }) => ({ - marginLeft: 'auto', - paddingLeft: theme.spacing(2), - display: 'flex', -})); - -const NavigationLink = ({ - path, - text, - handleClose, - ...props -}: INavigationLinkProps) => { - const { isPro } = useUiConfig(); - const showEnterpriseBadgeToPro = Boolean( - isPro() && - !props.mode?.includes('pro') && - props.mode?.includes('enterprise'), - ); - - return ( - { - handleClose(); - }} - > - - - {text} - - - - - } - /> - - - ); -}; - -export default NavigationLink; diff --git a/frontend/src/component/menu/Header/NavigationMenu/NavigationMenu.tsx b/frontend/src/component/menu/Header/NavigationMenu/NavigationMenu.tsx deleted file mode 100644 index 7cc4ec7efa..0000000000 --- a/frontend/src/component/menu/Header/NavigationMenu/NavigationMenu.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { Divider, Tooltip } from '@mui/material'; -import { Menu, MenuItem, styled } from '@mui/material'; -import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; -import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; -import type { INavigationMenuItem } from 'interfaces/route'; -import { Link } from 'react-router-dom'; -import { EnterpriseBadge } from '../../../common/EnterpriseBadge/EnterpriseBadge'; -import { useCallback } from 'react'; - -interface INavigationMenuProps { - options: INavigationMenuItem[]; - id: string; - anchorEl: any; - handleClose: () => void; - style: Object; -} - -const StyledLink = styled(Link)(({ theme }) => ({ - textDecoration: 'none', - alignItems: 'center', - display: 'flex', - color: 'inherit', - height: '100%', - width: '100%', - '&&': { - // Override MenuItem's built-in padding. - padding: theme.spacing(1, 2), - }, -})); - -const StyledSpan = styled('span')(({ theme }) => ({ - width: '12.5px', - height: '12.5px', - display: 'block', - backgroundColor: theme.palette.primary.main, - marginRight: theme.spacing(2), - borderRadius: '2px', -})); - -const StyledBadgeContainer = styled('div')(({ theme }) => ({ - marginLeft: 'auto', - paddingLeft: theme.spacing(2), - display: 'flex', -})); - -export const NavigationMenu = ({ - options, - id, - handleClose, - anchorEl, - style, -}: INavigationMenuProps) => { - const { isPro, isOss } = useUiConfig(); - - const showBadge = useCallback( - (mode?: INavigationMenuItem['menu']['mode']) => { - if ( - isPro() && - !mode?.includes('pro') && - mode?.includes('enterprise') - ) { - return true; - } - - return false; - }, - [isPro], - ); - - return ( - - {options - .flatMap((option, i) => { - const previousGroup = options[i - 1]?.group; - const addDivider = - previousGroup && - previousGroup !== option.group && - (!isOss() || option.group === 'log'); - - return [ - addDivider ? ( - - ) : null, - - - - {option.title} - - - - } - /> - - , - ]; - }) - .filter(Boolean)} - - ); -}; From d60ea1acd45e30ddb85e0996dcc24c7bb27e7ff7 Mon Sep 17 00:00:00 2001 From: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> Date: Thu, 17 Apr 2025 12:07:08 +0200 Subject: [PATCH 2/4] feat: redirect logic refactor (#9734) --- .github/workflows/e2e.frontend.yaml | 1 + frontend/cypress/global.d.ts | 1 + .../cypress/integration/login/login.spec.ts | 80 +++++++++++++++++++ frontend/cypress/support/UI.ts | 43 +++++----- frontend/cypress/support/commands.ts | 2 + frontend/src/component/App.tsx | 7 +- frontend/src/component/InitialRedirect.tsx | 74 +++++++++++------ .../FeatureToggleListTable.tsx | 6 +- .../project/Project/Project.styles.ts | 2 +- .../component/user/SimpleAuth/SimpleAuth.tsx | 8 +- .../api/getters/useAuth/useAuthEndpoint.ts | 7 +- frontend/src/hooks/useLocalStorageState.ts | 3 +- frontend/src/utils/createLocalStorage.ts | 5 +- frontend/src/utils/storage.ts | 4 +- 14 files changed, 185 insertions(+), 58 deletions(-) create mode 100644 frontend/cypress/integration/login/login.spec.ts diff --git a/.github/workflows/e2e.frontend.yaml b/.github/workflows/e2e.frontend.yaml index 9137e3ea6c..d37e8ca808 100644 --- a/.github/workflows/e2e.frontend.yaml +++ b/.github/workflows/e2e.frontend.yaml @@ -13,6 +13,7 @@ jobs: - groups/groups.spec.ts - projects/access.spec.ts - segments/segments.spec.ts + - login/login.spec.ts steps: - name: Dump GitHub context env: diff --git a/frontend/cypress/global.d.ts b/frontend/cypress/global.d.ts index eeb25b91a9..e3162a2cca 100644 --- a/frontend/cypress/global.d.ts +++ b/frontend/cypress/global.d.ts @@ -21,6 +21,7 @@ declare namespace Cypress { interface Chainable { runBefore(): Chainable; + do_login(user = AUTH_USER, password = AUTH_PASSWORD): Chainable; login_UI(user = AUTH_USER, password = AUTH_PASSWORD): Chainable; logout_UI(): Chainable; diff --git a/frontend/cypress/integration/login/login.spec.ts b/frontend/cypress/integration/login/login.spec.ts new file mode 100644 index 0000000000..7f6ed3b1c1 --- /dev/null +++ b/frontend/cypress/integration/login/login.spec.ts @@ -0,0 +1,80 @@ +/// + +describe('login', { testIsolation: true }, () => { + const baseUrl = Cypress.config().baseUrl; + const randomId = String(Math.random()).split('.')[1]; + const projectName = `unleash-e2e-login-project-${randomId}`; + + before(() => { + cy.runBefore(); + Cypress.session.clearAllSavedSessions(); + cy.login_UI(); + cy.createProject_API(projectName); + cy.logout_UI(); + }); + + after(() => { + cy.deleteProject_API(projectName); + }); + + beforeEach(() => { + cy.login_UI(); + }); + + it('is redirecting to /personal after first login', () => { + cy.visit('/'); + cy.url().should('eq', `${baseUrl}/personal`); + cy.visit('/'); + // "/" should again redirect to last "home" page + cy.url().should('eq', `${baseUrl}/personal`); + }); + + it('is redirecting to last visited projects', () => { + cy.visit('/projects'); + cy.visit('/'); + cy.url().should((url) => url.startsWith(`${baseUrl}/projects`)); + cy.contains('a', projectName).click(); + cy.get(`h1 span`).should('not.have.class', 'skeleton'); + cy.visit('/'); + cy.url().should((url) => + // last visited project + url.startsWith(`${baseUrl}/projects/${projectName}`), + ); + }); + + it('is redirecting to other pages', () => { + cy.visit('/search'); + cy.visit('/playground'); + cy.visit('/'); + cy.url().should('eq', `${baseUrl}/playground`); + cy.visit('/admin'); + cy.visit('/applications'); // not one of main pages + cy.visit('/'); + cy.url().should('eq', `${baseUrl}/admin`); + }); + + it('clears last visited page on manual logout', () => { + cy.visit('/search'); + cy.get('[data-testid=HEADER_USER_AVATAR]').click(); + cy.get('button').contains('Log out').click(); + cy.url().should('eq', `${baseUrl}/login`); + cy.visit('/'); + cy.url().should('eq', `${baseUrl}/login`); + cy.do_login(); + cy.visit('/'); + cy.url().should('eq', `${baseUrl}/personal`); + }); + + it('remembers last visited page on next login', () => { + cy.visit('/insights'); + cy.window().then((win) => { + win.sessionStorage.clear(); // not localStorage + win.location.reload(); + }); + cy.visit('/'); + cy.url().should('eq', `${baseUrl}/login`); + cy.do_login(); + cy.visit('/'); + cy.url().should('eq', `${baseUrl}/insights`); + }); +}); diff --git a/frontend/cypress/support/UI.ts b/frontend/cypress/support/UI.ts index e854ef234e..66b11ce76c 100644 --- a/frontend/cypress/support/UI.ts +++ b/frontend/cypress/support/UI.ts @@ -24,28 +24,35 @@ export const runBefore = () => { disableActiveSplashScreens(); }; +export const do_login = ( + user = AUTH_USER, + password = AUTH_PASSWORD, +): Chainable => { + cy.visit('/'); + cy.wait(200); + cy.get("[data-testid='LOGIN_EMAIL_ID']").type(user); + + if (AUTH_PASSWORD) { + cy.get("[data-testid='LOGIN_PASSWORD_ID']").type(password); + } + + cy.get("[data-testid='LOGIN_BUTTON']").click(); + + // Wait for the login redirect to complete. + cy.get("[data-testid='HEADER_USER_AVATAR']"); + + if (document.querySelector("[data-testid='CLOSE_SPLASH']")) { + cy.get("[data-testid='CLOSE_SPLASH']").click(); + } + + return cy; +}; + export const login_UI = ( user = AUTH_USER, password = AUTH_PASSWORD, ): Chainable => { - return cy.session(user, () => { - cy.visit('/'); - cy.wait(200); - cy.get("[data-testid='LOGIN_EMAIL_ID']").type(user); - - if (AUTH_PASSWORD) { - cy.get("[data-testid='LOGIN_PASSWORD_ID']").type(password); - } - - cy.get("[data-testid='LOGIN_BUTTON']").click(); - - // Wait for the login redirect to complete. - cy.get("[data-testid='HEADER_USER_AVATAR']"); - - if (document.querySelector("[data-testid='CLOSE_SPLASH']")) { - cy.get("[data-testid='CLOSE_SPLASH']").click(); - } - }); + return cy.session(user, () => do_login(user, password)); }; export const createFeature_UI = ( diff --git a/frontend/cypress/support/commands.ts b/frontend/cypress/support/commands.ts index 1674fa9ced..3183303723 100644 --- a/frontend/cypress/support/commands.ts +++ b/frontend/cypress/support/commands.ts @@ -12,6 +12,7 @@ import { addFlexibleRolloutStrategyToFeature_UI, addUserIdStrategyToFeature_UI, updateFlexibleRolloutStrategy_UI, + do_login, //@ts-ignore } from './UI'; import { @@ -32,6 +33,7 @@ Cypress.on('window:before:load', (window) => { }); Cypress.Commands.add('runBefore', runBefore); Cypress.Commands.add('login_UI', login_UI); +Cypress.Commands.add('do_login', do_login); Cypress.Commands.add('createSegment_UI', createSegment_UI); Cypress.Commands.add('deleteSegment_UI', deleteSegment_UI); Cypress.Commands.add('deleteFeature_API', deleteFeature_API); diff --git a/frontend/src/component/App.tsx b/frontend/src/component/App.tsx index 5f23afba35..813b728efd 100644 --- a/frontend/src/component/App.tsx +++ b/frontend/src/component/App.tsx @@ -1,5 +1,5 @@ import { Suspense, useEffect } from 'react'; -import { Route, Routes } from 'react-router-dom'; +import { Route, Routes, useLocation } from 'react-router-dom'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { FeedbackNPS } from 'component/feedback/FeedbackNPS/FeedbackNPS'; import { LayoutPicker } from 'component/layout/LayoutPicker/LayoutPicker'; @@ -16,7 +16,7 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import MaintenanceBanner from './maintenance/MaintenanceBanner'; import { styled } from '@mui/material'; -import { InitialRedirect } from './InitialRedirect'; +import { InitialRedirect, useLastViewedPage } from './InitialRedirect'; import { InternalBanners } from './banners/internalBanners/InternalBanners'; import { ExternalBanners } from './banners/externalBanners/ExternalBanners'; import { LicenseBanner } from './banners/internalBanners/LicenseBanner'; @@ -51,6 +51,9 @@ export const App = () => { const isLoggedIn = Boolean(user?.id); + const location = useLocation(); + useLastViewedPage(location); + return ( }> diff --git a/frontend/src/component/InitialRedirect.tsx b/frontend/src/component/InitialRedirect.tsx index 2ac97d278d..5c0ebd201a 100644 --- a/frontend/src/component/InitialRedirect.tsx +++ b/frontend/src/component/InitialRedirect.tsx @@ -1,37 +1,59 @@ -import { useCallback, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useEffect } from 'react'; +import { type Location, Navigate } from 'react-router-dom'; import useProjects from 'hooks/api/getters/useProjects/useProjects'; import { useLastViewedProject } from 'hooks/useLastViewedProject'; import Loader from './common/Loader/Loader'; -import { getSessionStorageItem, setSessionStorageItem } from 'utils/storage'; +import { useLocalStorageState } from 'hooks/useLocalStorageState'; +import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser'; -export const InitialRedirect = () => { - const { lastViewed } = useLastViewedProject(); - const { projects, loading } = useProjects(); - const navigate = useNavigate(); - const sessionRedirect = getSessionStorageItem('login-redirect'); - - // Redirect based on project and last viewed - const getRedirect = useCallback(() => { - if (projects && lastViewed) { - return `/projects/${lastViewed}`; - } - - return '/personal'; - }, [lastViewed, projects]); - - const redirect = () => { - navigate(sessionRedirect ?? getRedirect(), { replace: true }); - }; +export const useLastViewedPage = (location?: Location) => { + const [state, setState] = useLocalStorageState( + 'lastViewedPage', + '/personal', + 7 * 24 * 60 * 60 * 1000, // 7 days, left to promote seeing Personal dashboard from time to time + ); useEffect(() => { - setSessionStorageItem('login-redirect'); - redirect(); - }, [getRedirect]); + if (location) { + const page = [ + '/personal', + '/projects', + '/search', + '/playground', + '/insights', + '/admin', + ].find( + (page) => + page === location.pathname || + location.pathname.startsWith(`/{page}/`), + ); + if (page) { + setState(page); + } + } + }, [location]); - if (loading) { + return state; +}; + +export const InitialRedirect = () => { + const { user, loading: isLoadingAuth } = useAuthUser(); + const { loading: isLoadingProjects } = useProjects(); + const isLoggedIn = Boolean(user?.id); + const lastViewedPage = useLastViewedPage(); + const { lastViewed: lastViewedProject } = useLastViewedProject(); + + if (isLoadingAuth || isLoadingProjects) { return ; } - return null; + if (!isLoggedIn) { + return ; + } + + if (lastViewedPage === '/projects' && lastViewedProject) { + return ; + } + + return ; }; diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx index 7de1079cfd..254e5218ff 100644 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx +++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx @@ -401,7 +401,11 @@ export const FeatureToggleListTable: FC = () => { bodyClass='no-padding' header={ ({ alignItems: 'start', })); -export const StyledProjectTitle = styled('span')(({ theme }) => ({ +export const StyledProjectTitle = styled('h1')(({ theme }) => ({ margin: 0, width: '100%', fontSize: theme.typography.h1.fontSize, diff --git a/frontend/src/component/user/SimpleAuth/SimpleAuth.tsx b/frontend/src/component/user/SimpleAuth/SimpleAuth.tsx index a0c2f8911d..cbc2f505b9 100644 --- a/frontend/src/component/user/SimpleAuth/SimpleAuth.tsx +++ b/frontend/src/component/user/SimpleAuth/SimpleAuth.tsx @@ -21,21 +21,24 @@ interface ISimpleAuthProps { const SimpleAuth: VFC = ({ authDetails, redirect }) => { const [email, setEmail] = useState(''); + const [isPending, setIsPending] = useState(false); const { refetchUser } = useAuthUser(); const { emailAuth } = useAuthApi(); const navigate = useNavigate(); const { setToastApiError } = useToast(); const handleSubmit: FormEventHandler = async (evt) => { + setIsPending(true); evt.preventDefault(); try { await emailAuth(authDetails.path, email); - refetchUser(); - navigate(redirect, { replace: true }); + await refetchUser(); + navigate(redirect); } catch (error) { setToastApiError(formatUnknownError(error)); } + setIsPending(false); }; const handleChange: ChangeEventHandler = (e) => { @@ -81,6 +84,7 @@ const SimpleAuth: VFC = ({ authDetails, redirect }) => { color='primary' className={styles.button} data-testid={LOGIN_BUTTON} + disabled={isPending} > Sign in diff --git a/frontend/src/hooks/api/getters/useAuth/useAuthEndpoint.ts b/frontend/src/hooks/api/getters/useAuth/useAuthEndpoint.ts index 964d2a6d43..38b3e0270d 100644 --- a/frontend/src/hooks/api/getters/useAuth/useAuthEndpoint.ts +++ b/frontend/src/hooks/api/getters/useAuth/useAuthEndpoint.ts @@ -58,9 +58,10 @@ export const useAuthEndpoint = (): IUseAuthEndpointOutput => { swrConfig, ); - const refetchAuth = useCallback(() => { - mutate(USER_ENDPOINT_PATH).catch(console.warn); - }, []); + const refetchAuth = useCallback( + async () => mutate(USER_ENDPOINT_PATH).catch(console.warn), + [], + ); return { data, diff --git a/frontend/src/hooks/useLocalStorageState.ts b/frontend/src/hooks/useLocalStorageState.ts index 8e3dd4dedc..3846bf6395 100644 --- a/frontend/src/hooks/useLocalStorageState.ts +++ b/frontend/src/hooks/useLocalStorageState.ts @@ -4,9 +4,10 @@ import { createLocalStorage } from '../utils/createLocalStorage'; export const useLocalStorageState = ( key: string, initialValue: T, + timeToLive?: number, ) => { const { value: initialStoredValue, setValue: setStoredValue } = - createLocalStorage(key, initialValue); + createLocalStorage(key, initialValue, timeToLive); const [localValue, setLocalValue] = useState(initialStoredValue); diff --git a/frontend/src/utils/createLocalStorage.ts b/frontend/src/utils/createLocalStorage.ts index f1d9e11562..2be6be4757 100644 --- a/frontend/src/utils/createLocalStorage.ts +++ b/frontend/src/utils/createLocalStorage.ts @@ -4,6 +4,7 @@ import { getLocalStorageItem, setLocalStorageItem } from './storage'; export const createLocalStorage = ( key: string, initialValue: T, + timeToLive?: number, ) => { const internalKey = `${basePath}:${key}:localStorage:v2`; const value = (() => { @@ -18,11 +19,11 @@ export const createLocalStorage = ( if (newValue instanceof Function) { const previousValue = getLocalStorageItem(internalKey); const output = newValue(previousValue ?? initialValue); - setLocalStorageItem(internalKey, output); + setLocalStorageItem(internalKey, output, timeToLive); return output; } - setLocalStorageItem(internalKey, newValue); + setLocalStorageItem(internalKey, newValue, timeToLive); return newValue; }; diff --git a/frontend/src/utils/storage.ts b/frontend/src/utils/storage.ts index c5873fd4fa..0a15869888 100644 --- a/frontend/src/utils/storage.ts +++ b/frontend/src/utils/storage.ts @@ -60,7 +60,7 @@ export function getLocalStorageItem(key: string): T | undefined { export function setLocalStorageItem( key: string, value: T | undefined = undefined, - timeToLive?: number, + timeToLive?: number, // milliseconds ) { try { const item: Expirable = { @@ -80,7 +80,7 @@ export function setLocalStorageItem( export function setSessionStorageItem( key: string, value: T | undefined = undefined, - timeToLive?: number, + timeToLive?: number, // milliseconds ) { try { const item: Expirable = { From 7285607cad6aedf2f08b254cc29c76d052515682 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Thu, 17 Apr 2025 14:06:26 +0200 Subject: [PATCH 3/4] feat: remind me later about cleanup (#9790) --- .../CleanupReminder/CleanupReminder.test.tsx | 20 ++++- .../CleanupReminder/CleanupReminder.tsx | 57 ++++++++++---- .../CleanupReminder/useFlagReminders.test.tsx | 76 +++++++++++++++++++ .../CleanupReminder/useFlagReminders.ts | 53 +++++++++++++ 4 files changed, 191 insertions(+), 15 deletions(-) create mode 100644 frontend/src/component/feature/FeatureView/CleanupReminder/useFlagReminders.test.tsx create mode 100644 frontend/src/component/feature/FeatureView/CleanupReminder/useFlagReminders.ts diff --git a/frontend/src/component/feature/FeatureView/CleanupReminder/CleanupReminder.test.tsx b/frontend/src/component/feature/FeatureView/CleanupReminder/CleanupReminder.test.tsx index 9fd43374e5..c34676bac8 100644 --- a/frontend/src/component/feature/FeatureView/CleanupReminder/CleanupReminder.test.tsx +++ b/frontend/src/component/feature/FeatureView/CleanupReminder/CleanupReminder.test.tsx @@ -2,7 +2,7 @@ import { vi } from 'vitest'; import { CleanupReminder } from './CleanupReminder'; import { render } from 'utils/testRenderer'; import type { IFeatureToggle } from 'interfaces/featureToggle'; -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import { DELETE_FEATURE, UPDATE_FEATURE, @@ -11,6 +11,10 @@ import { const currentTime = '2024-04-25T08:05:00.000Z'; const monthAgo = '2024-03-25T06:05:00.000Z'; +beforeEach(() => { + window.localStorage.clear(); +}); + test('render complete feature reminder', async () => { vi.setSystemTime(currentTime); const feature = { @@ -55,6 +59,13 @@ test('render remove flag from code reminder', async () => { }); await screen.findByText('Time to remove flag from code?'); + + const reminder = await screen.findByText('Remind me later'); + reminder.click(); + + await waitFor(() => { + expect(screen.queryByText('Archive flag')).not.toBeInTheDocument(); + }); }); test('render archive flag reminder', async () => { @@ -78,4 +89,11 @@ test('render archive flag reminder', async () => { await screen.findByText('child1'); const okButton = await screen.findByText('OK'); okButton.click(); + + const reminder = await screen.findByText('Remind me later'); + reminder.click(); + + await waitFor(() => { + expect(screen.queryByText('Archive flag')).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/component/feature/FeatureView/CleanupReminder/CleanupReminder.tsx b/frontend/src/component/feature/FeatureView/CleanupReminder/CleanupReminder.tsx index 31c2de7cc2..56c3c9c835 100644 --- a/frontend/src/component/feature/FeatureView/CleanupReminder/CleanupReminder.tsx +++ b/frontend/src/component/feature/FeatureView/CleanupReminder/CleanupReminder.tsx @@ -1,5 +1,5 @@ import { type FC, useState } from 'react'; -import { Alert, Box, styled } from '@mui/material'; +import { Alert, Box, Button, styled } from '@mui/material'; import FlagIcon from '@mui/icons-material/OutlinedFlag'; import CleaningServicesIcon from '@mui/icons-material/CleaningServices'; import { parseISO } from 'date-fns'; @@ -17,12 +17,19 @@ import type { IFeatureToggle } from 'interfaces/featureToggle'; import { FeatureArchiveNotAllowedDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveNotAllowedDialog'; import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog'; import { useNavigate } from 'react-router-dom'; +import { useFlagReminders } from './useFlagReminders'; const StyledBox = styled(Box)(({ theme }) => ({ marginRight: theme.spacing(2), marginBottom: theme.spacing(2), })); +const ActionsBox = styled(Box)(({ theme }) => ({ + display: 'flex', + gap: theme.spacing(1), + alignItems: 'center', +})); + type ReminderType = 'complete' | 'removeCode' | 'archive' | null; export const CleanupReminder: FC<{ @@ -42,6 +49,7 @@ export const CleanupReminder: FC<{ const daysInStage = enteredStageAt ? differenceInDays(new Date(), parseISO(enteredStageAt)) : 0; + const { shouldShowReminder, snoozeReminder } = useFlagReminders(); const determineReminder = (): ReminderType => { if (!currentStage || !isRelevantType) return null; @@ -49,7 +57,10 @@ export const CleanupReminder: FC<{ if (currentStage.name === 'live' && daysInStage > 30) { return 'complete'; } - if (currentStage.name === 'completed') { + if ( + currentStage.name === 'completed' && + shouldShowReminder(feature.name) + ) { if (isSafeToArchive(currentStage.environments)) { return 'archive'; } @@ -76,7 +87,7 @@ export const CleanupReminder: FC<{ setMarkCompleteDialogueOpen(true) } @@ -109,16 +120,23 @@ export const CleanupReminder: FC<{ severity='warning' icon={} action={ - setArchiveDialogueOpen(true)} - projectId={feature.project} - > - Archive flag - + + + setArchiveDialogueOpen(true)} + projectId={feature.project} + > + Archive flag + + } > Time to clean up technical debt? @@ -149,7 +167,18 @@ export const CleanupReminder: FC<{ )} {reminder === 'removeCode' && ( - }> + } + action={ + + } + > Time to remove flag from code?

This flag was marked as complete and ready for cleanup. diff --git a/frontend/src/component/feature/FeatureView/CleanupReminder/useFlagReminders.test.tsx b/frontend/src/component/feature/FeatureView/CleanupReminder/useFlagReminders.test.tsx new file mode 100644 index 0000000000..4163cc7261 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/CleanupReminder/useFlagReminders.test.tsx @@ -0,0 +1,76 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useFlagReminders } from './useFlagReminders'; + +const TestComponent = ({ + days = 7, + maxReminders = 50, +}: { + days?: number; + maxReminders?: number; +}) => { + const { shouldShowReminder, snoozeReminder } = useFlagReminders({ + days, + maxReminders, + }); + + return ( +

+ + +
+ {shouldShowReminder('test-flag') ? 'yes' : 'no'} +
+
+ ); +}; + +describe('useFlagReminders (integration)', () => { + beforeEach(() => { + window.localStorage.clear(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should show reminder when no snooze exists', () => { + render(); + + expect(screen.getByTestId('result').textContent).toBe('yes'); + }); + + it('should not show reminder after snoozing', () => { + render(); + fireEvent.click(screen.getByText('Snooze')); + + expect(screen.getByTestId('result').textContent).toBe('no'); + }); + + it('should show reminder again after snooze expires', () => { + const { rerender } = render(); + fireEvent.click(screen.getByText('Snooze')); + + // Advance 4 days + vi.advanceTimersByTime(4 * 24 * 60 * 60 * 1000); + rerender(); + + expect(screen.getByTestId('result').textContent).toBe('yes'); + }); + + it('should respect max reminders and remove oldest entries', () => { + render(); + fireEvent.click(screen.getByText('Snooze')); + fireEvent.click(screen.getByText('Snooze Another')); + + expect(screen.getByTestId('result').textContent).toBe('yes'); + }); +}); diff --git a/frontend/src/component/feature/FeatureView/CleanupReminder/useFlagReminders.ts b/frontend/src/component/feature/FeatureView/CleanupReminder/useFlagReminders.ts new file mode 100644 index 0000000000..b1da8ed977 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/CleanupReminder/useFlagReminders.ts @@ -0,0 +1,53 @@ +import { isAfter, addDays } from 'date-fns'; +import { useLocalStorageState } from 'hooks/useLocalStorageState'; + +type FlagReminderMap = Record; // timestamp in ms + +const REMINDER_KEY = 'flag-reminders:v1'; +const MAX_REMINDERS = 50; +const DAYS = 7; + +export const useFlagReminders = ({ + days = DAYS, + maxReminders = MAX_REMINDERS, +}: { + days?: number; + maxReminders?: number; +} = {}) => { + const [reminders, setReminders] = useLocalStorageState( + REMINDER_KEY, + {}, + ); + + const shouldShowReminder = (flagName: string): boolean => { + const snoozedUntil = reminders[flagName]; + return !snoozedUntil || isAfter(new Date(), new Date(snoozedUntil)); + }; + + const snoozeReminder = (flagName: string, snoozeDays: number = days) => { + const snoozedUntil = addDays(new Date(), snoozeDays).getTime(); + + setReminders((prev) => { + const updated = { ...prev, [flagName]: snoozedUntil }; + + const entries = Object.entries(updated); + + if (entries.length > maxReminders) { + // Sort by timestamp (oldest first) + entries.sort((a, b) => a[1] - b[1]); + + // Keep only the newest maxReminders + const trimmed = entries.slice(entries.length - maxReminders); + + return Object.fromEntries(trimmed); + } + + return updated; + }); + }; + + return { + shouldShowReminder, + snoozeReminder, + }; +}; From e6813a4910ff11344f10eda319af9bcf36a3fc57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Thu, 17 Apr 2025 13:52:54 +0100 Subject: [PATCH 4/4] chore: set new demo step titles (#9795) https://linear.app/unleash/issue/2-3517/change-titles-on-various-steps-in-flow Updates the titles of our demo flow steps as per the designs. Took some liberties, as always, as e.g. some optional steps were not covered by the designs. --- frontend/src/component/demo/demo-topics.tsx | 43 +++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/frontend/src/component/demo/demo-topics.tsx b/frontend/src/component/demo/demo-topics.tsx index f641d3d91f..4771cb29ea 100644 --- a/frontend/src/component/demo/demo-topics.tsx +++ b/frontend/src/component/demo/demo-topics.tsx @@ -40,6 +40,7 @@ export const TOPICS: ITutorialTopic[] = [ title: 'Enable/disable a feature flag', steps: [ { + title: 'Enable/disable a feature flag', href: `/projects/${PROJECT}?sort=name`, target: 'body', placement: 'center', @@ -71,6 +72,7 @@ export const TOPICS: ITutorialTopic[] = [ nextButton: true, }, { + title: 'Control the flag', href: `/projects/${PROJECT}?sort=name`, target: `div[data-testid="TOGGLE-demoApp.step1-${ENVIRONMENT}"]`, content: ( @@ -93,6 +95,7 @@ export const TOPICS: ITutorialTopic[] = [ setup: specificUser, steps: [ { + title: 'Enable for a specific user', href: `/projects/${PROJECT}?sort=name`, target: 'body', placement: 'center', @@ -118,6 +121,7 @@ export const TOPICS: ITutorialTopic[] = [ nextButton: true, }, { + title: 'Select a flag', href: `/projects/${PROJECT}?sort=name`, target: `table a[href="${basePath}/projects/${PROJECT}/features/demoApp.step2"]`, content: ( @@ -130,6 +134,7 @@ export const TOPICS: ITutorialTopic[] = [ preventDefault: true, }, { + title: 'Select an environment', href: `/projects/${PROJECT}/features/demoApp.step2`, target: `div[data-testid="FEATURE_ENVIRONMENT_ACCORDION_${ENVIRONMENT}"] > div[aria-expanded="false"]`, content: ( @@ -142,6 +147,7 @@ export const TOPICS: ITutorialTopic[] = [ delay: 500, }, { + title: 'Add a strategy', target: `div[data-testid="FEATURE_ENVIRONMENT_ACCORDION_${ENVIRONMENT}"] button[data-testid="ADD_STRATEGY_BUTTON"]`, content: ( @@ -153,6 +159,7 @@ export const TOPICS: ITutorialTopic[] = [ backCollapseExpanded: true, }, { + title: 'Select a strategy', target: `a[href="${basePath}/projects/${PROJECT}/features/demoApp.step2/strategies/create?environmentId=${ENVIRONMENT}&strategyName=flexibleRollout&defaultStrategy=true"]`, content: ( Select the default strategy. @@ -162,6 +169,7 @@ export const TOPICS: ITutorialTopic[] = [ backCloseModal: true, }, { + title: 'Narrow down your target audience', target: 'button[data-testid="STRATEGY_TARGETING_TAB"]', content: ( <> @@ -173,6 +181,7 @@ export const TOPICS: ITutorialTopic[] = [ backCloseModal: true, }, { + title: 'Add a constraint', target: 'button[data-testid="ADD_CONSTRAINT_BUTTON"]', content: ( <> @@ -202,6 +211,7 @@ export const TOPICS: ITutorialTopic[] = [ backCloseModal: true, }, { + title: 'Select a context', target: '#context-field-select', content: ( <> @@ -225,6 +235,7 @@ export const TOPICS: ITutorialTopic[] = [ anyClick: true, }, { + title: 'Select a pre-defined context field', target: 'li[data-testid="SELECT_ITEM_ID-userId"]', content: ( @@ -236,6 +247,7 @@ export const TOPICS: ITutorialTopic[] = [ backCloseModal: true, }, { + title: 'Input value', target: 'div[data-testid="CONSTRAINT_VALUES_INPUT"]', content: ( <> @@ -263,6 +275,7 @@ export const TOPICS: ITutorialTopic[] = [ focus: 'input', }, { + title: 'Add value', target: 'button[data-testid="CONSTRAINT_VALUES_ADD_BUTTON"]', content: ( @@ -271,6 +284,7 @@ export const TOPICS: ITutorialTopic[] = [ ), }, { + title: 'Save constraint setup', target: 'button[data-testid="CONSTRAINT_SAVE_BUTTON"]', content: ( @@ -280,6 +294,7 @@ export const TOPICS: ITutorialTopic[] = [ optional: true, }, { + title: 'Save strategy for flag', target: 'button[data-testid="STRATEGY_FORM_SUBMIT_ID"]', content: ( @@ -289,6 +304,7 @@ export const TOPICS: ITutorialTopic[] = [ backCloseModal: true, }, { + title: 'Confirm your changes', target: 'button[data-testid="DIALOGUE_CONFIRM_ID"]', content: ( @@ -299,6 +315,7 @@ export const TOPICS: ITutorialTopic[] = [ backCloseModal: true, }, { + title: 'Control the flag', href: `/projects/${PROJECT}?sort=name`, target: `div[data-testid="TOGGLE-demoApp.step2-${ENVIRONMENT}"]`, content: ( @@ -322,6 +339,7 @@ export const TOPICS: ITutorialTopic[] = [ setup: gradualRollout, steps: [ { + title: 'Adjust gradual rollout', href: `/projects/${PROJECT}?sort=name`, target: 'body', placement: 'center', @@ -354,6 +372,7 @@ export const TOPICS: ITutorialTopic[] = [ nextButton: true, }, { + title: 'Select a flag', href: `/projects/${PROJECT}?sort=name`, target: `table a[href="${basePath}/projects/${PROJECT}/features/demoApp.step3"]`, content: ( @@ -366,6 +385,7 @@ export const TOPICS: ITutorialTopic[] = [ preventDefault: true, }, { + title: 'Select an environment', href: `/projects/${PROJECT}/features/demoApp.step3`, target: `div[data-testid="FEATURE_ENVIRONMENT_ACCORDION_${ENVIRONMENT}"] > div[aria-expanded="false"]`, content: ( @@ -378,6 +398,7 @@ export const TOPICS: ITutorialTopic[] = [ delay: 500, }, { + title: 'Edit strategy', target: `div[data-testid="FEATURE_ENVIRONMENT_ACCORDION_${ENVIRONMENT}"] a[data-testid="STRATEGY_EDIT-flexibleRollout"]`, content: ( @@ -388,6 +409,7 @@ export const TOPICS: ITutorialTopic[] = [ backCollapseExpanded: true, }, { + title: 'Edit rollout', target: 'span[data-testid="ROLLOUT_SLIDER_ID"]', content: ( <> @@ -405,6 +427,7 @@ export const TOPICS: ITutorialTopic[] = [ nextButton: true, }, { + title: 'Save changes for flag', target: 'button[data-testid="STRATEGY_FORM_SUBMIT_ID"]', content: ( @@ -413,6 +436,7 @@ export const TOPICS: ITutorialTopic[] = [ ), }, { + title: 'Confirm your changes', target: 'button[data-testid="DIALOGUE_CONFIRM_ID"]', content: ( @@ -423,6 +447,7 @@ export const TOPICS: ITutorialTopic[] = [ backCloseModal: true, }, { + title: 'Control the flag', href: `/projects/${PROJECT}?sort=name`, target: `div[data-testid="TOGGLE-demoApp.step3-${ENVIRONMENT}"]`, content: ( @@ -447,6 +472,7 @@ export const TOPICS: ITutorialTopic[] = [ setup: variants, steps: [ { + title: 'Adjust variants', href: `/projects/${PROJECT}?sort=name`, target: 'body', placement: 'center', @@ -472,6 +498,7 @@ export const TOPICS: ITutorialTopic[] = [ nextButton: true, }, { + title: 'Select a flag', href: `/projects/${PROJECT}?sort=name`, target: `table a[href="${basePath}/projects/${PROJECT}/features/demoApp.step4"]`, content: ( @@ -484,6 +511,7 @@ export const TOPICS: ITutorialTopic[] = [ preventDefault: true, }, { + title: 'Select an environment', href: `/projects/${PROJECT}/features/demoApp.step4`, target: `div[data-testid="FEATURE_ENVIRONMENT_ACCORDION_${ENVIRONMENT}"] > div[aria-expanded="false"]`, content: ( @@ -496,6 +524,7 @@ export const TOPICS: ITutorialTopic[] = [ delay: 500, }, { + title: 'Add a strategy', target: `div[data-testid="FEATURE_ENVIRONMENT_ACCORDION_${ENVIRONMENT}"] button[data-testid="ADD_STRATEGY_BUTTON"]`, content: ( @@ -507,6 +536,7 @@ export const TOPICS: ITutorialTopic[] = [ backCollapseExpanded: true, }, { + title: 'Select a strategy', target: `a[href="${basePath}/projects/${PROJECT}/features/demoApp.step4/strategies/create?environmentId=${ENVIRONMENT}&strategyName=flexibleRollout&defaultStrategy=true"]`, content: ( Select the default strategy. @@ -516,6 +546,7 @@ export const TOPICS: ITutorialTopic[] = [ backCloseModal: true, }, { + title: 'Narrow down your target audience', target: 'button[data-testid="STRATEGY_TARGETING_TAB"]', content: ( <> @@ -527,6 +558,7 @@ export const TOPICS: ITutorialTopic[] = [ backCloseModal: true, }, { + title: 'Add a constraint', target: 'button[data-testid="ADD_CONSTRAINT_BUTTON"]', content: ( <> @@ -556,6 +588,7 @@ export const TOPICS: ITutorialTopic[] = [ backCloseModal: true, }, { + title: 'Select a context', target: '#context-field-select', content: ( <> @@ -579,6 +612,7 @@ export const TOPICS: ITutorialTopic[] = [ anyClick: true, }, { + title: 'Select a pre-defined context field', target: 'li[data-testid="SELECT_ITEM_ID-userId"]', content: ( @@ -590,6 +624,7 @@ export const TOPICS: ITutorialTopic[] = [ backCloseModal: true, }, { + title: 'Input value', target: 'div[data-testid="CONSTRAINT_VALUES_INPUT"]', content: ( <> @@ -617,6 +652,7 @@ export const TOPICS: ITutorialTopic[] = [ focus: 'input', }, { + title: 'Add value', target: 'button[data-testid="CONSTRAINT_VALUES_ADD_BUTTON"]', content: ( @@ -625,6 +661,7 @@ export const TOPICS: ITutorialTopic[] = [ ), }, { + title: 'Save constraint setup', target: 'button[data-testid="CONSTRAINT_SAVE_BUTTON"]', content: ( @@ -634,6 +671,7 @@ export const TOPICS: ITutorialTopic[] = [ optional: true, }, { + title: 'Set up a variant', target: 'button[data-testid="STRATEGY_VARIANTS_TAB"]', content: ( <> @@ -645,6 +683,7 @@ export const TOPICS: ITutorialTopic[] = [ backCloseModal: true, }, { + title: 'Add new variant', target: 'button[data-testid="ADD_STRATEGY_VARIANT_BUTTON"]', content: ( <> @@ -674,6 +713,7 @@ export const TOPICS: ITutorialTopic[] = [ backCloseModal: true, }, { + title: 'Input variant name', target: 'div[data-testid="VARIANT"]:last-of-type div[data-testid="VARIANT_NAME_INPUT"]', content: ( <> @@ -692,6 +732,7 @@ export const TOPICS: ITutorialTopic[] = [ backCloseModal: true, }, { + title: 'Input variant value', target: 'div[data-testid="VARIANT"]:last-of-type #variant-payload-value', content: ( <> @@ -723,6 +764,7 @@ export const TOPICS: ITutorialTopic[] = [ focus: true, }, { + title: 'Save strategy for flag', target: 'button[data-testid="STRATEGY_FORM_SUBMIT_ID"]', content: ( @@ -732,6 +774,7 @@ export const TOPICS: ITutorialTopic[] = [ backCloseModal: true, }, { + title: 'Control the flag', href: `/projects/${PROJECT}?sort=name`, target: `div[data-testid="TOGGLE-demoApp.step4-${ENVIRONMENT}"]`, content: (