Skip to content

Commit f430559

Browse files
authored
Merge pull request #7917 from logilab/time-components
Add components to show only time
2 parents d443c22 + 1776d10 commit f430559

11 files changed

+473
-9
lines changed

docs/DateField.md

+12-3
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ title: "The DateField Component"
55

66
# `<DateField>`
77

8-
Displays a date or datetime using the browser locale (thanks to `Date.toLocaleDateString()` and `Date.toLocaleString()`).
8+
Displays a date, datetime or time using the browser locale (thanks to `Date.toLocaleDateString()`, `Date.toLocaleString()` and `Date.toLocaleTimeString()`).
99

1010
```jsx
1111
import { DateField } from 'react-admin';
@@ -19,13 +19,15 @@ import { DateField } from 'react-admin';
1919
| ---------- | -------- | ------- | ------- | -------------------------------------------------------------------------------------------------------- |
2020
| `locales` | Optional | string | '' | Override the browser locale in the date formatting. Passed as first argument to `Intl.DateTimeFormat()`. |
2121
| `options` | Optional | Object | - | Date formatting options. Passed as second argument to `Intl.DateTimeFormat()`. |
22-
| `showTime` | Optional | boolean | `false` | If true, show date and time. If false, show only date |
22+
| `showTime` | Optional | boolean | `false` | If true, show the time |
23+
| `showDate` | Optional | boolean | `true` | If true, show the date |
24+
2325

2426
`<DateField>` also accepts the [common field props](./Fields.md#common-field-props).
2527

2628
## Usage
2729

28-
This component accepts a `showTime` attribute (`false` by default) to force the display of time in addition to date. It uses `Intl.DateTimeFormat()` if available, passing the `locales` and `options` props as arguments. If Intl is not available, it ignores the `locales` and `options` props.
30+
This component accepts `showTime` and `showDate` props to decide whether to display a date (`showTime=false` and `showDate=true`), a datetime (`showTime=true` and `showDate=true`) or time (`showTime=true` and `showDate=false`). Setting `showTime` and `showDate` to false at the same time will throw and error. It uses `Intl.DateTimeFormat()` if available, passing the `locales` and `options` props as arguments. If Intl is not available, it ignores the `locales` and `options` props.
2931

3032
{% raw %}
3133
```jsx
@@ -37,6 +39,13 @@ This component accepts a `showTime` attribute (`false` by default) to force the
3739
// renders the record { id: 1234, publication_date: new Date('2017-04-23 23:05') } as
3840
<span>4/23/2017, 11:05:00 PM</span>
3941

42+
<DateField source="publication_date" showTime showDate={false} />
43+
// renders the record { id: 1234, publication_date: new Date('2017-04-23 23:05') } as
44+
<span>11:05:00 PM</span>
45+
46+
<DateField source="publication_date" showTime={false} showDate={false} />
47+
// throws an error as nothing would be displayed
48+
4049
<DateField source="publication_date" options={{ weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }} />
4150
// renders the record { id: 1234, publication_date: new Date('2017-04-23') } as
4251
<span>Sunday, April 23, 2017</span>

