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:
parent
d60ea1acd4
commit
7285607cad
@ -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={
|
||||||
|
<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.
|
||||||
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user