diff --git a/e2e/tests/plugin-form-Simple.spec.ts b/e2e/tests/plugin-form-Simple.spec.ts index 8010b4f63..d583f0d92 100755 --- a/e2e/tests/plugin-form-Simple.spec.ts +++ b/e2e/tests/plugin-form-Simple.spec.ts @@ -42,7 +42,7 @@ test('Simple form', async ({ page }) => { }) await test.step('Fill out date field', async () => { - await page.getByLabel('date').fill('2023-01-01T13:00') + await page.getByLabel('date').fill('01/01/2022') }) await test.step('Submitting form', async () => { @@ -63,6 +63,6 @@ test('Simple form', async ({ page }) => { await expect( page.getByLabel('A required checkbox (e.g. for confirmation purposes)') ).toBeChecked() - await expect(page.getByLabel('date')).toHaveValue('2023-01-01T13:00') + await expect(page.getByLabel('date')).toHaveValue('01/01/2022') }) }) diff --git a/example/index.html b/example/index.html index 25c030d2b..9042b1be2 100644 --- a/example/index.html +++ b/example/index.html @@ -1,22 +1,22 @@ - + - - - + + + - React App - - - -
- - + dm-core-packages + + + +
+ + diff --git a/example/package.json b/example/package.json index 54651b421..ebb9f2608 100644 --- a/example/package.json +++ b/example/package.json @@ -13,6 +13,7 @@ "react-router-dom": "6.15.0", "react-toastify": "9.1.3", "styled-components": "^5.3.11", + "ts-node": "^10.9.1", "tsconfig-paths-webpack-plugin": "^4.0.0" }, "devDependencies": { diff --git a/example/postcss.config.js b/example/postcss.config.js new file mode 100644 index 000000000..33ad091d2 --- /dev/null +++ b/example/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/example/tailwind.config.js b/example/tailwind.config.js new file mode 100644 index 000000000..b26f20a79 --- /dev/null +++ b/example/tailwind.config.js @@ -0,0 +1,22 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + './index.html', + './src/**/*.{ts,js,tsx,jsx}', + '../packages/dm-core/src/**/*.{ts,js,tsx,jsx}', + '../packages/dm-core-plugins/src/**/*.{ts,js,tsx,jsx}', + ], + theme: { + fontFamily: { + sans: ['Equinor', 'sans-serif'], + }, + extend: { + colors: { + current: 'currentColor', + 'equinor-green': '#007079', + 'equinor-green-light': 'rgba(230, 250, 236, 1)', + }, + }, + }, + plugins: [], +} diff --git a/packages/dm-core-plugins/src/form/widgets/DateTimeWidget.tsx b/packages/dm-core-plugins/src/form/widgets/DateTimeWidget.tsx index 2854043ce..b22071db0 100644 --- a/packages/dm-core-plugins/src/form/widgets/DateTimeWidget.tsx +++ b/packages/dm-core-plugins/src/form/widgets/DateTimeWidget.tsx @@ -1,36 +1,22 @@ import React from 'react' -import { TextField } from '@equinor/eds-core-react' - import { TWidget } from '../types' -import { DateTime } from 'luxon' +import { Datepicker } from '@development-framework/dm-core' const DateTimeWidget = (props: TWidget) => { - const { label, onChange, isDirty } = props - const onChangeHandler = (event: React.ChangeEvent) => { - onChange?.(new Date(event.target.value).toISOString()) - } + const { label, onChange, isDirty, id, value, readOnly, helperText } = props return ( - ) } diff --git a/packages/dm-core-plugins/src/job/DateRangePicker.tsx b/packages/dm-core-plugins/src/job/DateRangePicker.tsx index 19096c772..f3094b6f5 100644 --- a/packages/dm-core-plugins/src/job/DateRangePicker.tsx +++ b/packages/dm-core-plugins/src/job/DateRangePicker.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { TextField } from '@equinor/eds-core-react' +import { Datepicker } from '@development-framework/dm-core' const DateRangePicker = (props: { setDateRange: (dateRange: { startDate: string; endDate: string }) => void @@ -15,23 +15,19 @@ const DateRangePicker = (props: { gap: '.5rem', }} > - - setDateRange({ ...value, startDate: e.target.value }) - } - label={'Valid from'} + setDateRange({ ...value, start: date })} + label='Valid from' /> - - setDateRange({ ...value, endDate: e.target.value }) - } - label={'Valid to'} + setDateRange({ ...value, end: date })} + label='Valid to' /> ) diff --git a/packages/dm-core-plugins/tailwind.config.js b/packages/dm-core-plugins/tailwind.config.js new file mode 100644 index 000000000..2d952958f --- /dev/null +++ b/packages/dm-core-plugins/tailwind.config.js @@ -0,0 +1,16 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['./src/**/*.{ts,js,tsx,jsx}'], + theme: { + fontFamily: { + sans: ['Equinor', 'sans-serif'], + }, + extend: { + colors: { + current: 'currentColor', + 'equinor-green': '#007079', + }, + }, + }, + plugins: [], +} diff --git a/packages/dm-core/package.json b/packages/dm-core/package.json index 9e00b1cd4..a6e0cc8c1 100755 --- a/packages/dm-core/package.json +++ b/packages/dm-core/package.json @@ -18,6 +18,7 @@ "@equinor/eds-tokens": "^0.9.0", "axios": "^1.4.0", "dompurify": "^3.0.6", + "lodash.debounce": "^4.0.8", "luxon": "^3.4.3", "react-icons": "4.10.1", "react-oauth2-code-pkce": "^1.10.1", @@ -29,6 +30,7 @@ "@testing-library/react": "^14.0.0", "@types/dompurify": "^3.0.4", "@types/jest": "^29.5.3", + "@types/lodash.debounce": "^4.0.9", "@types/luxon": "^3.3.3", "@types/node": "^20.10.0", "@types/react-dom": "^18.2.7", diff --git a/packages/dm-core/src/components/Pickers/Datepicker/Calendar.tsx b/packages/dm-core/src/components/Pickers/Datepicker/Calendar.tsx new file mode 100644 index 000000000..24b2c9535 --- /dev/null +++ b/packages/dm-core/src/components/Pickers/Datepicker/Calendar.tsx @@ -0,0 +1,178 @@ +import React, { ReactElement, useState } from 'react' +import { + calendar, + CALENDAR_MONTHS, + DateSelection, + getNextMonth, + getPreviousMonth, + isSameDay, + isSameMonth, + THIS_MONTH, + THIS_YEAR, +} from './calendarUtils' +import { Icon } from '@equinor/eds-core-react' +import { + calendar_today, + chevron_down, + chevron_left, + chevron_right, +} from '@equinor/eds-icons' +import { DateTime } from 'luxon' + +interface CalendarProps { + dateTime: DateTime + handleDateSelection: (selection: DateSelection) => void +} + +export const Calendar = (props: CalendarProps): ReactElement => { + const { dateTime, handleDateSelection } = props + const [showMonthPicker, setShowMonthPicker] = useState(false) + const [activeMonth, setActiveMonth] = useState(THIS_MONTH) + const [activeYear, setActiveYear] = useState(THIS_YEAR) + const cal = calendar(activeMonth, activeYear) + + const currentMonthName = Object.keys(CALENDAR_MONTHS)[activeMonth - 1] + + function incrementMonth(): void { + const nextMonth = getNextMonth(activeMonth, activeYear) + setActiveMonth(nextMonth.month) + setActiveYear(nextMonth.year) + } + + function decrementMonth(): void { + const prevMonth = getPreviousMonth(activeMonth, activeYear) + setActiveMonth(prevMonth.month) + setActiveYear(prevMonth.year) + } + + function goToToday(): void { + const now = DateTime.now() + handleDateSelection({ day: now.day, month: now.month, year: now.year }) + setActiveMonth(THIS_MONTH) + setActiveYear(THIS_YEAR) + } + + return ( +
+
+ +
+ + + + +
+
+ {showMonthPicker ? ( + <> +
+ Year + setActiveYear(Number(event.target.value))} + /> +
+
+ Month +
+ {Object.keys(CALENDAR_MONTHS).map((month, index) => ( + + ))} +
+
+ + ) : ( +
+ {cal.map((date, index) => ( + + ))} +
+ )} +
+ ) +} diff --git a/packages/dm-core/src/components/Pickers/Datepicker/Datepicker.tsx b/packages/dm-core/src/components/Pickers/Datepicker/Datepicker.tsx new file mode 100644 index 000000000..f3768a9ab --- /dev/null +++ b/packages/dm-core/src/components/Pickers/Datepicker/Datepicker.tsx @@ -0,0 +1,207 @@ +import React, { ReactElement, useEffect, useRef, useState } from 'react' +import { Icon, InputWrapper } from '@equinor/eds-core-react' +import { Calendar } from './Calendar' +import { Timefield } from './Timefield' +import { useClickOutside } from '../../../hooks/useClickOutside' +import { DateTime } from 'luxon' +import { calendar } from '@equinor/eds-icons' +import { DateSelection, zeroPad } from './calendarUtils' +import { extractDateComponents } from './datepickerUtils' + +interface DatepickerProps { + id: string + readonly?: boolean + label?: string + variant: 'date' | 'datetime' + value: string // ISO + onChange: (date: string) => unknown + useMinutes?: boolean + helperText?: string + isDirty?: boolean +} + +export const Datepicker = (props: DatepickerProps): ReactElement => { + const { + variant, + value: selectedDate, + useMinutes, + onChange, + id, + readonly, + label, + helperText, + isDirty, + } = props + const [open, setOpen] = useState(false) + const datepickerRef = useRef(null) + const [datetime, setDatetime] = useState( + DateTime.fromISO(selectedDate).toUTC() + ) + const [fieldDateValue, setFieldDateValue] = useState( + datetime.toFormat('dd/MM/yyyy') ?? 'dd/mm/yyyy' + ) + const [fieldTimeValue, setFieldTimeValue] = useState( + datetime.toFormat('HH:mm') ?? '--:--' + ) + + useEffect(() => { + onChange(datetime.toISO() ?? '') + }, [datetime]) + + useClickOutside(datepickerRef, () => { + open && setOpen(false) + }) + + function handleDateInput(dateInput: string): void { + if (dateInput) { + const { day, month, year, max } = extractDateComponents(dateInput) + const convertedDate = DateTime.utc(year, month, day) + if (!convertedDate.invalidExplanation) { + setDatetime( + datetime.set({ + year: convertedDate.year, + month: convertedDate.month, + day: convertedDate.day, + }) + ) + } + if (dateInput.length <= max) setFieldDateValue(dateInput) + } else setFieldDateValue(String(dateInput)) + } + + function handleDateSelection(selection: DateSelection): void { + setDatetime(datetime.set(selection)) + setFieldDateValue(`${selection.day}/${selection.month}/${selection.year}`) + } + + function formatDate(date: string): void { + if (date && date !== 'dd/mm/yyyy') { + const { day, month, year } = extractDateComponents(date) + setFieldDateValue(`${zeroPad(day, 2)}/${zeroPad(month, 2)}/${year}`) + } else { + setFieldDateValue('dd/mm/yyyy') + } + } + + function handleTimeInput(timeInput: string): void { + const length = timeInput.length + + if (length > fieldTimeValue.length && length + 1 === 3) { + if (!timeInput.includes(':')) timeInput = timeInput + ':' + } + + if (length < 6) { + setFieldTimeValue(timeInput) + const [hour, minute] = timeInput.split(':') + + if ( + Number(hour) >= 0 && + Number(hour) <= 23 && + Number(minute) >= 0 && + Number(minute) <= 59 + ) { + setDatetime( + datetime.set({ + hour: Number(hour), + minute: useMinutes ? Number(minute) : 0, + }) + ) + } + } + } + function formatTime(time: string): void { + if (time.length === 0) setFieldTimeValue('--:--') + if (!time.includes(':')) { + const hour = Number(time.slice(0, 1)) + const min = Number(time.slice(2, 3)) + setFieldTimeValue( + `${zeroPad(hour, 2)}:${zeroPad(useMinutes ? min : 0, 2)}` + ) + } else { + const [hour, min] = time.split(':') + setFieldTimeValue( + `${zeroPad(Number(hour), 2)}:${zeroPad( + Number(useMinutes ? min : 0), + 2 + )}` + ) + } + } + + return ( +
+ +
(!readonly ? setOpen(!open) : null)} + > + handleDateInput(e.target.value)} + onBlur={(e) => formatDate(e.target.value)} + onFocus={() => (open ? setOpen(true) : null)} + className='h-full bg-transparent appearance-none w-24' + /> + (open ? setOpen(true) : null)} + value={fieldTimeValue} + onChange={(e: any) => handleTimeInput(e.target.value)} + onBlur={(e) => formatTime(e.target.value)} + /> + +
+
+ {open && ( +
+ +
+ {variant === 'datetime' && ( + <> + + + + Note: + {' '} + This datepicker uses UTC timing + + + )} +
+ )} +
+ ) +} diff --git a/packages/dm-core/src/components/Pickers/Datepicker/Timefield.tsx b/packages/dm-core/src/components/Pickers/Datepicker/Timefield.tsx new file mode 100644 index 000000000..2523326e4 --- /dev/null +++ b/packages/dm-core/src/components/Pickers/Datepicker/Timefield.tsx @@ -0,0 +1,29 @@ +import React, { Dispatch, ReactElement, SetStateAction } from 'react' +import { DateTime } from 'luxon' + +interface TimefieldProps { + datetime: DateTime + setDateTime: Dispatch> + useMinutes?: boolean + timeFieldValue: string + handleTimeFieldChange: (time: string) => void +} + +export const Timefield = (props: TimefieldProps): ReactElement => { + const { useMinutes, timeFieldValue, handleTimeFieldChange } = props + + return ( +
+ + handleTimeFieldChange(e.target.value)} + /> +
+ ) +} diff --git a/packages/dm-core/src/components/Pickers/Datepicker/calendarUtils.ts b/packages/dm-core/src/components/Pickers/Datepicker/calendarUtils.ts new file mode 100644 index 000000000..8848635ad --- /dev/null +++ b/packages/dm-core/src/components/Pickers/Datepicker/calendarUtils.ts @@ -0,0 +1,135 @@ +import { DateTime } from 'luxon' + +export interface DateSelection { + day: number + month: number + year: number +} + +const now = DateTime.now() +export const THIS_YEAR: number = now.year +export const THIS_MONTH: number = now.month +export const CALENDAR_MONTHS = { + January: 'Jan', + February: 'Feb', + March: 'Mar', + April: 'Apr', + May: 'May', + June: 'Jun', + July: 'Jul', + August: 'Aug', + September: 'Sep', + October: 'Oct', + November: 'Nov', + December: 'Dec', +} +export const CALENDAR_WEEKS = 6 +export const zeroPad = (value: number, length: number): string => { + return `${value}`.padStart(length, '0') +} + +export const getMonthDays = ( + month: number = THIS_MONTH, + year: number = THIS_YEAR +) => { + const months30 = [4, 6, 9, 11] + const leapYear = year % 4 === 0 + return month === 2 ? (leapYear ? 29 : 28) : months30.includes(month) ? 30 : 31 +} + +export const getMonthFirstDay = (month = THIS_MONTH, year = THIS_YEAR) => { + return new Date(`${year}-${zeroPad(month, 2)}-01`).getDay() + 1 +} + +export const isDate = (date: Date): boolean => { + const isDate = Object.prototype.toString.call(date) === '[object Date]' + const isValidDate = date && !Number.isNaN(date.valueOf()) + + return isDate && isValidDate +} + +export const isSameMonth = ( + date: Date, + basedate: Date = new Date() +): boolean => { + if (!(isDate(date) && isDate(basedate))) return false + const basedateMonth = basedate.getMonth() + 1 + const basedateYear = basedate.getFullYear() + const dateMonth = date.getMonth() + 1 + const dateYear = date.getFullYear() + return basedateMonth === dateMonth && basedateYear === dateYear +} + +export const isSameDay = (date: Date, basedate: Date = new Date()): boolean => { + if (!(isDate(date) && isDate(basedate))) return false + const baseDate = basedate.getDate() + const baseMonth = basedate.getMonth() + 1 + const baseYear = basedate.getFullYear() + const dateDate = date.getDate() + const dateMonth = date.getMonth() + 1 + const dateYear = date.getFullYear() + return ( + baseDate === dateDate && baseMonth === dateMonth && baseYear === dateYear + ) +} + +export const getPreviousMonth = (month: number, year: number) => { + const prevMonth = month > 1 ? month - 1 : 12 + const prevMonthYear = month > 1 ? year : year - 1 + return { month: prevMonth, year: prevMonthYear } +} + +export const getNextMonth = (month: number, year: number) => { + const nextMonth = month < 12 ? month + 1 : 1 + const nextMonthYear = month < 12 ? year : year + 1 + return { month: nextMonth, year: nextMonthYear } +} +// Calendar builder for a month in the specified year +// Returns an array of the calendar dates. +// Each calendar date is represented as an array => [YYYY, MM, DD] +export const calendar = ( + month: number = THIS_MONTH, + year: number = THIS_YEAR +): DateSelection[] => { + // Get number of days in the month and the month's first day + + const monthDays = getMonthDays(month, year) + const monthFirstDay = getMonthFirstDay(month, year) + // Get number of days to be displayed from previous and next months + // These ensure a total of 42 days (6 weeks) displayed on the calendar + + const daysFromPrevMonth = monthFirstDay - 1 + const daysFromNextMonth = CALENDAR_WEEKS * 7 - (daysFromPrevMonth + monthDays) + // Get the previous and next months and years + + const { month: prevMonth, year: prevMonthYear } = getPreviousMonth( + month, + year + ) + const { month: nextMonth, year: nextMonthYear } = getNextMonth(month, year) + // Get number of days in previous month + const prevMonthDays = getMonthDays(prevMonth, prevMonthYear) + // Builds dates to be displayed from previous month + + const prevMonthDates = [...new Array(daysFromPrevMonth)].map((n, index) => { + const day = index + 1 + (prevMonthDays - daysFromPrevMonth) + // return [ prevMonthYear, zeroPad(prevMonth, 2), zeroPad(day, 2) ] + return { day: day, month: prevMonthDays, year: prevMonthYear } + }) + // Builds dates to be displayed from current month + + const thisMonthDates = [...new Array(monthDays)].map((n, index) => { + const day = index + 1 + // return [ year, zeroPad(month, 2), zeroPad(day, 2) ] + return { day: day, month: month, year: year } + }) + // Builds dates to be displayed from next month + + const nextMonthDates = [...new Array(daysFromNextMonth)].map((n, index) => { + const day = index + 1 + // return [ nextMonthYear, zeroPad(nextMonth, 2), zeroPad(day, 2) ] + return { day: day, month: nextMonth, year: nextMonthYear } + }) + // Combines all dates from previous, current and next months + return [...prevMonthDates, ...thisMonthDates, ...nextMonthDates] +} diff --git a/packages/dm-core/src/components/Pickers/Datepicker/datepickerUtils.tsx b/packages/dm-core/src/components/Pickers/Datepicker/datepickerUtils.tsx new file mode 100644 index 000000000..3680749f3 --- /dev/null +++ b/packages/dm-core/src/components/Pickers/Datepicker/datepickerUtils.tsx @@ -0,0 +1,29 @@ +export function extractDateComponents(dateString: string): { + day: number + month: number + year: number + max: number +} { + let day: number + let month: number + let year: number + let max: number + if (dateString?.includes('/')) { + const [d, m, y] = dateString.split('/') + day = Number(d) + month = Number(m) + year = Number(y) + max = 10 + } else { + day = Number(dateString?.slice(0, 2)) + month = Number(dateString?.slice(2, 4)) + year = Number(dateString?.slice(4, 8)) + max = 8 + } + return { + day, + month, + year, + max, + } +} diff --git a/packages/dm-core/src/components/Pickers/Datepicker/index.tsx b/packages/dm-core/src/components/Pickers/Datepicker/index.tsx new file mode 100644 index 000000000..90f518931 --- /dev/null +++ b/packages/dm-core/src/components/Pickers/Datepicker/index.tsx @@ -0,0 +1 @@ +export * from './Datepicker' diff --git a/packages/dm-core/src/components/Pickers/index.tsx b/packages/dm-core/src/components/Pickers/index.tsx index 2b586c502..6fc19c514 100644 --- a/packages/dm-core/src/components/Pickers/index.tsx +++ b/packages/dm-core/src/components/Pickers/index.tsx @@ -3,3 +3,4 @@ export * from './DestinationPicker' export * from './EntityPickerDialog' export * from './EntityPickerDropdown' export * from './JobHandlerPicker' +export * from './Datepicker' diff --git a/packages/dm-core/src/hooks/useClickOutside.tsx b/packages/dm-core/src/hooks/useClickOutside.tsx new file mode 100644 index 000000000..268531b6b --- /dev/null +++ b/packages/dm-core/src/hooks/useClickOutside.tsx @@ -0,0 +1,21 @@ +import { MutableRefObject, useEffect } from 'react' + +export function useClickOutside( + elementRef: MutableRefObject, + callback: () => any +): any { + useEffect(() => { + const handleClickOutside = (event: Event) => { + if ( + elementRef.current && + !elementRef.current.contains(event.target as HTMLElement) + ) { + callback() + } + } + document.addEventListener('click', handleClickOutside, true) + return () => { + document.removeEventListener('click', handleClickOutside, true) + } + }, [elementRef]) +} diff --git a/packages/dm-core/tailwind.config.js b/packages/dm-core/tailwind.config.js new file mode 100644 index 000000000..2d952958f --- /dev/null +++ b/packages/dm-core/tailwind.config.js @@ -0,0 +1,16 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['./src/**/*.{ts,js,tsx,jsx}'], + theme: { + fontFamily: { + sans: ['Equinor', 'sans-serif'], + }, + extend: { + colors: { + current: 'currentColor', + 'equinor-green': '#007079', + }, + }, + }, + plugins: [], +}