docs/TimeInput.md

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
layout: default
3+
title: "The TimeInput Component"
4+
---
5+
6+
# `<TimeInput>`
7+
8+
An input for editing time. `<TimeInput>` renders a standard browser [Time Picker](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/time), so the appearance depends on the browser.
9+
10+
| Firefox | Edge |
11+
| ------- | ---- |
12+
| ![TimeInput Firefox](./img/time-input-firefox.gif) | ![TimeInput Edge](./img/time-input-edge.gif) |
13+
14+
This component works with Date objects to handle the timezone using the browser locale.
15+
You can still pass string values as long as those can be converted to a JavaScript Date object.
16+
17+
```jsx
18+
import { TimeInput } from 'react-admin';
19+
20+
<TimeInput source="published_at" />
21+
```
22+
23+
`<TimeInput>` also accepts the [common input props](./Inputs.md#common-input-props).
24+
25+
**Tip**: For a MUI styled `<TimeInput>` component, check out [vascofg/react-admin-date-inputs](https://github.com/vascofg/react-admin-date-inputs).

docs/img/time-input-edge.gif

109 KB
Loading

docs/img/time-input-firefox.gif

160 KB
Loading

docs/navigation.html

+1
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@
171171
<li {% if page.path == 'SelectArrayInput.md' %} class="active" {% endif %}><a class="nav-link" href="./SelectArrayInput.html"><code>&lt;SelectArrayInput&gt;</code></a></li>
172172
<li {% if page.path == 'SelectInput.md' %} class="active" {% endif %}><a class="nav-link" href="./SelectInput.html"><code>&lt;SelectInput&gt;</code></a></li>
173173
<li {% if page.path == 'TextInput.md' %} class="active" {% endif %}><a class="nav-link" href="./TextInput.html"><code>&lt;TextInput&gt;</code></a></li>
174+
<li {% if page.path == 'TimeInput.md' %} class="active" {% endif %}><a class="nav-link" href="./TimeInput.html"><code>&lt;TimeInput&gt;</code></a></li>
174175
<li {% if page.path == 'TranslatableInputs.md' %} class="active" {% endif %}><a class="nav-link" href="./TranslatableInputs.html"><code>&lt;TranslatableInputs&gt;</code></a></li>
175176
<li {% if page.path == 'PasswordInput.md' %} class="active" {% endif %}><a class="nav-link" href="./PasswordInput.html"><code>&lt;PasswordInput&gt;</code></a></li>
176177
<li {% if page.path == 'ReferenceArrayInput.md' %} class="active" {% endif %}><a class="nav-link" href="./ReferenceArrayInput.html"><code>&lt;ReferenceArrayInput&gt;</code></a></li>

packages/ra-ui-materialui/src/field/DateField.spec.tsx

+15
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,21 @@ describe('<DateField />', () => {
7171
expect(queryByText(date)).not.toBeNull();
7272
});
7373

74+
it('should render only a time when the showtime prop is true and showdate is false', () => {
75+
const { queryByText } = render(
76+
<DateField
77+
record={{ id: 123, foo: new Date('2017-04-23 23:05') }}
78+
showTime
79+
showDate={false}
80+
source="foo"
81+
locales="en-US"
82+
/>
83+
);
84+
85+
const date = new Date('2017-04-23 23:05').toLocaleTimeString('en-US');
86+
expect(queryByText(date)).not.toBeNull();
87+
});
88+
7489
it('should pass the options prop to toLocaleString', () => {
7590
const date = new Date('2017-04-23');
7691
const options = {

packages/ra-ui-materialui/src/field/DateField.tsx

+23-6
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,17 @@ export const DateField: FC<DateFieldProps> = memo(props => {
3939
locales,
4040
options,
4141
showTime = false,
42+
showDate = true,
4243
source,
4344
...rest
4445
} = props;
46+
47+
if (!showTime && !showDate) {
48+
throw new Error(
49+
'<DateField> cannot have showTime and showDate false at the same time'
50+
);
51+
}
52+
4553
const record = useRecordContext(props);
4654
if (!record) {
4755
return null;
@@ -73,13 +81,20 @@ export const DateField: FC<DateFieldProps> = memo(props => {
7381
// who may see a different date when calling toLocaleDateString().
7482
dateOptions = { timeZone: 'UTC' };
7583
}
76-
const dateString = showTime
77-
? toLocaleStringSupportsLocales
84+
let dateString = '';
85+
if (showTime && showDate) {
86+
dateString = toLocaleStringSupportsLocales
7887
? date.toLocaleString(locales, options)
79-
: date.toLocaleString()
80-
: toLocaleStringSupportsLocales
81-
? date.toLocaleDateString(locales, dateOptions)
82-
: date.toLocaleDateString();
88+
: date.toLocaleString();
89+
} else if (showDate) {
90+
dateString = toLocaleStringSupportsLocales
91+
? date.toLocaleDateString(locales, dateOptions)
92+
: date.toLocaleDateString();
93+
} else if (showTime) {
94+
dateString = toLocaleStringSupportsLocales
95+
? date.toLocaleTimeString(locales, options)
96+
: date.toLocaleTimeString();
97+
}
8398

8499
return (
85100
<Typography
@@ -103,6 +118,7 @@ DateField.propTypes = {
103118
]),
104119
options: PropTypes.object,
105120
showTime: PropTypes.bool,
121+
showDate: PropTypes.bool,
106122
};
107123

108124
DateField.displayName = 'DateField';
@@ -114,6 +130,7 @@ export interface DateFieldProps
114130
locales?: string | string[];
115131
options?: object;
116132
showTime?: boolean;
133+
showDate?: boolean;
117134
}
118135

119136
const toLocaleStringSupportsLocales = (() => {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import * as React from 'react';
2+
import expect from 'expect';
3+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
4+
import { required, testDataProvider } from 'ra-core';
5+
import { format } from 'date-fns';
6+
import { useFormState } from 'react-hook-form';
7+
8+
import { AdminContext } from '../AdminContext';
9+
import { SimpleForm, Toolbar } from '../form';
10+
import { TimeInput } from './TimeInput';
11+
import { ArrayInput, SimpleFormIterator } from './ArrayInput';
12+
import { SaveButton } from '../button';
13+
14+
describe('<TimeInput />', () => {
15+
const defaultProps = {
16+
resource: 'posts',
17+
source: 'publishedAt',
18+
};
19+
20+
it('should render a time input', () => {
21+
render(
22+
<AdminContext dataProvider={testDataProvider()}>
23+
<SimpleForm onSubmit={jest.fn()}>
24+
<TimeInput {...defaultProps} />
25+
</SimpleForm>
26+
</AdminContext>
27+
);
28+
const input = screen.getByLabelText(
29+
'resources.posts.fields.publishedAt'
30+
) as HTMLInputElement;
31+
expect(input.type).toBe('time');
32+
});
33+
34+
it('should not make the form dirty on initialization', () => {
35+
const publishedAt = new Date();
36+
const FormState = () => {
37+
const { isDirty } = useFormState();
38+
39+
return <p>Dirty: {isDirty.toString()}</p>;
40+
};
41+
render(
42+
<AdminContext dataProvider={testDataProvider()}>
43+
<SimpleForm
44+
onSubmit={jest.fn()}
45+
record={{ id: 1, publishedAt: publishedAt.toISOString() }}
46+
>
47+
<TimeInput {...defaultProps} />
48+
<FormState />
49+
</SimpleForm>
50+
</AdminContext>
51+
);
52+
expect(screen.getByDisplayValue(format(publishedAt, 'HH:mm')));
53+
expect(screen.queryByText('Dirty: false')).not.toBeNull();
54+
});
55+
56+
it('should display a default value inside an ArrayInput', () => {
57+
const date = new Date('Wed Oct 05 2011 16:48:00 GMT+0200');
58+
const backlinksDefaultValue = [
59+
{
60+
date,
61+
},
62+
];
63+
render(
64+
<AdminContext dataProvider={testDataProvider()}>
65+
<SimpleForm onSubmit={jest.fn()}>
66+
<ArrayInput
67+
defaultValue={backlinksDefaultValue}
68+
source="backlinks"
69+
>
70+
<SimpleFormIterator>
71+
<TimeInput source="date" />
72+
</SimpleFormIterator>
73+
</ArrayInput>
74+
</SimpleForm>
75+
</AdminContext>
76+
);
77+
78+
expect(screen.getByDisplayValue(format(date, 'HH:mm')));
79+
});
80+
81+
it('should submit the form default value with its timezone', async () => {
82+
const publishedAt = new Date('Wed Oct 05 2011 16:48:00 GMT+0200');
83+
const onSubmit = jest.fn();
84+
render(
85+
<AdminContext dataProvider={testDataProvider()}>
86+
<SimpleForm
87+
onSubmit={onSubmit}
88+
defaultValues={{ publishedAt }}
89+
toolbar={
90+
<Toolbar>
91+
<SaveButton alwaysEnable />
92+
</Toolbar>
93+
}
94+
>
95+
<TimeInput {...defaultProps} />
96+
</SimpleForm>
97+
</AdminContext>
98+
);
99+
expect(
100+
screen.queryByDisplayValue(format(publishedAt, 'HH:mm'))
101+
).not.toBeNull();
102+
fireEvent.click(screen.getByLabelText('ra.action.save'));
103+
await waitFor(() => {
104+
expect(onSubmit).toHaveBeenCalledWith({
105+
publishedAt,
106+
});
107+
});
108+
});
109+
110+
it('should submit the input default value with its timezone', async () => {
111+
const publishedAt = new Date('Wed Oct 05 2011 16:48:00 GMT+0200');
112+
const onSubmit = jest.fn();
113+
render(
114+
<AdminContext dataProvider={testDataProvider()}>
115+
<SimpleForm
116+
onSubmit={onSubmit}
117+
toolbar={
118+
<Toolbar>
119+
<SaveButton alwaysEnable />
120+
</Toolbar>
121+
}
122+
>
123+
<TimeInput {...defaultProps} defaultValue={publishedAt} />
124+
</SimpleForm>
125+
</AdminContext>
126+
);
127+
expect(
128+
screen.queryByDisplayValue(format(publishedAt, 'HH:mm'))
129+
).not.toBeNull();
130+
fireEvent.click(screen.getByLabelText('ra.action.save'));
131+
await waitFor(() => {
132+
expect(onSubmit).toHaveBeenCalledWith({
133+
publishedAt,
134+
});
135+
});
136+
});
137+
138+
describe('error message', () => {
139+
it('should not be displayed if field is pristine', () => {
140+
render(
141+
<AdminContext dataProvider={testDataProvider()}>
142+
<SimpleForm onSubmit={jest.fn()}>
143+
<TimeInput {...defaultProps} validate={required()} />
144+
</SimpleForm>
145+
</AdminContext>
146+
);
147+
expect(screen.queryByText('ra.validation.required')).toBeNull();
148+
});
149+
150+
it('should be displayed if field has been touched and is invalid', async () => {
151+
render(
152+
<AdminContext dataProvider={testDataProvider()}>
153+
<SimpleForm onSubmit={jest.fn()} mode="onBlur">
154+
<TimeInput {...defaultProps} validate={required()} />
155+
</SimpleForm>
156+
</AdminContext>
157+
);
158+
const input = screen.getByLabelText(
159+
'resources.posts.fields.publishedAt *'
160+
);
161+
fireEvent.blur(input);
162+
await waitFor(() => {
163+
expect(
164+
screen.queryByText('ra.validation.required')
165+
).not.toBeNull();
166+
});
167+
});
168+
169+
it('should be displayed if field has been touched multiple times and is invalid', async () => {
170+
const onSubmit = jest.fn();
171+
render(
172+
<AdminContext dataProvider={testDataProvider()}>
173+
<SimpleForm onSubmit={onSubmit}>
174+
<TimeInput {...defaultProps} validate={required()} />
175+
</SimpleForm>
176+
</AdminContext>
177+
);
178+
const input = screen.getByLabelText(
179+
'resources.posts.fields.publishedAt *'
180+
);
181+
fireEvent.change(input, {
182+
target: { value: new Date().toISOString() },
183+
});
184+
fireEvent.blur(input);
185+
await waitFor(() => {
186+
expect(screen.queryByText('ra.validation.required')).toBeNull();
187+
});
188+
fireEvent.change(input, {
189+
target: { value: '' },
190+
});
191+
fireEvent.blur(input);
192+
fireEvent.click(screen.getByText('ra.action.save'));
193+
await waitFor(() => {
194+
expect(
195+
screen.queryByText('ra.validation.required')
196+
).not.toBeNull();
197+
});
198+
});
199+
});
200+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import * as React from 'react';
2+
import polyglotI18nProvider from 'ra-i18n-polyglot';
3+
import englishMessages from 'ra-language-english';
4+
5+
import { AdminContext } from '../AdminContext';
6+
import { Create } from '../detail';
7+
import { SimpleForm } from '../form';
8+
import { TimeInput } from './TimeInput';
9+
10+
export default { title: 'ra-ui-materialui/input/TimeInput' };
11+
12+
export const Basic = () => (
13+
<Wrapper>
14+
<TimeInput source="published" />
15+
</Wrapper>
16+
);
17+
18+
export const FullWidth = () => (
19+
<Wrapper>
20+
<TimeInput source="published" fullWidth />
21+
</Wrapper>
22+
);
23+
24+
export const Disabled = () => (
25+
<Wrapper>
26+
<TimeInput source="published" disabled />
27+
</Wrapper>
28+
);
29+
30+
const i18nProvider = polyglotI18nProvider(() => englishMessages);
31+
32+
const Wrapper = ({ children }) => (
33+
<AdminContext i18nProvider={i18nProvider}>
34+
<Create resource="posts">
35+
<SimpleForm>{children}</SimpleForm>
36+
</Create>
37+
</AdminContext>
38+
);

0 commit comments

Comments
 (0)