1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-22 01:16:07 +02:00

feat: remind me later about cleanup (#9790)

This commit is contained in:
Mateusz Kwasniewski 2025-04-17 14:06:26 +02:00 committed by GitHub
parent d60ea1acd4
commit 7285607cad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 191 additions and 15 deletions

View File

@ -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();
});
}); });

View File

@ -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={
<ActionsBox>
<Button
size='medium'
onClick={() => snoozeReminder(feature.name)}
>
Remind me later
</Button>
<PermissionButton <PermissionButton
variant='contained' variant='contained'
permission={DELETE_FEATURE} permission={DELETE_FEATURE}
size='small' size='medium'
sx={{ mb: 2 }}
onClick={() => setArchiveDialogueOpen(true)} onClick={() => setArchiveDialogueOpen(true)}
projectId={feature.project} projectId={feature.project}
> >
Archive flag Archive flag
</PermissionButton> </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.

View File

@ -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');
});
});

View File

@ -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,
};
};