mirror of
https://github.com/Unleash/unleash.git
synced 2025-08-13 13:48:59 +02:00
feat: project-level cleanup reminders (#10464)
This commit is contained in:
parent
b0f4f1b3b9
commit
ac67a50693
@ -17,7 +17,7 @@ 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.ts';
|
import { useReminders } from './useReminders.ts';
|
||||||
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
||||||
import { useUncomplete } from '../FeatureOverview/FeatureLifecycle/useUncomplete.ts';
|
import { useUncomplete } from '../FeatureOverview/FeatureLifecycle/useUncomplete.ts';
|
||||||
|
|
||||||
@ -59,7 +59,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 { shouldShowReminder, snoozeReminder } = useReminders();
|
||||||
|
|
||||||
const determineReminder = (): ReminderType => {
|
const determineReminder = (): ReminderType => {
|
||||||
if (!currentStage || !isRelevantType) return null;
|
if (!currentStage || !isRelevantType) return null;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { useFlagReminders } from './useFlagReminders.ts';
|
import { useReminders } from './useReminders.ts';
|
||||||
|
|
||||||
const TestComponent = ({
|
const TestComponent = ({
|
||||||
days = 7,
|
days = 7,
|
||||||
@ -9,7 +9,7 @@ const TestComponent = ({
|
|||||||
days?: number;
|
days?: number;
|
||||||
maxReminders?: number;
|
maxReminders?: number;
|
||||||
}) => {
|
}) => {
|
||||||
const { shouldShowReminder, snoozeReminder } = useFlagReminders({
|
const { shouldShowReminder, snoozeReminder } = useReminders({
|
||||||
days,
|
days,
|
||||||
maxReminders,
|
maxReminders,
|
||||||
});
|
});
|
@ -7,7 +7,7 @@ const REMINDER_KEY = 'flag-reminders:v1';
|
|||||||
const MAX_REMINDERS = 50;
|
const MAX_REMINDERS = 50;
|
||||||
const DAYS = 7;
|
const DAYS = 7;
|
||||||
|
|
||||||
export const useFlagReminders = ({
|
export const useReminders = ({
|
||||||
days = DAYS,
|
days = DAYS,
|
||||||
maxReminders = MAX_REMINDERS,
|
maxReminders = MAX_REMINDERS,
|
||||||
}: {
|
}: {
|
||||||
@ -19,16 +19,16 @@ export const useFlagReminders = ({
|
|||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
const shouldShowReminder = (flagName: string): boolean => {
|
const shouldShowReminder = (key: string): boolean => {
|
||||||
const snoozedUntil = reminders[flagName];
|
const snoozedUntil = reminders[key];
|
||||||
return !snoozedUntil || isAfter(new Date(), new Date(snoozedUntil));
|
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();
|
const snoozedUntil = addDays(new Date(), snoozeDays).getTime();
|
||||||
|
|
||||||
setReminders((prev) => {
|
setReminders((prev) => {
|
||||||
const updated = { ...prev, [flagName]: snoozedUntil };
|
const updated = { ...prev, [key]: snoozedUntil };
|
||||||
|
|
||||||
const entries = Object.entries(updated);
|
const entries = Object.entries(updated);
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -54,6 +54,8 @@ import PermissionIconButton from 'component/common/PermissionIconButton/Permissi
|
|||||||
import { UPDATE_FEATURE } from '@server/types/permissions';
|
import { UPDATE_FEATURE } from '@server/types/permissions';
|
||||||
import { ImportModal } from '../Import/ImportModal.tsx';
|
import { ImportModal } from '../Import/ImportModal.tsx';
|
||||||
import { IMPORT_BUTTON } from 'utils/testIds';
|
import { IMPORT_BUTTON } from 'utils/testIds';
|
||||||
|
import { ProjectCleanupReminder } from './ProjectCleanupReminder/ProjectCleanupReminder.tsx';
|
||||||
|
import { useUiFlag } from 'hooks/useUiFlag.ts';
|
||||||
|
|
||||||
interface IPaginatedProjectFeatureTogglesProps {
|
interface IPaginatedProjectFeatureTogglesProps {
|
||||||
environments: string[];
|
environments: string[];
|
||||||
@ -92,6 +94,7 @@ export const ProjectFeatureToggles = ({
|
|||||||
const { project } = useProjectOverview(projectId);
|
const { project } = useProjectOverview(projectId);
|
||||||
const [connectSdkOpen, setConnectSdkOpen] = useState(false);
|
const [connectSdkOpen, setConnectSdkOpen] = useState(false);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const isFilterFlagsToArchiveEnabled = useUiFlag('filterFlagsToArchive');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
features,
|
features,
|
||||||
@ -466,6 +469,11 @@ export const ProjectFeatureToggles = ({
|
|||||||
|
|
||||||
const selectedData = useSelectedData(features, rowSelection);
|
const selectedData = useSelectedData(features, rowSelection);
|
||||||
|
|
||||||
|
const showCleanupReminder =
|
||||||
|
isFilterFlagsToArchiveEnabled &&
|
||||||
|
!tableState.lastSeenAt &&
|
||||||
|
!tableState.lifecycle;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
@ -479,19 +487,17 @@ export const ProjectFeatureToggles = ({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<ConditionallyRender
|
{setupCompletedState === 'show-setup' && !isOnboarding ? (
|
||||||
condition={
|
<ProjectOnboarded
|
||||||
setupCompletedState === 'show-setup' && !isOnboarding
|
projectId={projectId}
|
||||||
}
|
onClose={() => {
|
||||||
show={
|
setSetupCompletedState('hide-setup');
|
||||||
<ProjectOnboarded
|
}}
|
||||||
projectId={projectId}
|
/>
|
||||||
onClose={() => {
|
) : null}
|
||||||
setSetupCompletedState('hide-setup');
|
{showCleanupReminder ? (
|
||||||
}}
|
<ProjectCleanupReminder projectId={projectId} />
|
||||||
/>
|
) : null}
|
||||||
}
|
|
||||||
/>
|
|
||||||
<PageContent
|
<PageContent
|
||||||
disableLoading
|
disableLoading
|
||||||
disablePadding
|
disablePadding
|
||||||
@ -598,7 +604,6 @@ export const ProjectFeatureToggles = ({
|
|||||||
{featureToggleModals}
|
{featureToggleModals}
|
||||||
</div>
|
</div>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
|
||||||
<ConnectSdkDialog
|
<ConnectSdkDialog
|
||||||
open={connectSdkOpen}
|
open={connectSdkOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@ -637,7 +642,6 @@ export const ProjectFeatureToggles = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</BatchSelectionActionsBar>
|
</BatchSelectionActionsBar>
|
||||||
|
|
||||||
<ImportModal
|
<ImportModal
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
setOpen={setModalOpen}
|
setOpen={setModalOpen}
|
||||||
|
Loading…
Reference in New Issue
Block a user