mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	
							parent
							
								
									43100f9561
								
							
						
					
					
						commit
						6c5ce52470
					
				| @ -13,7 +13,7 @@ import CheckCircle from '@mui/icons-material/CheckCircle'; | ||||
| import CloudCircle from '@mui/icons-material/CloudCircle'; | ||||
| import Flag from '@mui/icons-material/Flag'; | ||||
| import WarningAmberRounded from '@mui/icons-material/WarningAmberRounded'; | ||||
| import TimeAgo from 'react-timeago'; | ||||
| import { TimeAgo } from 'component/common/TimeAgo/TimeAgo'; | ||||
| import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; | ||||
| import { getApplicationIssues } from './ApplicationIssues/ApplicationIssues'; | ||||
| 
 | ||||
| @ -305,17 +305,9 @@ export const ApplicationChart = ({ data }: IApplicationChartProps) => { | ||||
|                                         <tr> | ||||
|                                             <StyledCell>Last seen:</StyledCell> | ||||
|                                             <StyledCell> | ||||
|                                                 {environment.lastSeen && ( | ||||
|                                                     <TimeAgo | ||||
|                                                         key={`${environment.lastSeen}`} | ||||
|                                                         minPeriod={60} | ||||
|                                                         date={ | ||||
|                                                             new Date( | ||||
|                                                                 environment.lastSeen, | ||||
|                                                             ) | ||||
|                                                         } | ||||
|                                                     /> | ||||
|                                                 )} | ||||
|                                                 <TimeAgo | ||||
|                                                     date={environment.lastSeen} | ||||
|                                                 /> | ||||
|                                             </StyledCell> | ||||
|                                         </tr> | ||||
|                                     </tbody> | ||||
|  | ||||
| @ -13,7 +13,11 @@ const setupApi = (application: ApplicationOverviewSchema) => { | ||||
|         '/api/admin/metrics/applications/my-app/overview', | ||||
|         application, | ||||
|     ); | ||||
|     testServerRoute(server, '/api/admin/ui-config', {}); | ||||
|     testServerRoute(server, '/api/admin/ui-config', { | ||||
|         flags: { | ||||
|             timeAgoRefactor: true, | ||||
|         }, | ||||
|     }); | ||||
| }; | ||||
| 
 | ||||
