From f961c7bda79b22f9111f1e5bbaf0b9528b230ee2 Mon Sep 17 00:00:00 2001 From: frozenhelium Date: Fri, 1 Dec 2023 16:44:23 +0545 Subject: [PATCH] Add timeline chart in rapid response table --- src/components/DateOutput/index.tsx | 4 +- .../ColumnShortcuts/TimelineHeader/index.tsx | 43 +++++++++ .../TimelineHeader/styles.module.css | 7 ++ .../ColumnShortcuts/TimelineItem/index.tsx | 88 +++++++++++++++++++ .../TimelineItem/styles.module.css | 36 ++++++++ src/components/Table/ColumnShortcuts/index.ts | 82 +++++++++++------ .../Table/ColumnShortcuts/styles.module.css | 4 + src/components/Table/types.ts | 2 +- src/utils/common.ts | 28 ++++-- .../RapidResponsePerosnnelTable/i18n.json | 2 - .../RapidResponsePerosnnelTable/index.tsx | 79 +++++++++++++---- .../styles.module.css | 11 +++ 12 files changed, 332 insertions(+), 54 deletions(-) create mode 100644 src/components/Table/ColumnShortcuts/TimelineHeader/index.tsx create mode 100644 src/components/Table/ColumnShortcuts/TimelineHeader/styles.module.css create mode 100644 src/components/Table/ColumnShortcuts/TimelineItem/index.tsx create mode 100644 src/components/Table/ColumnShortcuts/TimelineItem/styles.module.css create mode 100644 src/views/EmergencySurge/RapidResponsePerosnnelTable/styles.module.css diff --git a/src/components/DateOutput/index.tsx b/src/components/DateOutput/index.tsx index 0bf32789ad..0d0ff6b78a 100644 --- a/src/components/DateOutput/index.tsx +++ b/src/components/DateOutput/index.tsx @@ -1,12 +1,12 @@ import { useMemo } from 'react'; import { _cs } from '@togglecorp/fujs'; -import { formatDate } from '#utils/common'; +import { DateLike, formatDate } from '#utils/common'; import styles from './styles.module.css'; export interface Props { className?: string; - value?: string | number | null; + value: DateLike | undefined | null; format?: string; invalidText?: React.ReactNode; } diff --git a/src/components/Table/ColumnShortcuts/TimelineHeader/index.tsx b/src/components/Table/ColumnShortcuts/TimelineHeader/index.tsx new file mode 100644 index 0000000000..a5f4f5dbdc --- /dev/null +++ b/src/components/Table/ColumnShortcuts/TimelineHeader/index.tsx @@ -0,0 +1,43 @@ +import DateOutput from '#components/DateOutput'; +import { _cs } from '@togglecorp/fujs'; + +import HeaderCell, { HeaderCellProps } from '../../HeaderCell'; + +import styles from './styles.module.css'; + +export interface Props extends HeaderCellProps { + className?: string; + dateRange: { + start: Date, + end: Date, + } | undefined; +} + +function TimelineHeader(props: Props) { + const { + className, + dateRange, + ...otherProps + } = props; + + return ( + + + + + )} + /> + ); +} + +export default TimelineHeader; diff --git a/src/components/Table/ColumnShortcuts/TimelineHeader/styles.module.css b/src/components/Table/ColumnShortcuts/TimelineHeader/styles.module.css new file mode 100644 index 0000000000..a043f8cef3 --- /dev/null +++ b/src/components/Table/ColumnShortcuts/TimelineHeader/styles.module.css @@ -0,0 +1,7 @@ +.timeline-header { + .title { + display: flex; + flex-grow: 1; + justify-content: space-between; + } +} diff --git a/src/components/Table/ColumnShortcuts/TimelineItem/index.tsx b/src/components/Table/ColumnShortcuts/TimelineItem/index.tsx new file mode 100644 index 0000000000..249b8ddc25 --- /dev/null +++ b/src/components/Table/ColumnShortcuts/TimelineItem/index.tsx @@ -0,0 +1,88 @@ +import { _cs, isNotDefined } from '@togglecorp/fujs'; + +import Tooltip from '#components/Tooltip'; +import TextOutput from '#components/TextOutput'; + +import { type DateLike, isValidDate } from '#utils/common'; + +import styles from './styles.module.css'; + +export interface Props { + className?: string; + startDate: DateLike | null | undefined; + endDate: DateLike | null | undefined; + dateRange: { + start: Date, + end: Date, + } | undefined; +} + +function TimelineItem(props: Props) { + const { + className, + startDate, + endDate, + dateRange, + } = props; + + if (isNotDefined(dateRange)) { + return null; + } + + if (!isValidDate(startDate)) { + return null; + } + + if (!isValidDate(endDate)) { + return null; + } + + const domainWidth = dateRange.end.getTime() - dateRange.start.getTime(); + + const start = 1 - (dateRange.end.getTime() - new Date(startDate).getTime()) / domainWidth; + const end = (dateRange.end.getTime() - new Date(endDate).getTime()) / domainWidth; + + const today = 1 - (dateRange.end.getTime() - new Date().getTime()) / domainWidth; + + return ( + <> +
+
+
+
+
+
+ + + + + )} + /> + + ); +} + +export default TimelineItem; diff --git a/src/components/Table/ColumnShortcuts/TimelineItem/styles.module.css b/src/components/Table/ColumnShortcuts/TimelineItem/styles.module.css new file mode 100644 index 0000000000..1cd9cca097 --- /dev/null +++ b/src/components/Table/ColumnShortcuts/TimelineItem/styles.module.css @@ -0,0 +1,36 @@ +.timeline-item { + position: absolute; + top: 0; + left: var(--go-ui-spacing-sm); + width: calc(100% - 2 * var(--go-ui-spacing-sm)); + height: 100%; + + .timeline-progress { + position: absolute; + top: 50%; + transform: translateY(-50%); + border-radius: 0.25em; + background-color: var(--go-ui-color-primary-red); + height: 0.5rem; + } + + .today-marker { + position: absolute; + border-left: var(--go-ui-width-separator-sm) dashed var(--go-ui-color-primary-blue); + height: 100%; + } + + .start-date-marker { + position: absolute; + left: 0; + border-left: var(--go-ui-width-separator-sm) dashed var(--go-ui-color-separator); + height: 100%; + } + + .end-date-marker { + position: absolute; + right: 0; + border-left: var(--go-ui-width-separator-sm) dashed var(--go-ui-color-separator); + height: 100%; + } +} diff --git a/src/components/Table/ColumnShortcuts/index.ts b/src/components/Table/ColumnShortcuts/index.ts index 7428949e5c..e67501cc78 100644 --- a/src/components/Table/ColumnShortcuts/index.ts +++ b/src/components/Table/ColumnShortcuts/index.ts @@ -7,21 +7,13 @@ import { randomString, } from '@togglecorp/fujs'; -import DateOutput from '#components/DateOutput'; -import { type Props as DateOutputProps } from '#components/DateOutput'; -import DateRangeOutput from '#components/DateRangeOutput'; -import { type Props as DateRangeOutputProps } from '#components/DateRangeOutput'; -import NumberOutput from '#components/NumberOutput'; -import { type Props as NumberOutputProps } from '#components/NumberOutput'; -import BooleanOutput from '#components/BooleanOutput'; -import { type Props as BooleanOutputProps } from '#components/BooleanOutput'; -import ProgressBar from '#components/ProgressBar'; -import { type Props as ProgressBarProps } from '#components/ProgressBar'; -import ReducedListDisplay, { - Props as ReducedListDisplayProps, -} from '#components/ReducedListDisplay'; -import { type Props as LinkProps } from '#components/Link'; -import Link from '#components/Link'; +import DateOutput, { type Props as DateOutputProps } from '#components/DateOutput'; +import DateRangeOutput, { type Props as DateRangeOutputProps } from '#components/DateRangeOutput'; +import NumberOutput, { type Props as NumberOutputProps } from '#components/NumberOutput'; +import BooleanOutput, { type Props as BooleanOutputProps } from '#components/BooleanOutput'; +import ProgressBar, { type Props as ProgressBarProps } from '#components/ProgressBar'; +import ReducedListDisplay, { Props as ReducedListDisplayProps } from '#components/ReducedListDisplay'; +import Link, { type Props as LinkProps } from '#components/Link'; import { numericIdSelector } from '#utils/selectors'; import { type GoApiResponse } from '#utils/restRequest'; @@ -38,6 +30,8 @@ import { type ExpandButtonProps } from './ExpandButton'; import ExpansionIndicator from './ExpansionIndicator'; import { type Props as ExpansionIndicatorProps } from './ExpansionIndicator'; import CountryLink from './CountryLink'; +import TimelineItem, { type Props as TimelineItemProps } from './TimelineItem'; +import TimelineHeader, { type Props as TimelineHeaderProps } from './TimelineHeader'; import type { Props as CountryLinkProps } from './CountryLink'; import RegionLink from './RegionLink'; @@ -383,10 +377,10 @@ export function createExpandColumn( return item; } -export function createExpansionIndicatorColumn( +export function createExpansionIndicatorColumn( isExpanded?: boolean, ) { - const item: Column = { + const item: Column = { id: randomString(), title: '', headerCellRenderer: HeaderCell, @@ -417,14 +411,14 @@ export function createExpansionIndicatorColumn( return item; } -export function createElementColumn( +export function createElementColumn( id: string, title: string, renderer: React.ComponentType, - rendererParams: (key: KEY, datum: DATA) => ELEMENT_PROPS, - options?: Options, + rendererParams: (key: KEY, datum: DATUM) => ELEMENT_PROPS, + options?: Options, ) { - const item: Column = { + const item: Column = { id, title, headerCellRenderer: HeaderCell, @@ -446,12 +440,50 @@ export function createElementColumn( return item; } -export function createActionColumn( +export function createTimelineColumn( id: string, - rendererParams: (datum: D) => TableActionsProps, - options?: Options, + dateRange: { + start: Date, + end: Date, + } | undefined, + rendererParams: (datum: DATUM) => Omit, + options?: Options, +) { + const item: Column = { + id, + title: '', + headerCellRenderer: TimelineHeader, + headerCellRendererParams: { + dateRange, + sortable: options?.sortable, + }, + cellRenderer: TimelineItem, + cellRendererParams: (_, datum) => ({ + dateRange, + ...rendererParams(datum), + }), + headerContainerClassName: options?.headerContainerClassName, + cellRendererClassName: options?.cellRendererClassName, + columnClassName: options?.columnClassName, + headerCellRendererClassName: options?.headerCellRendererClassName, + cellContainerClassName: _cs( + options?.cellContainerClassName, + styles.timelineCellContainer, + ), + columnWidth: options?.columnWidth, + columnStretch: options?.columnStretch, + columnStyle: options?.columnStyle, + }; + + return item; +} + +export function createActionColumn( + id: string, + rendererParams: (datum: DATUM) => TableActionsProps, + options?: Options, ) { - const item: Column = { + const item: Column = { id, title: '', headerCellRenderer: HeaderCell, diff --git a/src/components/Table/ColumnShortcuts/styles.module.css b/src/components/Table/ColumnShortcuts/styles.module.css index 25bce0f709..edb8305863 100644 --- a/src/components/Table/ColumnShortcuts/styles.module.css +++ b/src/components/Table/ColumnShortcuts/styles.module.css @@ -5,3 +5,7 @@ .expansion-indicator-cell-container { position: relative; } + +.timeline-cell-container { + position: relative; +} diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index adfb1aaea2..b33da8217f 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -6,7 +6,7 @@ export interface BaseHeader { name: string; index: number; - title?: string; + title?: React.ReactNode; } export interface BaseCell { diff --git a/src/utils/common.ts b/src/utils/common.ts index 0abf2278d9..77d99fea91 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -373,16 +373,10 @@ export function formatNumber( return newValue; } +export type DateLike = string | number | Date; + export function formatDate( - value: null | undefined, - format?: string, -): undefined; -export function formatDate( - value: Date | string | number, - format?: string, -): string | undefined; -export function formatDate( - value: Date | string | number | null | undefined, + value: DateLike | null | undefined, format = DEFAULT_DATE_FORMAT, ) { if (isNotDefined(value)) { @@ -668,3 +662,19 @@ export function addNumMonthsToDate( return dateSafe; } + +export function isValidDate( + value: T | null | undefined, +): value is T { + if (isNotDefined(value)) { + return false; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return false; + } + + return true; +} diff --git a/src/views/EmergencySurge/RapidResponsePerosnnelTable/i18n.json b/src/views/EmergencySurge/RapidResponsePerosnnelTable/i18n.json index a0ae2d10a1..75c194eff4 100644 --- a/src/views/EmergencySurge/RapidResponsePerosnnelTable/i18n.json +++ b/src/views/EmergencySurge/RapidResponsePerosnnelTable/i18n.json @@ -3,8 +3,6 @@ "strings": { "rapidResponseTitle": "Rapid Response Personnel", "rapidResponse": "Rapid Response", - "personnelTableStartDate": "Start Date", - "personnelTableEndDate": "End Date", "personnelTableName": "Name", "personnelTablePosition": "Position", "personnelTableType": "Type", diff --git a/src/views/EmergencySurge/RapidResponsePerosnnelTable/index.tsx b/src/views/EmergencySurge/RapidResponsePerosnnelTable/index.tsx index f96d794350..63f230c03a 100644 --- a/src/views/EmergencySurge/RapidResponsePerosnnelTable/index.tsx +++ b/src/views/EmergencySurge/RapidResponsePerosnnelTable/index.tsx @@ -2,6 +2,8 @@ import { useMemo, useCallback, } from 'react'; +import { isDefined, isNotDefined } from '@togglecorp/fujs'; + import useTranslation from '#hooks/useTranslation'; import { type GoApiResponse, @@ -14,15 +16,21 @@ import { } from '#components/Table/useSorting'; import { createStringColumn, - createDateColumn, createLinkColumn, + createTimelineColumn, } from '#components/Table/ColumnShortcuts'; import Table from '#components/Table'; import Link from '#components/Link'; import { numericIdSelector } from '#utils/selectors'; import useFilterState from '#hooks/useFilterState'; +import { + isValidDate, + maxSafe, + minSafe, +} from '#utils/common'; import i18n from './i18n.json'; +import styles from './styles.module.css'; type PersonnelTableItem = NonNullable['results']>[number]; const now = new Date().toISOString(); @@ -70,13 +78,57 @@ export default function RapidResponsePersonnelTable(props: Props) { }, }); + const dateRange = useMemo( + () => { + if ( + isNotDefined(personnelResponse) + || isNotDefined(personnelResponse.results) + || personnelResponse.results.length === 0 + ) { + return undefined; + } + + const startDateList = personnelResponse.results.map( + ({ start_date }) => ( + isValidDate(start_date) + ? new Date(start_date).getTime() + : undefined + ), + ).filter(isDefined); + + const endDateList = personnelResponse.results.map( + ({ end_date }) => ( + isValidDate(end_date) + ? new Date(end_date).getTime() + : undefined + ), + ).filter(isDefined); + + const start = minSafe([...startDateList, ...endDateList]); + const end = maxSafe([...startDateList, ...endDateList]); + + if (isNotDefined(start) || isNotDefined(end)) { + return undefined; + } + + return { + start: new Date(start), + end: new Date(end), + }; + }, + [personnelResponse], + ); + const columns = useMemo( () => ([ createStringColumn( 'role', strings.personnelTablePosition, (item) => item.role, - { sortable: true }, + { + sortable: true, + columnClassName: styles.role, + }, ), createStringColumn( 'type', @@ -110,17 +162,14 @@ export default function RapidResponsePersonnelTable(props: Props) { (item) => item.name, { sortable: true }, ), - createDateColumn( - 'start_date', - strings.personnelTableStartDate, - (item) => item.start_date, - { sortable: true }, - ), - createDateColumn( - 'end_date', - strings.personnelTableEndDate, - (item) => item.end_date, - { sortable: true }, + createTimelineColumn( + 'timeline', + dateRange, + (item) => ({ + startDate: item.start_date, + endDate: item.end_date, + }), + { columnClassName: styles.timeline }, ), ]), [ @@ -129,14 +178,14 @@ export default function RapidResponsePersonnelTable(props: Props) { strings.personnelTableDeployedParty, strings.personnelTableDeployedTo, strings.personnelTableName, - strings.personnelTableStartDate, - strings.personnelTableEndDate, getTypeName, + dateRange, ], ); return (