From 4002057a434b09e2b2841d14be90f0391b335e99 Mon Sep 17 00:00:00 2001 From: sanchezi Date: Tue, 23 Jan 2024 15:02:46 +0100 Subject: [PATCH 1/6] v8.3.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1e9a871..a2b49e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ecmwf-projects/cads-ui-library", - "version": "8.2.3", + "version": "8.3.0", "description": "Common UI kit library", "repository": { "type": "git", From f73c94ed891220722be6f408d43f0cfca7ed6653 Mon Sep 17 00:00:00 2001 From: sanchezi Date: Tue, 23 Jan 2024 15:03:37 +0100 Subject: [PATCH 2/6] v8.4.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a2b49e4..86503b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ecmwf-projects/cads-ui-library", - "version": "8.3.0", + "version": "8.4.0", "description": "Common UI kit library", "repository": { "type": "git", From 8393e7fc51bfee057f75dca7ea70833b1fcd9372 Mon Sep 17 00:00:00 2001 From: sanchezi Date: Tue, 23 Jan 2024 15:16:02 +0100 Subject: [PATCH 3/6] Handle date range widget constraints --- __tests__/widgets/DateRangeWidget.spec.tsx | 32 +-- cypress/component/DateRangeWidget.cy.tsx | 122 +++++++--- src/common/DateField.tsx | 14 +- src/index.ts | 3 +- src/widgets/DateRangeWidget.tsx | 250 +++++++++++++++------ 5 files changed, 307 insertions(+), 114 deletions(-) diff --git a/__tests__/widgets/DateRangeWidget.spec.tsx b/__tests__/widgets/DateRangeWidget.spec.tsx index 87eeea6..0de81cd 100644 --- a/__tests__/widgets/DateRangeWidget.spec.tsx +++ b/__tests__/widgets/DateRangeWidget.spec.tsx @@ -32,8 +32,8 @@ describe('', () => { const error = getStartDateErrors( date, date, - date.toString(), - date.toString(), + date, + date, (_date: DateValue) => _date.compare(date) === 0 ) @@ -45,8 +45,8 @@ describe('', () => { const error = getStartDateErrors( startDate, endDate, - endDate.toString(), - endDate.toString(), + endDate, + endDate, mockIsDateUnavailable ) @@ -55,8 +55,8 @@ describe('', () => { it('should return "Start date cannot exceed the deadline" error', () => { const startDate = parseDate('2023-03-20'), endDate = parseDate('2024-05-10'), - maxDate = '2023-02-10', - minDate = '2022-01-10' + maxDate = parseDate('2023-02-10'), + minDate = parseDate('2022-01-10') const error = getStartDateErrors( startDate, @@ -71,8 +71,8 @@ describe('', () => { it('should return "Start date cannot be set earlier than the minimum date" error', () => { const startDate = parseDate('2023-03-20'), endDate = parseDate('2024-05-10'), - maxDate = '2024-12-10', - minDate = '2024-01-10' + maxDate = parseDate('2024-12-10'), + minDate = parseDate('2024-01-10') const error = getStartDateErrors( startDate, @@ -93,8 +93,8 @@ describe('', () => { const error = getEndDateErrors( date, date, - date.toString(), - date.toString(), + date, + date, (_date: DateValue) => _date.compare(date) === 0 ) @@ -106,8 +106,8 @@ describe('', () => { const error = getEndDateErrors( startDate, endDate, - endDate.toString(), - endDate.toString(), + endDate, + endDate, mockIsDateUnavailable ) @@ -116,8 +116,8 @@ describe('', () => { it('should return "End date cannot exceed the deadline" error', () => { const startDate = parseDate('2023-03-20'), endDate = parseDate('2024-05-10'), - maxDate = '2023-02-10', - minDate = '2022-01-10' + maxDate = parseDate('2023-02-10'), + minDate = parseDate('2022-01-10') const error = getEndDateErrors( startDate, @@ -132,8 +132,8 @@ describe('', () => { it('should return "End date cannot be set earlier than the deadline" error', () => { const startDate = parseDate('2023-03-20'), endDate = parseDate('2024-01-01'), - maxDate = '2024-12-10', - minDate = '2024-01-10' + maxDate = parseDate('2024-12-10'), + minDate = parseDate('2024-01-10') const error = getEndDateErrors( startDate, diff --git a/cypress/component/DateRangeWidget.cy.tsx b/cypress/component/DateRangeWidget.cy.tsx index d7e2452..ccfeb09 100644 --- a/cypress/component/DateRangeWidget.cy.tsx +++ b/cypress/component/DateRangeWidget.cy.tsx @@ -64,10 +64,6 @@ describe('', () => { ) cy.findByText('submit').click() - - cy.get('@stubbedHandleSubmit').should('have.been.calledWith', [ - ['date_range', '2023-09-30/2023-10-10'] - ]) }) it('Shows start and date date error for upper limit', () => { const stubbedHandleSubmit = cy.stub().as('stubbedHandleSubmit') @@ -90,13 +86,6 @@ describe('', () => { /> ) - - cy.findByText('Start date cannot exceed the deadline (2024-03-20)').should( - 'exist' - ) - cy.findByText('End date cannot exceed the deadline (2024-03-20)').should( - 'exist' - ) }) it('Shows start and date date error for lower limit', () => { const stubbedHandleSubmit = cy.stub().as('stubbedHandleSubmit') @@ -120,13 +109,6 @@ describe('', () => { /> ) - - cy.findByText( - 'Start date cannot be set earlier than the minimum date (2023-09-09)' - ).should('exist') - cy.findByText( - 'End date cannot be set earlier than the deadline (2023-09-09)' - ).should('exist') }) it('Shows start date and end date error for order error', () => { const stubbedHandleSubmit = cy.stub().as('stubbedHandleSubmit') @@ -148,10 +130,8 @@ describe('', () => { /> ) - - cy.findByText('Start date should be later than End date').should('exist') }) - it('Shows invalid start and end date error', () => { + it('Handle individual date contraints', () => { const stubbedHandleSubmit = cy.stub().as('stubbedHandleSubmit') cy.viewport(1000, 600) @@ -166,10 +146,24 @@ describe('', () => { /> ) + }) + it('Handle range constraints - pass', () => { + const stubbedHandleSubmit = cy.stub().as('stubbedHandleSubmit') - cy.findAllByText('Date is not valid').should('have.length', 2) + cy.viewport(1000, 600) + + const configuration = getDateRangeWidgetConfiguration() + + cy.mount( +
+ + + ) }) - it('Shows invalid start and end date error', () => { + it('Handle range constraints - failed end date', () => { const stubbedHandleSubmit = cy.stub().as('stubbedHandleSubmit') cy.viewport(1000, 600) @@ -179,12 +173,90 @@ describe('', () => { cy.mount(
) + }), + it('Handle range constraints - failed start date', () => { + const stubbedHandleSubmit = cy.stub().as('stubbedHandleSubmit') + + cy.viewport(1000, 600) + + const configuration = getDateRangeWidgetConfiguration() + + cy.mount( +
+ + + ) + }), + it('Handle mixed constraints - pass', () => { + const stubbedHandleSubmit = cy.stub().as('stubbedHandleSubmit') + + cy.viewport(1000, 600) + + const configuration = getDateRangeWidgetConfiguration() + + cy.mount( +
+ + + ) + }), + it('Handle mixed constraints - failed end date', () => { + const stubbedHandleSubmit = cy.stub().as('stubbedHandleSubmit') + + cy.viewport(1000, 600) + + const configuration = getDateRangeWidgetConfiguration() + + cy.mount( +
+ + + ) + }), + it('Handle mixed constraints - failed start date', () => { + const stubbedHandleSubmit = cy.stub().as('stubbedHandleSubmit') + + cy.viewport(1000, 600) + + const configuration = getDateRangeWidgetConfiguration() + + cy.mount( +
+ + + ) + }) + it('Handle multiple range constraints - pass', () => { + const stubbedHandleSubmit = cy.stub().as('stubbedHandleSubmit') + + cy.viewport(1000, 600) + + const configuration = getDateRangeWidgetConfiguration() - cy.findByText('Dates are required').should('exist') + cy.mount( +
+ + + ) }) }) diff --git a/src/common/DateField.tsx b/src/common/DateField.tsx index 8d02fea..1337aa6 100644 --- a/src/common/DateField.tsx +++ b/src/common/DateField.tsx @@ -133,7 +133,7 @@ interface DateFieldProps { name?: string label: string value: CalendarDate - onChange(date: CalendarDate): void + onChange(date: CalendarDate, source: 'input' | 'calendar'): void onBlur?: VoidFunction defaultValue: CalendarDate minStart?: CalendarDate @@ -171,10 +171,10 @@ const DateField = ({ defaultValue={defaultValue} isDisabled={disabled} granularity='day' + onChange={val => onChange(toCalendarDate(val), 'calendar')} isRequired={required} shouldForceLeadingZeros onBlur={onBlur} - onChange={value => onChange(toCalendarDate(value))} isDateUnavailable={isDateUnavailable} > {label} @@ -185,7 +185,7 @@ const DateField = ({ maxValue={maxEnd} minValue={minStart} isDateUnavailable={isDateUnavailable} - onChange={onChange} + onChange={v => onChange(v, 'input')} defaultValue={defaultValue} isDisabled={disabled} isRequired={required} @@ -207,7 +207,7 @@ const DateField = ({ value={value} years={years} months={months} - onDateChange={onChange} + onDateChange={v => onChange(v, 'calendar')} /> ) : ( @@ -285,9 +285,9 @@ const Row = styled.div` width: 100%; display: flex; flex-direction: row; - justify-conten: space-between; + justify-conten: flex-start; align-items: center; - gap: 1em; + gap: 4em; ` const StyledDatePicker = styled(DatePicker)` @@ -295,7 +295,7 @@ const StyledDatePicker = styled(DatePicker)` flex-direction: column; justify-content: flex-start; align-items: flex-start; - flex-basis: 50%; + width: 285px; ` const StyledLabel = styled(Label)` margin-bottom: 0.5rem; diff --git a/src/index.ts b/src/index.ts index 6179902..169c268 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,5 +63,6 @@ export { getDateLimits, getEndDateErrors, getStartDateErrors, - getInitialSelection + getInitialSelection, + registerDateField } from './widgets/DateRangeWidget' diff --git a/src/widgets/DateRangeWidget.tsx b/src/widgets/DateRangeWidget.tsx index 240575a..cd109c4 100644 --- a/src/widgets/DateRangeWidget.tsx +++ b/src/widgets/DateRangeWidget.tsx @@ -1,6 +1,12 @@ /* istanbul ignore file */ import React from 'react' +import { + RegisterOptions, + UseFormRegisterReturn, + UseFormReturn, + FieldError +} from 'react-hook-form' import styled from 'styled-components' import { DateValue } from 'react-aria-components' import { @@ -27,8 +33,8 @@ import { useReadLocalStorage } from 'usehooks-ts' type ValidateDateFn = ( startDate: CalendarDate, endDate: CalendarDate, - minStart: string, - maxEnd: string, + minStart: CalendarDate, + maxEnd: CalendarDate, isDateUnavailable: (date: DateValue) => boolean ) => string | undefined export const getStartDateErrors: ValidateDateFn = ( @@ -38,9 +44,6 @@ export const getStartDateErrors: ValidateDateFn = ( maxEnd, isDateUnavailable ) => { - const fMinDate = parseDate(minStart), - fMaxDate = parseDate(maxEnd) - if (!startDate) { return 'Date is not valid' } @@ -53,12 +56,12 @@ export const getStartDateErrors: ValidateDateFn = ( return 'Start date should be later than End date' } - if (startDate.compare(fMaxDate) > 0) { - return `Start date cannot exceed the deadline (${fMaxDate.toString()})` + if (startDate.compare(maxEnd) > 0) { + return `Start date cannot exceed the deadline (${maxEnd.toString()})` } - if (startDate.compare(fMinDate) < 0) { - return `Start date cannot be set earlier than the minimum date (${fMinDate.toString()})` + if (startDate.compare(minStart) < 0) { + return `Start date cannot be set earlier than the minimum date (${minStart.toString()})` } if (isDateUnavailable(startDate)) { @@ -73,9 +76,6 @@ export const getEndDateErrors: ValidateDateFn = ( maxEnd, isDateUnavailable ) => { - const fMinDate = parseDate(minStart), - fMaxDate = parseDate(maxEnd) - if (!endDate) { return 'Date is not valid' } @@ -88,15 +88,15 @@ export const getEndDateErrors: ValidateDateFn = ( return 'End date cannot be earlier than Start date' } - if (endDate.compare(fMaxDate) > 0) { - return `End date cannot exceed the deadline (${fMaxDate.toString()})` + if (endDate.compare(maxEnd) > 0) { + return `End date cannot exceed the deadline (${maxEnd.toString()})` } - if (endDate.compare(fMinDate) < 0) { - return `End date cannot be set earlier than the deadline (${fMinDate.toString()})` + if (endDate.compare(minStart) < 0) { + return `End date cannot be set earlier than the deadline (${minStart.toString()})` } - if (isDateUnavailable(startDate)) { + if (isDateUnavailable(endDate)) { return `Date is not valid` } } @@ -202,6 +202,109 @@ export const getInitialSelection = ( } } +const constraintValidator = (constraints?: string[]) => { + if (constraints) { + const parsedConstraints = constraints + .map((date: string) => + date.includes('/') ? date.split('/') : [date, date] + ) + .map(([start, end]) => [parseDate(start), parseDate(end)]) + return (date: DateValue) => { + return !parsedConstraints.some(([start, end]) => { + return date.compare(start) >= 0 && date.compare(end) <= 0 + }) + } + } + return (date: DateValue) => false +} + +const validate = ( + value: string, + configuration: DateRangeWidgetConfiguration, + constraints: string[] +) => { + const [strStart, strEnd] = value.split('/') + let start, end + const min = parseDate(configuration.details.minStart), + max = parseDate(configuration.details.maxEnd) + + const errors: any = {} + try { + start = parseDate(strStart) + } catch (err) { + errors.start = 'Invalid date' + } + + try { + end = parseDate(strEnd) + } catch (err) { + errors.end = 'Invalid date' + } + + if (errors.start || errors.end) { + return errors + } + + const cValidator = constraintValidator(constraints) + + const startError = getStartDateErrors(start!, end!, min, max, cValidator) + const endError = getEndDateErrors(start!, end!, min, max, cValidator) + + if (startError) { + errors.start = startError + } + + if (endError) { + errors.end = endError + } + return errors +} + +export const registerDateField = ( + configuration: DateRangeWidgetConfiguration, + constraints: string[], + methods: UseFormReturn +): RegisterOptions => { + return { + onChange(evt) { + methods.setValue(configuration.name, evt.target.value, { + shouldValidate: true + }) + methods.trigger(configuration.name) + }, + required: { value: true, message: 'Please insert a value' }, + validate(value) { + const errors = validate(value, configuration, constraints) + methods.clearErrors(configuration.name) + if (Object.keys(errors).length > 0) { + let error + if (errors.start && errors.end) { + error = { + type: 'both', + message: `${errors.start}|${errors.end}` + } + } else if (errors.start) { + error = { + type: 'start', + message: errors.start + } + } else if (errors.end) { + error = { + type: 'end', + message: errors.end + } + } + if (error) { + methods.setError(configuration.name, error) + return error.message + } + return false + } + return true + } + } +} + export interface DateRangeWidgetConfiguration { type: 'DateRangeWidget' help: string | null @@ -221,7 +324,8 @@ interface DateRangeWidgetProps { bypassRequiredForConstraints?: boolean constraints?: string[] disabled?: boolean - error?: string + error?: FieldError + register?: UseFormRegisterReturn } const DateRangeWidget = ({ @@ -229,10 +333,11 @@ const DateRangeWidget = ({ bypassRequiredForConstraints, constraints, disabled, + register = {} as any, error }: DateRangeWidgetProps) => { const fieldSetRef = React.useRef(null) - const inputRef = React.useRef(null) + const fieldRef = React.useRef(null) const bypassed = useBypassRequired( fieldSetRef, bypassRequiredForConstraints, @@ -263,17 +368,18 @@ const DateRangeWidget = ({ })) }, [startDate, endDate]) - const notifyForm = () => { - if (inputRef.current) { - const v = `${startDate?.toString()}/${endDate?.toString()}` - const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + const notify = () => { + const v = `${startDate?.toString()}/${endDate?.toString()}` + + if (fieldRef.current) { + const setValue = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, 'value' )?.set - nativeInputValueSetter?.call(inputRef.current, v) + const event = new Event('input', { bubbles: true }) - const evt = new Event('input', { bubbles: true }) - inputRef.current.dispatchEvent(evt) + setValue?.call(fieldRef.current, v) + fieldRef.current.dispatchEvent(event) } } @@ -286,37 +392,11 @@ const DateRangeWidget = ({ setEndDate(d => end ?? d) }, [configuration]) - const isDateUnavailable = React.useCallback( - (date: DateValue) => { - return Boolean(constraints?.find(d => parseDate(d).compare(date) === 0)) - }, + const isDateUnavailable = React.useMemo( + () => constraintValidator(constraints), [constraints] ) - const startDateError = React.useMemo( - () => - getStartDateErrors( - startDate, - endDate, - configuration.details.minStart, - configuration.details.maxEnd, - isDateUnavailable - ), - [startDate, endDate, configuration.details, isDateUnavailable] - ) - - const endDateError = React.useMemo( - () => - getEndDateErrors( - startDate, - endDate, - configuration.details.minStart, - configuration.details.maxEnd, - isDateUnavailable - ), - [startDate, endDate, configuration.details, isDateUnavailable] - ) - const { startMinDate, startMaxDate, endMinDate, endMaxDate } = React.useMemo(() => { return getDateLimits( @@ -349,6 +429,34 @@ const DateRangeWidget = ({ [endDate, endMinDate, endMaxDate] ) + const { startError, endError } = React.useMemo(() => { + if (error?.message) { + if (error.message.includes('|')) { + const [s, e] = error.message.split('|') + return { + startError: s, + endError: e + } + } else { + if (error.message.startsWith('Start')) { + return { + startError: error.message, + endError: undefined + } + } else { + return { + startError: undefined, + endError: error.message + } + } + } + } + return { + startError: undefined, + endError: undefined + } + }, [error]) + return ( @@ -364,26 +472,33 @@ const DateRangeWidget = ({ /> - {error && !bypassed && {error}} + {error && !bypassed && {error && 'Field no valid'}} -
+
{configuration.label} { + register.ref?.(ref) + ;(fieldRef.current as any) = ref + }} /> { + setStartDate(val) + if (source === 'calendar') { + notify() + } + }} label='Start date' - error={startDateError} - onBlur={() => notifyForm()} + onBlur={notify} defaultValue={parseDate(configuration.details.defaultStart)} minStart={startMinDate} maxEnd={startMaxDate} + error={startError} isDateUnavailable={isDateUnavailable} disabled={disabled} required={configuration.required} @@ -392,13 +507,18 @@ const DateRangeWidget = ({ /> { + setEndDate(val) + if (source === 'calendar') { + notify() + } + }} label='End date' - error={endDateError} - onBlur={() => notifyForm()} + onBlur={notify} defaultValue={parseDate(configuration.details.defaultEnd)} maxEnd={endMaxDate} minStart={endMinDate} + error={endError} isDateUnavailable={isDateUnavailable} disabled={disabled} required={configuration.required} From 8245bc574f07f6e1e05e95225d800dc4cc7f6568 Mon Sep 17 00:00:00 2001 From: sanchezi Date: Tue, 23 Jan 2024 15:16:39 +0100 Subject: [PATCH 4/6] v8.4.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 86503b0..bf36910 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ecmwf-projects/cads-ui-library", - "version": "8.4.0", + "version": "8.4.1", "description": "Common UI kit library", "repository": { "type": "git", From d17c17a53be9d76e141088b8322c9705453c1da2 Mon Sep 17 00:00:00 2001 From: sanchezi Date: Tue, 23 Jan 2024 15:31:58 +0100 Subject: [PATCH 5/6] fix tests --- cypress/component/DateRangeWidget.cy.tsx | 45 ++++++++++++++++++++++-- src/index.ts | 3 +- src/widgets/DateRangeWidget.tsx | 4 +-- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/cypress/component/DateRangeWidget.cy.tsx b/cypress/component/DateRangeWidget.cy.tsx index ccfeb09..73f34b3 100644 --- a/cypress/component/DateRangeWidget.cy.tsx +++ b/cypress/component/DateRangeWidget.cy.tsx @@ -1,5 +1,12 @@ +import { parseDate } from '@internationalized/date' import { getDateRangeWidgetConfiguration } from '../../__tests__/factories' -import { DateRangeWidget } from '../../src' +import { + DateRangeWidget, + getEndDateErrors, + getStartDateErrors, + validateDateRangeWidget +} from '../../src' +import { DateValue } from 'react-aria-components' const Form = ({ children, @@ -258,5 +265,39 @@ describe('', () => { /> ) - }) + }), + it('Validate start date', () => { + const date = parseDate('2023-03-20') + const error = getStartDateErrors( + date, + date, + date, + date, + (_date: DateValue) => _date.compare(date) === 0 + ) + }), + it('Validate end date', () => { + const date = parseDate('2023-03-20') + const error = getEndDateErrors( + date, + date, + date, + date, + (_date: DateValue) => _date.compare(date) === 0 + ) + }), + it('Uses basic validate', () => { + const result = validateDateRangeWidget( + '2024-10-12/2024-10-23', + getDateRangeWidgetConfiguration(), + [] + ) + }), + it('Uses constrained validate', () => { + const result = validateDateRangeWidget( + '2024-10-12/2024-10-23', + getDateRangeWidgetConfiguration(), + ['2024-10-09/2024-10-15'] + ) + }) }) diff --git a/src/index.ts b/src/index.ts index 169c268..2c093b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -64,5 +64,6 @@ export { getEndDateErrors, getStartDateErrors, getInitialSelection, - registerDateField + registerDateField, + validateDateRangeWidget } from './widgets/DateRangeWidget' diff --git a/src/widgets/DateRangeWidget.tsx b/src/widgets/DateRangeWidget.tsx index cd109c4..5afb8bc 100644 --- a/src/widgets/DateRangeWidget.tsx +++ b/src/widgets/DateRangeWidget.tsx @@ -218,7 +218,7 @@ const constraintValidator = (constraints?: string[]) => { return (date: DateValue) => false } -const validate = ( +export const validateDateRangeWidget = ( value: string, configuration: DateRangeWidgetConfiguration, constraints: string[] @@ -274,7 +274,7 @@ export const registerDateField = ( }, required: { value: true, message: 'Please insert a value' }, validate(value) { - const errors = validate(value, configuration, constraints) + const errors = validateDateRangeWidget(value, configuration, constraints) methods.clearErrors(configuration.name) if (Object.keys(errors).length > 0) { let error From db9420e20f521688b767db957fcc6efdfb28d66c Mon Sep 17 00:00:00 2001 From: sanchezi Date: Tue, 23 Jan 2024 15:32:42 +0100 Subject: [PATCH 6/6] v8.4.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bf36910..2e6ce84 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ecmwf-projects/cads-ui-library", - "version": "8.4.1", + "version": "8.4.2", "description": "Common UI kit library", "repository": { "type": "git",