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,
+ };
+};
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 (
-
- );
-};
diff --git a/frontend/src/component/project/Project/Project.styles.ts b/frontend/src/component/project/Project/Project.styles.ts
index b2fd4e2072..95265cd284 100644
--- a/frontend/src/component/project/Project/Project.styles.ts
+++ b/frontend/src/component/project/Project/Project.styles.ts
@@ -50,7 +50,7 @@ export const StyledInnerContainer = styled('div')(({ theme }) => ({
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 = {