@dhis2-ui/calendar 10.16.1 → 10.16.3-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/package.json +10 -9
  2. package/src/__e2e__/calendar-input.e2e.stories.js +22 -0
  3. package/src/calendar/calendar-container.js +99 -0
  4. package/src/calendar/calendar-table-cell.js +97 -0
  5. package/src/calendar/calendar-table-days-header.js +40 -0
  6. package/src/calendar/calendar-table.js +74 -0
  7. package/src/calendar/calendar.js +104 -0
  8. package/src/calendar/navigation-container.js +295 -0
  9. package/src/calendar-input/__tests__/calendar-input.test.js +344 -0
  10. package/src/calendar-input/calendar-input.js +262 -0
  11. package/src/features/supports_calendar_clear_button/supports_calendar_clear_button.js +64 -0
  12. package/src/features/supports_calendar_clear_button.feature +23 -0
  13. package/src/features/supports_ethiopic_calendar/supports_ethiopic_calendar.js +61 -0
  14. package/src/features/supports_ethiopic_calendar.feature +20 -0
  15. package/src/features/supports_gregorian_calendar/supports_gregorian_calendar.js +62 -0
  16. package/src/features/supports_gregorian_calendar.feature +19 -0
  17. package/src/features/supports_islamic_calendar/supports_islamic_calendar.js +17 -0
  18. package/src/features/supports_islamic_calendar.feature +5 -0
  19. package/src/features/supports_nepali_calendar/supports_nepali_calendar.js +60 -0
  20. package/src/features/supports_nepali_calendar.feature +19 -0
  21. package/src/index.js +2 -0
  22. package/src/locales/en/translations.json +6 -0
  23. package/src/locales/index.js +16 -0
  24. package/src/stories/calendar-input.prod.stories.js +255 -0
  25. package/src/stories/calendar-story-wrapper.js +161 -0
  26. package/src/stories/calendar.prod.stories.js +91 -0
