mirror of
https://github.com/Unleash/unleash.git
synced 2025-11-10 01:19:53 +01:00
feat: make milestone progression more clear (#10899)
This commit is contained in:
parent
96f7f2f1bf
commit
2458e5d5aa
@ -4,6 +4,7 @@ import { useMilestoneProgressionForm } from '../hooks/useMilestoneProgressionFor
|
|||||||
import { MilestoneProgressionTimeInput } from './MilestoneProgressionTimeInput.tsx';
|
import { MilestoneProgressionTimeInput } from './MilestoneProgressionTimeInput.tsx';
|
||||||
import type { ChangeMilestoneProgressionSchema } from 'openapi';
|
import type { ChangeMilestoneProgressionSchema } from 'openapi';
|
||||||
import type { MilestoneStatus } from '../ReleasePlanMilestone/ReleasePlanMilestoneStatus.tsx';
|
import type { MilestoneStatus } from '../ReleasePlanMilestone/ReleasePlanMilestoneStatus.tsx';
|
||||||
|
import { useMilestoneProgressionInfo } from '../hooks/useMilestoneProgressionInfo.ts';
|
||||||
|
|
||||||
const StyledFormContainer = styled('div')(({ theme }) => ({
|
const StyledFormContainer = styled('div')(({ theme }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -54,6 +55,13 @@ const StyledErrorMessage = styled('span')(({ theme }) => ({
|
|||||||
paddingLeft: theme.spacing(3.25),
|
paddingLeft: theme.spacing(3.25),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const StyledInfoLine = styled('span')(({ theme }) => ({
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
fontSize: theme.typography.caption.fontSize,
|
||||||
|
paddingLeft: theme.spacing(3.25),
|
||||||
|
fontStyle: 'italic',
|
||||||
|
}));
|
||||||
|
|
||||||
interface IMilestoneProgressionFormProps {
|
interface IMilestoneProgressionFormProps {
|
||||||
sourceMilestoneId: string;
|
sourceMilestoneId: string;
|
||||||
targetMilestoneId: string;
|
targetMilestoneId: string;
|
||||||
@ -81,6 +89,12 @@ export const MilestoneProgressionForm = ({
|
|||||||
status,
|
status,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const progressionInfo = useMilestoneProgressionInfo(
|
||||||
|
form.getIntervalMinutes(),
|
||||||
|
sourceMilestoneStartedAt,
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!form.validate()) {
|
if (!form.validate()) {
|
||||||
return;
|
return;
|
||||||
@ -103,14 +117,18 @@ export const MilestoneProgressionForm = ({
|
|||||||
<StyledFormContainer onKeyDown={handleKeyDown}>
|
<StyledFormContainer onKeyDown={handleKeyDown}>
|
||||||
<StyledTopRow>
|
<StyledTopRow>
|
||||||
<StyledIcon />
|
<StyledIcon />
|
||||||
<StyledLabel>Proceed to the next milestone after</StyledLabel>
|
<StyledLabel>Proceed after</StyledLabel>
|
||||||
<MilestoneProgressionTimeInput
|
<MilestoneProgressionTimeInput
|
||||||
timeValue={form.timeValue}
|
timeValue={form.timeValue}
|
||||||
timeUnit={form.timeUnit}
|
timeUnit={form.timeUnit}
|
||||||
onTimeValueChange={form.handleTimeValueChange}
|
onTimeValueChange={form.handleTimeValueChange}
|
||||||
onTimeUnitChange={form.handleTimeUnitChange}
|
onTimeUnitChange={form.handleTimeUnitChange}
|
||||||
/>
|
/>
|
||||||
|
<StyledLabel>from milestone start</StyledLabel>
|
||||||
</StyledTopRow>
|
</StyledTopRow>
|
||||||
|
{progressionInfo && (
|
||||||
|
<StyledInfoLine>{progressionInfo}</StyledInfoLine>
|
||||||
|
)}
|
||||||
{form.errors.time && (
|
{form.errors.time && (
|
||||||
<StyledErrorMessage>{form.errors.time}</StyledErrorMessage>
|
<StyledErrorMessage>{form.errors.time}</StyledErrorMessage>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
import type { ChangeMilestoneProgressionSchema } from 'openapi';
|
import type { ChangeMilestoneProgressionSchema } from 'openapi';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
import { useMilestoneProgressionInfo } from '../hooks/useMilestoneProgressionInfo.ts';
|
||||||
|
|
||||||
const StyledFormWrapper = styled('div', {
|
const StyledFormWrapper = styled('div', {
|
||||||
shouldForwardProp: (prop) => prop !== 'hasChanged',
|
shouldForwardProp: (prop) => prop !== 'hasChanged',
|
||||||
@ -97,6 +98,13 @@ const StyledErrorMessage = styled('span')(({ theme }) => ({
|
|||||||
paddingLeft: theme.spacing(3.25),
|
paddingLeft: theme.spacing(3.25),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const StyledInfoLine = styled('span')(({ theme }) => ({
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
fontSize: theme.typography.caption.fontSize,
|
||||||
|
paddingLeft: theme.spacing(3.25),
|
||||||
|
fontStyle: 'italic',
|
||||||
|
}));
|
||||||
|
|
||||||
interface IMilestoneTransitionDisplayProps {
|
interface IMilestoneTransitionDisplayProps {
|
||||||
intervalMinutes: number;
|
intervalMinutes: number;
|
||||||
targetMilestoneId: string;
|
targetMilestoneId: string;
|
||||||
@ -129,6 +137,7 @@ export const ReadonlyMilestoneTransitionDisplay = ({
|
|||||||
<span style={{ fontSize: 'inherit' }}>
|
<span style={{ fontSize: 'inherit' }}>
|
||||||
{initial.value} {initial.unit}
|
{initial.value} {initial.unit}
|
||||||
</span>
|
</span>
|
||||||
|
<StyledLabel status={status}>from milestone start</StyledLabel>
|
||||||
</StyledContentGroup>
|
</StyledContentGroup>
|
||||||
</StyledDisplayContainer>
|
</StyledDisplayContainer>
|
||||||
);
|
);
|
||||||
@ -159,6 +168,12 @@ export const MilestoneTransitionDisplay = ({
|
|||||||
const currentIntervalMinutes = form.getIntervalMinutes();
|
const currentIntervalMinutes = form.getIntervalMinutes();
|
||||||
const hasChanged = currentIntervalMinutes !== intervalMinutes;
|
const hasChanged = currentIntervalMinutes !== intervalMinutes;
|
||||||
|
|
||||||
|
const progressionInfo = useMilestoneProgressionInfo(
|
||||||
|
currentIntervalMinutes,
|
||||||
|
sourceMilestoneStartedAt ?? null,
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newInitial = getTimeValueAndUnitFromMinutes(intervalMinutes);
|
const newInitial = getTimeValueAndUnitFromMinutes(intervalMinutes);
|
||||||
form.setTimeValue(newInitial.value);
|
form.setTimeValue(newInitial.value);
|
||||||
@ -214,15 +229,16 @@ export const MilestoneTransitionDisplay = ({
|
|||||||
<StyledDisplayContainer>
|
<StyledDisplayContainer>
|
||||||
<StyledContentGroup>
|
<StyledContentGroup>
|
||||||
<StyledIcon status={status} />
|
<StyledIcon status={status} />
|
||||||
<StyledLabel status={status}>
|
<StyledLabel status={status}>Proceed after</StyledLabel>
|
||||||
Proceed to the next milestone after
|
|
||||||
</StyledLabel>
|
|
||||||
<MilestoneProgressionTimeInput
|
<MilestoneProgressionTimeInput
|
||||||
timeValue={form.timeValue}
|
timeValue={form.timeValue}
|
||||||
timeUnit={form.timeUnit}
|
timeUnit={form.timeUnit}
|
||||||
onTimeValueChange={form.handleTimeValueChange}
|
onTimeValueChange={form.handleTimeValueChange}
|
||||||
onTimeUnitChange={form.handleTimeUnitChange}
|
onTimeUnitChange={form.handleTimeUnitChange}
|
||||||
/>
|
/>
|
||||||
|
<StyledLabel status={status}>
|
||||||
|
from milestone start
|
||||||
|
</StyledLabel>
|
||||||
</StyledContentGroup>
|
</StyledContentGroup>
|
||||||
{!hasChanged && (
|
{!hasChanged && (
|
||||||
<StyledButtonGroup hasChanged={false}>
|
<StyledButtonGroup hasChanged={false}>
|
||||||
@ -238,6 +254,9 @@ export const MilestoneTransitionDisplay = ({
|
|||||||
</StyledButtonGroup>
|
</StyledButtonGroup>
|
||||||
)}
|
)}
|
||||||
</StyledDisplayContainer>
|
</StyledDisplayContainer>
|
||||||
|
{progressionInfo && (
|
||||||
|
<StyledInfoLine>{progressionInfo}</StyledInfoLine>
|
||||||
|
)}
|
||||||
{form.errors.time && (
|
{form.errors.time && (
|
||||||
<StyledErrorMessage>{form.errors.time}</StyledErrorMessage>
|
<StyledErrorMessage>{form.errors.time}</StyledErrorMessage>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { getMilestoneProgressionInfo } from './getMilestoneProgressionInfo.js';
|
||||||
|
|
||||||
|
describe('getMilestoneProgressionInfo', () => {
|
||||||
|
const currentTime = new Date('2025-10-31T15:00:00.000Z');
|
||||||
|
|
||||||
|
it('returns immediate proceed message when elapsed >= interval', () => {
|
||||||
|
const startedAt = '2025-10-31T14:00:00.000Z';
|
||||||
|
const res = getMilestoneProgressionInfo(
|
||||||
|
30,
|
||||||
|
startedAt,
|
||||||
|
'en-US',
|
||||||
|
currentTime,
|
||||||
|
);
|
||||||
|
expect(res).toBeTruthy();
|
||||||
|
expect(res as string).toMatch(/^Already .* in this milestone\.$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns proceed time and remaining message when elapsed < interval', () => {
|
||||||
|
const startedAt = '2025-10-31T14:00:00.000Z';
|
||||||
|
const res = getMilestoneProgressionInfo(
|
||||||
|
120,
|
||||||
|
startedAt,
|
||||||
|
'en-US',
|
||||||
|
currentTime,
|
||||||
|
);
|
||||||
|
expect(res).toBeTruthy();
|
||||||
|
expect(res as string).toMatch(/^Will proceed at .* \(in .*\)\.$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
import { addMinutes, differenceInMinutes, formatDistance } from 'date-fns';
|
||||||
|
import { formatDateYMDHM } from 'utils/formatDate.ts';
|
||||||
|
|
||||||
|
export const getMilestoneProgressionInfo = (
|
||||||
|
intervalMinutes: number,
|
||||||
|
sourceMilestoneStartedAt: string | null | undefined,
|
||||||
|
locale: string,
|
||||||
|
currentTime: Date = new Date(),
|
||||||
|
): string | null => {
|
||||||
|
if (!sourceMilestoneStartedAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(sourceMilestoneStartedAt);
|
||||||
|
const elapsedMinutes = differenceInMinutes(currentTime, startDate);
|
||||||
|
const proceedDate = addMinutes(startDate, intervalMinutes);
|
||||||
|
|
||||||
|
if (elapsedMinutes >= intervalMinutes) {
|
||||||
|
const elapsedTime = formatDistance(startDate, currentTime, {
|
||||||
|
addSuffix: false,
|
||||||
|
});
|
||||||
|
return `Already ${elapsedTime} in this milestone.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proceedTime = formatDateYMDHM(proceedDate, locale);
|
||||||
|
const remainingTime = formatDistance(proceedDate, currentTime, {
|
||||||
|
addSuffix: false,
|
||||||
|
});
|
||||||
|
return `Will proceed at ${proceedTime} (in ${remainingTime}).`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default getMilestoneProgressionInfo;
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
import { useLocationSettings } from 'hooks/useLocationSettings';
|
||||||
|
import { getMilestoneProgressionInfo } from './getMilestoneProgressionInfo.ts';
|
||||||
|
import type { MilestoneStatus } from '../ReleasePlanMilestone/ReleasePlanMilestoneStatus.tsx';
|
||||||
|
|
||||||
|
export const useMilestoneProgressionInfo = (
|
||||||
|
intervalMinutes: number,
|
||||||
|
sourceMilestoneStartedAt?: string | null,
|
||||||
|
status?: MilestoneStatus,
|
||||||
|
) => {
|
||||||
|
const { locationSettings } = useLocationSettings();
|
||||||
|
if (!status || status.type !== 'active') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getMilestoneProgressionInfo(
|
||||||
|
intervalMinutes,
|
||||||
|
sourceMilestoneStartedAt,
|
||||||
|
locationSettings.locale,
|
||||||
|
);
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user