| test('Display application overview with environments', async () => { | ||||
| @ -51,7 +55,7 @@ test('Display application overview with environments', async () => { | ||||
|     await screen.findByText('development environment'); | ||||
|     await screen.findByText('999'); | ||||
|     await screen.findByText('unleash-client-node:5.5.0-beta.0'); | ||||
|     await screen.findByText('0 seconds ago'); | ||||
|     await screen.findByText('< 1 minute ago'); | ||||
| }); | ||||
| 
 | ||||
| test('Display application overview without environments', async () => { | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| import type { VFC } from 'react'; | ||||
| import TimeAgo from 'react-timeago'; | ||||
| import type { FC } from 'react'; | ||||
| import { TimeAgo } from 'component/common/TimeAgo/TimeAgo'; | ||||
| import { Tooltip, Typography, useTheme } from '@mui/material'; | ||||
| import { formatDateYMD } from 'utils/formatDate'; | ||||
| import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; | ||||
| @ -9,7 +9,7 @@ interface IFeatureArchivedCellProps { | ||||
|     value?: string | Date | null; | ||||
| } | ||||
| 
 | ||||
| export const FeatureArchivedCell: VFC<IFeatureArchivedCellProps> = ({ | ||||
| export const FeatureArchivedCell: FC<IFeatureArchivedCellProps> = ({ | ||||
|     value: archivedAt, | ||||
| }) => { | ||||
|     const { locationSettings } = useLocationSettings(); | ||||
| @ -37,12 +37,7 @@ export const FeatureArchivedCell: VFC<IFeatureArchivedCellProps> = ({ | ||||
|                 arrow | ||||
|             > | ||||
|                 <Typography noWrap variant='body2' data-loading> | ||||
|                     <TimeAgo | ||||
|                         key={`${archivedAt}`} | ||||
|                         date={new Date(archivedAt)} | ||||
|                         title='' | ||||
|                         live={false} | ||||
|                     /> | ||||
|                     <TimeAgo date={new Date(archivedAt)} refresh={false} /> | ||||
|                 </Typography> | ||||
|             </Tooltip> | ||||
|         </TextCell> | ||||
|  | ||||
| @ -2,7 +2,7 @@ import type { FC } from 'react'; | ||||
| import { Markdown } from 'component/common/Markdown/Markdown'; | ||||
| import Paper from '@mui/material/Paper'; | ||||
| import { Box, styled, Typography } from '@mui/material'; | ||||
| import TimeAgo from 'react-timeago'; | ||||
| import { TimeAgo } from 'component/common/TimeAgo/TimeAgo'; | ||||
| import { StyledAvatar } from './StyledAvatar'; | ||||
| import type { IChangeRequestComment } from '../../changeRequest.types'; | ||||
| 
 | ||||
| @ -35,12 +35,7 @@ export const ChangeRequestComment: FC<{ comment: IChangeRequestComment }> = ({ | ||||
|                 <Box> | ||||
|                     <strong>{comment.createdBy.username}</strong>{' '} | ||||
|                     <Typography color='text.secondary' component='span'> | ||||
|                         commented{' '} | ||||
|                         <TimeAgo | ||||
|                             key={`${comment.createdAt}`} | ||||
|                             minPeriod={60} | ||||
|                             date={new Date(comment.createdAt)} | ||||
|                         /> | ||||
|                         commented <TimeAgo date={comment.createdAt} /> | ||||
|                     </Typography> | ||||
|                 </Box> | ||||
|             </CommentHeader> | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| import { Box } from '@mui/material'; | ||||
| import { type FC, useState } from 'react'; | ||||
| import { Typography, Tooltip } from '@mui/material'; | ||||
| import TimeAgo from 'react-timeago'; | ||||
| import { TimeAgo } from 'component/common/TimeAgo/TimeAgo'; | ||||
| import type { ChangeRequestType } from 'component/changeRequest/changeRequest.types'; | ||||
| import { ChangeRequestStatusBadge } from 'component/changeRequest/ChangeRequestStatusBadge/ChangeRequestStatusBadge'; | ||||
| import { | ||||
| @ -38,13 +38,7 @@ export const ChangeRequestHeader: FC<{ changeRequest: ChangeRequestType }> = ({ | ||||
|                         margin: theme.spacing('auto', 0, 'auto', 2), | ||||
|                     })} | ||||
|                 > | ||||
|                     Created{' '} | ||||
|                     <TimeAgo | ||||
|                         key={`${changeRequest.createdAt}`} | ||||
|                         minPeriod={60} | ||||
|                         date={new Date(changeRequest.createdAt)} | ||||
|                     />{' '} | ||||
|                     by | ||||
|                     Created <TimeAgo date={changeRequest.createdAt} /> by | ||||
|                 </Typography> | ||||
|                 <Box | ||||
|                     sx={(theme) => ({ | ||||
|  | ||||
| @ -11,7 +11,7 @@ import type { | ||||
|     NotificationsSchemaItemNotificationType, | ||||
| } from 'openapi'; | ||||
| import { ReactComponent as ChangesAppliedIcon } from 'assets/icons/merge.svg'; | ||||
| import TimeAgo from 'react-timeago'; | ||||
| import { TimeAgo } from 'component/common/TimeAgo/TimeAgo'; | ||||
| import ToggleOffOutlined from '@mui/icons-material/ToggleOffOutlined'; | ||||
| import { flexRow } from 'themes/themeStyles'; | ||||
| 
 | ||||
| @ -157,11 +157,7 @@ export const Notification = ({ | ||||
|                     </StyledUserContainer> | ||||
| 
 | ||||
|                     <StyledTimeAgoTypography> | ||||
|                         <TimeAgo | ||||
|                             key={`${notification.createdAt}`} | ||||
|                             date={new Date(notification.createdAt)} | ||||
|                             minPeriod={60} | ||||
|                         /> | ||||
|                         <TimeAgo date={notification.createdAt} /> | ||||
|                     </StyledTimeAgoTypography> | ||||
|                 </StyledSecondaryInfoBox> | ||||
|             </StyledNotificationMessageBox> | ||||
|  | ||||
| @ -1,131 +0,0 @@ | ||||
| import type React from 'react'; | ||||
| import type { FC, VFC } from 'react'; | ||||
| import TimeAgo from 'react-timeago'; | ||||
| import { styled, Tooltip, useTheme } from '@mui/material'; | ||||
| import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; | ||||
| 
 | ||||
| function shortenUnitName(unit?: string): string { | ||||
|     switch (unit) { | ||||
|         case 'second': | ||||
|             return 's'; | ||||
|         case 'minute': | ||||
|             return 'm'; | ||||
|         case 'hour': | ||||
|             return 'h'; | ||||
|         case 'day': | ||||
|             return 'D'; | ||||
|         case 'week': | ||||
|             return 'W'; | ||||
|         case 'month': | ||||
|             return 'M'; | ||||
|         case 'year': | ||||
|             return 'Y'; | ||||
|         default: | ||||
|             return ''; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| const useFeatureColor = () => { | ||||
|     const theme = useTheme(); | ||||
| 
 | ||||
|     return (unit?: string): string => { | ||||
|         switch (unit) { | ||||
|             case 'second': | ||||
|                 return theme.palette.seen.recent; | ||||
|             case 'minute': | ||||
|                 return theme.palette.seen.recent; | ||||
|             case 'hour': | ||||
|                 return theme.palette.seen.recent; | ||||
|             case 'day': | ||||
|                 return theme.palette.seen.recent; | ||||
|             case 'week': | ||||
|                 return theme.palette.seen.inactive; | ||||
|             case 'month': | ||||
|                 return theme.palette.seen.abandoned; | ||||
|             case 'year': | ||||
|                 return theme.palette.seen.abandoned; | ||||
|             default: | ||||
|                 return theme.palette.seen.unknown; | ||||
|         } | ||||
|     }; | ||||
| }; | ||||
| 
 | ||||
| const StyledContainer = styled('div')(({ theme }) => ({ | ||||
|     display: 'flex', | ||||
|     padding: theme.spacing(1.5), | ||||
| })); | ||||
| 
 | ||||
| const StyledBox = styled('div')(({ theme }) => ({ | ||||
|     width: '38px', | ||||
|     height: '38px', | ||||
|     background: theme.palette.background.paper, | ||||
|     borderRadius: `${theme.shape.borderRadius}px`, | ||||
|     textAlign: 'center', | ||||
|     display: 'flex', | ||||
|     alignItems: 'center', | ||||
|     justifyContent: 'center', | ||||
|     fontSize: theme.typography.body2.fontSize, | ||||
|     margin: '0 auto', | ||||
| })); | ||||
| 
 | ||||
| interface IFeatureSeenCellProps { | ||||
|     value?: string | Date | null; | ||||
| } | ||||
| 
 | ||||
| const Wrapper: FC<{ | ||||
|     unit?: string; | ||||
|     tooltip: string; | ||||
|     children?: React.ReactNode; | ||||
| }> = ({ unit, tooltip, children }) => { | ||||
|     const getColor = useFeatureColor(); | ||||
| 
 | ||||
|     return ( | ||||
|         <StyledContainer> | ||||
|             <Tooltip title={tooltip} arrow describeChild> | ||||
|                 <StyledBox style={{ background: getColor(unit) }} data-loading> | ||||
|                     {children} | ||||
|                 </StyledBox> | ||||
|             </Tooltip> | ||||
|         </StyledContainer> | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| export const FeatureSeenCell: VFC<IFeatureSeenCellProps> = ({ | ||||
|     value: lastSeenAt, | ||||
| }) => { | ||||
|     return ( | ||||
|         <ConditionallyRender | ||||
|             condition={Boolean(lastSeenAt)} | ||||
|             show={ | ||||
|                 <TimeAgo | ||||
|                     key={`${lastSeenAt}`} | ||||
|                     date={lastSeenAt!} | ||||
|                     title='' | ||||
|                     live={false} | ||||
|                     formatter={( | ||||
|                         value: number, | ||||
|                         unit: string, | ||||
|                         suffix: string, | ||||
|                     ) => { | ||||
|                         return ( | ||||
|                             <Wrapper | ||||
|                                 tooltip={`Last usage reported ${value} ${unit}${ | ||||
|                                     value !== 1 ? 's' : '' | ||||
|                                 } ${suffix}`}
 | ||||
|                                 unit={unit} | ||||
|                             > | ||||
|                                 {value} | ||||
|                                 {shortenUnitName(unit)} | ||||
|                             </Wrapper> | ||||
|                         ); | ||||
|                     }} | ||||
|                 /> | ||||
|             } | ||||
|             elseShow={ | ||||
|                 <Wrapper tooltip='No usage reported from connected applications'> | ||||
|                     – | ||||
|                 </Wrapper> | ||||
|             } | ||||
|         /> | ||||
|     ); | ||||
| }; | ||||
| @ -1,5 +1,5 @@ | ||||
| import { styled, type SxProps, type Theme, Typography } from '@mui/material'; | ||||
| import TimeAgo from 'react-timeago'; | ||||
| import { TimeAgo } from 'component/common/TimeAgo/TimeAgo'; | ||||
| import type { ILastSeenEnvironments } from 'interfaces/featureToggle'; | ||||
| import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; | ||||
| import { useLastSeenColors } from 'component/feature/FeatureView/FeatureEnvironmentSeen/useLastSeenColors'; | ||||
| @ -74,10 +74,10 @@ export const LastSeenTooltip = ({ | ||||
|     ...rest | ||||
| }: ILastSeenTooltipProps) => { | ||||
|     const getColor = useLastSeenColors(); | ||||
|     const [, defaultTextColor] = getColor(); | ||||
|     const environmentsHaveLastSeen = environments?.some((environment) => | ||||
|         Boolean(environment.lastSeenAt), | ||||
|     ); | ||||
| 
 | ||||
|     return ( | ||||
|         <StyledDescription {...rest} data-loading> | ||||
|             <StyledDescriptionHeader> | ||||
| @ -85,7 +85,9 @@ export const LastSeenTooltip = ({ | ||||
|             </StyledDescriptionHeader> | ||||
|             <ConditionallyRender | ||||
|                 condition={ | ||||
|                     Boolean(environments) && Boolean(environmentsHaveLastSeen) | ||||
|                     Boolean(environments) && | ||||
|                     Boolean(environmentsHaveLastSeen) && | ||||
|                     false | ||||
|                 } | ||||
|                 show={ | ||||
|                     <StyledListContainer> | ||||
| @ -95,43 +97,15 @@ export const LastSeenTooltip = ({ | ||||
|                                     {name} | ||||
|                                 </StyledDescriptionBlockHeader> | ||||
|                                 <StyledValueContainer> | ||||
|                                     <ConditionallyRender | ||||
|                                         condition={Boolean(lastSeenAt)} | ||||
|                                         show={ | ||||
|                                             <TimeAgo | ||||
|                                                 key={`${lastSeenAt}`} | ||||
|                                                 date={lastSeenAt!} | ||||
|                                                 title='' | ||||
|                                                 live={false} | ||||
|                                                 formatter={( | ||||
|                                                     value: number, | ||||
|                                                     unit: string, | ||||
|                                                     suffix: string, | ||||
|                                                 ) => { | ||||
|                                                     const [, textColor] = | ||||
|                                                         getColor(unit); | ||||
|                                                     return ( | ||||
|                                                         <StyledValue | ||||
|                                                             color={textColor} | ||||
|                                                         > | ||||
|                                                             {`${value} ${unit}${ | ||||
|                                                                 value !== 1 | ||||
|                                                                     ? 's' | ||||
|                                                                     : '' | ||||
|                                                             } ${suffix}`}
 | ||||
|                                                         </StyledValue> | ||||
|                                                     ); | ||||
|                                                 }} | ||||
|                                             /> | ||||
|                                         } | ||||
|                                         elseShow={ | ||||
|                                             <StyledValue | ||||
|                                                 color={defaultTextColor} | ||||
|                                             > | ||||
|                                                 no usage | ||||
|                                             </StyledValue> | ||||
|                                         } | ||||
|                                     /> | ||||
|                                     <StyledValue | ||||
|                                         color={getColor(lastSeenAt).text} | ||||
|                                     > | ||||
|                                         <TimeAgo | ||||
|                                             date={lastSeenAt} | ||||
|                                             refresh={false} | ||||
|                                             fallback='no usage' | ||||
|                                         /> | ||||
|                                     </StyledValue> | ||||
|                                 </StyledValueContainer> | ||||
|                                 <LastSeenProgress yes={yes} no={no} /> | ||||
|                             </StyledDescriptionBlock> | ||||
| @ -139,27 +113,12 @@ export const LastSeenTooltip = ({ | ||||
|                     </StyledListContainer> | ||||
|                 } | ||||
|                 elseShow={ | ||||
|                     <TimeAgo | ||||
|                         date={featureLastSeen} | ||||
|                         title='' | ||||
|                         live={false} | ||||
|                         formatter={( | ||||
|                             value: number, | ||||
|                             unit: string, | ||||
|                             suffix: string, | ||||
|                         ) => { | ||||
|                             return ( | ||||
|                                 <Typography | ||||
|                                     fontWeight={'bold'} | ||||
|                                     color={'text.primary'} | ||||
|                                 > | ||||
|                                     {`Reported ${value} ${unit}${ | ||||
|                                         value !== 1 ? 's' : '' | ||||
|                                     } ${suffix}`}
 | ||||
|                                 </Typography> | ||||
|                             ); | ||||
|                         }} | ||||
|                     /> | ||||
|                     <Typography | ||||
|                         fontWeight={'bold'} | ||||
|                         color={getColor(featureLastSeen).text} | ||||
|                     > | ||||
|                         Reported <TimeAgo date={featureLastSeen} /> | ||||
|                     </Typography> | ||||
|                 } | ||||
|             /> | ||||
|             <StyledDescriptionSubHeader> | ||||
|  | ||||
| @ -24,6 +24,11 @@ const StyledWrapper = styled(Box, { | ||||
|     }, | ||||
| })); | ||||
| 
 | ||||
| const StyledSpan = styled('span')(() => ({ | ||||
|     display: 'inline-block', | ||||
|     maxWidth: '100%', | ||||
| })); | ||||
| 
 | ||||
| export const TextCell: FC<ITextCellProps> = ({ | ||||
|     value, | ||||
|     children, | ||||
| @ -32,8 +37,8 @@ export const TextCell: FC<ITextCellProps> = ({ | ||||
|     'data-testid': testid, | ||||
| }) => ( | ||||
|     <StyledWrapper lineClamp={lineClamp} sx={sx}> | ||||
|         <span data-loading='true' data-testid={testid}> | ||||
|         <StyledSpan data-loading='true' data-testid={testid}> | ||||
|             {children ?? value} | ||||
|         </span> | ||||
|         </StyledSpan> | ||||
|     </StyledWrapper> | ||||
| ); | ||||
|  | ||||
| @ -3,7 +3,7 @@ import { useLocationSettings } from 'hooks/useLocationSettings'; | ||||
| import type { FC } from 'react'; | ||||
| import { formatDateYMD } from 'utils/formatDate'; | ||||
| import { TextCell } from '../TextCell/TextCell'; | ||||
| import TimeAgo from 'react-timeago'; | ||||
| import { TimeAgo } from 'component/common/TimeAgo/TimeAgo'; | ||||
| 
 | ||||
| interface ITimeAgoCellProps { | ||||
|     value?: string | number | Date; | ||||
| @ -31,16 +31,15 @@ export const TimeAgoCell: FC<ITimeAgoCellProps> = ({ | ||||
|             <Tooltip title={title?.(date) ?? date} arrow> | ||||
|                 <Typography | ||||
|                     noWrap | ||||
|                     sx={{ | ||||
|                         display: 'inline-block', | ||||
|                         maxWidth: '100%', | ||||
|                     }} | ||||
|                     component='span' | ||||
|                     variant='body2' | ||||
|                     data-loading | ||||
|                 > | ||||
|                     <TimeAgo | ||||
|                         key={`${value}`} | ||||
|                         date={new Date(value)} | ||||
|                         live={live} | ||||
|                         title={''} | ||||
|                     /> | ||||
|                     <TimeAgo date={value} refresh={live} /> | ||||
|                 </Typography> | ||||
|             </Tooltip> | ||||
|         </TextCell> | ||||
|  | ||||
							
								
								
									
										139
									
								
								frontend/src/component/common/TimeAgo/TimeAgo.test.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								frontend/src/component/common/TimeAgo/TimeAgo.test.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,139 @@ | ||||
| import { vi } from 'vitest'; | ||||
| import { act, render, screen } from '@testing-library/react'; | ||||
| import { NewTimeAgo as TimeAgo } from './TimeAgo'; | ||||
| 
 | ||||
| const h = 3_600_000 as const; | ||||
| const min = 60_000 as const; | ||||
| const s = 1_000 as const; | ||||
| 
 | ||||
| beforeAll(() => { | ||||
|     vi.useFakeTimers(); | ||||
| }); | ||||
| 
 | ||||
| afterAll(() => { | ||||
|     vi.useRealTimers(); | ||||
| }); | ||||
| 
 | ||||
| test('renders fallback when date is null or undefined', () => { | ||||
|     render(<TimeAgo date={null} fallback='N/A' />); | ||||
|     expect(screen.getByText('N/A')).toBeInTheDocument(); | ||||
| 
 | ||||
|     render(<TimeAgo date={undefined} fallback='unknown' />); | ||||
|     expect(screen.getByText('unknown')).toBeInTheDocument(); | ||||
| }); | ||||
| 
 | ||||
| test('formats date correctly', () => { | ||||
|     const date = new Date(); | ||||
|     render(<TimeAgo date={date} />); | ||||
|     expect(screen.getByText('< 1 minute ago')).toBeInTheDocument(); | ||||
| }); | ||||
| 
 | ||||
| test('updates time periodically', () => { | ||||
|     const date = new Date(); | ||||
|     render(<TimeAgo date={date} />); | ||||
| 
 | ||||
|     expect(screen.getByText('< 1 minute ago')).toBeInTheDocument(); | ||||
| 
 | ||||
|     act(() => vi.advanceTimersByTime(61 * s)); | ||||
| 
 | ||||
|     expect(screen.getByText('1 minute ago')).toBeInTheDocument(); | ||||
| }); | ||||
| 
 | ||||
| test('stops updating when live is false', () => { | ||||
|     const date = new Date(); | ||||
|     const setIntervalSpy = vi.spyOn(global, 'setInterval'); | ||||
|     render(<TimeAgo date={date} refresh={false} />); | ||||
| 
 | ||||
|     expect(screen.getByText('< 1 minute ago')).toBeInTheDocument(); | ||||
| 
 | ||||
|     act(() => vi.advanceTimersByTime(61 * s)); | ||||
| 
 | ||||
|     expect(screen.getByText('< 1 minute ago')).toBeInTheDocument(); | ||||
| 
 | ||||
|     expect(setIntervalSpy).not.toHaveBeenCalled(); | ||||
| }); | ||||
| 
 | ||||
| test('handles string dates', () => { | ||||
|     const dateString = '2024-01-01T00:00:00Z'; | ||||
|     vi.setSystemTime(new Date('2024-01-01T01:01:00Z')); | ||||
| 
 | ||||
|     render(<TimeAgo date={dateString} />); | ||||
|     expect(screen.getByText('1 hour ago')).toBeInTheDocument(); | ||||
| }); | ||||
| 
 | ||||
| test('cleans up interval on unmount', () => { | ||||
|     const date = new Date(); | ||||
|     const { unmount } = render(<TimeAgo date={date} refresh />); | ||||
| 
 | ||||
|     const clearIntervalSpy = vi.spyOn(global, 'clearInterval'); | ||||
|     unmount(); | ||||
|     expect(clearIntervalSpy).toHaveBeenCalled(); | ||||
| }); | ||||
| 
 | ||||
| test('renders fallback for invalid date', () => { | ||||
|     render(<TimeAgo date='invalid-date' fallback='Invalid date' />); | ||||
|     expect(screen.getByText('Invalid date')).toBeInTheDocument(); | ||||
| }); | ||||
| 
 | ||||
| test('on date change, current time should be updated', () => { | ||||
|     const start = new Date().getTime(); | ||||
| 
 | ||||
|     vi.advanceTimersByTime(60_000); | ||||
| 
 | ||||
|     const Component = ({ date }: { date: number }) => ( | ||||
|         <> | ||||
|             <TimeAgo date={date} /> | ||||
|         </> | ||||
|     ); | ||||
| 
 | ||||
|     const { rerender } = render(<Component date={start} />); | ||||
|     expect(screen.getByText('1 minute ago')).toBeInTheDocument(); | ||||
| 
 | ||||
|     act(() => vi.advanceTimersByTime(2 * min)); | ||||
|     rerender(<Component date={start} />); | ||||
| 
 | ||||
|     expect(screen.getByText('3 minutes ago')).toBeInTheDocument(); | ||||
| 
 | ||||
|     rerender(<Component date={start + min} />); | ||||
| 
 | ||||
|     expect(screen.getByText('2 minutes ago')).toBeInTheDocument(); | ||||
| }); | ||||
| 
 | ||||
| test('should refresh on fallback change', () => { | ||||
|     const date = null; | ||||
|     const { rerender } = render( | ||||
|         <TimeAgo date={date} fallback='Initial fallback' />, | ||||
|     ); | ||||
|     expect(screen.getByText('Initial fallback')).toBeInTheDocument(); | ||||
| 
 | ||||
|     rerender(<TimeAgo date={date} fallback='Updated fallback' />); | ||||
|     expect(screen.getByText('Updated fallback')).toBeInTheDocument(); | ||||
| }); | ||||
| 
 | ||||
| test('should create `time` element', () => { | ||||
|     const now = 1724222592978; | ||||
|     vi.setSystemTime(now); | ||||
|     const { container } = render(<TimeAgo date={now - 3 * min} />); | ||||
|     expect(container).toMatchInlineSnapshot(` | ||||
|       <div> | ||||
|         <time | ||||
|           datetime="2024-08-21T06:40:12.978Z" | ||||
|         > | ||||
|           3 minutes ago | ||||
|         </time> | ||||
|       </div> | ||||
|     `);
 | ||||
| }); | ||||
| 
 | ||||
| test('should not create `time` element if `timeElement` is false', () => { | ||||
|     const now = 1724222592978; | ||||
|     vi.setSystemTime(now); | ||||
|     const { container } = render( | ||||
|         <TimeAgo date={now - 5 * h} timeElement={false} />, | ||||
|     ); | ||||
|     expect(container).toMatchInlineSnapshot(` | ||||
|       <div> | ||||
|         5 hours ago | ||||
|       </div> | ||||
|     `);
 | ||||
| }); | ||||
							
								
								
									
										73
									
								
								frontend/src/component/common/TimeAgo/TimeAgo.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								frontend/src/component/common/TimeAgo/TimeAgo.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,73 @@ | ||||
| import { useEffect, useState, type FC } from 'react'; | ||||
| import { formatDistanceToNow, secondsToMilliseconds } from 'date-fns'; | ||||
| import { default as LegacyTimeAgo } from 'react-timeago'; | ||||
| import { useUiFlag } from 'hooks/useUiFlag'; | ||||
| 
 | ||||
| type TimeAgoProps = { | ||||
|     date: Date | number | string | null | undefined; | ||||
|     fallback?: string; | ||||
|     refresh?: boolean; | ||||
|     timeElement?: boolean; | ||||
| }; | ||||
| 
 | ||||
| const formatTimeAgo = (date: string | number | Date) => | ||||
|     formatDistanceToNow(new Date(date), { | ||||
|         addSuffix: true, | ||||
|     }) | ||||
|         .replace('about ', '') | ||||
|         .replace('less than a minute ago', '< 1 minute ago'); | ||||
| 
 | ||||
| export const TimeAgo: FC<TimeAgoProps> = ({ ...props }) => { | ||||
|     const { date, fallback, refresh } = props; | ||||
|     const timeAgoRefactorEnabled = useUiFlag('timeAgoRefactor'); | ||||
| 
 | ||||
|     if (timeAgoRefactorEnabled) return <NewTimeAgo {...props} />; | ||||
|     if (!date) return fallback; | ||||
|     return ( | ||||
|         <LegacyTimeAgo key={`${date}`} date={new Date(date)} live={refresh} /> | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| export const NewTimeAgo: FC<TimeAgoProps> = ({ | ||||
|     date, | ||||
|     fallback = '', | ||||
|     refresh = true, | ||||
|     timeElement = true, | ||||
| }) => { | ||||
|     const getValue = (): { description: string; dateTime?: Date } => { | ||||
|         try { | ||||
|             if (!date) return { description: fallback }; | ||||
|             return { | ||||
|                 description: formatTimeAgo(date), | ||||
|                 dateTime: timeElement ? new Date(date) : undefined, | ||||
|             }; | ||||
|         } catch { | ||||
|             return { description: fallback }; | ||||
|         } | ||||
|     }; | ||||
|     const [state, setState] = useState(getValue); | ||||
| 
 | ||||
|     useEffect(() => { | ||||
|         setState(getValue); | ||||
|     }, [date, fallback]); | ||||
| 
 | ||||
|     useEffect(() => { | ||||
|         if (!date || !refresh) return; | ||||
| 
 | ||||
|         const intervalId = setInterval(() => { | ||||
|             setState(getValue); | ||||
|         }, secondsToMilliseconds(12)); | ||||
| 
 | ||||
|         return () => clearInterval(intervalId); | ||||
|     }, [refresh]); | ||||
| 
 | ||||
|     if (!state.dateTime) { | ||||
|         return state.description; | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|         <time dateTime={state.dateTime.toISOString()}>{state.description}</time> | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| export default TimeAgo; | ||||
| @ -1,4 +1,3 @@ | ||||
| import TimeAgo from 'react-timeago'; | ||||
| import { LastSeenTooltip } from 'component/common/Table/cells/FeatureSeenCell/LastSeenTooltip'; | ||||
| import type React from 'react'; | ||||
| import type { FC, ReactElement } from 'react'; | ||||
| @ -85,45 +84,36 @@ export const FeatureEnvironmentSeen = ({ | ||||
| 
 | ||||
|     const lastSeen = getLatestLastSeenAt(environments) || featureLastSeen; | ||||
| 
 | ||||
|     return ( | ||||
|         <> | ||||
|             {lastSeen ? ( | ||||
|                 <TimeAgo | ||||
|                     key={`${lastSeen}`} | ||||
|                     date={lastSeen} | ||||
|                     title='' | ||||
|                     live={false} | ||||
|                     formatter={(value: number, unit: string) => { | ||||
|                         const [color, textColor] = getColor(unit); | ||||
|                         return ( | ||||
|                             <TooltipContainer | ||||
|                                 sx={sx} | ||||
|                                 tooltip={ | ||||
|                                     <LastSeenTooltip | ||||
|                                         featureLastSeen={lastSeen} | ||||
|                                         environments={environments} | ||||
|                                         {...rest} | ||||
|                                     /> | ||||
|                                 } | ||||
|                                 color={color} | ||||
|                             > | ||||
|                                 <UsageRate stroke={textColor} /> | ||||
|                             </TooltipContainer> | ||||
|                         ); | ||||
|                     }} | ||||
|                 /> | ||||
|             ) : ( | ||||
|                 <TooltipContainer | ||||
|                     sx={sx} | ||||
|                     tooltip='No usage reported from connected applications' | ||||
|                 > | ||||
|                     <Box data-loading> | ||||
|                         <Box> | ||||
|                             <UsageLine /> | ||||
|                         </Box> | ||||
|     if (!lastSeen) { | ||||
|         return ( | ||||
|             <TooltipContainer | ||||
|                 sx={sx} | ||||
|                 tooltip='No usage reported from connected applications' | ||||
|             > | ||||
|                 <Box data-loading> | ||||
|                     <Box> | ||||
|                         <UsageLine /> | ||||
|                     </Box> | ||||
|                 </TooltipContainer> | ||||
|             )} | ||||
|         </> | ||||
|                 </Box> | ||||
|             </TooltipContainer> | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     const { background, text } = getColor(lastSeen); | ||||
| 
 | ||||
|     return ( | ||||
|         <TooltipContainer | ||||
|             sx={sx} | ||||
|             tooltip={ | ||||
|                 <LastSeenTooltip | ||||
|                     featureLastSeen={lastSeen} | ||||
|                     environments={environments} | ||||
|                     {...rest} | ||||
|                 /> | ||||
|             } | ||||
|             color={background} | ||||
|         > | ||||
|             <UsageRate stroke={text} /> | ||||
|         </TooltipContainer> | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| @ -1,25 +1,47 @@ | ||||
| import { useTheme } from '@mui/material'; | ||||
| import { differenceInDays } from 'date-fns'; | ||||
| 
 | ||||
| export const useLastSeenColors = () => { | ||||
| type Color = { | ||||
|     background: string; | ||||
|     text: string; | ||||
| }; | ||||
| 
 | ||||
| export const useLastSeenColors = (): (( | ||||
|     date?: Date | number | string | null, | ||||
| ) => Color) => { | ||||
|     const theme = useTheme(); | ||||
|     const colorsForUnknown = { | ||||
|         background: theme.palette.seen.unknown, | ||||
|         text: theme.palette.grey.A400, | ||||
|     }; | ||||
| 
 | ||||
|     return (unit?: string): [string, string] => { | ||||
|         switch (unit) { | ||||
|             case 'second': | ||||
|             case 'minute': | ||||
|             case 'hour': | ||||
|             case 'day': | ||||
|                 return [theme.palette.seen.recent, theme.palette.success.main]; | ||||
|             case 'week': | ||||
|                 return [ | ||||
|                     theme.palette.seen.inactive, | ||||
|                     theme.palette.warning.main, | ||||
|                 ]; | ||||
|             case 'month': | ||||
|             case 'year': | ||||
|                 return [theme.palette.seen.abandoned, theme.palette.error.main]; | ||||
|             default: | ||||
|                 return [theme.palette.seen.unknown, theme.palette.grey.A400]; | ||||
|     return (date?: Date | number | string | null): Color => { | ||||
|         if (!date) { | ||||
|             return colorsForUnknown; | ||||
|         } | ||||
| 
 | ||||
|         try { | ||||
|             const days = differenceInDays(Date.now(), new Date(date)); | ||||
| 
 | ||||
|             if (days < 1) { | ||||
|                 return { | ||||
|                     background: theme.palette.seen.recent, | ||||
|                     text: theme.palette.success.main, | ||||
|                 }; | ||||
|             } | ||||
|             if (days <= 7) { | ||||
|                 return { | ||||
|                     background: theme.palette.seen.inactive, | ||||
|                     text: theme.palette.warning.main, | ||||
|                 }; | ||||
|             } | ||||
| 
 | ||||
|             return { | ||||
|                 background: theme.palette.seen.abandoned, | ||||
|                 text: theme.palette.error.main, | ||||
|             }; | ||||
|         } catch {} | ||||
| 
 | ||||
|         return colorsForUnknown; | ||||
|     }; | ||||
| }; | ||||
|  | ||||
| @ -11,7 +11,7 @@ import { ReactComponent as ArchivedStageIcon } from 'assets/icons/stage-archived | ||||
| import CloudCircle from '@mui/icons-material/CloudCircle'; | ||||
| import { ReactComponent as UsageRate } from 'assets/icons/usage-rate.svg'; | ||||
| import { FeatureLifecycleStageIcon } from './FeatureLifecycleStageIcon'; | ||||
| import TimeAgo from 'react-timeago'; | ||||
| import { TimeAgo } from 'component/common/TimeAgo/TimeAgo'; | ||||
| import { StyledIconWrapper } from '../../FeatureEnvironmentSeen/FeatureEnvironmentSeen'; | ||||
| import { useLastSeenColors } from '../../FeatureEnvironmentSeen/useLastSeenColors'; | ||||
| import type { LifecycleStage } from './LifecycleStage'; | ||||
| @ -114,22 +114,12 @@ const LastSeenIcon: FC<{ | ||||
|     lastSeen: string; | ||||
| }> = ({ lastSeen }) => { | ||||
|     const getColor = useLastSeenColors(); | ||||
|     const { text, background } = getColor(lastSeen); | ||||
| 
 | ||||
|     return ( | ||||
|         <TimeAgo | ||||
|             key={`${lastSeen}`} | ||||
|             date={lastSeen} | ||||
|             title='' | ||||
|             live={false} | ||||
|             formatter={(value: number, unit: string) => { | ||||
|                 const [color, textColor] = getColor(unit); | ||||
|                 return ( | ||||
|                     <StyledIconWrapper style={{ background: color }}> | ||||
|                         <UsageRate stroke={textColor} /> | ||||
|                     </StyledIconWrapper> | ||||
|                 ); | ||||
|             }} | ||||
|         /> | ||||
|         <StyledIconWrapper style={{ background }}> | ||||
|             <UsageRate stroke={text} /> | ||||
|         </StyledIconWrapper> | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| @ -230,11 +220,7 @@ const Environments: FC<{ | ||||
|                             <Box>{environment.name}</Box> | ||||
|                         </CenteredBox> | ||||
|                         <CenteredBox> | ||||
|                             <TimeAgo | ||||
|                                 key={`${environment.lastSeenAt}`} | ||||
|                                 minPeriod={60} | ||||
|                                 date={environment.lastSeenAt} | ||||
|                             /> | ||||
|                             <TimeAgo date={environment.lastSeenAt} /> | ||||
|                             <LastSeenIcon lastSeen={environment.lastSeenAt} /> | ||||
|                         </CenteredBox> | ||||
|                     </EnvironmentLine> | ||||
|  | ||||
| @ -17,7 +17,6 @@ import { formatDateYMDHM } from 'utils/formatDate'; | ||||
| import { useLocationSettings } from 'hooks/useLocationSettings'; | ||||
| import { parseISO } from 'date-fns'; | ||||
| import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; | ||||
| import TimeAgo from 'react-timeago'; | ||||
| import { Box, Link, Tooltip } from '@mui/material'; | ||||
| import { Link as RouterLink } from 'react-router-dom'; | ||||
| import { | ||||
| @ -29,6 +28,7 @@ import PermissionIconButton from 'component/common/PermissionIconButton/Permissi | ||||
| import Delete from '@mui/icons-material/Delete'; | ||||
| import { Highlighter } from 'component/common/Highlighter/Highlighter'; | ||||
| import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; | ||||
| import { TimeAgo } from 'component/common/TimeAgo/TimeAgo'; | ||||
| 
 | ||||
| export type ProjectArchiveCardProps = { | ||||
|     id: string; | ||||
| @ -91,12 +91,8 @@ export const ProjectArchiveCard: FC<ProjectArchiveCardProps> = ({ | ||||
|                                     <p data-loading> | ||||
|                                         Archived:{' '} | ||||
|                                         <TimeAgo | ||||
|                                             key={`${archivedAt}`} | ||||
|                                             minPeriod={60} | ||||
|                                             date={ | ||||
|                                                 new Date(archivedAt as string) | ||||
|                                             } | ||||
|                                             live={false} | ||||
|                                             date={archivedAt} | ||||
|                                             refresh={false} | ||||
|                                         /> | ||||
|                                     </p> | ||||
|                                 </Box> | ||||
|  | ||||
| @ -93,6 +93,7 @@ export type UiFlags = { | ||||
|     newEventSearch?: boolean; | ||||
|     archiveProjects?: boolean; | ||||
|     projectListImprovements?: boolean; | ||||
|     timeAgoRefactor?: boolean; | ||||
| }; | ||||
| 
 | ||||
| export interface IVersionInfo { | ||||
|  | ||||
| @ -149,6 +149,7 @@ exports[`should create default config 1`] = ` | ||||
|       "showInactiveUsers": false, | ||||
|       "signals": false, | ||||
|       "strictSchemaValidation": false, | ||||
|       "timeAgoRefactor": false, | ||||
|       "useMemoizedActiveTokens": false, | ||||
|       "useProjectReadModel": false, | ||||
|       "userAccessUIEnabled": false, | ||||
|  | ||||
| @ -66,7 +66,8 @@ export type IFlagKey = | ||||
|     | 'projectListImprovements' | ||||
|     | 'useProjectReadModel' | ||||
|     | 'webhookServiceNameLogging' | ||||
|     | 'addonUsageMetrics'; | ||||
|     | 'addonUsageMetrics' | ||||
|     | 'timeAgoRefactor'; | ||||
| 
 | ||||
| export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; | ||||
| 
 | ||||
| @ -323,6 +324,10 @@ const flags: IFlags = { | ||||
|         process.env.UNLEASH_EXPERIMENTAL_ADDON_USAGE_METRICS, | ||||
|         false, | ||||
|     ), | ||||
|     timeAgoRefactor: parseEnvVarBoolean( | ||||
|         process.env.UNLEASH_TIMEAGO_REFACTOR, | ||||
|         false, | ||||
|     ), | ||||
| }; | ||||
| 
 | ||||
| export const defaultExperimentalOptions: IExperimentalOptions = { | ||||
|  | ||||
| @ -59,6 +59,7 @@ process.nextTick(async () => { | ||||
|                         useProjectReadModel: true, | ||||
|                         webhookServiceNameLogging: true, | ||||
|                         addonUsageMetrics: true, | ||||
|                         timeAgoRefactor: true, | ||||
|                     }, | ||||
|                 }, | ||||
|                 authentication: { | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user