Skip to content
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
Original file line number Diff line number Diff line change
@@ -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: '<p>long</p>',
tooltip: '<p>short</p>',
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: '<strong>Date and Time</strong>', richText: true, visible: true },
};
const { renderResponse } = await helper(f);
expect(renderResponse.container.innerHTML).toContain('<strong>Date and Time</strong>');
});

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();
});
});
98 changes: 98 additions & 0 deletions packages/react-vanilla-components/src/components/DateTimeInput.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>) => {
props.dispatchChange(e.target.value);
}, [props.dispatchChange]);

const handleFocus = useCallback(() => {
props.dispatchFocus();
}, [props.dispatchFocus]);

const handleBlur = useCallback(() => {
props.dispatchBlur();
}, [props.dispatchBlur]);

return (
<div
className={`cmp-adaptiveform-datetime cmp-adaptiveform-datetime--${value ? 'filled' : 'empty'} ${appliedCssClassNames || ''}`}
data-cmp-is="adaptiveFormdatetime"
data-cmp-visible={visible}
data-cmp-enabled={enabled}
data-cmp-required={required}
data-cmp-valid={valid}
>
<FieldWrapper
bemBlock='cmp-adaptiveform-datetime'
label={label}
id={id}
tooltip={props.tooltip}
description={props.description}
isError={props.isError}
errorMessage={props.errorMessage}
>
<input
type='datetime-local'
id={`${id}-widget`}
className='cmp-adaptiveform-datetime__widget'
title={props.tooltipText || ''}
value={finalValue}
name={name}
required={required}
min={minimum}
max={maximum}
readOnly={readOnly}
placeholder={placeholder}
disabled={!enabled}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
aria-label={label?.value}
aria-invalid={!valid}
aria-describedby={syncAriaDescribedBy(id, props.tooltip, props.description, props.errorMessage)}
/>
</FieldWrapper>
</div>
);
};

export default withRuleEngine(datetime);
2 changes: 2 additions & 0 deletions packages/react-vanilla-components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -42,6 +43,7 @@ export {
DropDown,
NumberField,
DateInput,
DateTimeInput,
Button,
CheckBox,
FileUpload,
Expand Down
3 changes: 3 additions & 0 deletions packages/react-vanilla-components/src/utils/mappings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 8 additions & 4 deletions packages/react-vanilla-components/src/utils/withRuleEngine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '') => {
Expand Down Expand Up @@ -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;
});
};
Expand Down
Loading