mirror of
https://github.com/Unleash/unleash.git
synced 2025-09-05 17:53:12 +02:00
Merge remote-tracking branch 'origin/main' into cypress-against-current-ui-nginx
This commit is contained in:
commit
edfc8cb253
1
.github/workflows/e2e.frontend.yaml
vendored
1
.github/workflows/e2e.frontend.yaml
vendored
@ -13,6 +13,7 @@ jobs:
|
|||||||
- groups/groups.spec.ts
|
- groups/groups.spec.ts
|
||||||
- projects/access.spec.ts
|
- projects/access.spec.ts
|
||||||
- segments/segments.spec.ts
|
- segments/segments.spec.ts
|
||||||
|
- login/login.spec.ts
|
||||||
steps:
|
steps:
|
||||||
- name: Dump GitHub context
|
- name: Dump GitHub context
|
||||||
env:
|
env:
|
||||||
|
1
frontend/cypress/global.d.ts
vendored
1
frontend/cypress/global.d.ts
vendored
@ -21,6 +21,7 @@ declare namespace Cypress {
|
|||||||
interface Chainable {
|
interface Chainable {
|
||||||
runBefore(): Chainable;
|
runBefore(): Chainable;
|
||||||
|
|
||||||
|
do_login(user = AUTH_USER, password = AUTH_PASSWORD): Chainable;
|
||||||
login_UI(user = AUTH_USER, password = AUTH_PASSWORD): Chainable;
|
login_UI(user = AUTH_USER, password = AUTH_PASSWORD): Chainable;
|
||||||
logout_UI(): Chainable;
|
logout_UI(): Chainable;
|
||||||
|
|
||||||
|
80
frontend/cypress/integration/login/login.spec.ts
Normal file
80
frontend/cypress/integration/login/login.spec.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
///<reference path="../../global.d.ts" />
|
||||||
|
|
||||||
|
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`);
|
||||||
|
});
|
||||||
|
});
|
@ -24,28 +24,35 @@ export const runBefore = () => {
|
|||||||
disableActiveSplashScreens();
|
disableActiveSplashScreens();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const do_login = (
|
||||||
|
user = AUTH_USER,
|
||||||
|
password = AUTH_PASSWORD,
|
||||||
|
): Chainable<any> => {
|
||||||
|
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 = (
|
export const login_UI = (
|
||||||
user = AUTH_USER,
|
user = AUTH_USER,
|
||||||
password = AUTH_PASSWORD,
|
password = AUTH_PASSWORD,
|
||||||
): Chainable<any> => {
|
): Chainable<any> => {
|
||||||
return cy.session(user, () => {
|
return cy.session(user, () => do_login(user, password));
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createFeature_UI = (
|
export const createFeature_UI = (
|
||||||
|
@ -12,6 +12,7 @@ import {
|
|||||||
addFlexibleRolloutStrategyToFeature_UI,
|
addFlexibleRolloutStrategyToFeature_UI,
|
||||||
addUserIdStrategyToFeature_UI,
|
addUserIdStrategyToFeature_UI,
|
||||||
updateFlexibleRolloutStrategy_UI,
|
updateFlexibleRolloutStrategy_UI,
|
||||||
|
do_login,
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
} from './UI';
|
} from './UI';
|
||||||
import {
|
import {
|
||||||
@ -32,6 +33,7 @@ Cypress.on('window:before:load', (window) => {
|
|||||||
});
|
});
|
||||||
Cypress.Commands.add('runBefore', runBefore);
|
Cypress.Commands.add('runBefore', runBefore);
|
||||||
Cypress.Commands.add('login_UI', login_UI);
|
Cypress.Commands.add('login_UI', login_UI);
|
||||||
|
Cypress.Commands.add('do_login', do_login);
|
||||||
Cypress.Commands.add('createSegment_UI', createSegment_UI);
|
Cypress.Commands.add('createSegment_UI', createSegment_UI);
|
||||||
Cypress.Commands.add('deleteSegment_UI', deleteSegment_UI);
|
Cypress.Commands.add('deleteSegment_UI', deleteSegment_UI);
|
||||||
Cypress.Commands.add('deleteFeature_API', deleteFeature_API);
|
Cypress.Commands.add('deleteFeature_API', deleteFeature_API);
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Suspense, useEffect } from 'react';
|
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 { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { FeedbackNPS } from 'component/feedback/FeedbackNPS/FeedbackNPS';
|
import { FeedbackNPS } from 'component/feedback/FeedbackNPS/FeedbackNPS';
|
||||||
import { LayoutPicker } from 'component/layout/LayoutPicker/LayoutPicker';
|
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 MaintenanceBanner from './maintenance/MaintenanceBanner';
|
||||||
import { styled } from '@mui/material';
|
import { styled } from '@mui/material';
|
||||||
import { InitialRedirect } from './InitialRedirect';
|
import { InitialRedirect, useLastViewedPage } from './InitialRedirect';
|
||||||
import { InternalBanners } from './banners/internalBanners/InternalBanners';
|
import { InternalBanners } from './banners/internalBanners/InternalBanners';
|
||||||
import { ExternalBanners } from './banners/externalBanners/ExternalBanners';
|
import { ExternalBanners } from './banners/externalBanners/ExternalBanners';
|
||||||
import { LicenseBanner } from './banners/internalBanners/LicenseBanner';
|
import { LicenseBanner } from './banners/internalBanners/LicenseBanner';
|
||||||
@ -51,6 +51,9 @@ export const App = () => {
|
|||||||
|
|
||||||
const isLoggedIn = Boolean(user?.id);
|
const isLoggedIn = Boolean(user?.id);
|
||||||
|
|
||||||
|
const location = useLocation();
|
||||||
|
useLastViewedPage(location);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SWRProvider>
|
<SWRProvider>
|
||||||
<Suspense fallback={<Loader type='fullscreen' />}>
|
<Suspense fallback={<Loader type='fullscreen' />}>
|
||||||
|
@ -1,37 +1,59 @@
|
|||||||
import { useCallback, useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { type Location, Navigate } from 'react-router-dom';
|
||||||
import useProjects from 'hooks/api/getters/useProjects/useProjects';
|
import useProjects from 'hooks/api/getters/useProjects/useProjects';
|
||||||
import { useLastViewedProject } from 'hooks/useLastViewedProject';
|
import { useLastViewedProject } from 'hooks/useLastViewedProject';
|
||||||
import Loader from './common/Loader/Loader';
|
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 = () => {
|
export const useLastViewedPage = (location?: Location) => {
|
||||||
const { lastViewed } = useLastViewedProject();
|
const [state, setState] = useLocalStorageState<string>(
|
||||||
const { projects, loading } = useProjects();
|
'lastViewedPage',
|
||||||
const navigate = useNavigate();
|
'/personal',
|
||||||
const sessionRedirect = getSessionStorageItem('login-redirect');
|
7 * 24 * 60 * 60 * 1000, // 7 days, left to promote seeing Personal dashboard from time to time
|
||||||
|
);
|
||||||
// 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 });
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSessionStorageItem('login-redirect');
|
if (location) {
|
||||||
redirect();
|
const page = [
|
||||||
}, [getRedirect]);
|
'/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 <Loader type='fullscreen' />;
|
return <Loader type='fullscreen' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
if (!isLoggedIn) {
|
||||||
|
return <Navigate to='/login' replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastViewedPage === '/projects' && lastViewedProject) {
|
||||||
|
return <Navigate to={`/projects/${lastViewedProject}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Navigate to={lastViewedPage} replace />;
|
||||||
};
|
};
|
||||||
|
@ -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 <StyledNavLink to={to}>{children}</StyledNavLink>;
|
|
||||||
};
|
|
@ -40,6 +40,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
title: 'Enable/disable a feature flag',
|
title: 'Enable/disable a feature flag',
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
|
title: 'Enable/disable a feature flag',
|
||||||
href: `/projects/${PROJECT}?sort=name`,
|
href: `/projects/${PROJECT}?sort=name`,
|
||||||
target: 'body',
|
target: 'body',
|
||||||
placement: 'center',
|
placement: 'center',
|
||||||
@ -71,6 +72,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
nextButton: true,
|
nextButton: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Control the flag',
|
||||||
href: `/projects/${PROJECT}?sort=name`,
|
href: `/projects/${PROJECT}?sort=name`,
|
||||||
target: `div[data-testid="TOGGLE-demoApp.step1-${ENVIRONMENT}"]`,
|
target: `div[data-testid="TOGGLE-demoApp.step1-${ENVIRONMENT}"]`,
|
||||||
content: (
|
content: (
|
||||||
@ -93,6 +95,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
setup: specificUser,
|
setup: specificUser,
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
|
title: 'Enable for a specific user',
|
||||||
href: `/projects/${PROJECT}?sort=name`,
|
href: `/projects/${PROJECT}?sort=name`,
|
||||||
target: 'body',
|
target: 'body',
|
||||||
placement: 'center',
|
placement: 'center',
|
||||||
@ -118,6 +121,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
nextButton: true,
|
nextButton: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Select a flag',
|
||||||
href: `/projects/${PROJECT}?sort=name`,
|
href: `/projects/${PROJECT}?sort=name`,
|
||||||
target: `table a[href="${basePath}/projects/${PROJECT}/features/demoApp.step2"]`,
|
target: `table a[href="${basePath}/projects/${PROJECT}/features/demoApp.step2"]`,
|
||||||
content: (
|
content: (
|
||||||
@ -130,6 +134,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
preventDefault: true,
|
preventDefault: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Select an environment',
|
||||||
href: `/projects/${PROJECT}/features/demoApp.step2`,
|
href: `/projects/${PROJECT}/features/demoApp.step2`,
|
||||||
target: `div[data-testid="FEATURE_ENVIRONMENT_ACCORDION_${ENVIRONMENT}"] > div[aria-expanded="false"]`,
|
target: `div[data-testid="FEATURE_ENVIRONMENT_ACCORDION_${ENVIRONMENT}"] > div[aria-expanded="false"]`,
|
||||||
content: (
|
content: (
|
||||||
@ -142,6 +147,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
delay: 500,
|
delay: 500,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Add a strategy',
|
||||||
target: `div[data-testid="FEATURE_ENVIRONMENT_ACCORDION_${ENVIRONMENT}"] button[data-testid="ADD_STRATEGY_BUTTON"]`,
|
target: `div[data-testid="FEATURE_ENVIRONMENT_ACCORDION_${ENVIRONMENT}"] button[data-testid="ADD_STRATEGY_BUTTON"]`,
|
||||||
content: (
|
content: (
|
||||||
<Description>
|
<Description>
|
||||||
@ -153,6 +159,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
backCollapseExpanded: true,
|
backCollapseExpanded: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Select a strategy',
|
||||||
target: `a[href="${basePath}/projects/${PROJECT}/features/demoApp.step2/strategies/create?environmentId=${ENVIRONMENT}&strategyName=flexibleRollout&defaultStrategy=true"]`,
|
target: `a[href="${basePath}/projects/${PROJECT}/features/demoApp.step2/strategies/create?environmentId=${ENVIRONMENT}&strategyName=flexibleRollout&defaultStrategy=true"]`,
|
||||||
content: (
|
content: (
|
||||||
<Description>Select the default strategy.</Description>
|
<Description>Select the default strategy.</Description>
|
||||||
@ -162,6 +169,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
backCloseModal: true,
|
backCloseModal: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Narrow down your target audience',
|
||||||
target: 'button[data-testid="STRATEGY_TARGETING_TAB"]',
|
target: 'button[data-testid="STRATEGY_TARGETING_TAB"]',
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
@ -173,6 +181,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
backCloseModal: true,
|
backCloseModal: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Add a constraint',
|
||||||
target: 'button[data-testid="ADD_CONSTRAINT_BUTTON"]',
|
target: 'button[data-testid="ADD_CONSTRAINT_BUTTON"]',
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
@ -202,6 +211,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
backCloseModal: true,
|
backCloseModal: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Select a context',
|
||||||
target: '#context-field-select',
|
target: '#context-field-select',
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
@ -225,6 +235,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
anyClick: true,
|
anyClick: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Select a pre-defined context field',
|
||||||
target: 'li[data-testid="SELECT_ITEM_ID-userId"]',
|
target: 'li[data-testid="SELECT_ITEM_ID-userId"]',
|
||||||
content: (
|
content: (
|
||||||
<Description>
|
<Description>
|
||||||
@ -236,6 +247,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
backCloseModal: true,
|
backCloseModal: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Input value',
|
||||||
target: 'div[data-testid="CONSTRAINT_VALUES_INPUT"]',
|
target: 'div[data-testid="CONSTRAINT_VALUES_INPUT"]',
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
@ -263,6 +275,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
focus: 'input',
|
focus: 'input',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Add value',
|
||||||
target: 'button[data-testid="CONSTRAINT_VALUES_ADD_BUTTON"]',
|
target: 'button[data-testid="CONSTRAINT_VALUES_ADD_BUTTON"]',
|
||||||
content: (
|
content: (
|
||||||
<Description>
|
<Description>
|
||||||
@ -271,6 +284,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Save constraint setup',
|
||||||
target: 'button[data-testid="CONSTRAINT_SAVE_BUTTON"]',
|
target: 'button[data-testid="CONSTRAINT_SAVE_BUTTON"]',
|
||||||
content: (
|
content: (
|
||||||
<Description>
|
<Description>
|
||||||
@ -280,6 +294,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
optional: true,
|
optional: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Save strategy for flag',
|
||||||
target: 'button[data-testid="STRATEGY_FORM_SUBMIT_ID"]',
|
target: 'button[data-testid="STRATEGY_FORM_SUBMIT_ID"]',
|
||||||
content: (
|
content: (
|
||||||
<Description>
|
<Description>
|
||||||
@ -289,6 +304,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
backCloseModal: true,
|
backCloseModal: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Confirm your changes',
|
||||||
target: 'button[data-testid="DIALOGUE_CONFIRM_ID"]',
|
target: 'button[data-testid="DIALOGUE_CONFIRM_ID"]',
|
||||||
content: (
|
content: (
|
||||||
<Description>
|
<Description>
|
||||||
@ -299,6 +315,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
backCloseModal: true,
|
backCloseModal: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Control the flag',
|
||||||
href: `/projects/${PROJECT}?sort=name`,
|
href: `/projects/${PROJECT}?sort=name`,
|
||||||
target: `div[data-testid="TOGGLE-demoApp.step2-${ENVIRONMENT}"]`,
|
target: `div[data-testid="TOGGLE-demoApp.step2-${ENVIRONMENT}"]`,
|
||||||
content: (
|
content: (
|
||||||
@ -322,6 +339,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
setup: gradualRollout,
|
setup: gradualRollout,
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
|
title: 'Adjust gradual rollout',
|
||||||
href: `/projects/${PROJECT}?sort=name`,
|
href: `/projects/${PROJECT}?sort=name`,
|
||||||
target: 'body',
|
target: 'body',
|
||||||
placement: 'center',
|
placement: 'center',
|
||||||
@ -354,6 +372,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
nextButton: true,
|
nextButton: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Select a flag',
|
||||||
href: `/projects/${PROJECT}?sort=name`,
|
href: `/projects/${PROJECT}?sort=name`,
|
||||||
target: `table a[href="${basePath}/projects/${PROJECT}/features/demoApp.step3"]`,
|
target: `table a[href="${basePath}/projects/${PROJECT}/features/demoApp.step3"]`,
|
||||||
content: (
|
content: (
|
||||||
@ -366,6 +385,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
preventDefault: true,
|
preventDefault: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Select an environment',
|
||||||
href: `/projects/${PROJECT}/features/demoApp.step3`,
|
href: `/projects/${PROJECT}/features/demoApp.step3`,
|
||||||
target: `div[data-testid="FEATURE_ENVIRONMENT_ACCORDION_${ENVIRONMENT}"] > div[aria-expanded="false"]`,
|
target: `div[data-testid="FEATURE_ENVIRONMENT_ACCORDION_${ENVIRONMENT}"] > div[aria-expanded="false"]`,
|
||||||
content: (
|
content: (
|
||||||
@ -378,6 +398,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
delay: 500,
|
delay: 500,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Edit strategy',
|
||||||
target: `div[data-testid="FEATURE_ENVIRONMENT_ACCORDION_${ENVIRONMENT}"] a[data-testid="STRATEGY_EDIT-flexibleRollout"]`,
|
target: `div[data-testid="FEATURE_ENVIRONMENT_ACCORDION_${ENVIRONMENT}"] a[data-testid="STRATEGY_EDIT-flexibleRollout"]`,
|
||||||
content: (
|
content: (
|
||||||
<Description>
|
<Description>
|
||||||
@ -388,6 +409,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
backCollapseExpanded: true,
|
backCollapseExpanded: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Edit rollout',
|
||||||
target: 'span[data-testid="ROLLOUT_SLIDER_ID"]',
|
target: 'span[data-testid="ROLLOUT_SLIDER_ID"]',
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
@ -405,6 +427,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
nextButton: true,
|
nextButton: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Save changes for flag',
|
||||||
target: 'button[data-testid="STRATEGY_FORM_SUBMIT_ID"]',
|
target: 'button[data-testid="STRATEGY_FORM_SUBMIT_ID"]',
|
||||||
content: (
|
content: (
|
||||||
<Description>
|
<Description>
|
||||||
@ -413,6 +436,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Confirm your changes',
|
||||||
target: 'button[data-testid="DIALOGUE_CONFIRM_ID"]',
|
target: 'button[data-testid="DIALOGUE_CONFIRM_ID"]',
|
||||||
content: (
|
content: (
|
||||||
<Description>
|
<Description>
|
||||||
@ -423,6 +447,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
backCloseModal: true,
|
backCloseModal: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Control the flag',
|
||||||
href: `/projects/${PROJECT}?sort=name`,
|
href: `/projects/${PROJECT}?sort=name`,
|
||||||
target: `div[data-testid="TOGGLE-demoApp.step3-${ENVIRONMENT}"]`,
|
target: `div[data-testid="TOGGLE-demoApp.step3-${ENVIRONMENT}"]`,
|
||||||
content: (
|
content: (
|
||||||
@ -447,6 +472,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
setup: variants,
|
setup: variants,
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
|
title: 'Adjust variants',
|
||||||
href: `/projects/${PROJECT}?sort=name`,
|
href: `/projects/${PROJECT}?sort=name`,
|
||||||
target: 'body',
|
target: 'body',
|
||||||
placement: 'center',
|
placement: 'center',
|
||||||
@ -472,6 +498,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
nextButton: true,
|
nextButton: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Select a flag',
|
||||||
href: `/projects/${PROJECT}?sort=name`,
|
href: `/projects/${PROJECT}?sort=name`,
|
||||||
target: `table a[href="${basePath}/projects/${PROJECT}/features/demoApp.step4"]`,
|
target: `table a[href="${basePath}/projects/${PROJECT}/features/demoApp.step4"]`,
|
||||||
content: (
|
content: (
|
||||||
@ -484,6 +511,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
preventDefault: true,
|
preventDefault: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Select an environment',
|
||||||
href: `/projects/${PROJECT}/features/demoApp.step4`,
|
href: `/projects/${PROJECT}/features/demoApp.step4`,
|
||||||
target: `div[data-testid="FEATURE_ENVIRONMENT_ACCORDION_${ENVIRONMENT}"] > div[aria-expanded="false"]`,
|
target: `div[data-testid="FEATURE_ENVIRONMENT_ACCORDION_${ENVIRONMENT}"] > div[aria-expanded="false"]`,
|
||||||
content: (
|
content: (
|
||||||
@ -496,6 +524,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
delay: 500,
|
delay: 500,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Add a strategy',
|
||||||
target: `div[data-testid="FEATURE_ENVIRONMENT_ACCORDION_${ENVIRONMENT}"] button[data-testid="ADD_STRATEGY_BUTTON"]`,
|
target: `div[data-testid="FEATURE_ENVIRONMENT_ACCORDION_${ENVIRONMENT}"] button[data-testid="ADD_STRATEGY_BUTTON"]`,
|
||||||
content: (
|
content: (
|
||||||
<Description>
|
<Description>
|
||||||
@ -507,6 +536,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
backCollapseExpanded: true,
|
backCollapseExpanded: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Select a strategy',
|
||||||
target: `a[href="${basePath}/projects/${PROJECT}/features/demoApp.step4/strategies/create?environmentId=${ENVIRONMENT}&strategyName=flexibleRollout&defaultStrategy=true"]`,
|
target: `a[href="${basePath}/projects/${PROJECT}/features/demoApp.step4/strategies/create?environmentId=${ENVIRONMENT}&strategyName=flexibleRollout&defaultStrategy=true"]`,
|
||||||
content: (
|
content: (
|
||||||
<Description>Select the default strategy.</Description>
|
<Description>Select the default strategy.</Description>
|
||||||
@ -516,6 +546,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
backCloseModal: true,
|
backCloseModal: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Narrow down your target audience',
|
||||||
target: 'button[data-testid="STRATEGY_TARGETING_TAB"]',
|
target: 'button[data-testid="STRATEGY_TARGETING_TAB"]',
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
@ -527,6 +558,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
backCloseModal: true,
|
backCloseModal: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Add a constraint',
|
||||||
target: 'button[data-testid="ADD_CONSTRAINT_BUTTON"]',
|
target: 'button[data-testid="ADD_CONSTRAINT_BUTTON"]',
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
@ -556,6 +588,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
backCloseModal: true,
|
backCloseModal: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Select a context',
|
||||||
target: '#context-field-select',
|
target: '#context-field-select',
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
@ -579,6 +612,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
anyClick: true,
|
anyClick: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Select a pre-defined context field',
|
||||||
target: 'li[data-testid="SELECT_ITEM_ID-userId"]',
|
target: 'li[data-testid="SELECT_ITEM_ID-userId"]',
|
||||||
content: (
|
content: (
|
||||||
<Description>
|
<Description>
|
||||||
@ -590,6 +624,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
backCloseModal: true,
|
backCloseModal: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Input value',
|
||||||
target: 'div[data-testid="CONSTRAINT_VALUES_INPUT"]',
|
target: 'div[data-testid="CONSTRAINT_VALUES_INPUT"]',
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
@ -617,6 +652,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
focus: 'input',
|
focus: 'input',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Add value',
|
||||||
target: 'button[data-testid="CONSTRAINT_VALUES_ADD_BUTTON"]',
|
target: 'button[data-testid="CONSTRAINT_VALUES_ADD_BUTTON"]',
|
||||||
content: (
|
content: (
|
||||||
<Description>
|
<Description>
|
||||||
@ -625,6 +661,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Save constraint setup',
|
||||||
target: 'button[data-testid="CONSTRAINT_SAVE_BUTTON"]',
|
target: 'button[data-testid="CONSTRAINT_SAVE_BUTTON"]',
|
||||||
content: (
|
content: (
|
||||||
<Description>
|
<Description>
|
||||||
@ -634,6 +671,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
optional: true,
|
optional: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Set up a variant',
|
||||||
target: 'button[data-testid="STRATEGY_VARIANTS_TAB"]',
|
target: 'button[data-testid="STRATEGY_VARIANTS_TAB"]',
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
@ -645,6 +683,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
backCloseModal: true,
|
backCloseModal: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Add new variant',
|
||||||
target: 'button[data-testid="ADD_STRATEGY_VARIANT_BUTTON"]',
|
target: 'button[data-testid="ADD_STRATEGY_VARIANT_BUTTON"]',
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
@ -674,6 +713,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
backCloseModal: true,
|
backCloseModal: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Input variant name',
|
||||||
target: 'div[data-testid="VARIANT"]:last-of-type div[data-testid="VARIANT_NAME_INPUT"]',
|
target: 'div[data-testid="VARIANT"]:last-of-type div[data-testid="VARIANT_NAME_INPUT"]',
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
@ -692,6 +732,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
backCloseModal: true,
|
backCloseModal: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Input variant value',
|
||||||
target: 'div[data-testid="VARIANT"]:last-of-type #variant-payload-value',
|
target: 'div[data-testid="VARIANT"]:last-of-type #variant-payload-value',
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
@ -723,6 +764,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
focus: true,
|
focus: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Save strategy for flag',
|
||||||
target: 'button[data-testid="STRATEGY_FORM_SUBMIT_ID"]',
|
target: 'button[data-testid="STRATEGY_FORM_SUBMIT_ID"]',
|
||||||
content: (
|
content: (
|
||||||
<Description>
|
<Description>
|
||||||
@ -732,6 +774,7 @@ export const TOPICS: ITutorialTopic[] = [
|
|||||||
backCloseModal: true,
|
backCloseModal: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: 'Control the flag',
|
||||||
href: `/projects/${PROJECT}?sort=name`,
|
href: `/projects/${PROJECT}?sort=name`,
|
||||||
target: `div[data-testid="TOGGLE-demoApp.step4-${ENVIRONMENT}"]`,
|
target: `div[data-testid="TOGGLE-demoApp.step4-${ENVIRONMENT}"]`,
|
||||||
content: (
|
content: (
|
||||||
|
@ -401,7 +401,11 @@ export const FeatureToggleListTable: FC = () => {
|
|||||||
bodyClass='no-padding'
|
bodyClass='no-padding'
|
||||||
header={
|
header={
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title='Search'
|
title={
|
||||||
|
flagsReleaseManagementUIEnabled
|
||||||
|
? 'Flags overview'
|
||||||
|
: 'Search'
|
||||||
|
}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
|
@ -2,7 +2,7 @@ import { vi } from 'vitest';
|
|||||||
import { CleanupReminder } from './CleanupReminder';
|
import { CleanupReminder } from './CleanupReminder';
|
||||||
import { render } from 'utils/testRenderer';
|
import { render } from 'utils/testRenderer';
|
||||||
import type { IFeatureToggle } from 'interfaces/featureToggle';
|
import type { IFeatureToggle } from 'interfaces/featureToggle';
|
||||||
import { screen } from '@testing-library/react';
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
import {
|
import {
|
||||||
DELETE_FEATURE,
|
DELETE_FEATURE,
|
||||||
UPDATE_FEATURE,
|
UPDATE_FEATURE,
|
||||||
@ -11,6 +11,10 @@ import {
|
|||||||
const currentTime = '2024-04-25T08:05:00.000Z';
|
const currentTime = '2024-04-25T08:05:00.000Z';
|
||||||
const monthAgo = '2024-03-25T06:05:00.000Z';
|
const monthAgo = '2024-03-25T06:05:00.000Z';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
test('render complete feature reminder', async () => {
|
test('render complete feature reminder', async () => {
|
||||||
vi.setSystemTime(currentTime);
|
vi.setSystemTime(currentTime);
|
||||||
const feature = {
|
const feature = {
|
||||||
@ -55,6 +59,13 @@ test('render remove flag from code reminder', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await screen.findByText('Time to remove flag from code?');
|
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 () => {
|
test('render archive flag reminder', async () => {
|
||||||
@ -78,4 +89,11 @@ test('render archive flag reminder', async () => {
|
|||||||
await screen.findByText('child1');
|
await screen.findByText('child1');
|
||||||
const okButton = await screen.findByText('OK');
|
const okButton = await screen.findByText('OK');
|
||||||
okButton.click();
|
okButton.click();
|
||||||
|
|
||||||
|
const reminder = await screen.findByText('Remind me later');
|
||||||
|
reminder.click();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Archive flag')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { type FC, useState } from 'react';
|
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 FlagIcon from '@mui/icons-material/OutlinedFlag';
|
||||||
import CleaningServicesIcon from '@mui/icons-material/CleaningServices';
|
import CleaningServicesIcon from '@mui/icons-material/CleaningServices';
|
||||||
import { parseISO } from 'date-fns';
|
import { parseISO } from 'date-fns';
|
||||||
@ -17,12 +17,19 @@ import type { IFeatureToggle } from 'interfaces/featureToggle';
|
|||||||
import { FeatureArchiveNotAllowedDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveNotAllowedDialog';
|
import { FeatureArchiveNotAllowedDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveNotAllowedDialog';
|
||||||
import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog';
|
import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useFlagReminders } from './useFlagReminders';
|
||||||
|
|
||||||
const StyledBox = styled(Box)(({ theme }) => ({
|
const StyledBox = styled(Box)(({ theme }) => ({
|
||||||
marginRight: theme.spacing(2),
|
marginRight: theme.spacing(2),
|
||||||
marginBottom: 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;
|
type ReminderType = 'complete' | 'removeCode' | 'archive' | null;
|
||||||
|
|
||||||
export const CleanupReminder: FC<{
|
export const CleanupReminder: FC<{
|
||||||
@ -42,6 +49,7 @@ export const CleanupReminder: FC<{
|
|||||||
const daysInStage = enteredStageAt
|
const daysInStage = enteredStageAt
|
||||||
? differenceInDays(new Date(), parseISO(enteredStageAt))
|
? differenceInDays(new Date(), parseISO(enteredStageAt))
|
||||||
: 0;
|
: 0;
|
||||||
|
const { shouldShowReminder, snoozeReminder } = useFlagReminders();
|
||||||
|
|
||||||
const determineReminder = (): ReminderType => {
|
const determineReminder = (): ReminderType => {
|
||||||
if (!currentStage || !isRelevantType) return null;
|
if (!currentStage || !isRelevantType) return null;
|
||||||
@ -49,7 +57,10 @@ export const CleanupReminder: FC<{
|
|||||||
if (currentStage.name === 'live' && daysInStage > 30) {
|
if (currentStage.name === 'live' && daysInStage > 30) {
|
||||||
return 'complete';
|
return 'complete';
|
||||||
}
|
}
|
||||||
if (currentStage.name === 'completed') {
|
if (
|
||||||
|
currentStage.name === 'completed' &&
|
||||||
|
shouldShowReminder(feature.name)
|
||||||
|
) {
|
||||||
if (isSafeToArchive(currentStage.environments)) {
|
if (isSafeToArchive(currentStage.environments)) {
|
||||||
return 'archive';
|
return 'archive';
|
||||||
}
|
}
|
||||||
@ -76,7 +87,7 @@ export const CleanupReminder: FC<{
|
|||||||
<PermissionButton
|
<PermissionButton
|
||||||
variant='contained'
|
variant='contained'
|
||||||
permission={UPDATE_FEATURE}
|
permission={UPDATE_FEATURE}
|
||||||
size='small'
|
size='medium'
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setMarkCompleteDialogueOpen(true)
|
setMarkCompleteDialogueOpen(true)
|
||||||
}
|
}
|
||||||
@ -109,16 +120,23 @@ export const CleanupReminder: FC<{
|
|||||||
severity='warning'
|
severity='warning'
|
||||||
icon={<CleaningServicesIcon />}
|
icon={<CleaningServicesIcon />}
|
||||||
action={
|
action={
|
||||||
<PermissionButton
|
<ActionsBox>
|
||||||
variant='contained'
|
<Button
|
||||||
permission={DELETE_FEATURE}
|
size='medium'
|
||||||
size='small'
|
onClick={() => snoozeReminder(feature.name)}
|
||||||
sx={{ mb: 2 }}
|
>
|
||||||
onClick={() => setArchiveDialogueOpen(true)}
|
Remind me later
|
||||||
projectId={feature.project}
|
</Button>
|
||||||
>
|
<PermissionButton
|
||||||
Archive flag
|
variant='contained'
|
||||||
</PermissionButton>
|
permission={DELETE_FEATURE}
|
||||||
|
size='medium'
|
||||||
|
onClick={() => setArchiveDialogueOpen(true)}
|
||||||
|
projectId={feature.project}
|
||||||
|
>
|
||||||
|
Archive flag
|
||||||
|
</PermissionButton>
|
||||||
|
</ActionsBox>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<b>Time to clean up technical debt?</b>
|
<b>Time to clean up technical debt?</b>
|
||||||
@ -149,7 +167,18 @@ export const CleanupReminder: FC<{
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{reminder === 'removeCode' && (
|
{reminder === 'removeCode' && (
|
||||||
<Alert severity='warning' icon={<CleaningServicesIcon />}>
|
<Alert
|
||||||
|
severity='warning'
|
||||||
|
icon={<CleaningServicesIcon />}
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
size='medium'
|
||||||
|
onClick={() => snoozeReminder(feature.name)}
|
||||||
|
>
|
||||||
|
Remind me later
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
<b>Time to remove flag from code?</b>
|
<b>Time to remove flag from code?</b>
|
||||||
<p>
|
<p>
|
||||||
This flag was marked as complete and ready for cleanup.
|
This flag was marked as complete and ready for cleanup.
|
||||||
|
@ -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 (
|
||||||
|
<div>
|
||||||
|
<button type='button' onClick={() => snoozeReminder('test-flag')}>
|
||||||
|
Snooze
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => snoozeReminder('test-flag-another')}
|
||||||
|
>
|
||||||
|
Snooze Another
|
||||||
|
</button>
|
||||||
|
<div data-testid='result'>
|
||||||
|
{shouldShowReminder('test-flag') ? 'yes' : 'no'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('useFlagReminders (integration)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show reminder when no snooze exists', () => {
|
||||||
|
render(<TestComponent />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('result').textContent).toBe('yes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not show reminder after snoozing', () => {
|
||||||
|
render(<TestComponent />);
|
||||||
|
fireEvent.click(screen.getByText('Snooze'));
|
||||||
|
|
||||||
|
expect(screen.getByTestId('result').textContent).toBe('no');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show reminder again after snooze expires', () => {
|
||||||
|
const { rerender } = render(<TestComponent days={3} />);
|
||||||
|
fireEvent.click(screen.getByText('Snooze'));
|
||||||
|
|
||||||
|
// Advance 4 days
|
||||||
|
vi.advanceTimersByTime(4 * 24 * 60 * 60 * 1000);
|
||||||
|
rerender(<TestComponent days={3} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('result').textContent).toBe('yes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect max reminders and remove oldest entries', () => {
|
||||||
|
render(<TestComponent maxReminders={1} />);
|
||||||
|
fireEvent.click(screen.getByText('Snooze'));
|
||||||
|
fireEvent.click(screen.getByText('Snooze Another'));
|
||||||
|
|
||||||
|
expect(screen.getByTestId('result').textContent).toBe('yes');
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,53 @@
|
|||||||
|
import { isAfter, addDays } from 'date-fns';
|
||||||
|
import { useLocalStorageState } from 'hooks/useLocalStorageState';
|
||||||
|
|
||||||
|
type FlagReminderMap = Record<string, number>; // 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<FlagReminderMap>(
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
@ -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 (
|
|
||||||
<AdminMenu>
|
|
||||||
<AdminMainLayoutContent>{children}</AdminMainLayoutContent>
|
|
||||||
</AdminMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <MainLayoutContent>{children}</MainLayoutContent>;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<StyledAdminMainGrid container spacing={1}>
|
|
||||||
<Grid item>
|
|
||||||
<StickyContainer>
|
|
||||||
<StyledMenuPaper>
|
|
||||||
<AdminMenuNavigation onClick={onClick} />
|
|
||||||
</StyledMenuPaper>
|
|
||||||
</StickyContainer>
|
|
||||||
</Grid>
|
|
||||||
<Grid item md={breakpointedSize}>
|
|
||||||
{children}
|
|
||||||
</Grid>
|
|
||||||
</StyledAdminMainGrid>
|
|
||||||
);
|
|
||||||
};
|
|
@ -39,20 +39,6 @@
|
|||||||
color: inherit;
|
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 {
|
.navigationIcon {
|
||||||
margin-right: 16px;
|
margin-right: 16px;
|
||||||
fill: #635dc5;
|
fill: #635dc5;
|
||||||
|
@ -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 (
|
|
||||||
<StyledListItem
|
|
||||||
onClick={() => {
|
|
||||||
handleClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
style={{ textDecoration: 'none' }}
|
|
||||||
component={StyledLink}
|
|
||||||
to={path}
|
|
||||||
underline='hover'
|
|
||||||
>
|
|
||||||
<StyledSpan />
|
|
||||||
{text}
|
|
||||||
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={showEnterpriseBadgeToPro}
|
|
||||||
show={
|
|
||||||
<StyledBadgeContainer>
|
|
||||||
<EnterpriseBadge />
|
|
||||||
</StyledBadgeContainer>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</StyledListItem>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NavigationLink;
|
|
@ -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 (
|
|
||||||
<Menu
|
|
||||||
id={id}
|
|
||||||
onClose={handleClose}
|
|
||||||
anchorEl={anchorEl}
|
|
||||||
open={Boolean(anchorEl)}
|
|
||||||
style={style}
|
|
||||||
>
|
|
||||||
{options
|
|
||||||
.flatMap((option, i) => {
|
|
||||||
const previousGroup = options[i - 1]?.group;
|
|
||||||
const addDivider =
|
|
||||||
previousGroup &&
|
|
||||||
previousGroup !== option.group &&
|
|
||||||
(!isOss() || option.group === 'log');
|
|
||||||
|
|
||||||
return [
|
|
||||||
addDivider ? (
|
|
||||||
<Divider variant='middle' key={option.group} />
|
|
||||||
) : null,
|
|
||||||
<Tooltip
|
|
||||||
title={
|
|
||||||
showBadge(option?.menu?.mode)
|
|
||||||
? 'This is an Enterprise feature'
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
arrow
|
|
||||||
placement='left'
|
|
||||||
key={option.path}
|
|
||||||
>
|
|
||||||
<MenuItem
|
|
||||||
component={StyledLink}
|
|
||||||
to={option.path}
|
|
||||||
onClick={handleClose}
|
|
||||||
>
|
|
||||||
<StyledSpan />
|
|
||||||
{option.title}
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={showBadge(option?.menu?.mode)}
|
|
||||||
show={
|
|
||||||
<StyledBadgeContainer>
|
|
||||||
<EnterpriseBadge />
|
|
||||||
</StyledBadgeContainer>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</MenuItem>
|
|
||||||
</Tooltip>,
|
|
||||||
];
|
|
||||||
})
|
|
||||||
.filter(Boolean)}
|
|
||||||
</Menu>
|
|
||||||
);
|
|
||||||
};
|
|
@ -50,7 +50,7 @@ export const StyledInnerContainer = styled('div')(({ theme }) => ({
|
|||||||
alignItems: 'start',
|
alignItems: 'start',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const StyledProjectTitle = styled('span')(({ theme }) => ({
|
export const StyledProjectTitle = styled('h1')(({ theme }) => ({
|
||||||
margin: 0,
|
margin: 0,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
fontSize: theme.typography.h1.fontSize,
|
fontSize: theme.typography.h1.fontSize,
|
||||||
|
@ -21,21 +21,24 @@ interface ISimpleAuthProps {
|
|||||||
|
|
||||||
const SimpleAuth: VFC<ISimpleAuthProps> = ({ authDetails, redirect }) => {
|
const SimpleAuth: VFC<ISimpleAuthProps> = ({ authDetails, redirect }) => {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
|
const [isPending, setIsPending] = useState(false);
|
||||||
const { refetchUser } = useAuthUser();
|
const { refetchUser } = useAuthUser();
|
||||||
const { emailAuth } = useAuthApi();
|
const { emailAuth } = useAuthApi();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { setToastApiError } = useToast();
|
const { setToastApiError } = useToast();
|
||||||
|
|
||||||
const handleSubmit: FormEventHandler<HTMLFormElement> = async (evt) => {
|
const handleSubmit: FormEventHandler<HTMLFormElement> = async (evt) => {
|
||||||
|
setIsPending(true);
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await emailAuth(authDetails.path, email);
|
await emailAuth(authDetails.path, email);
|
||||||
refetchUser();
|
await refetchUser();
|
||||||
navigate(redirect, { replace: true });
|
navigate(redirect);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setToastApiError(formatUnknownError(error));
|
setToastApiError(formatUnknownError(error));
|
||||||
}
|
}
|
||||||
|
setIsPending(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
const handleChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
@ -81,6 +84,7 @@ const SimpleAuth: VFC<ISimpleAuthProps> = ({ authDetails, redirect }) => {
|
|||||||
color='primary'
|
color='primary'
|
||||||
className={styles.button}
|
className={styles.button}
|
||||||
data-testid={LOGIN_BUTTON}
|
data-testid={LOGIN_BUTTON}
|
||||||
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -58,9 +58,10 @@ export const useAuthEndpoint = (): IUseAuthEndpointOutput => {
|
|||||||
swrConfig,
|
swrConfig,
|
||||||
);
|
);
|
||||||
|
|
||||||
const refetchAuth = useCallback(() => {
|
const refetchAuth = useCallback(
|
||||||
mutate(USER_ENDPOINT_PATH).catch(console.warn);
|
async () => mutate(USER_ENDPOINT_PATH).catch(console.warn),
|
||||||
}, []);
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
|
@ -4,9 +4,10 @@ import { createLocalStorage } from '../utils/createLocalStorage';
|
|||||||
export const useLocalStorageState = <T extends object | string>(
|
export const useLocalStorageState = <T extends object | string>(
|
||||||
key: string,
|
key: string,
|
||||||
initialValue: T,
|
initialValue: T,
|
||||||
|
timeToLive?: number,
|
||||||
) => {
|
) => {
|
||||||
const { value: initialStoredValue, setValue: setStoredValue } =
|
const { value: initialStoredValue, setValue: setStoredValue } =
|
||||||
createLocalStorage<T>(key, initialValue);
|
createLocalStorage<T>(key, initialValue, timeToLive);
|
||||||
|
|
||||||
const [localValue, setLocalValue] = useState<T>(initialStoredValue);
|
const [localValue, setLocalValue] = useState<T>(initialStoredValue);
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ import { getLocalStorageItem, setLocalStorageItem } from './storage';
|
|||||||
export const createLocalStorage = <T extends object | string>(
|
export const createLocalStorage = <T extends object | string>(
|
||||||
key: string,
|
key: string,
|
||||||
initialValue: T,
|
initialValue: T,
|
||||||
|
timeToLive?: number,
|
||||||
) => {
|
) => {
|
||||||
const internalKey = `${basePath}:${key}:localStorage:v2`;
|
const internalKey = `${basePath}:${key}:localStorage:v2`;
|
||||||
const value = (() => {
|
const value = (() => {
|
||||||
@ -18,11 +19,11 @@ export const createLocalStorage = <T extends object | string>(
|
|||||||
if (newValue instanceof Function) {
|
if (newValue instanceof Function) {
|
||||||
const previousValue = getLocalStorageItem<T>(internalKey);
|
const previousValue = getLocalStorageItem<T>(internalKey);
|
||||||
const output = newValue(previousValue ?? initialValue);
|
const output = newValue(previousValue ?? initialValue);
|
||||||
setLocalStorageItem(internalKey, output);
|
setLocalStorageItem(internalKey, output, timeToLive);
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLocalStorageItem(internalKey, newValue);
|
setLocalStorageItem(internalKey, newValue, timeToLive);
|
||||||
return newValue;
|
return newValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -60,7 +60,7 @@ export function getLocalStorageItem<T>(key: string): T | undefined {
|
|||||||
export function setLocalStorageItem<T>(
|
export function setLocalStorageItem<T>(
|
||||||
key: string,
|
key: string,
|
||||||
value: T | undefined = undefined,
|
value: T | undefined = undefined,
|
||||||
timeToLive?: number,
|
timeToLive?: number, // milliseconds
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const item: Expirable<T> = {
|
const item: Expirable<T> = {
|
||||||
@ -80,7 +80,7 @@ export function setLocalStorageItem<T>(
|
|||||||
export function setSessionStorageItem<T>(
|
export function setSessionStorageItem<T>(
|
||||||
key: string,
|
key: string,
|
||||||
value: T | undefined = undefined,
|
value: T | undefined = undefined,
|
||||||
timeToLive?: number,
|
timeToLive?: number, // milliseconds
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const item: Expirable<T> = {
|
const item: Expirable<T> = {
|
||||||
|
Loading…
Reference in New Issue
Block a user