Date picker (#3572)

* Add date picker dependency
* Add DateInput component
* Add DateInput to edit panels
* Add DateInput to DateFilter
* Add time to DateInput and add to Timestamp filter
* Use calendar icon for button
This commit is contained in:
WithoutPants
2023-03-22 11:25:50 +11:00
committed by GitHub
parent b602ed2381
commit b6b275edc8
14 changed files with 458 additions and 73 deletions

View File

@@ -39,6 +39,7 @@ import { galleryTitle } from "src/core/galleries";
import { useRatingKeybinds } from "src/hooks/keybinds";
import { ConfigurationContext } from "src/hooks/Config";
import isEqual from "lodash-es/isEqual";
import { DateInput } from "src/components/Shared/DateInput";
interface IProps {
gallery: Partial<GQL.GalleryDataFragment>;
@@ -458,11 +459,18 @@ export const GalleryEditPanel: React.FC<IProps> = ({
/>
</Col>
</Form.Group>
{renderTextField(
"date",
intl.formatMessage({ id: "date" }),
"YYYY-MM-DD"
)}
<Form.Group controlId="date" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "date" }),
})}
<Col xs={9}>
<DateInput
value={formik.values.date}
onValueChange={(value) => formik.setFieldValue("date", value)}
error={formik.errors.date}
/>
</Col>
</Form.Group>
<Form.Group controlId="rating" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "rating" }),

View File

@@ -20,6 +20,7 @@ import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { useRatingKeybinds } from "src/hooks/keybinds";
import { ConfigurationContext } from "src/hooks/Config";
import isEqual from "lodash-es/isEqual";
import { DateInput } from "src/components/Shared/DateInput";
interface IProps {
image: GQL.ImageDataFragment;
@@ -205,11 +206,18 @@ export const ImageEditPanel: React.FC<IProps> = ({
/>
</Col>
</Form.Group>
{renderTextField(
"date",
intl.formatMessage({ id: "date" }),
"YYYY-MM-DD"
)}
<Form.Group controlId="date" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "date" }),
})}
<Col xs={9}>
<DateInput
value={formik.values.date}
onValueChange={(value) => formik.setFieldValue("date", value)}
error={formik.errors.date}
/>
</Col>
</Form.Group>
<Form.Group controlId="rating" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "rating" }),

View File

