From 4915c86b8a7f2a8b06638d4ffeb0bb2fe7dff8e5 Mon Sep 17 00:00:00 2001 From: Armaan Gupta Date: Wed, 17 Jun 2026 16:10:23 +0530 Subject: [PATCH] feat(dateTime): added datetimeinput component in react vanilla components --- .../components/DateTimeInput.test.tsx | 198 ++++++++++++++++++ .../src/components/DateTimeInput.tsx | 98 +++++++++ .../react-vanilla-components/src/index.ts | 2 + .../src/utils/mappings.ts | 3 + .../src/utils/withRuleEngine.tsx | 12 +- 5 files changed, 309 insertions(+), 4 deletions(-) create mode 100644 packages/react-vanilla-components/__tests__/components/DateTimeInput.test.tsx create mode 100644 packages/react-vanilla-components/src/components/DateTimeInput.tsx diff --git a/packages/react-vanilla-components/__tests__/components/DateTimeInput.test.tsx b/packages/react-vanilla-components/__tests__/components/DateTimeInput.test.tsx new file mode 100644 index 00000000..8f24ca9c --- /dev/null +++ b/packages/react-vanilla-components/__tests__/components/DateTimeInput.test.tsx @@ -0,0 +1,198 @@ +/* + * Copyright 2023 Adobe, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import DateTimeInput from '../../src/components/DateTimeInput'; +import userEvent from '@testing-library/user-event'; +import { fireEvent, waitFor } from '@testing-library/react'; +import { renderComponent, DEFAULT_ERROR_MESSAGE } from '../utils'; +import '@testing-library/jest-dom/extend-expect'; + +const field = { + name: 'datetime1', + label: { value: 'Date and Time', visible: true }, + fieldType: 'datetime-input', + type: 'string', + format: 'date-time', + visible: true, + required: true, + enabled: true, + readOnly: false, +}; + +// Full CRISP JSON with top-level minimum/maximum (af-core 0.22.94+ preserves and validates these). +const fullField = { + id: 'datetime-6a4986a577', + fieldType: 'datetime-input', + name: 'datetime1780381446201', + visible: true, + description: '

long

', + tooltip: '

short

', + type: 'string', + required: true, + enabled: true, + constraintMessages: { required: 'wrong date', minimum: 'minimum date', maximum: 'max date' }, + readOnly: false, + default: '2026-05-06 12:30:25', + format: 'date-time', + label: { visible: true, value: 'Date and Time' }, + minimum: '2025-11-04T12:55', + maximum: '2026-06-27T12:55', + properties: { + 'afs:layout': { tooltipVisible: false }, + 'fd:path': '/content/forms/af/table-component/jcr:content/guideContainer/datetime', + }, +}; + +const helper = renderComponent(DateTimeInput); + +describe('DateTimeInput', () => { + + test('value changed by user is set in model', async () => { + const { renderResponse, element } = await helper(field); + const input = renderResponse.container.querySelector('input[type="datetime-local"]') as HTMLInputElement; + fireEvent.change(input, { target: { value: '2024-06-15T10:30' } }); + expect(element.getState().value).toEqual('2024-06-15T10:30'); + }); + + test('it should handle visible property', async () => { + const f = { ...field, visible: false }; + const { renderResponse } = await helper(f); + expect(renderResponse.queryByText(field.label.value)).toBeNull(); + }); + + test('error message element exists when the field is invalid', async () => { + const f = { ...field, valid: false, errorMessage: DEFAULT_ERROR_MESSAGE }; + const { renderResponse } = await helper(f); + expect(renderResponse.queryByText(DEFAULT_ERROR_MESSAGE)).not.toBeNull(); + }); + + test('labels and inputs are linked with for and id attribute', async () => { + const { renderResponse } = await helper(field); + const input = renderResponse.container.querySelector('input[type="datetime-local"]') as HTMLInputElement; + const label = renderResponse.queryByText(field.label.value); + expect(input?.getAttribute('id')).toEqual(label?.getAttribute('for')); + }); + + test('input renders with datetime-local type', async () => { + const { renderResponse } = await helper(field); + const input = renderResponse.container.querySelector('input[type="datetime-local"]'); + expect(input).not.toBeNull(); + }); + + test('disabled attribute is set when enabled is false', async () => { + const f = { ...field, enabled: false }; + const { renderResponse } = await helper(f); + const input = renderResponse.container.querySelector('input[type="datetime-local"]') as HTMLInputElement; + expect(input).toBeDisabled(); + }); + + test('default value with space separator is normalised to datetime-local format', async () => { + // af-core surfaces default as "YYYY-MM-DD HH:MM:SS"; datetime-local requires "YYYY-MM-DDTHH:MM" + const { renderResponse } = await helper(fullField); + const input = renderResponse.container.querySelector('input[type="datetime-local"]') as HTMLInputElement; + expect(input.value).toEqual('2026-05-06T12:30'); + }); + + test('min and max HTML attributes are set from JSON minimum/maximum', async () => { + const { renderResponse } = await helper(fullField); + const input = renderResponse.container.querySelector('input[type="datetime-local"]') as HTMLInputElement; + expect(input).toHaveAttribute('min', '2025-11-04T12:55'); + expect(input).toHaveAttribute('max', '2026-06-27T12:55'); + }); + + test('minimum constraint message is shown when value is below minimum', async () => { + const f = { + ...field, + minimum: '2025-11-04T12:55', + maximum: '2026-06-27T12:55', + constraintMessages: { minimum: 'minimum date', maximum: 'max date' }, + }; + const { renderResponse } = await helper(f); + const input = renderResponse.container.querySelector('input[type="datetime-local"]') as HTMLInputElement; + fireEvent.change(input, { target: { value: '2020-01-01T10:00' } }); + fireEvent.blur(input); + await waitFor(() => { + expect(renderResponse.queryByText('minimum date')).not.toBeNull(); + }); + }); + + test('maximum constraint message is shown when value is above maximum', async () => { + const f = { + ...field, + minimum: '2025-11-04T12:55', + maximum: '2026-06-27T12:55', + constraintMessages: { minimum: 'minimum date', maximum: 'max date' }, + }; + const { renderResponse } = await helper(f); + const input = renderResponse.container.querySelector('input[type="datetime-local"]') as HTMLInputElement; + fireEvent.change(input, { target: { value: '2027-01-01T10:00' } }); + fireEvent.blur(input); + await waitFor(() => { + expect(renderResponse.queryByText('max date')).not.toBeNull(); + }); + }); + + test('tooltip and description toggle correctly', async () => { + const f = { + ...field, + tooltip: 'Short Description', + description: 'Long Description', + properties: { 'afs:layout': { tooltipVisible: true } }, + }; + const { renderResponse } = await helper(f); + expect(renderResponse.getByText('Short Description')).not.toBeNull(); + const button = renderResponse.container.getElementsByClassName('cmp-adaptiveform-datetimeinput__questionmark'); + userEvent.click(button[0]); + expect(renderResponse.getByText('Long Description')).not.toBeNull(); + }); + + test('aria-describedby contains long and short description ids when both are present', async () => { + const f = { + ...field, + id: 'datetime-123', + tooltip: 'short desc', + description: 'long desc', + properties: { 'afs:layout': { tooltipVisible: true } }, + }; + const { renderResponse } = await helper(f); + const input = renderResponse.container.querySelector('input[type="datetime-local"]') as HTMLInputElement; + expect(input).toHaveAttribute( + 'aria-describedby', + `${f.id}__longdescription ${f.id}__shortdescription` + ); + }); + + test('html in the label should be rendered for rich text', async () => { + const f = { + ...field, + label: { value: 'Date and Time', richText: true, visible: true }, + }; + const { renderResponse } = await helper(f); + expect(renderResponse.container.innerHTML).toContain('Date and Time'); + }); + + test('required constraint message is shown on validation failure', async () => { + const f = { ...fullField, valid: false, errorMessage: 'wrong date' }; + const { renderResponse } = await helper(f); + expect(renderResponse.queryByText('wrong date')).not.toBeNull(); + }); + + test('full CRISP JSON field renders without errors', async () => { + const { renderResponse } = await helper(fullField); + expect(renderResponse.container.querySelector('input[type="datetime-local"]')).not.toBeNull(); + expect(renderResponse.queryByText('Date and Time')).not.toBeNull(); + }); +}); diff --git a/packages/react-vanilla-components/src/components/DateTimeInput.tsx b/packages/react-vanilla-components/src/components/DateTimeInput.tsx new file mode 100644 index 00000000..300b82cc --- /dev/null +++ b/packages/react-vanilla-components/src/components/DateTimeInput.tsx @@ -0,0 +1,98 @@ +// ******************************************************************************* +// * Copyright 2023 Adobe +// * +// * Licensed under the Apache License, Version 2.0 (the "License"); +// * you may not use this file except in compliance with the License. +// * You may obtain a copy of the License at +// * +// * http://www.apache.org/licenses/LICENSE-2.0 +// * +// * Unless required by applicable law or agreed to in writing, software +// * distributed under the License is distributed on an "AS IS" BASIS, +// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// * See the License for the specific language governing permissions and +// * limitations under the License. +// * +// * BEM markup follows AEM core form components guidelines. +// * LINK- https://github.com/adobe/aem-core-forms-components +// ****************************************************************************** + +import React, { useCallback } from 'react'; +import { withRuleEngine } from '../utils/withRuleEngine'; +import { PROPS } from '../utils/type'; +import FieldWrapper from './common/FieldWrapper'; +import { syncAriaDescribedBy } from '../utils/utils'; + +// datetime-local requires "YYYY-MM-DDTHH:MM". af-core may surface the default +// or user-entered value with a space separator ("YYYY-MM-DD HH:MM:SS"), so we +// normalise to the T-separated format and strip seconds. +const toDateTimeLocalValue = (val: string | undefined): string => { + if (!val) { return ''; } + const normalised = val.replace(' ', 'T'); + return normalised.length > 16 ? normalised.slice(0, 16) : normalised; +}; + +const datetime = (props: PROPS) => { + const { + id, label, value, required, name, readOnly, + placeholder, visible, enabled, appliedCssClassNames, valid, minimum, maximum + } = props; + + const finalValue = toDateTimeLocalValue(value as string | undefined); + + const handleChange = useCallback((e: React.ChangeEvent) => { + props.dispatchChange(e.target.value); + }, [props.dispatchChange]); + + const handleFocus = useCallback(() => { + props.dispatchFocus(); + }, [props.dispatchFocus]); + + const handleBlur = useCallback(() => { + props.dispatchBlur(); + }, [props.dispatchBlur]); + + return ( +
+ + + +
+ ); +}; + +export default withRuleEngine(datetime); diff --git a/packages/react-vanilla-components/src/index.ts b/packages/react-vanilla-components/src/index.ts index 8e414725..dbbaa2a4 100644 --- a/packages/react-vanilla-components/src/index.ts +++ b/packages/react-vanilla-components/src/index.ts @@ -12,6 +12,7 @@ import RadioGroup from './components/RadioButtonGroup'; import DropDown from './components/DropDown'; import NumberField from './components/NumberField'; import DateInput from './components/DateInput'; +import DateTimeInput from './components/DateTimeInput'; import CheckBox from './components/CheckBox'; import Button from './components/Button'; import TextFieldArea from './components/TextFieldArea'; @@ -42,6 +43,7 @@ export { DropDown, NumberField, DateInput, + DateTimeInput, Button, CheckBox, FileUpload, diff --git a/packages/react-vanilla-components/src/utils/mappings.ts b/packages/react-vanilla-components/src/utils/mappings.ts index 614df5aa..478a9e1b 100644 --- a/packages/react-vanilla-components/src/utils/mappings.ts +++ b/packages/react-vanilla-components/src/utils/mappings.ts @@ -18,6 +18,7 @@ import Button from '../components/Button'; import CheckBox from '../components/CheckBox'; import CheckBoxGroup from '../components/CheckBoxGroup'; import DateInput from '../components/DateInput'; +import DateTimeInput from '../components/DateTimeInput'; import DropDown from '../components/DropDown'; import NumberField from '../components/NumberField'; import RadioGroup from '../components/RadioButtonGroup'; @@ -46,6 +47,8 @@ const mappings = { 'drop-down': DropDown, 'number-input': NumberField, 'date-input': DateInput, + 'datetime-input': DateTimeInput, + 'forms-components-examples/components/form/datetime': DateTimeInput, button: Button, checkbox: CheckBox, 'file-input': FileUpload, diff --git a/packages/react-vanilla-components/src/utils/withRuleEngine.tsx b/packages/react-vanilla-components/src/utils/withRuleEngine.tsx index 6acb5121..6e8bf71d 100644 --- a/packages/react-vanilla-components/src/utils/withRuleEngine.tsx +++ b/packages/react-vanilla-components/src/utils/withRuleEngine.tsx @@ -15,10 +15,14 @@ // ****************************************************************************** import React, { JSXElementConstructor } from 'react'; -import { State, FieldJson, FieldsetJson, getOrElse, isEmpty, checkIfConstraintsArePresent, EnumName } from '@aemforms/af-core'; +import { State, FieldJson, FieldsetJson, getOrElse, isEmpty, checkIfConstraintsArePresent } from '@aemforms/af-core'; import { useRuleEngine, useFormIntl } from '@aemforms/af-react-renderer'; import sanitizeHTML from 'sanitize-html'; import { FieldViewState } from './type'; + +/** Legacy rich-text enum option shape; af-core 0.22.175+ types enumNames as string[] only. */ +type EnumNameOption = { value: string; richText?: boolean }; +type EnumNameItem = EnumNameOption | string; const DEFAULT_ERROR_MESSAGE = 'There is an error in the field'; export const richTextString = (stringMsg = '') => { @@ -56,10 +60,10 @@ const getLocalizePlaceholder = (i18n: any, state: FieldViewState) => { const getLocalizeEnumNames = (i18n: any, state: FieldViewState) => { const enumNames = state?.enumNames || []; - return enumNames.map((item: EnumName | string, index: number) => { - const EnumName = typeof item === 'object' ? item.value : item; + return enumNames.map((item: EnumNameItem, index: number) => { + const displayName = typeof item === 'object' ? item.value : item; const localizeEnumId = getOrElse(state, ['properties', 'afs:translationIds', 'enumNames']); - const localizeEnumName = localizeEnumId ? i18n.formatMessage({ id: `localizeEnumId##${index}`, defaultMessage: EnumName }) : EnumName; + const localizeEnumName = localizeEnumId ? i18n.formatMessage({ id: `localizeEnumId##${index}`, defaultMessage: displayName }) : displayName; return typeof item === 'object' && item?.richText ? richTextString(localizeEnumName) : localizeEnumName; }); };