vmui: improve usability of date/time picker (#3968)

* vmui: allow manually set input date and time
* vmui/docs: improve usability of date/time picker
This commit is contained in:
Yury Molodov 2023-03-20 09:22:49 +01:00 committed by Aliaksandr Valialkin
parent 693a3de0a6
commit b66953d8e1
No known key found for this signature in database
GPG key ID: A72BEC6CD3D0DED1
14 changed files with 352 additions and 320 deletions

View file

@ -18,6 +18,7 @@
"@types/marked": "^4.0.2", "@types/marked": "^4.0.2",
"@types/node": "^17.0.21", "@types/node": "^17.0.21",
"@types/qs": "^6.9.7", "@types/qs": "^6.9.7",
"@types/react-input-mask": "^3.0.2",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@types/webpack-env": "^1.16.3", "@types/webpack-env": "^1.16.3",
"classnames": "^2.3.2", "classnames": "^2.3.2",
@ -28,6 +29,7 @@
"marked": "^4.0.14", "marked": "^4.0.14",
"preact": "^10.7.1", "preact": "^10.7.1",
"qs": "^6.10.3", "qs": "^6.10.3",
"react-input-mask": "^2.0.4",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.3.0",
"sass": "^1.56.0", "sass": "^1.56.0",
"typescript": "~4.6.2", "typescript": "~4.6.2",
@ -4392,6 +4394,14 @@
"@types/react": "*" "@types/react": "*"
} }
}, },
"node_modules/@types/react-input-mask": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/react-input-mask/-/react-input-mask-3.0.2.tgz",
"integrity": "sha512-WTli3kUyvUqqaOLYG/so2pLqUvRb+n4qnx2He5klfqZDiQmRyD07jVIt/bco/1BrcErkPMtpOm+bHii4Oed6cQ==",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-router": { "node_modules/@types/react-router": {
"version": "5.1.20", "version": "5.1.20",
"resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz",
@ -10236,6 +10246,14 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.0.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.0.1.tgz",
@ -16403,6 +16421,19 @@
"dev": true, "dev": true,
"peer": true "peer": true
}, },
"node_modules/react-input-mask": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/react-input-mask/-/react-input-mask-2.0.4.tgz",
"integrity": "sha512-1hwzMr/aO9tXfiroiVCx5EtKohKwLk/NT8QlJXHQ4N+yJJFyUuMT+zfTpLBwX/lK3PkuMlievIffncpMZ3HGRQ==",
"dependencies": {
"invariant": "^2.2.4",
"warning": "^4.0.2"
},
"peerDependencies": {
"react": ">=0.14.0",
"react-dom": ">=0.14.0"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "17.0.2", "version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@ -18811,6 +18842,14 @@
"makeerror": "1.0.12" "makeerror": "1.0.12"
} }
}, },
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/watchpack": { "node_modules/watchpack": {
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
@ -18851,9 +18890,9 @@
} }
}, },
"node_modules/webpack": { "node_modules/webpack": {
"version": "5.75.0", "version": "5.76.2",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.75.0.tgz", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.76.2.tgz",
"integrity": "sha512-piaIaoVJlqMsPtX/+3KTTO6jfvrSYgauFVdt8cr9LTHKmcq/AMd4mhzsiP7ZF/PGRNPGA8336jldh9l2Kt2ogQ==", "integrity": "sha512-Th05ggRm23rVzEOlX8y67NkYCHa9nTNcwHPBhdg+lKG+mtiW7XgggjAeeLnADAe7mLjJ6LUNfgHAuRRh+Z6J7w==",
"dev": true, "dev": true,
"peer": true, "peer": true,
"dependencies": { "dependencies": {

View file

@ -14,6 +14,7 @@
"@types/marked": "^4.0.2", "@types/marked": "^4.0.2",
"@types/node": "^17.0.21", "@types/node": "^17.0.21",
"@types/qs": "^6.9.7", "@types/qs": "^6.9.7",
"@types/react-input-mask": "^3.0.2",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@types/webpack-env": "^1.16.3", "@types/webpack-env": "^1.16.3",
"classnames": "^2.3.2", "classnames": "^2.3.2",
@ -24,6 +25,7 @@
"marked": "^4.0.14", "marked": "^4.0.14",
"preact": "^10.7.1", "preact": "^10.7.1",
"qs": "^6.10.3", "qs": "^6.10.3",
"react-input-mask": "^2.0.4",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.3.0",
"sass": "^1.56.0", "sass": "^1.56.0",
"typescript": "~4.6.2", "typescript": "~4.6.2",

View file

@ -1,7 +1,7 @@
@use "src/styles/variables" as *; @use "src/styles/variables" as *;
.vm-time-duration { .vm-time-duration {
max-height: 200px; max-height: 227px;
overflow: auto; overflow: auto;
font-size: $font-size; font-size: $font-size;

View file

@ -4,18 +4,18 @@ import TimeDurationSelector from "../TimeDurationSelector/TimeDurationSelector";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { getAppModeEnable } from "../../../../utils/app-mode"; import { getAppModeEnable } from "../../../../utils/app-mode";
import { useTimeDispatch, useTimeState } from "../../../../state/time/TimeStateContext"; import { useTimeDispatch, useTimeState } from "../../../../state/time/TimeStateContext";
import { AlarmIcon, ArrowDownIcon, CalendarIcon, ClockIcon } from "../../../Main/Icons"; import { AlarmIcon, ArrowDownIcon, ClockIcon } from "../../../Main/Icons";
import Button from "../../../Main/Button/Button"; import Button from "../../../Main/Button/Button";
import Popper from "../../../Main/Popper/Popper"; import Popper from "../../../Main/Popper/Popper";
import Tooltip from "../../../Main/Tooltip/Tooltip"; import Tooltip from "../../../Main/Tooltip/Tooltip";
import { DATE_TIME_FORMAT } from "../../../../constants/date"; import { DATE_TIME_FORMAT } from "../../../../constants/date";
import useResize from "../../../../hooks/useResize"; import useResize from "../../../../hooks/useResize";
import DatePicker from "../../../Main/DatePicker/DatePicker";
import "./style.scss"; import "./style.scss";
import useClickOutside from "../../../../hooks/useClickOutside"; import useClickOutside from "../../../../hooks/useClickOutside";
import classNames from "classnames"; import classNames from "classnames";
import { useAppState } from "../../../../state/common/StateContext"; import { useAppState } from "../../../../state/common/StateContext";
import useDeviceDetect from "../../../../hooks/useDeviceDetect"; import useDeviceDetect from "../../../../hooks/useDeviceDetect";
import DateTimeInput from "../../../Main/DatePicker/DateTimeInput/DateTimeInput";
export const TimeSelector: FC = () => { export const TimeSelector: FC = () => {
const { isMobile } = useDeviceDetect(); const { isMobile } = useDeviceDetect();
@ -27,9 +27,6 @@ export const TimeSelector: FC = () => {
const [until, setUntil] = useState<string>(); const [until, setUntil] = useState<string>();
const [from, setFrom] = useState<string>(); const [from, setFrom] = useState<string>();
const formFormat = useMemo(() => dayjs.tz(from).format(DATE_TIME_FORMAT), [from]);
const untilFormat = useMemo(() => dayjs.tz(until).format(DATE_TIME_FORMAT), [until]);
const { period: { end, start }, relativeTime, timezone, duration } = useTimeState(); const { period: { end, start }, relativeTime, timezone, duration } = useTimeState();
const dispatch = useTimeDispatch(); const dispatch = useTimeDispatch();
const appModeEnable = getAppModeEnable(); const appModeEnable = getAppModeEnable();
@ -55,10 +52,7 @@ export const TimeSelector: FC = () => {
const formatRange = useMemo(() => { const formatRange = useMemo(() => {
const startFormat = dayjs.tz(dateFromSeconds(start)).format(DATE_TIME_FORMAT); const startFormat = dayjs.tz(dateFromSeconds(start)).format(DATE_TIME_FORMAT);
const endFormat = dayjs.tz(dateFromSeconds(end)).format(DATE_TIME_FORMAT); const endFormat = dayjs.tz(dateFromSeconds(end)).format(DATE_TIME_FORMAT);
return { return { start: startFormat, end: endFormat };
start: startFormat,
end: endFormat
};
}, [start, end, timezone]); }, [start, end, timezone]);
const dateTitle = useMemo(() => { const dateTitle = useMemo(() => {
@ -66,8 +60,6 @@ export const TimeSelector: FC = () => {
return isRelativeTime ? relativeTime.replace(/_/g, " ") : `${formatRange.start} - ${formatRange.end}`; return isRelativeTime ? relativeTime.replace(/_/g, " ") : `${formatRange.start} - ${formatRange.end}`;
}, [relativeTime, formatRange]); }, [relativeTime, formatRange]);
const fromRef = useRef<HTMLDivElement>(null);
const untilRef = useRef<HTMLDivElement>(null);
const fromPickerRef = useRef<HTMLDivElement>(null); const fromPickerRef = useRef<HTMLDivElement>(null);
const untilPickerRef = useRef<HTMLDivElement>(null); const untilPickerRef = useRef<HTMLDivElement>(null);
const [openOptions, setOpenOptions] = useState(false); const [openOptions, setOpenOptions] = useState(false);
@ -82,11 +74,6 @@ export const TimeSelector: FC = () => {
} }
setOpenOptions(false); setOpenOptions(false);
}; };
const handleFromChange = (from: string) => setFrom(from);
const handleUntilChange = (until: string) => setUntil(until);
const onApplyClick = () => setTimeAndClosePicker();
const onSwitchToNow = () => dispatch({ type: "RUN_QUERY_TO_NOW" }); const onSwitchToNow = () => dispatch({ type: "RUN_QUERY_TO_NOW" });
@ -116,11 +103,9 @@ export const TimeSelector: FC = () => {
useClickOutside(wrapperRef, (e) => { useClickOutside(wrapperRef, (e) => {
if (isMobile) return; if (isMobile) return;
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
const isFromButton = fromRef?.current && fromRef.current.contains(target);
const isUntilButton = untilRef?.current && untilRef.current.contains(target);
const isFromPicker = fromPickerRef?.current && fromPickerRef?.current?.contains(target); const isFromPicker = fromPickerRef?.current && fromPickerRef?.current?.contains(target);
const isUntilPicker = untilPickerRef?.current && untilPickerRef?.current?.contains(target); const isUntilPicker = untilPickerRef?.current && untilPickerRef?.current?.contains(target);
if (isFromButton || isUntilButton || isFromPicker || isUntilPicker) return; if (isFromPicker || isUntilPicker) return;
handleCloseOptions(); handleCloseOptions();
}); });
@ -174,38 +159,22 @@ export const TimeSelector: FC = () => {
"vm-time-selector-left-inputs_dark": isDarkTheme "vm-time-selector-left-inputs_dark": isDarkTheme
})} })}
> >
<div <DateTimeInput
className="vm-time-selector-left-inputs__date" value={from}
ref={fromRef} label="From:"
> pickerLabel="Date From"
<label>From:</label> pickerRef={fromPickerRef}
<span>{formFormat}</span> onChange={setFrom}
<CalendarIcon/> onEnter={setTimeAndClosePicker}
<DatePicker />
label={"Date From"} <DateTimeInput
ref={fromPickerRef} value={until}
date={from || ""} label="To:"
onChange={handleFromChange} pickerLabel="Date To"
targetRef={fromRef} pickerRef={untilPickerRef}
timepicker={true} onChange={setUntil}
/> onEnter={setTimeAndClosePicker}
</div> />
<div
className="vm-time-selector-left-inputs__date"
ref={untilRef}
>
<label>To:</label>
<span>{untilFormat}</span>
<CalendarIcon/>
<DatePicker
label={"Date To"}
ref={untilPickerRef}
date={until || ""}
onChange={handleUntilChange}
targetRef={untilRef}
timepicker={true}
/>
</div>
</div> </div>
<div className="vm-time-selector-left-timezone"> <div className="vm-time-selector-left-timezone">
<div className="vm-time-selector-left-timezone__title">{activeTimezone.region}</div> <div className="vm-time-selector-left-timezone__title">{activeTimezone.region}</div>
@ -228,7 +197,7 @@ export const TimeSelector: FC = () => {
</Button> </Button>
<Button <Button
color="primary" color="primary"
onClick={onApplyClick} onClick={setTimeAndClosePicker}
> >
Apply Apply
</Button> </Button>

View file

@ -31,48 +31,6 @@
display: grid; display: grid;
align-items: flex-start; align-items: flex-start;
justify-content: stretch; justify-content: stretch;
&_dark &__date {
border-color: $color-text-disabled;
}
&__date {
display: grid;
grid-template-columns: 1fr 14px;
gap: $padding-small;
align-items: center;
justify-content: center;
padding-bottom: $padding-small;
margin-bottom: $padding-global;
border-bottom: $border-divider;
cursor: pointer;
transition: color 200ms ease-in-out, border-bottom-color 300ms ease;
&:last-child {
margin-bottom: 0;
}
&:hover {
border-bottom-color: $color-primary;
}
&:hover svg,
&:hover {
color: $color-primary;
}
label {
grid-column: 1/3;
font-size: $font-size-small;
color: $color-text-secondary;
user-select: none;
}
svg {
color: $color-text-secondary;
transition: color 200ms ease-in-out;
}
}
} }
&-timezone { &-timezone {

View file

@ -3,65 +3,45 @@ import dayjs, { Dayjs } from "dayjs";
import CalendarHeader from "./CalendarHeader/CalendarHeader"; import CalendarHeader from "./CalendarHeader/CalendarHeader";
import CalendarBody from "./CalendarBody/CalendarBody"; import CalendarBody from "./CalendarBody/CalendarBody";
import YearsList from "./YearsList/YearsList"; import YearsList from "./YearsList/YearsList";
import TimePicker from "../TImePicker/TimePicker";
import { DATE_TIME_FORMAT } from "../../../../constants/date"; import { DATE_TIME_FORMAT } from "../../../../constants/date";
import "./style.scss"; import "./style.scss";
import { CalendarIcon, ClockIcon } from "../../Icons";
import Tabs from "../../Tabs/Tabs";
import useDeviceDetect from "../../../../hooks/useDeviceDetect"; import useDeviceDetect from "../../../../hooks/useDeviceDetect";
import classNames from "classnames"; import classNames from "classnames";
import MonthsList from "./MonthsList/MonthsList";
interface DatePickerProps { interface DatePickerProps {
date: Date | Dayjs date: Date | Dayjs
format?: string format?: string
timepicker?: boolean,
onChange: (date: string) => void onChange: (date: string) => void
onClose?: () => void
} }
const tabs = [ enum CalendarTypeView {
{ value: "date", icon: <CalendarIcon/> }, "days",
{ value: "time", icon: <ClockIcon/> } "months",
]; "years"
}
const Calendar: FC<DatePickerProps> = ({ const Calendar: FC<DatePickerProps> = ({
date, date,
timepicker = false,
format = DATE_TIME_FORMAT, format = DATE_TIME_FORMAT,
onChange, onChange,
onClose
}) => { }) => {
const [displayYears, setDisplayYears] = useState(false); const [viewType, setViewType] = useState<CalendarTypeView>(CalendarTypeView.days);
const [viewDate, setViewDate] = useState(dayjs.tz(date)); const [viewDate, setViewDate] = useState(dayjs.tz(date));
const [selectDate, setSelectDate] = useState(dayjs.tz(date)); const [selectDate, setSelectDate] = useState(dayjs.tz(date));
const [tab, setTab] = useState(tabs[0].value);
const { isMobile } = useDeviceDetect(); const { isMobile } = useDeviceDetect();
const toggleDisplayYears = () => { const toggleDisplayYears = () => {
setDisplayYears(prev => !prev); setViewType(prev => prev === CalendarTypeView.years ? CalendarTypeView.days : CalendarTypeView.years);
}; };
const handleChangeViewDate = (date: Dayjs) => { const handleChangeViewDate = (date: Dayjs) => {
setViewDate(date); setViewDate(date);
setDisplayYears(false); setViewType(prev => prev === CalendarTypeView.years ? CalendarTypeView.months : CalendarTypeView.days);
}; };
const handleChangeSelectDate = (date: Dayjs) => { const handleChangeSelectDate = (date: Dayjs) => {
setSelectDate(date); setSelectDate(date);
if (timepicker) setTab("time");
};
const handleChangeTime = (time: string) => {
const [hour, minute, second] = time.split(":");
setSelectDate(prev => prev.set("hour", +hour).set("minute", +minute).set("second", +second));
};
const handleChangeTab = (value: string) => {
setTab(value);
};
const handleClose = () => {
onClose && onClose();
}; };
useEffect(() => { useEffect(() => {
@ -69,6 +49,12 @@ const Calendar: FC<DatePickerProps> = ({
onChange(selectDate.format(format)); onChange(selectDate.format(format));
}, [selectDate]); }, [selectDate]);
useEffect(() => {
const value = dayjs.tz(date);
setViewDate(value);
setSelectDate(value);
}, [date]);
return ( return (
<div <div
className={classNames({ className={classNames({
@ -76,51 +62,32 @@ const Calendar: FC<DatePickerProps> = ({
"vm-calendar_mobile": isMobile, "vm-calendar_mobile": isMobile,
})} })}
> >
{tab === "date" && ( <CalendarHeader
<CalendarHeader viewDate={viewDate}
onChangeViewDate={handleChangeViewDate}
toggleDisplayYears={toggleDisplayYears}
showArrowNav={viewType === CalendarTypeView.days}
/>
{viewType === CalendarTypeView.days && (
<CalendarBody
viewDate={viewDate}
selectDate={selectDate}
onChangeSelectDate={handleChangeSelectDate}
/>
)}
{viewType === CalendarTypeView.years && (
<YearsList
viewDate={viewDate} viewDate={viewDate}
onChangeViewDate={handleChangeViewDate} onChangeViewDate={handleChangeViewDate}
toggleDisplayYears={toggleDisplayYears}
displayYears={displayYears}
/> />
)} )}
{viewType === CalendarTypeView.months && (
{tab === "date" && ( <MonthsList
<>
{!displayYears && (
<CalendarBody
viewDate={viewDate}
selectDate={selectDate}
onChangeSelectDate={handleChangeSelectDate}
/>
)}
{displayYears && (
<YearsList
viewDate={viewDate}
onChangeViewDate={handleChangeViewDate}
/>
)}
</>
)}
{tab === "time" && (
<TimePicker
selectDate={selectDate} selectDate={selectDate}
onChangeTime={handleChangeTime} viewDate={viewDate}
onClose={handleClose} onChangeViewDate={handleChangeViewDate}
/> />
)} )}
{timepicker && (
<div className="vm-calendar__tabs">
<Tabs
activeItem={tab}
items={tabs}
onChange={handleChangeTab}
indicatorPlacement="top"
/>
</div>
)}
</div> </div>
); );
}; };

View file

@ -5,11 +5,11 @@ import { ArrowDownIcon, ArrowDropDownIcon } from "../../../Icons";
interface CalendarHeaderProps { interface CalendarHeaderProps {
viewDate: Dayjs viewDate: Dayjs
onChangeViewDate: (date: Dayjs) => void onChangeViewDate: (date: Dayjs) => void
displayYears: boolean showArrowNav: boolean
toggleDisplayYears: () => void toggleDisplayYears: () => void
} }
const CalendarHeader: FC<CalendarHeaderProps> = ({ viewDate, displayYears, onChangeViewDate, toggleDisplayYears }) => { const CalendarHeader: FC<CalendarHeaderProps> = ({ viewDate, showArrowNav, onChangeViewDate, toggleDisplayYears }) => {
const setPrevMonth = () => { const setPrevMonth = () => {
onChangeViewDate(viewDate.subtract(1, "month")); onChangeViewDate(viewDate.subtract(1, "month"));
@ -32,7 +32,7 @@ const CalendarHeader: FC<CalendarHeaderProps> = ({ viewDate, displayYears, onCha
<ArrowDropDownIcon/> <ArrowDropDownIcon/>
</div> </div>
</div> </div>
{!displayYears && ( {showArrowNav && (
<div className="vm-calendar-header-right"> <div className="vm-calendar-header-right">
<div <div
className="vm-calendar-header-right__prev" className="vm-calendar-header-right__prev"

View file

@ -0,0 +1,50 @@
import React, { FC, useEffect, useMemo } from "preact/compat";
import dayjs, { Dayjs } from "dayjs";
import classNames from "classnames";
interface CalendarMonthsProps {
viewDate: Dayjs,
selectDate: Dayjs
onChangeViewDate: (date: Dayjs) => void
}
const MonthsList: FC<CalendarMonthsProps> = ({ viewDate, selectDate, onChangeViewDate }) => {
const today = dayjs().format("MM");
const currentMonths = useMemo(() => selectDate.format("MM"), [selectDate]);
const months: Dayjs[] = useMemo(() => {
return new Array(12).fill("").map((d, i) => dayjs(viewDate).month(i));
}, [viewDate]);
useEffect(() => {
const selectedEl = document.getElementById(`vm-calendar-year-${currentMonths}`);
if (!selectedEl) return;
selectedEl.scrollIntoView({ block: "center" });
}, []);
const createHandlerClick = (date: Dayjs) => () => {
onChangeViewDate(date);
};
return (
<div className="vm-calendar-years">
{months.map(m => (
<div
className={classNames({
"vm-calendar-years__year": true,
"vm-calendar-years__year_selected": m.format("MM") === currentMonths,
"vm-calendar-years__year_today": m.format("MM") === today
})}
id={`vm-calendar-year-${m.format("MM")}`}
key={m.format("MM")}
onClick={createHandlerClick(m)}
>
{m.format("MMMM")}
</div>
))}
</div>
);
};
export default MonthsList;

View file

@ -9,9 +9,10 @@ interface CalendarYearsProps {
const YearsList: FC<CalendarYearsProps> = ({ viewDate, onChangeViewDate }) => { const YearsList: FC<CalendarYearsProps> = ({ viewDate, onChangeViewDate }) => {
const today = dayjs().format("YYYY");
const currentYear = useMemo(() => viewDate.format("YYYY"), [viewDate]); const currentYear = useMemo(() => viewDate.format("YYYY"), [viewDate]);
const years: Dayjs[] = useMemo(() => { const years: Dayjs[] = useMemo(() => {
const displayYears = 206; const displayYears = 18;
const year = dayjs(); const year = dayjs();
const startYear = year.subtract(displayYears/2, "year"); const startYear = year.subtract(displayYears/2, "year");
return new Array(displayYears).fill(startYear).map((d, i) => d.add(i, "year")); return new Array(displayYears).fill(startYear).map((d, i) => d.add(i, "year"));
@ -33,7 +34,8 @@ const YearsList: FC<CalendarYearsProps> = ({ viewDate, onChangeViewDate }) => {
<div <div
className={classNames({ className={classNames({
"vm-calendar-years__year": true, "vm-calendar-years__year": true,
"vm-calendar-years__year_selected": y.format("YYYY") === currentYear "vm-calendar-years__year_selected": y.format("YYYY") === currentYear,
"vm-calendar-years__year_today": y.format("YYYY") === today
})} })}
id={`vm-calendar-year-${y.format("YYYY")}`} id={`vm-calendar-year-${y.format("YYYY")}`}
key={y.format("YYYY")} key={y.format("YYYY")}

View file

@ -13,12 +13,6 @@
padding: 0 $padding-global; padding: 0 $padding-global;
} }
&__tabs {
margin: 0 0-$padding-global 0-$padding-global;
border-top: $border-divider;
margin-top: $padding-global;
}
&-header { &-header {
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
@ -88,14 +82,14 @@
&-body { &-body {
display: grid; display: grid;
grid-template-columns: repeat(7, 32px); grid-template-columns: repeat(7, 32px);
grid-template-rows: repeat(6, 32px); grid-template-rows: repeat(7, 32px);
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 2px; gap: 2px;
@media (max-width: 500px) { @media (max-width: 500px) {
grid-template-columns: repeat(7, calc((100vw - ($padding-global * 2) - (6 * 2px))/7)); grid-template-columns: repeat(7, calc((100vw - ($padding-global * 2) - (6 * 2px))/7));
grid-template-rows: repeat(6, calc((100vw - ($padding-global * 2) - (5 * 2px))/7)); grid-template-rows: repeat(7, calc((100vw - ($padding-global * 2) - (6 * 2px))/7));
} }
&-cell { &-cell {
@ -166,143 +160,9 @@
background-color: $color-primary; background-color: $color-primary;
} }
} }
}
}
&-time-picker { &_today {
display: flex; border: 1px solid $color-primary;
flex-direction: column;
align-items: center;
justify-content: center;
&-clock {
$clock-size: 230px;
$clock-offset: 42px;
position: relative;
height: $clock-size;
width: $clock-size;
border-radius: 50%;
border: $border-divider;
box-shadow: $box-shadow;
box-sizing: content-box;
&:after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 6px;
height: 6px;
background-color: $color-primary;
border-radius: 50%;
}
&__arrow {
position: absolute;
top: 0;
left: calc(($clock-size/2) - 1px);
width: 2px;
margin-top: $padding-small;
height: calc(($clock-size/2) - $padding-small);
background-color: $color-primary;
transform-origin: bottom;
transition: transform 200ms ease-in-out;
opacity: 0.8;
z-index: 0;
&_offset {
margin-top: $clock-offset;
height: calc(($clock-size/2) - $clock-offset);
z-index: 2;
}
&:after {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 30px;
height: 30px;
background-color: $color-primary;
border-radius: 50%;
}
}
&__time {
display: flex;
align-items: flex-start;
justify-content: center;
text-align: center;
padding-top: $padding-small;
position: absolute;
top: 0;
width: 30px;
left: calc(($clock-size/2) - 15px);
height: calc($clock-size/2);
transform-origin: bottom;
cursor: pointer;
z-index: 1;
&_hide {
display: none;
}
&_offset {
padding: 0;
margin-top: $clock-offset;
height: calc(($clock-size/2) - $clock-offset);
z-index: 2;
}
&:hover span {
background-color: rgba($color-black, 0.1);
}
span {
position: relative;
display: grid;
align-items: center;
justify-content: center;
min-width: 30px;
min-height: 30px;
border-radius: 50%;
transform-origin: center;
transition: background-color 300ms ease;
}
}
}
&-fields {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: $padding-global;
&_dark &__input {
border-color: $color-text-disabled;
}
span {
margin: 0 $padding-small;
}
&__input {
width: 64px;
height: 32px;
border: $border-divider;
border-radius: $border-radius-small;
font-size: $font-size-medium;
padding: 2px $padding-small;
text-align: center;
background-color: transparent;
color: $color-text;
&:focus {
border-color: $color-primary;
}
} }
} }
} }

View file

@ -9,7 +9,6 @@ interface DatePickerProps {
date: string | Date | Dayjs, date: string | Date | Dayjs,
targetRef: Ref<HTMLElement> targetRef: Ref<HTMLElement>
format?: string format?: string
timepicker?: boolean
label?: string label?: string
onChange: (val: string) => void onChange: (val: string) => void
} }
@ -18,12 +17,11 @@ const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(({
date, date,
targetRef, targetRef,
format = DATE_TIME_FORMAT, format = DATE_TIME_FORMAT,
timepicker,
onChange, onChange,
label label
}, ref) => { }, ref) => {
const [openCalendar, setOpenCalendar] = useState(false); const [openCalendar, setOpenCalendar] = useState(false);
const dateDayjs = useMemo(() => date ? dayjs.tz(date) : dayjs().tz(), [date]); const dateDayjs = useMemo(() => dayjs(date).isValid() ? dayjs.tz(date) : dayjs().tz(), [date]);
const { isMobile } = useDeviceDetect(); const { isMobile } = useDeviceDetect();
const toggleOpenCalendar = () => { const toggleOpenCalendar = () => {
@ -35,8 +33,8 @@ const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(({
}; };
const handleChangeDate = (val: string) => { const handleChangeDate = (val: string) => {
if (!timepicker) handleCloseCalendar();
onChange(val); onChange(val);
handleCloseCalendar();
}; };
const handleKeyUp = (e: KeyboardEvent) => { const handleKeyUp = (e: KeyboardEvent) => {
@ -71,9 +69,7 @@ const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(({
<Calendar <Calendar
date={dateDayjs} date={dateDayjs}
format={format} format={format}
timepicker={timepicker}
onChange={handleChangeDate} onChange={handleChangeDate}
onClose={handleCloseCalendar}
/> />
</div> </div>
</Popper> </Popper>

View file

@ -0,0 +1,127 @@
import React, { FC, useEffect, useRef, useState } from "preact/compat";
import { ChangeEvent, KeyboardEvent } from "react";
import { CalendarIcon } from "../../Icons";
import DatePicker from "../DatePicker";
import Button from "../../Button/Button";
import { DATE_TIME_FORMAT } from "../../../../constants/date";
import InputMask from "react-input-mask";
import dayjs from "dayjs";
import classNames from "classnames";
import "./style.scss";
const formatStringDate = (val: string) => {
return dayjs(val).isValid() ? dayjs.tz(val).format(DATE_TIME_FORMAT) : val;
};
interface DateTimeInputProps {
value?: string;
label: string;
pickerLabel: string;
pickerRef: React.RefObject<HTMLDivElement>;
onChange: (date: string) => void;
onEnter: () => void;
}
const DateTimeInput: FC<DateTimeInputProps> = ({
value = "",
label,
pickerLabel,
pickerRef,
onChange,
onEnter
}) => {
const wrapperRef = useRef<HTMLDivElement>(null);
const [inputRef, setInputRef] = useState<HTMLInputElement | null>(null);
const [maskedValue, setMaskedValue] = useState(formatStringDate(value));
const [focusToTime, setFocusToTime] = useState(false);
const [awaitChangeForEnter, setAwaitChangeForEnter] = useState(false);
const error = dayjs(maskedValue).isValid() ? "" : "Expected format: YYYY-MM-DD HH:mm:ss";
const handleMaskedChange = (e: ChangeEvent<HTMLInputElement>) => {
setMaskedValue(e.currentTarget.value);
};
const handleBlur = () => {
onChange(maskedValue);
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === "Enter") {
onChange(maskedValue);
setAwaitChangeForEnter(true);
}
};
const handleChangeDate = (val: string) => {
setMaskedValue(val);
setFocusToTime(true);
};
useEffect(() => {
const newValue = formatStringDate(value);
if (newValue !== maskedValue) {
setMaskedValue(newValue);
}
if (awaitChangeForEnter) {
onEnter();
setAwaitChangeForEnter(false);
}
}, [value]);
useEffect(() => {
if (focusToTime && inputRef) {
inputRef.focus();
inputRef.setSelectionRange(11, 11);
setFocusToTime(false);
}
}, [focusToTime]);
return (
<div
className={classNames({
"vm-date-time-input": true,
"vm-date-time-input_error": error
})}
>
<label>{label}</label>
<InputMask
tabIndex={1}
inputRef={setInputRef}
mask="9999-99-99 99:99:99"
placeholder="YYYY-MM-DD HH:mm:ss"
value={maskedValue}
autoCapitalize={"none"}
inputMode={"numeric"}
maskChar={null}
onChange={handleMaskedChange}
onBlur={handleBlur}
onKeyUp={handleKeyUp}
/>
{error && (
<span className="vm-date-time-input__error-text">{error}</span>
)}
<div
className="vm-date-time-input__icon"
ref={wrapperRef}
>
<Button
variant="text"
color="gray"
size="small"
startIcon={<CalendarIcon/>}
/>
</div>
<DatePicker
label={pickerLabel}
ref={pickerRef}
date={maskedValue}
onChange={handleChangeDate}
targetRef={wrapperRef}
/>
</div>
);
};
export default DateTimeInput;

View file

@ -0,0 +1,61 @@
@use "src/styles/variables" as *;
.vm-date-time-input {
position: relative;
display: grid;
grid-template-columns: 1fr;
gap: $padding-small 0;
align-items: center;
justify-content: center;
margin-bottom: $padding-global;
cursor: pointer;
transition: color 200ms ease-in-out, border-bottom-color 300ms ease;
&:hover input {
border-bottom-color: $color-primary;
}
label {
grid-column: 1/3;
width: 100%;
font-size: $font-size-small;
color: $color-text-secondary;
user-select: none;
}
&__icon {
position: absolute;
bottom: 2px;
right: 0;
}
input {
padding: 0 0 $padding-small;
border-bottom: $border-divider;
border-top: none;
border-left: none;
border-right: none;
background: transparent;
color: $color-text;
&:focus {
border-bottom-color: $color-primary;
}
}
&_error input {
border-color: $color-error;
&:focus {
border-bottom-color: $color-error;
}
}
&__error-text {
color: $color-error;
font-size: $font-size-small;
position: absolute;
left: 0;
bottom: -$font-size-small;
}
}

View file

@ -24,6 +24,7 @@ created by v1.90.0 or newer versions. The solution is to upgrade to v1.90.0 or n
* FEATURE: [vmagent](https://docs.victoriametrics.com/vmagent.html): add support for [VictoriaMetrics remote write protocol](https://docs.victoriametrics.com/vmagent.html#victoriametrics-remote-write-protocol) when [sending / receiving data to / from Kafka](https://docs.victoriametrics.com/vmagent.html#kafka-integration). This protocol allows saving egress network bandwidth costs when sending data from `vmagent` to `Kafka` located in another datacenter or availability zone. See [this feature request](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/1225). * FEATURE: [vmagent](https://docs.victoriametrics.com/vmagent.html): add support for [VictoriaMetrics remote write protocol](https://docs.victoriametrics.com/vmagent.html#victoriametrics-remote-write-protocol) when [sending / receiving data to / from Kafka](https://docs.victoriametrics.com/vmagent.html#kafka-integration). This protocol allows saving egress network bandwidth costs when sending data from `vmagent` to `Kafka` located in another datacenter or availability zone. See [this feature request](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/1225).
* FEATURE: [vmagent](https://docs.victoriametrics.com/vmagent.html): add `--kafka.consumer.topic.concurrency` command-line flag. It controls the number of Kafka consumer workers to use by `vmagent`. It should eliminate the need to start multiple `vmagent` instances to improve data transfer rate. See [this feature request](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/1957). * FEATURE: [vmagent](https://docs.victoriametrics.com/vmagent.html): add `--kafka.consumer.topic.concurrency` command-line flag. It controls the number of Kafka consumer workers to use by `vmagent`. It should eliminate the need to start multiple `vmagent` instances to improve data transfer rate. See [this feature request](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/1957).
* FEATURE: [vmagent](https://docs.victoriametrics.com/vmagent.html): add support for [Kafka producer and consumer](https://docs.victoriametrics.com/vmagent.html#kafka-integration) on `arm64` machines. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/2271). * FEATURE: [vmagent](https://docs.victoriametrics.com/vmagent.html): add support for [Kafka producer and consumer](https://docs.victoriametrics.com/vmagent.html#kafka-integration) on `arm64` machines. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/2271).
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): Add the ability to manually input date and time when selecting a time range. See [this pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/3968).
* BUGFIX: prevent from slow [snapshot creating](https://docs.victoriametrics.com/#how-to-work-with-snapshots) under high data ingestion rate. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3551). * BUGFIX: prevent from slow [snapshot creating](https://docs.victoriametrics.com/#how-to-work-with-snapshots) under high data ingestion rate. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3551).