@@ -4,6 +4,7 @@ import { useIntl } from "react-intl";
import { CriterionModifier } from "../../../core/generated-graphql";
import { IDateValue } from "../../../models/list-filter/types";
import { Criterion } from "../../../models/list-filter/criteria/criterion";
import { DateInput } from "src/components/Shared/DateInput";
interface IDateFilterProps {
criterion: Criterion<IDateValue>;
@@ -18,11 +19,7 @@ export const DateFilter: React.FC<IDateFilterProps> = ({
const { value } = criterion;
function onChanged(
event: React.ChangeEvent<HTMLInputElement>,
property: "value" | "value2"
) {
const newValue = event.target.value;
function onChanged(newValue: string, property: "value" | "value2") {
const valueCopy = { ...value };
valueCopy[property] = newValue;
@@ -36,16 +33,10 @@ export const DateFilter: React.FC<IDateFilterProps> = ({
) {
equalsControl = (
<Form.Group>
<Form.Control
className="btn-secondary"
type="text"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChanged(e, "value")
}
<DateInput
value={value?.value ?? ""}
placeholder={
intl.formatMessage({ id: "criterion.value" }) + " (YYYY-MM-DD)"
}
onValueChange={(v) => onChanged(v, "value")}
placeholder={intl.formatMessage({ id: "criterion.value" })}
/>
</Form.Group>
);
@@ -59,17 +50,10 @@ export const DateFilter: React.FC<IDateFilterProps> = ({
) {
lowerControl = (
<Form.Group>
<Form.Control
className="btn-secondary"
type="text"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChanged(e, "value")
}
<DateInput
value={value?.value ?? ""}
placeholder={
intl.formatMessage({ id: "criterion.greater_than" }) +
" (YYYY-MM-DD)"
}
onValueChange={(v) => onChanged(v, "value")}
placeholder={intl.formatMessage({ id: "criterion.greater_than" })}
/>
</Form.Group>
);
@@ -83,25 +67,21 @@ export const DateFilter: React.FC<IDateFilterProps> = ({
) {
upperControl = (
<Form.Group>
<Form.Control
className="btn-secondary"
type="text"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChanged(
e,
criterion.modifier === CriterionModifier.LessThan
? "value"
: "value2"
)
}
<DateInput
value={
(criterion.modifier === CriterionModifier.LessThan
? value?.value
: value?.value2) ?? ""
}
placeholder={
intl.formatMessage({ id: "criterion.less_than" }) + " (YYYY-MM-DD)"
onValueChange={(v) =>
onChanged(
v,
criterion.modifier === CriterionModifier.LessThan
? "value"
: "value2"
)
}
placeholder={intl.formatMessage({ id: "criterion.less_than" })}
/>
</Form.Group>
);

View File

@@ -4,6 +4,7 @@ import { useIntl } from "react-intl";
import { CriterionModifier } from "../../../core/generated-graphql";
import { ITimestampValue } from "../../../models/list-filter/types";
import { Criterion } from "../../../models/list-filter/criteria/criterion";
import { DateInput } from "src/components/Shared/DateInput";
interface ITimestampFilterProps {
criterion: Criterion<ITimestampValue>;
@@ -18,11 +19,7 @@ export const TimestampFilter: React.FC<ITimestampFilterProps> = ({
const { value } = criterion;
function onChanged(
event: React.ChangeEvent<HTMLInputElement>,
property: "value" | "value2"
) {
const newValue = event.target.value;
function onChanged(newValue: string, property: "value" | "value2") {
const valueCopy = { ...value };
valueCopy[property] = newValue;
@@ -36,7 +33,13 @@ export const TimestampFilter: React.FC<ITimestampFilterProps> = ({
) {
equalsControl = (
<Form.Group>
<Form.Control
<DateInput
value={value?.value ?? ""}
onValueChange={(v) => onChanged(v, "value")}
placeholder={intl.formatMessage({ id: "criterion.value" })}
isTime
/>
{/* <Form.Control
className="btn-secondary"
type="text"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
@@ -47,7 +50,7 @@ export const TimestampFilter: React.FC<ITimestampFilterProps> = ({
intl.formatMessage({ id: "criterion.value" }) +
" (YYYY-MM-DD HH:MM)"
}
/>
/> */}
</Form.Group>
);
}
@@ -60,7 +63,13 @@ export const TimestampFilter: React.FC<ITimestampFilterProps> = ({
) {
lowerControl = (
<Form.Group>
<Form.Control
<DateInput
value={value?.value ?? ""}
onValueChange={(v) => onChanged(v, "value")}
placeholder={intl.formatMessage({ id: "criterion.greater_than" })}
isTime
/>
{/* <Form.Control
className="btn-secondary"
type="text"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
@@ -71,7 +80,7 @@ export const TimestampFilter: React.FC<ITimestampFilterProps> = ({
intl.formatMessage({ id: "criterion.greater_than" }) +
" (YYYY-MM-DD HH:MM)"
}
/>
/> */}
</Form.Group>
);
}
@@ -84,7 +93,24 @@ export const TimestampFilter: React.FC<ITimestampFilterProps> = ({
) {
upperControl = (
<Form.Group>
<Form.Control
<DateInput
value={
(criterion.modifier === CriterionModifier.LessThan
? value?.value
: value?.value2) ?? ""
}
onValueChange={(v) =>
onChanged(
v,
criterion.modifier === CriterionModifier.LessThan
? "value"
: "value2"
)
}
placeholder={intl.formatMessage({ id: "criterion.less_than" })}
isTime
/>
{/* <Form.Control
className="btn-secondary"
type="text"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
@@ -104,7 +130,7 @@ export const TimestampFilter: React.FC<ITimestampFilterProps> = ({
intl.formatMessage({ id: "criterion.less_than" }) +
" (YYYY-MM-DD HH:MM)"
}
/>
/> */}
</Form.Group>
);
}

View File

@@ -24,6 +24,7 @@ import { MovieScrapeDialog } from "./MovieScrapeDialog";
import { useRatingKeybinds } from "src/hooks/keybinds";
import { ConfigurationContext } from "src/hooks/Config";
import isEqual from "lodash-es/isEqual";
import { DateInput } from "src/components/Shared/DateInput";
interface IMovieEditPanel {
movie: Partial<GQL.MovieDataFragment>;
@@ -410,11 +411,18 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
</Col>
</Form.Group>
{renderTextField(
"date",
intl.formatMessage({ id: "date" }),
"YYYY-MM-DD"
)}
<Form.Group controlId="date" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "date" }),
})}
<Col xs={9}>
<DateInput
value={formik.values.date}
onValueChange={(value) => formik.setFieldValue("date", value)}
error={formik.errors.date}
/>
</Col>
</Form.Group>
<Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({

View File

@@ -43,6 +43,7 @@ import {
} from "@fortawesome/free-solid-svg-icons";
import { StringListInput } from "src/components/Shared/StringListInput";
import isEqual from "lodash-es/isEqual";
import { DateInput } from "src/components/Shared/DateInput";
const isScraper = (
scraper: GQL.Scraper | GQL.StashBox
@@ -927,8 +928,35 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
</Col>
</Form.Group>
{renderField("birthdate", { placeholder: "YYYY-MM-DD" })}
{renderField("death_date", { placeholder: "YYYY-MM-DD" })}
<Form.Group controlId="birthdate" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="birthdate" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<DateInput
value={formik.values.birthdate}
onValueChange={(value) =>
formik.setFieldValue("birthdate", value)
}
error={formik.errors.birthdate}
/>
</Col>
</Form.Group>
<Form.Group controlId="death_date" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="death_date" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<DateInput
value={formik.values.death_date}
onValueChange={(value) =>
formik.setFieldValue("death_date", value)
}
error={formik.errors.death_date}
/>
</Col>
</Form.Group>
<Form.Group as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>

View File

@@ -52,6 +52,7 @@ import { galleryTitle } from "src/core/galleries";
import { useRatingKeybinds } from "src/hooks/keybinds";
import { lazyComponent } from "src/utils/lazyComponent";
import isEqual from "lodash-es/isEqual";
import { DateInput } from "src/components/Shared/DateInput";
const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog"));
const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal"));
@@ -769,11 +770,20 @@ export const SceneEditPanel: React.FC<IProps> = ({
/>
</Col>
</Form.Group>
{renderTextField(
"date",
intl.formatMessage({ id: "date" }),
"YYYY-MM-DD"
)}
<Form.Group controlId="date" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "date" }),
})}
<Col xs={9}>
<DateInput
value={formik.values.date}
onValueChange={(value) => formik.setFieldValue("date", value)}
error={formik.errors.date}
/>
</Col>
</Form.Group>
{renderTextField(
"director",
intl.formatMessage({ id: "director" })

View File

@@ -0,0 +1,105 @@
import { faCalendar } from "@fortawesome/free-regular-svg-icons";
import React, { useMemo } from "react";
import { Button, InputGroup, Form } from "react-bootstrap";
import ReactDatePicker from "react-datepicker";
import TextUtils from "src/utils/text";
import { Icon } from "./Icon";
import "react-datepicker/dist/react-datepicker.css";
import { useIntl } from "react-intl";
interface IProps {
disabled?: boolean;
value: string | undefined;
isTime?: boolean;
onValueChange(value: string): void;
placeholder?: string;
error?: string;
}
export const DateInput: React.FC<IProps> = (props: IProps) => {
const intl = useIntl();
const date = useMemo(() => {
const toDate = props.isTime
? TextUtils.stringToFuzzyDateTime
: TextUtils.stringToFuzzyDate;
if (props.value) {
const ret = toDate(props.value);
if (!ret || isNaN(ret.getTime())) {
return undefined;
}
return ret;
}
}, [props.value, props.isTime]);
function maybeRenderButton() {
if (!props.disabled) {
const ShowPickerButton = ({
onClick,
}: {
onClick: (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => void;
}) => (
<Button variant="secondary" onClick={onClick}>
<Icon icon={faCalendar} />
</Button>
);
const dateToString = props.isTime
? TextUtils.dateTimeToString
: TextUtils.dateToString;
return (
<ReactDatePicker
selected={date}
onChange={(v) => {
props.onValueChange(v ? dateToString(v) : "");
}}
customInput={React.createElement(ShowPickerButton)}
showMonthDropdown
showYearDropdown
scrollableMonthYearDropdown
scrollableYearDropdown
maxDate={new Date()}
yearDropdownItemNumber={100}
portalId="date-picker-portal"
showTimeSelect={props.isTime}
/>
);
}
}
const placeholderText = intl.formatMessage({
id: props.isTime ? "datetime_format" : "date_format",
});
return (
<div>
<InputGroup hasValidation>
<Form.Control
className="date-input text-input"
disabled={props.disabled}
value={props.value}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
props.onValueChange(e.currentTarget.value)
}
placeholder={
!props.disabled
? props.placeholder
? `${props.placeholder} (${placeholderText})`
: placeholderText
: undefined
}
isInvalid={!!props.error}
/>
<InputGroup.Append>{maybeRenderButton()}</InputGroup.Append>
<Form.Control.Feedback type="invalid">
{props.error}
</Form.Control.Feedback>
</InputGroup>
</div>
);
};

View File

@@ -323,3 +323,97 @@ button.collapse-button.btn-primary:not(:disabled):not(.disabled):active {
}
/* stylelint-enable */
}
.date-input.form-control:focus {
// z-index gets set to 3 in input groups
z-index: inherit;
}
/* stylelint-disable */
div.react-datepicker {
background-color: $body-bg;
border-color: $card-bg;
color: $text-color;
.react-datepicker__header,
.react-datepicker-time__header {
background-color: $secondary;
color: $text-color;
}
.react-datepicker__day {
color: $text-color;
&.react-datepicker__day--disabled {
color: $text-muted;
}
&:hover {
background: rgba(138, 155, 168, 0.15);
}
}
div.react-datepicker__time-container div.react-datepicker__time {
background-color: $body-bg;
color: $text-color;
ul.react-datepicker__time-list li.react-datepicker__time-list-item:hover {
background-color: rgba(138, 155, 168, 0.15);
}
}
.react-datepicker__day-name {
color: $text-color;
}
// replace the current month with the dropdowns
.react-datepicker__current-month {
display: none;
}
.react-datepicker__triangle {
display: none;
}
.react-datepicker__month-dropdown-container {
margin-left: 0;
margin-right: 0.25rem;
}
.react-datepicker__year-dropdown-container {
margin-left: 0.25rem;
margin-right: 0;
}
.react-datepicker__month-dropdown-container
.react-datepicker__month-read-view,
.react-datepicker__year-dropdown-container .react-datepicker__year-read-view {
font-weight: bold;
font-size: 0.944rem;
// react-datepicker hides these fields when the dropdown is shown
visibility: visible !important;
}
// hide the dropdown arrows
.react-datepicker__month-dropdown-container
.react-datepicker__month-read-view--down-arrow,
.react-datepicker__year-dropdown-container
.react-datepicker__year-read-view--down-arrow {
display: none;
}
.react-datepicker__year-dropdown,
.react-datepicker__month-dropdown {
background-color: $body-bg;
.react-datepicker__year-option:hover,
.react-datepicker__month-option:hover {
background-color: #8a9ba826;
}
}
}
/* stylelint-enable */
#date-picker-portal .react-datepicker-popper {
z-index: 1600;
}

View File

@@ -25,6 +25,7 @@ Once migrated, these files can be deleted. The files can be optionally deleted d
* Added toggleable favorite button to Performer cards. ([#3369](https://github.com/stashapp/stash/pull/3369))
### 🎨 Improvements
* Added date/time pickers for date and timestamp fields. ([#3572](https://github.com/stashapp/stash/pull/3572))
* Added folder browser to path filter UI. ([#3570](https://github.com/stashapp/stash/pull/3570))
* Include Organized flag in merge dialog. ([#3565](https://github.com/stashapp/stash/pull/3565))
* Scene cover generation is now optional during scanning, and can be generated using the Generate task. ([#3187](https://github.com/stashapp/stash/pull/3187))

View File

@@ -728,6 +728,8 @@
"custom": "Custom",
"date": "Date",
"death_date": "Death Date",
"date_format": "YYYY-MM-DD",
"datetime_format": "YYYY-MM-DD HH:MM",
"death_year": "Death Year",
"descending": "Descending",
"description": "Description",

View File

@@ -189,6 +189,70 @@ const stringToDate = (dateString: string) => {
return new Date(year, monthIndex, day, 0, 0, 0, 0);
};
const stringToFuzzyDate = (dateString: string) => {
if (!dateString) return null;
const parts = dateString.split("-");
// Invalid date string
let year = Number(parts[0]);
if (isNaN(year)) year = new Date().getFullYear();
let monthIndex = 0;
if (parts.length > 1) {
monthIndex = Math.max(0, Number(parts[1]) - 1);
if (monthIndex > 11 || isNaN(monthIndex)) monthIndex = 0;
}
let day = 1;
if (parts.length > 2) {
day = Number(parts[2]);
if (day > 31 || isNaN(day)) day = 1;
}
return new Date(year, monthIndex, day, 0, 0, 0, 0);
};
const stringToFuzzyDateTime = (dateString: string) => {
if (!dateString) return null;
const dateTime = dateString.split(" ");
let date: Date | null = null;
if (dateTime.length > 0) {
date = stringToFuzzyDate(dateTime[0]);
}
if (!date) {
date = new Date();
}
if (dateTime.length > 1) {
const timeParts = dateTime[1].split(":");
if (date && timeParts.length > 0) {
date.setHours(Number(timeParts[0]));
}
if (date && timeParts.length > 1) {
date.setMinutes(Number(timeParts[1]));
}
if (date && timeParts.length > 2) {
date.setSeconds(Number(timeParts[2]));
}
}
return date;
};
function dateToString(date: Date) {
return `${date.getFullYear()}-${(date.getMonth() + 1)
.toString()
.padStart(2, "0")}-${date.getDate().toString().padStart(2, "0")}`;
}
function dateTimeToString(date: Date) {
return `${dateToString(date)} ${date
.getHours()
.toString()
.padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`;
}
const getAge = (dateString?: string | null, fromDateString?: string | null) => {
if (!dateString) return 0;
@@ -355,6 +419,10 @@ const TextUtils = {
secondsToTimestamp,
fileNameFromPath,
stringToDate,
stringToFuzzyDate,
stringToFuzzyDateTime,
dateToString,
dateTimeToString,
age: getAge,
bitRate,
resolution,