Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add DateSelector dateFormat prop #2726

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/components/forms/DatePicker/DatePicker.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,23 @@ import { FormGroup } from '../FormGroup/FormGroup'
import { Label } from '../Label/Label'
import { TextInput } from '../TextInput/TextInput'
import { ValidationStatus } from '../../../types/validationStatus'
import {
DEFAULT_EXTERNAL_DATE_FORMAT,
DateFormat,
INTERNAL_DATE_FORMAT,
} from './constants'

export default {
title: 'Components/Date picker',
component: DatePicker,
argTypes: {
dateFormat: {
control: 'radio',
options: [
DEFAULT_EXTERNAL_DATE_FORMAT as DateFormat,
INTERNAL_DATE_FORMAT as DateFormat,
],
},
onSubmit: { action: 'submitted' },
disabled: { control: { type: 'boolean' } },
validationStatus: {
Expand Down Expand Up @@ -50,6 +62,7 @@ We may find that we want to expose props for custom event handlers or even a ref
}

type StorybookArguments = {
dateFormat: DateFormat
onSubmit: React.FormEventHandler<HTMLFormElement>
disabled?: boolean
validationStatus?: ValidationStatus
Expand All @@ -66,7 +79,7 @@ export const CompleteDatePicker = {
Appointment date
</Label>
<div className="usa-hint" id="appointment-date-hint">
mm/dd/yyyy
{argTypes.dateFormat ?? DEFAULT_EXTERNAL_DATE_FORMAT}
</div>
<DatePicker
id="appointment-date"
Expand All @@ -75,6 +88,7 @@ export const CompleteDatePicker = {
aria-labelledby="appointment-date-label"
disabled={argTypes.disabled}
validationStatus={argTypes.validationStatus}
dateFormat={argTypes.dateFormat}
/>
</FormGroup>
<Label htmlFor="otherInput">Another unrelated input</Label>
Expand Down
28 changes: 18 additions & 10 deletions src/components/forms/DatePicker/DatePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
DEFAULT_EXTERNAL_DATE_FORMAT,
VALIDATION_MESSAGE,
DEFAULT_MIN_DATE,
type DateFormat,
} from './constants'
import { DatePickerLocalization, EN_US } from './i18n'
import {
Expand All @@ -33,6 +34,7 @@ type BaseDatePickerProps = {
validationStatus?: ValidationStatus
disabled?: boolean
required?: boolean
dateFormat?: DateFormat
defaultValue?: string
minDate?: string
maxDate?: string
Expand All @@ -57,6 +59,7 @@ export const DatePicker = ({
name,
className,
validationStatus,
dateFormat = DEFAULT_EXTERNAL_DATE_FORMAT,
defaultValue,
disabled,
required,
Expand All @@ -68,6 +71,8 @@ export const DatePicker = ({
i18n = EN_US,
...inputProps
}: DatePickerProps): React.ReactElement => {
dateFormat ??= DEFAULT_EXTERNAL_DATE_FORMAT

const datePickerEl = useRef<HTMLDivElement>(null)
const externalInputEl = useRef<HTMLInputElement>(null)

Expand All @@ -92,7 +97,12 @@ export const DatePicker = ({
const parsedRangeDate = rangeDate ? parseDateString(rangeDate) : undefined

const validateInput = (): void => {
const isInvalid = isDateInvalid(externalValue, parsedMinDate, parsedMaxDate)
const isInvalid = isDateInvalid(
externalValue,
dateFormat,
parsedMinDate,
parsedMaxDate
)

if (isInvalid && !externalInputEl?.current?.validationMessage) {
externalInputEl?.current?.setCustomValidity(VALIDATION_MESSAGE)
Expand All @@ -108,8 +118,7 @@ export const DatePicker = ({

const handleSelectDate = (dateString: string, closeCalendar = true): void => {
const parsedValue = parseDateString(dateString)
const formattedValue =
parsedValue && formatDate(parsedValue, DEFAULT_EXTERNAL_DATE_FORMAT)
const formattedValue = parsedValue && formatDate(parsedValue, dateFormat)

if (parsedValue) setInternalValue(dateString)
if (formattedValue) setExternalValue(formattedValue)
Expand All @@ -128,9 +137,12 @@ export const DatePicker = ({
setExternalValue(value)
if (onChange) onChange(value)

const inputDate = parseDateString(value, DEFAULT_EXTERNAL_DATE_FORMAT, true)
const inputDate = parseDateString(value, dateFormat, true)
let newValue = ''
if (inputDate && !isDateInvalid(value, parsedMinDate, parsedMaxDate)) {
if (
inputDate &&
!isDateInvalid(value, dateFormat, parsedMinDate, parsedMaxDate)
) {
newValue = formatDate(inputDate)
}

Expand Down Expand Up @@ -179,11 +191,7 @@ export const DatePicker = ({
setStatuses([])
} else {
// calendar is closed, show it
const inputDate = parseDateString(
externalValue,
DEFAULT_EXTERNAL_DATE_FORMAT,
true
)
const inputDate = parseDateString(externalValue, dateFormat, true)

const displayDate = keepDateBetweenMinAndMax(
inputDate || (defaultValue && parseDateString(defaultValue)) || today(),
Expand Down
3 changes: 3 additions & 0 deletions src/components/forms/DatePicker/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,6 @@ export const YEAR_CHUNK = 12
export const DEFAULT_MIN_DATE = '0000-01-01'
export const DEFAULT_EXTERNAL_DATE_FORMAT = 'MM/DD/YYYY'
Copy link
Contributor

@shkeating shkeating Dec 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: I am good with passing the Happo diffs if you can explain why the switch to all caps for the help text? my thoughts would be to not change the default text if it can be avoided

(I am not at Truss anymore but will see if I can reach out to someone who is with Codeowner status to look at this, sorry about the wait!)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know why the case changed. I see what you mean, but I don't see anything in my patch that would cause it to do that.

export const INTERNAL_DATE_FORMAT = 'YYYY-MM-DD'
export type DateFormat =
| typeof INTERNAL_DATE_FORMAT
| typeof DEFAULT_EXTERNAL_DATE_FORMAT
79 changes: 54 additions & 25 deletions src/components/forms/DatePicker/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,34 +81,63 @@ describe('formatDate', () => {
})

describe('isDateInvalid', () => {
it('returns false if the date is within the min & max', () => {
const testMin = new Date('May 1, 1988')
const testMax = new Date('June 1, 1988')
expect(isDateInvalid('05/16/1988', testMin, testMax)).toEqual(false)
})

it('returns true if the date is not within the min & max', () => {
const testMin = new Date('May 1, 1988')
const testMax = new Date('June 1, 1988')
expect(isDateInvalid('08/16/1988', testMin, testMax)).toEqual(true)
})

it('returns true if the date is not valid', () => {
const testMin = new Date('May 1, 1988')
const testMax = new Date('June 1, 1988')
expect(isDateInvalid('not a date', testMin, testMax)).toEqual(true)
})
it.each([
['05/16/1988', DEFAULT_EXTERNAL_DATE_FORMAT],
['1988-05-16', INTERNAL_DATE_FORMAT],
] as const)(
'returns false if the date is within the min & max',
(date, format) => {
const testMin = new Date('May 1, 1988')
const testMax = new Date('June 1, 1988')
expect(isDateInvalid(date, format, testMin, testMax)).toEqual(false)
}
)

it.each([
['08/16/1988', DEFAULT_EXTERNAL_DATE_FORMAT],
['1988-08-16', INTERNAL_DATE_FORMAT],
] as const)(
'returns true if the date is not within the min & max',
(date, format) => {
const testMin = new Date('May 1, 1988')
const testMax = new Date('June 1, 1988')
expect(isDateInvalid(date, format, testMin, testMax)).toEqual(true)
}
)

it.each([DEFAULT_EXTERNAL_DATE_FORMAT, INTERNAL_DATE_FORMAT] as const)(
'returns true if the date is not valid',
(format) => {
const testMin = new Date('May 1, 1988')
const testMax = new Date('June 1, 1988')
expect(isDateInvalid('not a date', format, testMin, testMax)).toEqual(
true
)
}
)

describe('with no max date', () => {
it('returns false if the date is after the min', () => {
const testMin = new Date('May 1, 1988')
expect(isDateInvalid('05/16/1988', testMin)).toEqual(false)
})
it.each([
['05/16/1988', DEFAULT_EXTERNAL_DATE_FORMAT],
['1988-05-16', INTERNAL_DATE_FORMAT],
] as const)(
'returns false if the date is after the min',
(date, format) => {
const testMin = new Date('May 1, 1988')
expect(isDateInvalid(date, format, testMin)).toEqual(false)
}
)

it('returns true if the date is not after the min', () => {
const testMin = new Date('May 1, 1988')
expect(isDateInvalid('02/16/1988', testMin)).toEqual(true)
})
it.each([
['02/16/1988', DEFAULT_EXTERNAL_DATE_FORMAT],
['1988-02-16', INTERNAL_DATE_FORMAT],
] as const)(
'returns true if the date is not after the min',
(date, format) => {
const testMin = new Date('May 1, 1988')
expect(isDateInvalid(date, format, testMin)).toEqual(true)
}
)
})
})

Expand Down
32 changes: 24 additions & 8 deletions src/components/forms/DatePicker/utils.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import React, { KeyboardEvent } from 'react'

import { DEFAULT_EXTERNAL_DATE_FORMAT, INTERNAL_DATE_FORMAT } from './constants'
import {
type DateFormat,
DEFAULT_EXTERNAL_DATE_FORMAT,
INTERNAL_DATE_FORMAT,
} from './constants'

/**
* This file contains the USWDS DatePicker date manipulation functions converted to TypeScript
Expand Down Expand Up @@ -355,13 +359,13 @@ export const isDatesYearOutsideMinOrMax = (
* Parse a date with format M-D-YY
*
* @param {string} dateString the date string to parse
* @param {string} dateFormat the format of the date string
* @param {DateFormat} dateFormat the format of the date string
* @param {boolean} adjustDate should the date be adjusted
* @returns {Date} the parsed date
*/
export const parseDateString = (
dateString: string,
dateFormat: string = INTERNAL_DATE_FORMAT,
dateFormat: DateFormat = INTERNAL_DATE_FORMAT,
adjustDate = false
): Date | undefined => {
let date
Expand Down Expand Up @@ -430,12 +434,12 @@ export const parseDateString = (
* Format a date to format YYYY-MM-DD
*
* @param {Date} date the date to format
* @param {string} dateFormat the format of the date string
* @param {DateFormat} dateFormat the format of the date string
* @returns {string} the formatted date string
*/
export const formatDate = (
date: Date,
dateFormat: string = INTERNAL_DATE_FORMAT
dateFormat: DateFormat = INTERNAL_DATE_FORMAT
): string => {
const padZeros = (value: number, length: number): string => {
return `0000${value}`.slice(-length)
Expand All @@ -456,6 +460,7 @@ export const formatDate = (

export const isDateInvalid = (
dateString: string,
dateFormat: DateFormat,
minDate: Date,
maxDate?: Date
): boolean => {
Expand All @@ -464,22 +469,33 @@ export const isDateInvalid = (
if (dateString) {
isInvalid = true

const dateStringParts = dateString.split('/')
const [month, day, year] = dateStringParts.map((str) => {
const dateStringParts = dateString.split(
lpsinger marked this conversation as resolved.
Show resolved Hide resolved
dateFormat === DEFAULT_EXTERNAL_DATE_FORMAT ? '/' : '-'
)
const dateParts = dateStringParts.map((str) => {
let value
const parsed = parseInt(str, 10)
if (!Number.isNaN(parsed)) value = parsed
return value
})

let month, day, year, yearStringPart
if (dateFormat === DEFAULT_EXTERNAL_DATE_FORMAT) {
yearStringPart = dateStringParts[2]
;[month, day, year] = dateParts
} else {
yearStringPart = dateStringParts[0]
;[year, month, day] = dateParts
}

if (month && day && year != null) {
const checkDate = setDate(year, month - 1, day)

if (
checkDate.getMonth() === month - 1 &&
checkDate.getDate() === day &&
checkDate.getFullYear() === year &&
dateStringParts[2].length === 4 &&
yearStringPart.length === 4 &&
isDateWithinMinAndMax(checkDate, minDate, maxDate)
) {
isInvalid = false
Expand Down
Loading