sforkowany z mirror/soapbox
Merge branch 'improve-datepicker' into 'develop'
Add new custom datepicker for improved UX See merge request soapbox-pub/soapbox-fe!1507chats-fixes
commit
cedbc468bd
|
@ -0,0 +1,83 @@
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { queryAllByRole, render, screen } from '../../../../jest/test-helpers';
|
||||||
|
import Datepicker from '../datepicker';
|
||||||
|
|
||||||
|
describe('<Datepicker />', () => {
|
||||||
|
it('defaults to the current date', () => {
|
||||||
|
const handler = jest.fn();
|
||||||
|
render(<Datepicker onChange={handler} />);
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
expect(screen.getByTestId('datepicker-month')).toHaveValue(String(today.getMonth()));
|
||||||
|
expect(screen.getByTestId('datepicker-day')).toHaveValue(String(today.getDate()));
|
||||||
|
expect(screen.getByTestId('datepicker-year')).toHaveValue(String(today.getFullYear()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes number of days based on selected month and year', async() => {
|
||||||
|
const handler = jest.fn();
|
||||||
|
render(<Datepicker onChange={handler} />);
|
||||||
|
|
||||||
|
await userEvent.selectOptions(
|
||||||
|
screen.getByTestId('datepicker-month'),
|
||||||
|
screen.getByRole('option', { name: 'February' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.selectOptions(
|
||||||
|
screen.getByTestId('datepicker-year'),
|
||||||
|
screen.getByRole('option', { name: '2020' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
let daySelect: HTMLElement;
|
||||||
|
daySelect = document.querySelector('[data-testid="datepicker-day"]');
|
||||||
|
expect(queryAllByRole(daySelect, 'option')).toHaveLength(29);
|
||||||
|
|
||||||
|
await userEvent.selectOptions(
|
||||||
|
screen.getByTestId('datepicker-year'),
|
||||||
|
screen.getByRole('option', { name: '2021' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
daySelect = document.querySelector('[data-testid="datepicker-day"]') as HTMLElement;
|
||||||
|
expect(queryAllByRole(daySelect, 'option')).toHaveLength(28);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ranges from the current year to 120 years ago', () => {
|
||||||
|
const handler = jest.fn();
|
||||||
|
render(<Datepicker onChange={handler} />);
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
const yearSelect = document.querySelector('[data-testid="datepicker-year"]') as HTMLElement;
|
||||||
|
expect(queryAllByRole(yearSelect, 'option')).toHaveLength(121);
|
||||||
|
expect(queryAllByRole(yearSelect, 'option')[0]).toHaveValue(String(today.getFullYear()));
|
||||||
|
expect(queryAllByRole(yearSelect, 'option')[120]).toHaveValue(String(today.getFullYear() - 120));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls the onChange function when the inputs change', async() => {
|
||||||
|
const handler = jest.fn();
|
||||||
|
render(<Datepicker onChange={handler} />);
|
||||||
|
|
||||||
|
expect(handler.mock.calls.length).toEqual(1);
|
||||||
|
|
||||||
|
await userEvent.selectOptions(
|
||||||
|
screen.getByTestId('datepicker-month'),
|
||||||
|
screen.getByRole('option', { name: 'February' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(handler.mock.calls.length).toEqual(2);
|
||||||
|
|
||||||
|
await userEvent.selectOptions(
|
||||||
|
screen.getByTestId('datepicker-year'),
|
||||||
|
screen.getByRole('option', { name: '2020' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(handler.mock.calls.length).toEqual(3);
|
||||||
|
|
||||||
|
await userEvent.selectOptions(
|
||||||
|
screen.getByTestId('datepicker-day'),
|
||||||
|
screen.getByRole('option', { name: '5' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(handler.mock.calls.length).toEqual(4);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,94 @@
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import Select from '../select/select';
|
||||||
|
import Stack from '../stack/stack';
|
||||||
|
import Text from '../text/text';
|
||||||
|
|
||||||
|
const getDaysInMonth = (month: number, year: number) => new Date(year, month + 1, 0).getDate();
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
interface IDatepicker {
|
||||||
|
onChange(date: Date): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datepicker that allows a user to select month, day, and year.
|
||||||
|
*/
|
||||||
|
const Datepicker = ({ onChange }: IDatepicker) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const [month, setMonth] = useState<number>(new Date().getMonth());
|
||||||
|
const [day, setDay] = useState<number>(new Date().getDate());
|
||||||
|
const [year, setYear] = useState<number>(2022);
|
||||||
|
|
||||||
|
const numberOfDays = useMemo(() => {
|
||||||
|
return getDaysInMonth(month, year);
|
||||||
|
}, [month, year]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChange(new Date(year, month, day));
|
||||||
|
}, [month, day, year]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='grid grid-cols-1 gap-y-2 gap-x-2 sm:grid-cols-3'>
|
||||||
|
<div className='sm:col-span-1'>
|
||||||
|
<Stack>
|
||||||
|
<Text size='sm' weight='medium' theme='muted'>
|
||||||
|
<FormattedMessage id='datepicker.month' defaultMessage='Month' />
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={month}
|
||||||
|
onChange={(event) => setMonth(Number(event.target.value))}
|
||||||
|
data-testid='datepicker-month'
|
||||||
|
>
|
||||||
|
{[...Array(12)].map((_, idx) => (
|
||||||
|
<option key={idx} value={idx}>
|
||||||
|
{intl.formatDate(new Date(year, idx, 1), { month: 'long' })}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='sm:col-span-1'>
|
||||||
|
<Stack>
|
||||||
|
<Text size='sm' weight='medium' theme='muted'>
|
||||||
|
<FormattedMessage id='datepicker.day' defaultMessage='Day' />
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={day}
|
||||||
|
onChange={(event) => setDay(Number(event.target.value))}
|
||||||
|
data-testid='datepicker-day'
|
||||||
|
>
|
||||||
|
{[...Array(numberOfDays)].map((_, idx) => (
|
||||||
|
<option key={idx} value={idx + 1}>{idx + 1}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='sm:col-span-1'>
|
||||||
|
<Stack>
|
||||||
|
<Text size='sm' weight='medium' theme='muted'>
|
||||||
|
<FormattedMessage id='datepicker.year' defaultMessage='Year' />
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={year}
|
||||||
|
onChange={(event) => setYear(Number(event.target.value))}
|
||||||
|
data-testid='datepicker-year'
|
||||||
|
>
|
||||||
|
{[...Array(121)].map((_, idx) => (
|
||||||
|
<option key={idx} value={currentYear - idx}>{currentYear - idx}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Datepicker;
|
|
@ -4,6 +4,7 @@ export { Card, CardBody, CardHeader, CardTitle } from './card/card';
|
||||||
export { default as Checkbox } from './checkbox/checkbox';
|
export { default as Checkbox } from './checkbox/checkbox';
|
||||||
export { default as Column } from './column/column';
|
export { default as Column } from './column/column';
|
||||||
export { default as Counter } from './counter/counter';
|
export { default as Counter } from './counter/counter';
|
||||||
|
export { default as Datepicker } from './datepicker/datepicker';
|
||||||
export { default as Emoji } from './emoji/emoji';
|
export { default as Emoji } from './emoji/emoji';
|
||||||
export { default as EmojiSelector } from './emoji-selector/emoji-selector';
|
export { default as EmojiSelector } from './emoji-selector/emoji-selector';
|
||||||
export { default as FileInput } from './file-input/file-input';
|
export { default as FileInput } from './file-input/file-input';
|
||||||
|
|
|
@ -1,13 +1,17 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
|
interface ISelect extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
|
children: Iterable<React.ReactNode>,
|
||||||
|
}
|
||||||
|
|
||||||
/** Multiple-select dropdown. */
|
/** Multiple-select dropdown. */
|
||||||
const Select = React.forwardRef<HTMLSelectElement>((props, ref) => {
|
const Select = React.forwardRef<HTMLSelectElement, ISelect>((props, ref) => {
|
||||||
const { children, ...filteredProps } = props;
|
const { children, ...filteredProps } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className='pl-3 pr-10 py-2 text-base border-gray-300 dark:border-slate-700 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-slate-800 sm:text-sm rounded-md'
|
className='w-full pl-3 pr-10 py-2 text-base border-gray-300 dark:border-slate-700 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-slate-800 dark:text-white sm:text-sm rounded-md disabled:opacity-50'
|
||||||
{...filteredProps}
|
{...filteredProps}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
@ -39,7 +39,10 @@ describe('<AgeVerification />', () => {
|
||||||
store,
|
store,
|
||||||
);
|
);
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText('Birth Date'), '{enter}');
|
await userEvent.selectOptions(
|
||||||
|
screen.getByTestId('datepicker-year'),
|
||||||
|
screen.getByRole('option', { name: '2020' }),
|
||||||
|
);
|
||||||
|
|
||||||
fireEvent.submit(
|
fireEvent.submit(
|
||||||
screen.getByRole('button'), {
|
screen.getByRole('button'), {
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import DatePicker from 'react-datepicker';
|
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
import snackbar from 'soapbox/actions/snackbar';
|
import snackbar from 'soapbox/actions/snackbar';
|
||||||
import { verifyAge } from 'soapbox/actions/verification';
|
import { verifyAge } from 'soapbox/actions/verification';
|
||||||
import { Button, Form, FormGroup, Text } from 'soapbox/components/ui';
|
import { Button, Datepicker, Form, Text } from 'soapbox/components/ui';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
fail: {
|
fail: {
|
||||||
|
@ -23,13 +22,6 @@ function meetsAgeMinimum(birthday, ageMinimum) {
|
||||||
return new Date(year + ageMinimum, month, day) <= new Date();
|
return new Date(year + ageMinimum, month, day) <= new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMaximumDate(ageMinimum) {
|
|
||||||
const date = new Date();
|
|
||||||
date.setUTCFullYear(date.getUTCFullYear() - ageMinimum);
|
|
||||||
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AgeVerification = () => {
|
const AgeVerification = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
@ -67,21 +59,9 @@ const AgeVerification = () => {
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto'>
|
<div className='sm:pt-10 sm:w-2/3 mx-auto'>
|
||||||
<Form onSubmit={handleSubmit}>
|
<Form onSubmit={handleSubmit}>
|
||||||
<FormGroup labelText='Birth Date'>
|
<Datepicker onChange={onChange} />
|
||||||
<DatePicker
|
|
||||||
selected={date}
|
|
||||||
dateFormat='MMMM d, yyyy'
|
|
||||||
onChange={onChange}
|
|
||||||
showMonthDropdown
|
|
||||||
showYearDropdown
|
|
||||||
maxDate={getMaximumDate(ageMinimum)}
|
|
||||||
className='block w-full sm:text-sm border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500'
|
|
||||||
dropdownMode='select'
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<Text theme='muted' size='sm'>
|
<Text theme='muted' size='sm'>
|
||||||
{siteTitle} requires users to be at least {ageMinimum} years old to
|
{siteTitle} requires users to be at least {ageMinimum} years old to
|
||||||
|
|
|
@ -344,6 +344,9 @@
|
||||||
"crypto_donate_panel.actions.view": "Click to see {count} {count, plural, one {wallet} other {wallets}}",
|
"crypto_donate_panel.actions.view": "Click to see {count} {count, plural, one {wallet} other {wallets}}",
|
||||||
"crypto_donate_panel.heading": "Donate Cryptocurrency",
|
"crypto_donate_panel.heading": "Donate Cryptocurrency",
|
||||||
"crypto_donate_panel.intro.message": "{siteTitle} accepts cryptocurrency donations to fund our service. Thank you for your support!",
|
"crypto_donate_panel.intro.message": "{siteTitle} accepts cryptocurrency donations to fund our service. Thank you for your support!",
|
||||||
|
"datepicker.month": "Month",
|
||||||
|
"datepicker.day": "Day",
|
||||||
|
"datepicker.year": "Year",
|
||||||
"datepicker.hint": "Scheduled to post at…",
|
"datepicker.hint": "Scheduled to post at…",
|
||||||
"datepicker.next_month": "Next month",
|
"datepicker.next_month": "Next month",
|
||||||
"datepicker.next_year": "Next year",
|
"datepicker.next_year": "Next year",
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
select {
|
select {
|
||||||
@apply pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md;
|
@apply pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md;
|
||||||
|
|
||||||
|
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-error::before,
|
.form-error::before,
|
||||||
|
|
Ładowanie…
Reference in New Issue