@spaced-out/ui-design-system
Version:
Sense UI components library
443 lines (391 loc) • 12.4 kB
Flow
// @flow strict
// $FlowFixMe - strict types for date-fns
import parseISO from 'date-fns/parseISO';
import invariant from 'invariant';
import {chunk, isEmpty, range} from 'lodash';
// $FlowFixMe[untyped-import]
import moment from 'moment';
import type {
DateRange,
DateRangePickerError,
DateRangePickerErrorTypes,
DateRangeWithTimezone,
TimeUnit,
} from 'src/types/date-range-picker';
import type {MenuOption} from '../../components/Menu';
import {TIMEZONES} from './timezones';
export const makeKey = (value: string): string => {
try {
if (value && typeof value === 'string') {
return value
.replace(/\(s\)/gi, '_s') // Replace (s) with _s (case-insensitive)
.replace(/[^\w\s']/g, '') // Remove special chars except apostrophes
.replace(/\s+/g, '_') // Spaces → underscores
.replace(/_+/g, '_') // Collapse multiple ____ into _
.replace(/^_|_$/g, ''); // Trim leading/trailing _
}
return value;
} catch {
return value;
}
};
export const NAVIGATION_ACTION = Object.freeze({
NEXT: 'next',
PREV: 'prev',
});
export const MARKERS = Object.freeze({
DATE_RANGE_START: 'FIRST',
DATE_RANGE_END: 'SECOND',
});
export const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
export const getMonths = (
t: ?(key: string, fallback: string) => string,
): Array<MenuOption> => [
{key: '0', label: getTranslation(t, 'Jan')},
{key: '1', label: getTranslation(t, 'Feb')},
{key: '2', label: getTranslation(t, 'Mar')},
{key: '3', label: getTranslation(t, 'Apr')},
{key: '4', label: getTranslation(t, 'May')},
{key: '5', label: getTranslation(t, 'Jun')},
{key: '6', label: getTranslation(t, 'Jul')},
{key: '7', label: getTranslation(t, 'Aug')},
{key: '8', label: getTranslation(t, 'Sep')},
{key: '9', label: getTranslation(t, 'Oct')},
{key: '10', label: getTranslation(t, 'Nov')},
{key: '11', label: getTranslation(t, 'Dec')},
];
export const getDateRangePickerErrors = (
t: ?(key: string, fallback: string) => string,
): DateRangePickerErrorTypes => ({
MIN_MAX_INVALID: {
type: 'MIN_MAX_INVALID',
description: getTranslation(t, 'Given minDate and maxDate are invalid.'),
},
START_DATE_EARLY: {
type: 'START_DATE_EARLY',
description: getTranslation(
t,
'Given startDate can not come before minDate.',
),
},
START_DATE_LATE: {
type: 'START_DATE_LATE',
description: getTranslation(
t,
'Given startDate can not come after endDate.',
),
},
END_DATE_LATE: {
type: 'END_DATE_LATE',
description: getTranslation(t, 'Given endDate can not come after maxDate.'),
},
});
export const checkRangeValidity = (
rangeStart?: ?string,
rangeEnd?: ?string,
errorBody: DateRangePickerError,
onError?: (DateRangePickerError) => void,
): boolean => {
const isRangeStartValid = isValid(rangeStart);
const isRangeEndValid = isRangeStartValid && isValid(rangeEnd);
const isRangeValid = isRangeEndValid && isSameOrBefore(rangeStart, rangeEnd);
invariant(isRangeValid, JSON.stringify(errorBody));
if (!isRangeValid) {
onError?.(errorBody);
}
return isRangeValid;
};
export const wrangleMoment = (date?: string | Date): Date => {
if (date instanceof Date) {
return date;
} else if (!date) {
return new Date();
}
return date instanceof moment ? date.toDate() : parseISO(date);
};
export const formatIsoDate = (
date?: string | Date,
format?: string = 'YYYY-MM-DD',
): string => moment.utc(date).format(format);
export const isStartOfRange = ({startDate}: DateRange, date: string): boolean =>
Boolean(startDate) && moment.utc(date).isSame(moment.utc(startDate), 'd');
export const isEndOfRange = ({endDate}: DateRange, date: string): boolean =>
Boolean(endDate) && moment.utc(date).isSame(moment.utc(endDate), 'd');
export const inDateRange = (
{startDate, endDate}: DateRange,
date: string,
): boolean => {
if (startDate && endDate) {
const momentDay = moment.utc(date);
const momentStartDate = moment.utc(startDate);
const momentEndDate = moment.utc(endDate);
return isBetween(momentDay, momentStartDate, momentEndDate);
}
return false;
};
export const isStartDateEndDateSame = ({
startDate,
endDate,
}: DateRange): boolean => {
if (startDate && endDate) {
return moment.utc(startDate).isSame(moment.utc(endDate), 'd');
}
return false;
};
const getMonthAndYear = (date) => {
const momentDate = date ? moment.utc(date) : moment.utc();
return [momentDate.month(), momentDate.year()];
};
export const getDaysInMonth = (date: string): Array<Array<string>> => {
const startWeek = moment.utc(date).startOf('month').startOf('week');
const endWeek = moment.utc(date).endOf('month').endOf('week');
const days = [],
current = startWeek;
while (isBefore(current, endWeek)) {
days.push(current.clone().format('YYYY-MM-DD'));
current.add(1, 'd');
}
const daysInChunks = chunk(days, 7);
// if total rows in calendar are 5 add one more week to the calendar
if (daysInChunks.length === 5) {
const nextWeek = getAddedDate(endWeek, WEEKDAYS.length, 'd');
const extraDays = [];
while (isSameOrBefore(current, nextWeek)) {
extraDays.push(current.clone().format('YYYY-MM-DD'));
current.add(1, 'd');
}
daysInChunks.push(extraDays);
}
return daysInChunks;
};
export const getAddedDate = (
date: string,
addCount: number,
timeUnit: TimeUnit,
): string => formatIsoDate(moment.utc(date).add(addCount, timeUnit));
export const getSubtractedDate = (
date: string,
subtractCount: number,
timeUnit: TimeUnit,
): string => formatIsoDate(moment.utc(date).subtract(subtractCount, timeUnit));
export const getMonthEndDate = (date: string): string =>
formatIsoDate(moment.utc(date).endOf('M'));
export const getTimezones = (
t: ?(key: string, fallback: string) => string,
): Array<MenuOption> =>
Object.keys(TIMEZONES).reduce((menuOptions, key) => {
menuOptions.push({
key,
label: getTranslation(t, TIMEZONES[key]),
});
return menuOptions;
}, []);
export const generateAvailableYears = ({
marker,
minDate,
maxDate,
rangeStartMonth,
rangeEndMonth,
}: {
marker: $Values<typeof MARKERS>,
minDate: string,
maxDate: string,
rangeStartMonth: string,
rangeEndMonth: string,
}): Array<MenuOption> => {
const rangeStartYear = moment.utc(rangeStartMonth).year();
const rangeEndYear = moment.utc(rangeEndMonth).year();
const isWithinRange = (year: number) =>
marker === MARKERS.DATE_RANGE_START
? year <= rangeEndYear
: year >= rangeStartYear;
return range(moment.utc(minDate).year(), moment.utc(maxDate).year() + 1)
.filter((year) => isWithinRange(year))
.map((year) => ({
key: year.toString(),
label: year.toString(),
}));
};
export const getAvailableMonths = ({
marker,
minDate,
maxDate,
rangeStartMonth,
rangeEndMonth,
t,
}: {
marker: $Values<typeof MARKERS>,
minDate: string,
maxDate: string,
rangeStartMonth: string,
rangeEndMonth: string,
t: ?(key: string, fallback: string) => string,
}): Array<MenuOption> => {
const [rangeStartMonthKey, rangeStartYear] = getMonthAndYear(rangeStartMonth);
const [rangeEndMonthKey, rangeEndYear] = getMonthAndYear(rangeEndMonth);
const [minDateMonth, minDateYear] = getMonthAndYear(minDate);
const [maxDateMonth, maxDateYear] = getMonthAndYear(maxDate);
const MONTHS = getMonths(t);
return MONTHS.filter((month: MenuOption) => {
const isSameYear = rangeStartYear === rangeEndYear;
const isFirstAndMinDateYearSame = rangeStartYear === minDateYear;
const isSecondAndMaxDateYearSame = rangeEndYear === maxDateYear;
if (marker === MARKERS.DATE_RANGE_START) {
if (isSameYear && month.key >= rangeEndMonthKey) {
return false;
} else if (isFirstAndMinDateYearSame && month.key < minDateMonth) {
return false;
} else {
return true;
}
} else {
if (isSameYear && month.key <= rangeStartMonthKey) {
return false;
} else if (isSecondAndMaxDateYearSame && month.key > maxDateMonth) {
return false;
} else {
return true;
}
}
});
};
export const getValidDates = ({
selectedDateRange,
minDate,
maxDate,
today,
onError,
t,
}: {
selectedDateRange: DateRangeWithTimezone,
minDate?: ?string,
maxDate?: ?string,
today: string,
onError?: (DateRangePickerError) => void,
t: ?(key: string, fallback: string) => string,
}): {
validMinDate: string,
validMaxDate: string,
validDateRange: DateRange,
} => {
const {startDate, endDate} = selectedDateRange;
const validMaxDate = maxDate && !isEmpty(maxDate) ? maxDate : today;
const validMinDate =
minDate && !isEmpty(minDate) ? minDate : getSubtractedDate(today, 8, 'y');
const isRangeValid = (min, max, errorMessage) =>
checkRangeValidity(min, max, errorMessage, onError);
// minDate should be after maxDate
const isMinMaxRangeInvalid = !isRangeValid(
validMinDate,
validMaxDate,
getDateRangePickerErrors(t).MIN_MAX_INVALID,
);
// if startDate is defined and then it should be after minDate
const isStartDateInvalid =
isMinMaxRangeInvalid ||
isEmpty(startDate) ||
!isRangeValid(
validMinDate,
startDate,
getDateRangePickerErrors(t).START_DATE_EARLY,
);
if (isMinMaxRangeInvalid || isStartDateInvalid) {
return {
validDateRange: {startDate: null, endDate: null},
validMinDate,
validMaxDate,
};
}
// if endDate is defined then it should be before maxDate
const isEndDateInvalid =
isEmpty(endDate) ||
!isRangeValid(
endDate,
validMaxDate,
getDateRangePickerErrors(t).END_DATE_LATE,
);
// startDate should be before endDate
const isStartEndRangeInvalid =
isEndDateInvalid ||
!isRangeValid(
startDate,
endDate,
getDateRangePickerErrors(t).START_DATE_LATE,
);
if (isEndDateInvalid || isStartEndRangeInvalid) {
return {
validDateRange: {startDate, endDate: null},
validMinDate,
validMaxDate,
};
}
return {
validDateRange: {
startDate: isEmpty(startDate) ? null : startDate,
endDate: isEmpty(endDate) ? null : endDate,
},
validMinDate,
validMaxDate,
};
};
// If date1 is same as date2 w.r.t the unit passed
export const isSame = (
date1: string,
date2: string,
unit: 'd' | 'month',
): boolean => moment.utc(date1).isSame(moment.utc(date2), unit);
// If date1 is before date2
export const isBefore = (date1: string, date2: string): boolean =>
moment.utc(date1).isBefore(moment.utc(date2));
// If date1 is after date2 w.r.t the unit passed
export const isAfter = (date1: string, date2: string): boolean =>
moment.utc(date1).isAfter(moment.utc(date2));
// If date1 is same or before date2 w.r.t the unit passed
export const isSameOrBefore = (
date1?: ?string,
date2?: ?string,
unit?: 'd' | 'month',
): boolean => moment.utc(date1).isSameOrBefore(moment.utc(date2), unit);
// If date1 is same or after date2 w.r.t the unit passed
export const isSameOrAfter = (
date1?: ?string,
date2?: ?string,
unit?: 'd' | 'month',
): boolean => moment.utc(date1).isSameOrAfter(moment.utc(date2), unit);
// If date is between startRange and endRange
export const isBetween = (
date: string,
startRange: string,
endRange: string,
): boolean =>
moment
.utc(date)
.isBetween(moment.utc(startRange), moment.utc(endRange), null, '[]');
// If the date results in a date that exists in the calendar
export const isValid = (date?: ?string): boolean => moment.utc(date).isValid();
export const getFormattedDate = (
marker: string,
dateRange: DateRange,
locale?: string,
): string => {
const {startDate, endDate} = dateRange;
// set locale if provided
if (locale) {
moment.locale(locale);
}
switch (marker) {
case MARKERS.DATE_RANGE_START:
return startDate
? moment.utc(startDate).format('MMM DD, YYYY')
: 'MMM DD, YYYY';
default:
return endDate
? moment.utc(endDate).format('MMM DD, YYYY')
: 'MMM DD, YYYY';
}
};
export const getTranslation = (
t: ?(key: string, fallback: string) => string,
labelToTranslate: string,
): string =>
t ? t(makeKey(labelToTranslate), labelToTranslate) : labelToTranslate;