1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-09 13:47:13 +02:00

feat: project-level cleanup reminders (#10464)

This commit is contained in:
Tymoteusz Czech 2025-08-07 11:44:08 +02:00 committed by GitHub
parent b0f4f1b3b9
commit ac67a50693
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 117 additions and 24 deletions

View File

@ -17,7 +17,7 @@ import type { IFeatureToggle } from 'interfaces/featureToggle';
import { FeatureArchiveNotAllowedDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveNotAllowedDialog';
import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog';
import { useNavigate } from 'react-router-dom';
import { useFlagReminders } from './useFlagReminders.ts';
import { useReminders } from './useReminders.ts';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { useUncomplete } from '../FeatureOverview/FeatureLifecycle/useUncomplete.ts';
@ -59,7 +59,7 @@ export const CleanupReminder: FC<{
const daysInStage = enteredStageAt
? differenceInDays(new Date(), parseISO(enteredStageAt))
: 0;
const { shouldShowReminder, snoozeReminder } = useFlagReminders();
const { shouldShowReminder, snoozeReminder } = useReminders();
const determineReminder = (): ReminderType => {
if (!currentStage || !isRelevantType) return null;

View File

@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useFlagReminders } from './useFlagReminders.ts';
import { useReminders } from './useReminders.ts';
const TestComponent = ({
days = 7,
@ -9,7 +9,7 @@ const TestComponent = ({
days?: number;
maxReminders?: number;
}) => {
const { shouldShowReminder, snoozeReminder } = useFlagReminders({
const { shouldShowReminder, snoozeReminder } = useReminders({
days,
maxReminders,
});

View File

@ -7,7 +7,7 @@ const REMINDER_KEY = 'flag-reminders:v1';
const MAX_REMINDERS = 50;
const DAYS = 7;
export const useFlagReminders = ({
export const useReminders = ({
days = DAYS,
maxReminders = MAX_REMINDERS,
}: {
@ -19,16 +19,16 @@ export const useFlagReminders = ({
{},
);
const shouldShowReminder = (flagName: string): boolean => {
const snoozedUntil = reminders[flagName];
const shouldShowReminder = (key: string): boolean => {
const snoozedUntil = reminders[key];
return !snoozedUntil || isAfter(new Date(), new Date(snoozedUntil));
};
const snoozeReminder = (flagName: string, snoozeDays: number = days) => {
const snoozeReminder = (key: string, snoozeDays: number = days) => {
const snoozedUntil = addDays(new Date(), snoozeDays).getTime();
setReminders((prev) => {
const updated = { ...prev, [flagName]: snoozedUntil };
const updated = { ...prev, [key]: snoozedUntil };
const entries = Object.entries(updated);

View File

@ -0,0 +1,89 @@
import type { FC } from 'react';
import { Alert, Box, Button, styled } from '@mui/material';
import CleaningServicesIcon from '@mui/icons-material/CleaningServices';
import { useFeatureSearch } from 'hooks/api/getters/useFeatureSearch/useFeatureSearch';
import { useNavigate } from 'react-router-dom';
import { subDays, formatISO } from 'date-fns';
import { useReminders } from 'component/feature/FeatureView/CleanupReminder/useReminders';
import { useHasRootAccess } from 'hooks/useHasAccess';
import { DELETE_FEATURE } from 'component/providers/AccessProvider/permissions';
const StyledBox = styled(Box)(({ theme }) => ({
marginBottom: theme.spacing(2),
}));
const ActionsBox = styled(Box)(({ theme }) => ({
display: 'flex',
gap: theme.spacing(1),
alignItems: 'center',
}));
const lastSeenAtDays = 7;
const refreshInterval = 60 * 1_000;
const snoozeReminderDays = 7;
const getQuery = (projectId: string) => {
const date = subDays(new Date(), lastSeenAtDays);
const formattedDate = formatISO(date, { representation: 'date' });
return {
project: `IS:${projectId}`,
lifecycle: 'IS:completed',
lastSeenAt: `IS_BEFORE:${formattedDate}`,
};
};
export const ProjectCleanupReminder: FC<{
projectId: string;
}> = ({ projectId }) => {
const navigate = useNavigate();
const { shouldShowReminder, snoozeReminder } = useReminders();
const hasAccess = useHasRootAccess(DELETE_FEATURE, projectId);
const reminderKey = `project-cleanup-${projectId}`;
const query = getQuery(projectId);
const { total, loading } = useFeatureSearch(query, {
refreshInterval,
});
if (!hasAccess || !shouldShowReminder(reminderKey) || loading || !total) {
return null;
}
const handleViewFlags = () => {
navigate(
`/projects/${projectId}/features?${new URLSearchParams(query).toString()}`,
);
};
const handleDismiss = () => {
snoozeReminder(reminderKey, snoozeReminderDays);
};
return (
<Alert
severity='warning'
icon={<CleaningServicesIcon />}
action={
<ActionsBox>
<Button size='medium' onClick={handleDismiss}>
Remind me later
</Button>
<Button
variant='contained'
size='medium'
onClick={handleViewFlags}
>
View flags
</Button>
</ActionsBox>
}
>
<b>Time to clean up technical debt?</b>
<p>
We haven't observed any metrics for {total} of the flags lately.
Can {total > 1 ? 'they' : 'it'} be archived?
</p>
</Alert>
);
};

View File

@ -54,6 +54,8 @@ import PermissionIconButton from 'component/common/PermissionIconButton/Permissi
import { UPDATE_FEATURE } from '@server/types/permissions';
import { ImportModal } from '../Import/ImportModal.tsx';
import { IMPORT_BUTTON } from 'utils/testIds';
import { ProjectCleanupReminder } from './ProjectCleanupReminder/ProjectCleanupReminder.tsx';
import { useUiFlag } from 'hooks/useUiFlag.ts';
interface IPaginatedProjectFeatureTogglesProps {
environments: string[];
@ -92,6 +94,7 @@ export const ProjectFeatureToggles = ({
const { project } = useProjectOverview(projectId);
const [connectSdkOpen, setConnectSdkOpen] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const isFilterFlagsToArchiveEnabled = useUiFlag('filterFlagsToArchive');
const {
features,
@ -466,6 +469,11 @@ export const ProjectFeatureToggles = ({
const selectedData = useSelectedData(features, rowSelection);
const showCleanupReminder =
isFilterFlagsToArchiveEnabled &&
!tableState.lastSeenAt &&
!tableState.lifecycle;
return (
<Container>
<ConditionallyRender
@ -479,19 +487,17 @@ export const ProjectFeatureToggles = ({
/>
}
/>
<ConditionallyRender
condition={
setupCompletedState === 'show-setup' && !isOnboarding
}
show={
<ProjectOnboarded
projectId={projectId}
onClose={() => {
setSetupCompletedState('hide-setup');
}}
/>
}
/>
{setupCompletedState === 'show-setup' && !isOnboarding ? (
<ProjectOnboarded
projectId={projectId}
onClose={() => {
setSetupCompletedState('hide-setup');
}}
/>
) : null}
{showCleanupReminder ? (
<ProjectCleanupReminder projectId={projectId} />
) : null}
<PageContent
disableLoading
disablePadding
@ -598,7 +604,6 @@ export const ProjectFeatureToggles = ({
{featureToggleModals}
</div>
</PageContent>
<ConnectSdkDialog
open={connectSdkOpen}
onClose={() => {
@ -637,7 +642,6 @@ export const ProjectFeatureToggles = ({
/>
)}
</BatchSelectionActionsBar>
<ImportModal
open={modalOpen}
setOpen={setModalOpen}