Skip to content

Commit 4915c86

Browse files
Armaan GuptaArmaan Gupta
authored andcommitted
feat(dateTime): added datetimeinput component in react vanilla components
1 parent 1a99bf5 commit 4915c86

5 files changed

Lines changed: 309 additions & 4 deletions

File tree

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
/*
2+
* Copyright 2023 Adobe, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import DateTimeInput from '../../src/components/DateTimeInput';
18+
import userEvent from '@testing-library/user-event';
19+
import { fireEvent, waitFor } from '@testing-library/react';
20+
import { renderComponent, DEFAULT_ERROR_MESSAGE } from '../utils';
21+
import '@testing-library/jest-dom/extend-expect';
22+
23+
const field = {
24+
name: 'datetime1',
25+
label: { value: 'Date and Time', visible: true },
26+
fieldType: 'datetime-input',
27+
type: 'string',
28+
format: 'date-time',
29+
visible: true,
30+
required: true,
31+
enabled: true,
32+
readOnly: false,
33+
};
34+
35+
// Full CRISP JSON with top-level minimum/maximum (af-core 0.22.94+ preserves and validates these).
36+
const fullField = {
37+
id: 'datetime-6a4986a577',
38+
fieldType: 'datetime-input',
39+
name: 'datetime1780381446201',
40+
visible: true,
41+
description: '<p>long</p>',
42+
tooltip: '<p>short</p>',
43+
type: 'string',
44+
required: true,
45+
enabled: true,
46+
constraintMessages: { required: 'wrong date', minimum: 'minimum date', maximum: 'max date' },
47+
readOnly: false,
48+
default: '2026-05-06 12:30:25',
49+
format: 'date-time',
50+
label: { visible: true, value: 'Date and Time' },
51+
minimum: '2025-11-04T12:55',
52+
maximum: '2026-06-27T12:55',
53+
properties: {
54+
'afs:layout': { tooltipVisible: false },
55+
'fd:path': '/content/forms/af/table-component/jcr:content/guideContainer/datetime',
56+
},
57+
};
58+
59+
const helper = renderComponent(DateTimeInput);
60+
61+
describe('DateTimeInput', () => {
62+
63+
test('value changed by user is set in model', async () => {
64+
const { renderResponse, element } = await helper(field);
65+
const input = renderResponse.container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
66+
fireEvent.change(input, { target: { value: '2024-06-15T10:30' } });
67+
expect(element.getState().value).toEqual('2024-06-15T10:30');
68+
});
69+
70+
test('it should handle visible property', async () => {
71+
const f = { ...field, visible: false };
72+
const { renderResponse } = await helper(f);
73+
expect(renderResponse.queryByText(field.label.value)).toBeNull();
74+
});
75+
76+
test('error message element exists when the field is invalid', async () => {
77+
const f = { ...field, valid: false, errorMessage: DEFAULT_ERROR_MESSAGE };
78+
const { renderResponse } = await helper(f);
79+
expect(renderResponse.queryByText(DEFAULT_ERROR_MESSAGE)).not.toBeNull();
80+
});
81+
82+
test('labels and inputs are linked with for and id attribute', async () => {
83+
const { renderResponse } = await helper(field);
84+
const input = renderResponse.container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
85+
const label = renderResponse.queryByText(field.label.value);
86+
expect(input?.getAttribute('id')).toEqual(label?.getAttribute('for'));
87+
});
88+
89+
test('input renders with datetime-local type', async () => {
90+
const { renderResponse } = await helper(field);
91+
const input = renderResponse.container.querySelector('input[type="datetime-local"]');
92+
expect(input).not.toBeNull();
93+
});
94+
95+
test('disabled attribute is set when enabled is false', async () => {
96+
const f = { ...field, enabled: false };
97+
const { renderResponse } = await helper(f);
98+
const input = renderResponse.container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
99+
expect(input).toBeDisabled();
100+
});
101+
102+
test('default value with space separator is normalised to datetime-local format', async () => {
103+
// af-core surfaces default as "YYYY-MM-DD HH:MM:SS"; datetime-local requires "YYYY-MM-DDTHH:MM"
104+
const { renderResponse } = await helper(fullField);
105+
const input = renderResponse.container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
106+
expect(input.value).toEqual('2026-05-06T12:30');
107+
});
108+
109+
test('min and max HTML attributes are set from JSON minimum/maximum', async () => {
110+
const { renderResponse } = await helper(fullField);
111+
const input = renderResponse.container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
112+
expect(input).toHaveAttribute('min', '2025-11-04T12:55');
113+
expect(input).toHaveAttribute('max', '2026-06-27T12:55');
114+
});
115+
116+
test('minimum constraint message is shown when value is below minimum', async () => {
117+
const f = {
118+
...field,
119+
minimum: '2025-11-04T12:55',
120+
maximum: '2026-06-27T12:55',
121+
constraintMessages: { minimum: 'minimum date', maximum: 'max date' },
122+
};
123+
const { renderResponse } = await helper(f);
124+
const input = renderResponse.container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
125+
fireEvent.change(input, { target: { value: '2020-01-01T10:00' } });
126+
fireEvent.blur(input);
127+
await waitFor(() => {
128+
expect(renderResponse.queryByText('minimum date')).not.toBeNull();
129+
});
130+
});
131+
132+
test('maximum constraint message is shown when value is above maximum', async () => {
133+
const f = {
134+
...field,
135+
minimum: '2025-11-04T12:55',
136+
maximum: '2026-06-27T12:55',
137+
constraintMessages: { minimum: 'minimum date', maximum: 'max date' },
138+
};
139+
const { renderResponse } = await helper(f);
140+
const input = renderResponse.container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
141+
fireEvent.change(input, { target: { value: '2027-01-01T10:00' } });
142+
fireEvent.blur(input);
143+
await waitFor(() => {
144+
expect(renderResponse.queryByText('max date')).not.toBeNull();
145+
});
146+
});
147+
148+
test('tooltip and description toggle correctly', async () => {
149+
const f = {
150+
...field,
151+
tooltip: 'Short Description',
152+
description: 'Long Description',
153+
properties: { 'afs:layout': { tooltipVisible: true } },
154+
};
155+
const { renderResponse } = await helper(f);
156+
expect(renderResponse.getByText('Short Description')).not.toBeNull();
157+
const button = renderResponse.container.getElementsByClassName('cmp-adaptiveform-datetimeinput__questionmark');
158+
userEvent.click(button[0]);
159+
expect(renderResponse.getByText('Long Description')).not.toBeNull();
160+
});
161+
162+
test('aria-describedby contains long and short description ids when both are present', async () => {
163+
const f = {
164+
...field,
165+
id: 'datetime-123',
166+
tooltip: 'short desc',
167+
description: 'long desc',
168+
properties: { 'afs:layout': { tooltipVisible: true } },
169+
};
170+
const { renderResponse } = await helper(f);
171+
const input = renderResponse.container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
172+
expect(input).toHaveAttribute(
173+
'aria-describedby',
174+
`${f.id}__longdescription ${f.id}__shortdescription`
175+
);
176+
});
177+
178+
test('html in the label should be rendered for rich text', async () => {
179+
const f = {
180+
...field,
181+
label: { value: '<strong>Date and Time</strong>', richText: true, visible: true },
182+
};
183+
const { renderResponse } = await helper(f);
184+
expect(renderResponse.container.innerHTML).toContain('<strong>Date and Time</strong>');
185+
});
186+
187+
test('required constraint message is shown on validation failure', async () => {
188+
const f = { ...fullField, valid: false, errorMessage: 'wrong date' };
189+
const { renderResponse } = await helper(f);
190+
expect(renderResponse.queryByText('wrong date')).not.toBeNull();
191+
});
192+
193+
test('full CRISP JSON field renders without errors', async () => {
194+
const { renderResponse } = await helper(fullField);
195+
expect(renderResponse.container.querySelector('input[type="datetime-local"]')).not.toBeNull();
196+
expect(renderResponse.queryByText('Date and Time')).not.toBeNull();
197+
});
198+
});
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// *******************************************************************************
2+
// * Copyright 2023 Adobe
3+
// *
4+
// * Licensed under the Apache License, Version 2.0 (the "License");
5+
// * you may not use this file except in compliance with the License.
6+
// * You may obtain a copy of the License at
7+
// *
8+
// * http://www.apache.org/licenses/LICENSE-2.0
9+
// *
10+
// * Unless required by applicable law or agreed to in writing, software
11+
// * distributed under the License is distributed on an "AS IS" BASIS,
12+
// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// * See the License for the specific language governing permissions and
14+
// * limitations under the License.
15+
// *
16+
// * BEM markup follows AEM core form components guidelines.
17+
// * LINK- https://github.com/adobe/aem-core-forms-components
18+
// ******************************************************************************
19+
20+
import React, { useCallback } from 'react';
21+
import { withRuleEngine } from '../utils/withRuleEngine';
22+
import { PROPS } from '../utils/type';
23+
import FieldWrapper from './common/FieldWrapper';
24+
import { syncAriaDescribedBy } from '../utils/utils';
25+
26+
// datetime-local requires "YYYY-MM-DDTHH:MM". af-core may surface the default
27+
// or user-entered value with a space separator ("YYYY-MM-DD HH:MM:SS"), so we
28+
// normalise to the T-separated format and strip seconds.
29+
const toDateTimeLocalValue = (val: string | undefined): string => {
30+
if (!val) { return ''; }
31+
const normalised = val.replace(' ', 'T');
32+
return normalised.length > 16 ? normalised.slice(0, 16) : normalised;
33+
};
34+
35+
const datetime = (props: PROPS) => {
36+
const {
37+
id, label, value, required, name, readOnly,
38+
placeholder, visible, enabled, appliedCssClassNames, valid, minimum, maximum
39+
} = props;
40+
41+
const finalValue = toDateTimeLocalValue(value as string | undefined);
42+
43+
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
44+
props.dispatchChange(e.target.value);
45+
}, [props.dispatchChange]);
46+
47+
const handleFocus = useCallback(() => {
48+
props.dispatchFocus();
49+
}, [props.dispatchFocus]);
50+
51+
const handleBlur = useCallback(() => {
52+
props.dispatchBlur();
53+
}, [props.dispatchBlur]);
54+
55+
return (
56+
<div
57+
className={`cmp-adaptiveform-datetime cmp-adaptiveform-datetime--${value ? 'filled' : 'empty'} ${appliedCssClassNames || ''}`}
58+
data-cmp-is="adaptiveFormdatetime"
59+
data-cmp-visible={visible}
60+
data-cmp-enabled={enabled}
61+
data-cmp-required={required}
62+
data-cmp-valid={valid}
63+
>
64+
<FieldWrapper
65+
bemBlock='cmp-adaptiveform-datetime'
66+
label={label}
67+
id={id}
68+
tooltip={props.tooltip}
69+
description={props.description}
70+
isError={props.isError}
71+
errorMessage={props.errorMessage}
72+
>
73+
<input
74+
type='datetime-local'
75+
id={`${id}-widget`}
76+
className='cmp-adaptiveform-datetime__widget'
77+
title={props.tooltipText || ''}
78+
value={finalValue}
79+
name={name}
80+
required={required}
81+
min={minimum}
82+
max={maximum}
83+
readOnly={readOnly}
84+
placeholder={placeholder}
85+
disabled={!enabled}
86+
onChange={handleChange}
87+
onFocus={handleFocus}
88+
onBlur={handleBlur}
89+
aria-label={label?.value}
90+
aria-invalid={!valid}
91+
aria-describedby={syncAriaDescribedBy(id, props.tooltip, props.description, props.errorMessage)}
92+
/>
93+
</FieldWrapper>
94+
</div>
95+
);
96+
};
97+
98+
export default withRuleEngine(datetime);

packages/react-vanilla-components/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import RadioGroup from './components/RadioButtonGroup';
1212
import DropDown from './components/DropDown';
1313
import NumberField from './components/NumberField';
1414
import DateInput from './components/DateInput';
15+
import DateTimeInput from './components/DateTimeInput';
1516
import CheckBox from './components/CheckBox';
1617
import Button from './components/Button';
1718
import TextFieldArea from './components/TextFieldArea';
@@ -42,6 +43,7 @@ export {
4243
DropDown,
4344
NumberField,
4445
DateInput,
46+
DateTimeInput,
4547
Button,
4648
CheckBox,
4749
FileUpload,

packages/react-vanilla-components/src/utils/mappings.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import Button from '../components/Button';
1818
import CheckBox from '../components/CheckBox';
1919
import CheckBoxGroup from '../components/CheckBoxGroup';
2020
import DateInput from '../components/DateInput';
21+
import DateTimeInput from '../components/DateTimeInput';
2122
import DropDown from '../components/DropDown';
2223
import NumberField from '../components/NumberField';
2324
import RadioGroup from '../components/RadioButtonGroup';
@@ -46,6 +47,8 @@ const mappings = {
4647
'drop-down': DropDown,
4748
'number-input': NumberField,
4849
'date-input': DateInput,
50+
'datetime-input': DateTimeInput,
51+
'forms-components-examples/components/form/datetime': DateTimeInput,
4952
button: Button,
5053
checkbox: CheckBox,
5154
'file-input': FileUpload,

packages/react-vanilla-components/src/utils/withRuleEngine.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,14 @@
1515
// ******************************************************************************
1616

1717
import React, { JSXElementConstructor } from 'react';
18-
import { State, FieldJson, FieldsetJson, getOrElse, isEmpty, checkIfConstraintsArePresent, EnumName } from '@aemforms/af-core';
18+
import { State, FieldJson, FieldsetJson, getOrElse, isEmpty, checkIfConstraintsArePresent } from '@aemforms/af-core';
1919
import { useRuleEngine, useFormIntl } from '@aemforms/af-react-renderer';
2020
import sanitizeHTML from 'sanitize-html';
2121
import { FieldViewState } from './type';
22+
23+
/** Legacy rich-text enum option shape; af-core 0.22.175+ types enumNames as string[] only. */
24+
type EnumNameOption = { value: string; richText?: boolean };
25+
type EnumNameItem = EnumNameOption | string;
2226
const DEFAULT_ERROR_MESSAGE = 'There is an error in the field';
2327

2428
export const richTextString = (stringMsg = '') => {
@@ -56,10 +60,10 @@ const getLocalizePlaceholder = (i18n: any, state: FieldViewState) => {
5660

5761
const getLocalizeEnumNames = (i18n: any, state: FieldViewState) => {
5862
const enumNames = state?.enumNames || [];
59-
return enumNames.map((item: EnumName | string, index: number) => {
60-
const EnumName = typeof item === 'object' ? item.value : item;
63+
return enumNames.map((item: EnumNameItem, index: number) => {
64+
const displayName = typeof item === 'object' ? item.value : item;
6165
const localizeEnumId = getOrElse(state, ['properties', 'afs:translationIds', 'enumNames']);
62-
const localizeEnumName = localizeEnumId ? i18n.formatMessage({ id: `localizeEnumId##${index}`, defaultMessage: EnumName }) : EnumName;
66+
const localizeEnumName = localizeEnumId ? i18n.formatMessage({ id: `localizeEnumId##${index}`, defaultMessage: displayName }) : displayName;
6367
return typeof item === 'object' && item?.richText ? richTextString(localizeEnumName) : localizeEnumName;
6468
});
6569
};

0 commit comments

Comments
 (0)