@@ -0,0 +1,295 @@
1
+ import { colors, spacers } from '@dhis2/ui-constants'
2
+ import { IconChevronLeft16, IconChevronRight16 } from '@dhis2/ui-icons'
3
+ import PropTypes from 'prop-types'
4
+ import React from 'react'
5
+ import i18n from '../locales/index.js'
6
+
7
+ const wrapperBorderColor = colors.grey300
8
+ const headerBackground = colors.grey100
9
+
10
+ export const NavigationContainer = ({
11
+ languageDirection,
12
+ currMonth,
13
+ currYear,
14
+ nextMonth,
15
+ nextYear,
16
+ prevMonth,
17
+ prevYear,
18
+ navigateToYear,
19
+ navigateToMonth,
20
+ months,
21
+ years,
22
+ }) => {
23
+ const PreviousIcon =
24
+ languageDirection === 'ltr' ? IconChevronLeft16 : IconChevronRight16
25
+ const NextIcon =
26
+ languageDirection === 'ltr' ? IconChevronRight16 : IconChevronLeft16
27
+
28
+ const handleYearChange = (e) => {
29
+ const targetYear = parseInt(e.target.value)
30
+ navigateToYear(targetYear)
31
+ }
32
+
33
+ const handleMonthChange = (e) => {
34
+ const selectedMonth = months.find(
35
+ (month) => month.label === e.target.value
36
+ )
37
+
38
+ if (selectedMonth) {
39
+ navigateToMonth(selectedMonth.value)
40
+ }
41
+ }
42
+
43
+ return (
44
+ <>
45
+ <div className="navigation-container">
46
+ <div className="month">
47
+ <div className="prev">
48
+ <button
49
+ onClick={prevMonth.navigateTo}
50
+ name="previous-month"
51
+ data-test="calendar-previous-month"
52
+ aria-label={`${i18n.t(`Go to ${prevMonth.label}`)}`}
53
+ type="button"
54
+ >
55
+ <PreviousIcon />
56
+ </button>
57
+ </div>
58
+ <div className="monthList">
59
+ <select
60
+ value={currMonth.label}
61
+ onChange={handleMonthChange}
62
+ className="month-select"
63
+ data-test="calendar-month-select"
64
+ >
65
+ {months.map((month) => (
66
+ <option key={month.value} value={month.label}>
67
+ {month.label}
68
+ </option>
69
+ ))}
70
+ </select>
71
+ <svg
72
+ width="16"
73
+ height="16"
74
+ viewBox="0 0 16 16"
75
+ fill="none"
76
+ xmlns="http://www.w3.org/2000/svg"
77
+ >
78
+ <path
79
+ d="M10.1465 6.85363L10.8536 6.14652L8.00004 3.29297L5.14648 6.14652L5.85359 6.85363L8.00004 4.70718L10.1465 6.85363ZM5.85367 9.1466L5.14656 9.8537L8.00011 12.7073L10.8537 9.8537L10.1466 9.1466L8.00011 11.293L5.85367 9.1466Z"
80
+ fill={colors.grey700}
81
+ />
82
+ </svg>
83
+ </div>
84
+ <div className="next">
85
+ <button
86
+ onClick={nextMonth.navigateTo}
87
+ data-test="calendar-next-month"
88
+ name="next-month"
89
+ aria-label={`${i18n.t(`Go to ${nextMonth.label}`)}`}
90
+ type="button"
91
+ >
92
+ <NextIcon />
93
+ </button>
94
+ </div>
95
+ </div>
96
+ <div className="year">
97
+ <div className="prev">
98
+ <button
99
+ onClick={prevYear.navigateTo}
100
+ name="previous-year"
101
+ aria-label={`${i18n.t('Go to previous year')}`}
102
+ type="button"
103
+ >
104
+ <PreviousIcon />
105
+ </button>
106
+ </div>
107
+ <div className="yearList">
108
+ <select
109
+ value={currYear.value}
110
+ onChange={handleYearChange}
111
+ className="year-select"
112
+ data-test="calendar-year-select"
113
+ >
114
+ {years.map((year) => (
115
+ <option key={year.value} value={year.value}>
116
+ {/* ToDo: this is a workaround for Ethiopic years showing the era
117
+ The workaround is needed but should be done in multi-calendar lib */}
118
+ {year.label?.replace(/ERA\d/, '')}
119
+ </option>
120
+ ))}
121
+ </select>
122
+ <svg
123
+ width="16"
124
+ height="16"
125
+ viewBox="0 0 16 16"
126
+ fill="none"
127
+ xmlns="http://www.w3.org/2000/svg"
128
+ >
129
+ <path
130
+ d="M10.1465 6.85363L10.8536 6.14652L8.00004 3.29297L5.14648 6.14652L5.85359 6.85363L8.00004 4.70718L10.1465 6.85363ZM5.85367 9.1466L5.14656 9.8537L8.00011 12.7073L10.8537 9.8537L10.1466 9.1466L8.00011 11.293L5.85367 9.1466Z"
131
+ fill={colors.grey700}
132
+ />
133
+ </svg>
134
+ </div>
135
+ <div className="next">
136
+ <button
137
+ onClick={nextYear.navigateTo}
138
+ name="next-year"
139
+ aria-label={`${i18n.t('Go to next year')}`}
140
+ type="button"
141
+ >
142
+ <NextIcon />
143
+ </button>
144
+ </div>
145
+ </div>
146
+ </div>
147
+ <style jsx>{`
148
+ .navigation-container {
149
+ display: flex;
150
+ justify-content: space-between;
151
+ gap: ${spacers.dp4};
152
+ padding: ${spacers.dp4};
153
+ border-bottom: 1px solid ${wrapperBorderColor};
154
+ background-color: ${headerBackground};
155
+ font-size: 1em;
156
+ width: 100%;
157
+ }
158
+ .month,
159
+ .year {
160
+ display: flex;
161
+ align-items: center;
162
+ justify-content: space-between;
163
+ border: 1px solid ${colors.grey300};
164
+ border-radius: 3px;
165
+ background: ${colors.white};
166
+ }
167
+ .month {
168
+ flex-grow: 1;
169
+ }
170
+ .prev {
171
+ border-inline-end: 1px solid ${colors.grey300};
172
+ }
173
+ .next {
174
+ border-inline-start: 1px solid ${colors.grey300};
175
+ }
176
+ .prev,
177
+ .next,
178
+ .monthList,
179
+ .yearList {
180
+ display: flex;
181
+ align-items: center;
182
+ justify-content: center;
183
+ }
184
+ .prev,
185
+ .next {
186
+ width: 20px;
187
+ flex-shrink: 0;
188
+ }
189
+ .monthList,
190
+ .yearList {
191
+ flex: 0 1 auto;
192
+ overflow: hidden;
193
+ position: relative;
194
+ }
195
+ .monthList {
196
+ flex-grow: 1;
197
+ width: 100%;
198
+ }
199
+ .monthList svg,
200
+ .yearList svg {
201
+ position: absolute;
202
+ inset-inline-end: 0px;
203
+ pointer-events: none;
204
+ }
205
+ button {
206
+ background: none;
207
+ border: 0;
208
+ padding: ${spacers.dp4} 2px;
209
+ height: 24px;
210
+ width: 20px;
211
+ color: ${colors.grey700};
212
+ display: flex;
213
+ align-items: center;
214
+ justify-content: center;
215
+ }
216
+ button:hover {
217
+ background-color: ${colors.grey200};
218
+ color: ${colors.grey900};
219
+ cursor: pointer;
220
+ }
221
+ .month-select,
222
+ .year-select {
223
+ padding-inline-start: 4px;
224
+ padding-inline-end: 16px;
225
+ height: 24px;
226
+ white-space: nowrap;
227
+ overflow: hidden;
228
+ text-align: start;
229
+ width: 100%;
230
+ max-width: 100%;
231
+ border-radius: 0px;
232
+ border: 0;
233
+ color: ${colors.grey800};
234
+ background: none;
235
+ appearance: none;
236
+ }
237
+ .month-select:hover,
238
+ .year-select:hover {
239
+ background: ${colors.grey200};
240
+ cursor: pointer;
241
+ }
242
+ .month-select:focus,
243
+ .month-select-active,
244
+ .year-select:focus,
245
+ .year-select-active {
246
+ background: ${colors.grey200};
247
+ outline-color: ${colors.grey700};
248
+ }
249
+ `}</style>
250
+ </>
251
+ )
252
+ }
253
+
254
+ export const NavigationContainerProps = {
255
+ currMonth: PropTypes.shape({
256
+ label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
257
+ }),
258
+ currYear: PropTypes.shape({
259
+ label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
260
+ value: PropTypes.number,
261
+ }),
262
+ languageDirection: PropTypes.oneOf(['ltr', 'rtl']),
263
+ months: PropTypes.arrayOf(
264
+ PropTypes.shape({
265
+ label: PropTypes.string.isRequired,
266
+ value: PropTypes.number.isRequired,
267
+ })
268
+ ),
269
+ navigateToMonth: PropTypes.func,
270
+ navigateToYear: PropTypes.func,
271
+ nextMonth: PropTypes.shape({
272
+ label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
273
+ navigateTo: PropTypes.func,
274
+ }),
275
+ nextYear: PropTypes.shape({
276
+ label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
277
+ navigateTo: PropTypes.func,
278
+ }),
279
+ prevMonth: PropTypes.shape({
280
+ label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
281
+ navigateTo: PropTypes.func,
282
+ }),
283
+ prevYear: PropTypes.shape({
284
+ label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
285
+ navigateTo: PropTypes.func,
286
+ }),
287
+ years: PropTypes.arrayOf(
288
+ PropTypes.shape({
289
+ label: PropTypes.string.isRequired,
290
+ value: PropTypes.number.isRequired,
291
+ })
292
+ ),
293
+ }
294
+
295
+ NavigationContainer.propTypes = NavigationContainerProps
@@ -0,0 +1,344 @@
1
+ import { Button } from '@dhis2-ui/button'
2
+ import { fireEvent, render, waitFor, within } from '@testing-library/react'
3
+ import React, { useState } from 'react'
4
+ import { Field, Form } from 'react-final-form'
5
+ import { CalendarInput } from '../calendar-input.js'
6
+
7
+ describe('Calendar Input', () => {
8
+ it('allow selection of a date through the calendar widget', async () => {
9
+ jest.useFakeTimers()
10
+ jest.setSystemTime(new Date('2024-10-22T09:05:00.000Z'))
11
+
12
+ const onDateSelectMock = jest.fn()
13
+ const screen = render(
14
+ <CalendarInput calendar="gregory" onDateSelect={onDateSelectMock} />
15
+ )
16
+
17
+ const dateInput = within(
18
+ screen.getByTestId('dhis2-uicore-input')
19
+ ).getByRole('textbox')
20
+
21
+ fireEvent.focus(dateInput)
22
+
23
+ const calendar = await screen.findByTestId('calendar')
24
+ expect(calendar).toBeInTheDocument()
25
+
26
+ const todayString = '2024-10-22'
27
+ const today = within(calendar).getByTestId(todayString)
28
+
29
+ fireEvent.click(today)
30
+
31
+ await waitFor(() => {
32
+ expect(calendar).not.toBeInTheDocument()
33
+ })
34
+ expect(onDateSelectMock).toHaveBeenCalledWith(
35
+ expect.objectContaining({
36
+ calendarDateString: todayString,
37
+ })
38
+ )
39
+
40
+ jest.useRealTimers()
41
+ })
42
+
43
+ it('allow selection of a date through the input', async () => {
44
+ const onDateSelectMock = jest.fn()
45
+ const screen = render(
46
+ <CalendarInput calendar="gregory" onDateSelect={onDateSelectMock} />
47
+ )
48
+
49
+ const dateInputString = '2024/10/12'
50
+ const dateInput = within(
51
+ screen.getByTestId('dhis2-uicore-input')
52
+ ).getByRole('textbox')
53
+
54
+ fireEvent.change(dateInput, { target: { value: dateInputString } })
55
+ fireEvent.blur(dateInput)
56
+
57
+ expect(onDateSelectMock).toHaveBeenCalledWith(
58
+ expect.objectContaining({
59
+ calendarDateString: dateInputString,
60
+ })
61
+ )
62
+ })
63
+
64
+ describe('validation', () => {
65
+ it('should validate minimum date', () => {
66
+ const onDateSelectMock = jest.fn()
67
+ const screen = render(
68
+ <CalendarWithValidation
69
+ calendar="gregory"
70
+ minDate="2024-01-01"
71
+ onDateSelect={onDateSelectMock}
72
+ />
73
+ )
74
+
75
+ const dateInputString = '2023-10-12'
76
+ const dateInput = within(
77
+ screen.getByTestId('dhis2-uicore-input')
78
+ ).getByRole('textbox')
79
+
80
+ fireEvent.change(dateInput, { target: { value: dateInputString } })
81
+ fireEvent.blur(dateInput)
82
+
83
+ expect(
84
+ screen.getByText(
85
+ 'Date 2023-10-12 is less than the minimum allowed date 2024-01-01.'
86
+ )
87
+ ).toBeInTheDocument()
88
+ expect(onDateSelectMock).toHaveBeenCalledTimes(1)
89
+ })
90
+ it('should validate maximum date', () => {
91
+ const { getByTestId, getByText } = render(
92
+ <CalendarWithValidation
93
+ calendar="gregory"
94
+ maxDate="2024-01-01"
95
+ />
96
+ )
97
+
98
+ const dateInputString = '2024-10-12'
99
+ const dateInput = within(
100
+ getByTestId('dhis2-uicore-input')
101
+ ).getByRole('textbox')
102
+
103
+ fireEvent.change(dateInput, { target: { value: dateInputString } })
104
+ fireEvent.blur(dateInput)
105
+
106
+ expect(
107
+ getByText(
108
+ 'Date 2024-10-12 is greater than the maximum allowed date 2024-01-01.'
109
+ )
110
+ ).toBeInTheDocument()
111
+ })
112
+ // Temporarily disabled
113
+ it.skip('should validate date in ethiopic calendar', () => {
114
+ const onDateSelectMock = jest.fn()
115
+ const { getByTestId, getByText, queryByText } = render(
116
+ <CalendarWithValidation
117
+ calendar="ethiopian"
118
+ minDate="2018-13-04"
119
+ onDateSelect={onDateSelectMock}
120
+ />
121
+ )
122
+
123
+ let dateInputString = '2018-13-02'
124
+ const dateInput = within(
125
+ getByTestId('dhis2-uicore-input')
126
+ ).getByRole('textbox')
127
+
128
+ fireEvent.change(dateInput, { target: { value: dateInputString } })
129
+ fireEvent.blur(dateInput)
130
+
131
+ expect(
132
+ getByTestId('dhis2-uiwidgets-calendar-inputfield-validation')
133
+ ).toBeInTheDocument()
134
+ expect(
135
+ getByText(
136
+ 'Date 2018-13-02 is less than the minimum allowed date 2018-13-04.'
137
+ )
138
+ ).toBeInTheDocument()
139
+
140
+ dateInputString = '2018-13-05'
141
+ fireEvent.change(dateInput, { target: { value: dateInputString } })
142
+ fireEvent.blur(dateInput)
143
+
144
+ expect(
145
+ queryByText(
146
+ 'Date 2018-13-04 is less than the minimum allowed date 2018-13-05.'
147
+ )
148
+ ).not.toBeInTheDocument()
149
+
150
+ dateInputString = '2018-13-07'
151
+ fireEvent.change(dateInput, { target: { value: dateInputString } })
152
+ fireEvent.blur(dateInput)
153
+
154
+ expect(
155
+ getByText('Invalid date in specified calendar')
156
+ ).toBeInTheDocument()
157
+ })
158
+ // ToDo: these scenarios seem to work but they timeout on CI sporadically - ticket: https://dhis2.atlassian.net/browse/LIBS-763
159
+ it('should validate date in nepali calendar', () => {
160
+ const onDateSelectMock = jest.fn()
161
+ const { getByTestId, getByText, queryByText } = render(
162
+ <CalendarWithValidation
163
+ calendar="nepali"
164
+ maxDate="2080-05-30"
165
+ onDateSelect={onDateSelectMock}
166
+ />
167
+ )
168
+
169
+ let dateInputString = '2080-06-01'
170
+ const dateInput = within(
171
+ getByTestId('dhis2-uicore-input')
172
+ ).getByRole('textbox')
173
+
174
+ fireEvent.change(dateInput, { target: { value: dateInputString } })
175
+ fireEvent.blur(dateInput)
176
+
177
+ expect(
178
+ getByText(
179
+ 'Date 2080-06-01 is greater than the maximum allowed date 2080-05-30.'
180
+ )
181
+ ).toBeInTheDocument()
182
+
183
+ dateInputString = '2080-04-32'
184
+ fireEvent.change(dateInput, { target: { value: dateInputString } })
185
+ fireEvent.blur(dateInput)
186
+
187
+ expect(
188
+ queryByText(/greater than the maximum allowed date/)
189
+ ).not.toBeInTheDocument()
190
+
191
+ dateInputString = '2080-01-32'
192
+ fireEvent.change(dateInput, { target: { value: dateInputString } })
193
+ fireEvent.blur(dateInput)
194
+
195
+ expect(
196
+ getByText('Invalid date in specified calendar')
197
+ ).toBeInTheDocument()
198
+ })
199
+ it('should validate from date picker', () => {
200
+ jest.useFakeTimers()
201
+ jest.setSystemTime(new Date('2024-10-22T09:05:00.000Z'))
202
+
203
+ const onDateSelectMock = jest.fn()
204
+ const { queryByText, getByText, getByTestId } = render(
205
+ <CalendarWithValidation
206
+ calendar="gregory"
207
+ minDate="2024-02-16"
208
+ onDateSelect={onDateSelectMock}
209
+ />
210
+ )
211
+
212
+ const dateInput = within(
213
+ getByTestId('dhis2-uicore-input')
214
+ ).getByRole('textbox')
215
+
216
+ fireEvent.focusIn(dateInput)
217
+ fireEvent.click(getByText('17'))
218
+
219
+ expect(queryByText('17')).not.toBeInTheDocument()
220
+
221
+ // Checking fix for Bug where callback used to be called twice - first with undefined
222
+ expect(onDateSelectMock).toHaveBeenCalledTimes(1)
223
+ expect(onDateSelectMock).toHaveBeenCalledWith({
224
+ calendarDateString: '2024-10-17',
225
+ validation: { error: false, valid: true, warning: false },
226
+ })
227
+
228
+ jest.useRealTimers()
229
+ })
230
+
231
+ it('should validate with Clear', () => {
232
+ const onDateSelectMock = jest.fn()
233
+ const { queryByText, getByText, getByTestId } = render(
234
+ <CalendarWithValidation
235
+ calendar="gregory"
236
+ minDate="2024-02-16"
237
+ onDateSelect={onDateSelectMock}
238
+ clearable
239
+ />
240
+ )
241
+
242
+ const dateInputString = '2023-10-12'
243
+ const dateInput = within(
244
+ getByTestId('dhis2-uicore-input')
245
+ ).getByRole('textbox')
246
+
247
+ fireEvent.change(dateInput, { target: { value: dateInputString } })
248
+ fireEvent.blur(dateInput)
249
+
250
+ expect(
251
+ getByTestId('dhis2-uiwidgets-calendar-inputfield-validation')
252
+ ).toBeInTheDocument()
253
+
254
+ fireEvent.click(getByText('Clear'))
255
+ expect(queryByText('17')).not.toBeInTheDocument()
256
+
257
+ expect(onDateSelectMock).toHaveBeenLastCalledWith({
258
+ calendarDateString: null,
259
+ validation: { valid: true },
260
+ })
261
+ })
262
+
263
+ it('should validate when Clearing manually (i.e. deleting text not using clear button)', () => {
264
+ const onDateSelectMock = jest.fn()
265
+ const { getByTestId } = render(
266
+ <CalendarWithValidation
267
+ calendar="gregory"
268
+ minDate="2024-02-16"
269
+ onDateSelect={onDateSelectMock}
270
+ clearable
271
+ />
272
+ )
273
+
274
+ const dateInputString = '2023-10-12'
275
+ const dateInput = within(
276
+ getByTestId('dhis2-uicore-input')
277
+ ).getByRole('textbox')
278
+
279
+ fireEvent.change(dateInput, { target: { value: dateInputString } })
280
+ fireEvent.blur(dateInput)
281
+
282
+ expect(
283
+ getByTestId('dhis2-uiwidgets-calendar-inputfield-validation')
284
+ ).toBeInTheDocument()
285
+
286
+ fireEvent.change(dateInput, { target: { value: '' } })
287
+ fireEvent.blur(dateInput)
288
+
289
+ expect(onDateSelectMock).toHaveBeenCalledWith({
290
+ calendarDateString: null,
291
+ validation: { valid: true },
292
+ })
293
+ })
294
+ })
295
+ })
296
+
297
+ const CalendarWithValidation = (propsFromParent) => {
298
+ const [date, setDate] = useState()
299
+
300
+ const [validation, setValidation] = useState({})
301
+
302
+ const errored = () => {
303
+ if (validation?.error) {
304
+ return { calendar: validation.validationText }
305
+ }
306
+ }
307
+
308
+ return (
309
+ <Form onSubmit={() => {}} validate={errored}>
310
+ {({ handleSubmit, invalid }) => {
311
+ return (
312
+ <form>
313
+ <Field name="calendar">
314
+ {(props) => (
315
+ <CalendarInput
316
+ {...props}
317
+ date={date}
318
+ label="Enter a date"
319
+ editable
320
+ calendar="gregory"
321
+ {...validation}
322
+ {...propsFromParent}
323
+ onDateSelect={(date) => {
324
+ setDate(date?.calendarDateString)
325
+ setValidation(date?.validation)
326
+ propsFromParent.onDateSelect?.(date)
327
+ }}
328
+ />
329
+ )}
330
+ </Field>
331
+
332
+ <Button
333
+ type="submit"
334
+ disabled={invalid}
335
+ onClick={handleSubmit}
336
+ >
337
+ Submit
338
+ </Button>
339
+ </form>
340
+ )
341
+ }}
342
+ </Form>
343
+ )
344
+ }