Refactor bulk edit dialogs (#6647)

* Add BulkUpdateDateInput
* Refactor edit scenes dialog
* Improve bulk date input styling
* Make fields inline in edit performers dialog
* Refactor edit images dialog
* Refactor edit galleries dialog
* Add date and synopsis to bulk update group input
* Refactor edit groups dialog
* Change edit dialog titles to 'Edit x entities'
* Update styling of bulk fields to be consistent with other UI
* Rename BulkUpdateTextInput to generic BulkUpdate

We'll collect other bulk inputs here

* Add and use BulkUpdateFormGroup
* Handle null dates correctly
* Add date clear button and validation
This commit is contained in:
WithoutPants 2026-03-14 17:56:31 +11:00 committed by GitHub
parent 300e7edb75
commit b8bd8953f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1253 additions and 1155 deletions

View file

@ -99,6 +99,8 @@ input BulkGroupUpdateInput {
ids: [ID!]
# rating expressed as 1-100
rating100: Int
date: String
synopsis: String
studio_id: ID
director: String
urls: BulkUpdateStrings

View file

@ -227,6 +227,12 @@ func (r *mutationResolver) GroupUpdate(ctx context.Context, input GroupUpdateInp
func groupPartialFromBulkGroupUpdateInput(translator changesetTranslator, input BulkGroupUpdateInput) (ret models.GroupPartial, err error) {
updatedGroup := models.NewGroupPartial()
updatedGroup.Date, err = translator.optionalDate(input.Date, "date")
if err != nil {
err = fmt.Errorf("converting date: %w", err)
return
}
updatedGroup.Synopsis = translator.optionalString(input.Synopsis, "synopsis")
updatedGroup.Rating = translator.optionalInt(input.Rating100, "rating100")
updatedGroup.Director = translator.optionalString(input.Director, "director")

View file

@ -1,100 +1,129 @@
import React, { useEffect, useState } from "react";
import { Form, Col, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import isEqual from "lodash-es/isEqual";
import React, { useEffect, useMemo, useState } from "react";
import { Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import { useBulkGalleryUpdate } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql";
import { StudioSelect } from "../Shared/Select";
import { ModalComponent } from "../Shared/Modal";
import { useToast } from "src/hooks/Toast";
import * as FormUtils from "src/utils/form";
import { MultiSet } from "../Shared/MultiSet";
import { useToast } from "src/hooks/Toast";
import { RatingSystem } from "../Shared/Rating/RatingSystem";
import {
getAggregateInputIDs,
getAggregateInputValue,
getAggregatePerformerIds,
getAggregateRating,
getAggregateStudioId,
getAggregateStateObject,
getAggregateTagIds,
getAggregateStudioId,
getAggregateSceneIds,
} from "src/utils/bulkUpdate";
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox";
import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate";
import { BulkUpdateDateInput } from "../Shared/DateInput";
import { getDateError } from "src/utils/yup";
interface IListOperationProps {
selected: GQL.SlimGalleryDataFragment[];
onClose: (applied: boolean) => void;
}
const galleryFields = [
"code",
"rating100",
"details",
"organized",
"photographer",
"date",
];
export const EditGalleriesDialog: React.FC<IListOperationProps> = (
props: IListOperationProps
) => {
const intl = useIntl();
const Toast = useToast();
const [rating100, setRating] = useState<number>();
const [studioId, setStudioId] = useState<string>();
const [performerMode, setPerformerMode] =
React.useState<GQL.BulkUpdateIdMode>(GQL.BulkUpdateIdMode.Add);
const [performerIds, setPerformerIds] = useState<string[]>();
const [existingPerformerIds, setExistingPerformerIds] = useState<string[]>();
const [tagMode, setTagMode] = React.useState<GQL.BulkUpdateIdMode>(
GQL.BulkUpdateIdMode.Add
);
const [tagIds, setTagIds] = useState<string[]>();
const [existingTagIds, setExistingTagIds] = useState<string[]>();
const [organized, setOrganized] = useState<boolean | undefined>();
const [updateInput, setUpdateInput] = useState<GQL.BulkGalleryUpdateInput>({
ids: props.selected.map((gallery) => {
return gallery.id;
}),
});
const [performerIds, setPerformerIds] = useState<GQL.BulkUpdateIds>({
mode: GQL.BulkUpdateIdMode.Add,
});
const [tagIds, setTagIds] = useState<GQL.BulkUpdateIds>({
mode: GQL.BulkUpdateIdMode.Add,
});
const [sceneIds, setSceneIds] = useState<GQL.BulkUpdateIds>({
mode: GQL.BulkUpdateIdMode.Add,
});
const unsetDisabled = props.selected.length < 2;
const [dateError, setDateError] = useState<string | undefined>();
const [updateGalleries] = useBulkGalleryUpdate();
// Network state
const [isUpdating, setIsUpdating] = useState(false);
const checkboxRef = React.createRef<HTMLInputElement>();
const aggregateState = useMemo(() => {
const updateState: Partial<GQL.BulkGalleryUpdateInput> = {};
const state = props.selected;
updateState.studio_id = getAggregateStudioId(props.selected);
const updateTagIds = getAggregateTagIds(props.selected);
const updatePerformerIds = getAggregatePerformerIds(props.selected);
const updateSceneIds = getAggregateSceneIds(props.selected);
let first = true;
state.forEach((gallery: GQL.SlimGalleryDataFragment) => {
getAggregateStateObject(updateState, gallery, galleryFields, first);
first = false;
});
return {
state: updateState,
tagIds: updateTagIds,
performerIds: updatePerformerIds,
sceneIds: updateSceneIds,
};
}, [props.selected]);
// update initial state from aggregate
useEffect(() => {
setUpdateInput((current) => ({ ...current, ...aggregateState.state }));
}, [aggregateState]);
useEffect(() => {
setDateError(getDateError(updateInput.date ?? "", intl));
}, [updateInput.date, intl]);
function setUpdateField(input: Partial<GQL.BulkGalleryUpdateInput>) {
setUpdateInput((current) => ({ ...current, ...input }));
}
function getGalleryInput(): GQL.BulkGalleryUpdateInput {
// need to determine what we are actually setting on each gallery
const aggregateRating = getAggregateRating(props.selected);
const aggregateStudioId = getAggregateStudioId(props.selected);
const aggregatePerformerIds = getAggregatePerformerIds(props.selected);
const aggregateTagIds = getAggregateTagIds(props.selected);
const galleryInput: GQL.BulkGalleryUpdateInput = {
ids: props.selected.map((gallery) => {
return gallery.id;
}),
...updateInput,
tag_ids: tagIds,
performer_ids: performerIds,
scene_ids: sceneIds,
};
galleryInput.rating100 = getAggregateInputValue(rating100, aggregateRating);
galleryInput.studio_id = getAggregateInputValue(
studioId,
aggregateStudioId
// we don't have unset functionality for the rating star control
// so need to determine if we are setting a rating or not
galleryInput.rating100 = getAggregateInputValue(
updateInput.rating100,
aggregateState.state.rating100
);
galleryInput.performer_ids = getAggregateInputIDs(
performerMode,
performerIds,
aggregatePerformerIds
);
galleryInput.tag_ids = getAggregateInputIDs(
tagMode,
tagIds,
aggregateTagIds
);
if (organized !== undefined) {
galleryInput.organized = organized;
}
return galleryInput;
}
async function onSave() {
setIsUpdating(true);
try {
await updateGalleries({
variables: {
input: getGalleryInput(),
},
});
await updateGalleries({ variables: { input: getGalleryInput() } });
Toast.success(
intl.formatMessage(
{ id: "toast.updated_entity" },
@ -110,129 +139,13 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
setIsUpdating(false);
}
useEffect(() => {
const state = props.selected;
let updateRating: number | undefined;
let updateStudioID: string | undefined;
let updatePerformerIds: string[] = [];
let updateTagIds: string[] = [];
let updateOrganized: boolean | undefined;
let first = true;
state.forEach((gallery: GQL.SlimGalleryDataFragment) => {
const galleryRating = gallery.rating100;
const GalleriestudioID = gallery?.studio?.id;
const galleryPerformerIDs = (gallery.performers ?? [])
.map((p) => p.id)
.sort();
const galleryTagIDs = (gallery.tags ?? []).map((p) => p.id).sort();
if (first) {
updateRating = galleryRating ?? undefined;
updateStudioID = GalleriestudioID;
updatePerformerIds = galleryPerformerIDs;
updateTagIds = galleryTagIDs;
updateOrganized = gallery.organized;
first = false;
} else {
if (galleryRating !== updateRating) {
updateRating = undefined;
}
if (GalleriestudioID !== updateStudioID) {
updateStudioID = undefined;
}
if (!isEqual(galleryPerformerIDs, updatePerformerIds)) {
updatePerformerIds = [];
}
if (!isEqual(galleryTagIDs, updateTagIds)) {
updateTagIds = [];
}
if (gallery.organized !== updateOrganized) {
updateOrganized = undefined;
}
}
});
setRating(updateRating);
setStudioId(updateStudioID);
setExistingPerformerIds(updatePerformerIds);
setExistingTagIds(updateTagIds);
setOrganized(updateOrganized);
}, [props.selected]);
useEffect(() => {
if (checkboxRef.current) {
checkboxRef.current.indeterminate = organized === undefined;
}
}, [organized, checkboxRef]);
function renderMultiSelect(
type: "performers" | "tags",
ids: string[] | undefined
) {
let mode = GQL.BulkUpdateIdMode.Add;
let existingIds: string[] | undefined = [];
switch (type) {
case "performers":
mode = performerMode;
existingIds = existingPerformerIds;
break;
case "tags":
mode = tagMode;
existingIds = existingTagIds;
break;
}
return (
<MultiSet
type={type}
disabled={isUpdating}
onUpdate={(itemIDs) => {
switch (type) {
case "performers":
setPerformerIds(itemIDs);
break;
case "tags":
setTagIds(itemIDs);
break;
}
}}
onSetMode={(newMode) => {
switch (type) {
case "performers":
setPerformerMode(newMode);
break;
case "tags":
setTagMode(newMode);
break;
}
}}
existingIds={existingIds ?? []}
ids={ids ?? []}
mode={mode}
menuPortalTarget={document.body}
/>
);
}
function cycleOrganized() {
if (organized) {
setOrganized(undefined);
} else if (organized === undefined) {
setOrganized(false);
} else {
setOrganized(true);
}
}
function render() {
return (
<ModalComponent
show
icon={faPencilAlt}
header={intl.formatMessage(
{ id: "dialogs.edit_entity_title" },
{ id: "dialogs.edit_entity_count_title" },
{
count: props?.selected?.length ?? 1,
singularEntity: intl.formatMessage({ id: "gallery" }),
@ -243,6 +156,7 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
onClick: onSave,
text: intl.formatMessage({ id: "actions.apply" }),
}}
disabled={isUpdating || !!dateError}
cancel={{
onClick: () => props.onClose(false),
text: intl.formatMessage({ id: "actions.cancel" }),
@ -251,55 +165,119 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
isRunning={isUpdating}
>
<Form>
<Form.Group controlId="rating" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
<RatingSystem
value={rating100}
onSetRating={(value) => setRating(value ?? undefined)}
disabled={isUpdating}
/>
</Col>
</Form.Group>
<Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "studio" }),
})}
<Col xs={9}>
<StudioSelect
onSelect={(items) =>
setStudioId(items.length > 0 ? items[0]?.id : undefined)
}
ids={studioId ? [studioId] : []}
isDisabled={isUpdating}
menuPortalTarget={document.body}
/>
</Col>
</Form.Group>
<BulkUpdateFormGroup name="rating">
<RatingSystem
value={updateInput.rating100}
onSetRating={(value) =>
setUpdateField({ rating100: value ?? undefined })
}
disabled={isUpdating}
/>
</BulkUpdateFormGroup>
<Form.Group controlId="performers">
<Form.Label>
<FormattedMessage id="performers" />
</Form.Label>
{renderMultiSelect("performers", performerIds)}
</Form.Group>
<BulkUpdateFormGroup name="scene_code">
<BulkUpdateTextInput
value={updateInput.code}
valueChanged={(newValue) => setUpdateField({ code: newValue })}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="date">
<BulkUpdateDateInput
value={updateInput.date}
valueChanged={(newValue) => setUpdateField({ date: newValue })}
unsetDisabled={unsetDisabled}
error={dateError}
/>
</BulkUpdateFormGroup>
<Form.Group controlId="tags">
<Form.Label>
<FormattedMessage id="tags" />
</Form.Label>
{renderMultiSelect("tags", tagIds)}
</Form.Group>
<BulkUpdateFormGroup name="photographer">
<BulkUpdateTextInput
value={updateInput.photographer}
valueChanged={(newValue) =>
setUpdateField({ photographer: newValue })
}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="studio">
<StudioSelect
onSelect={(items) =>
setUpdateField({
studio_id: items.length > 0 ? items[0]?.id : undefined,
})
}
ids={updateInput.studio_id ? [updateInput.studio_id] : []}
isDisabled={isUpdating}
menuPortalTarget={document.body}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="performers" inline={false}>
<MultiSet
type={"performers"}
disabled={isUpdating}
onUpdate={(itemIDs) => {
setPerformerIds((c) => ({ ...c, ids: itemIDs }));
}}
onSetMode={(newMode) => {
setPerformerIds((c) => ({ ...c, mode: newMode }));
}}
ids={performerIds.ids ?? []}
existingIds={aggregateState.performerIds}
mode={performerIds.mode}
menuPortalTarget={document.body}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="scenes" inline={false}>
<MultiSet
type={"scenes"}
disabled={isUpdating}
onUpdate={(itemIDs) => {
setSceneIds((c) => ({ ...c, ids: itemIDs }));
}}
onSetMode={(newMode) => {
setSceneIds((c) => ({ ...c, mode: newMode }));
}}
ids={sceneIds.ids ?? []}
existingIds={aggregateState.sceneIds}
mode={sceneIds.mode}
menuPortalTarget={document.body}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="tags" inline={false}>
<MultiSet
type={"tags"}
disabled={isUpdating}
onUpdate={(itemIDs) => {
setTagIds((c) => ({ ...c, ids: itemIDs }));
}}
onSetMode={(newMode) => {
setTagIds((c) => ({ ...c, mode: newMode }));
}}
ids={tagIds.ids ?? []}
existingIds={aggregateState.tagIds}
mode={tagIds.mode}
menuPortalTarget={document.body}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="details" inline={false}>
<BulkUpdateTextInput
value={updateInput.details}
valueChanged={(newValue) => setUpdateField({ details: newValue })}
unsetDisabled={unsetDisabled}
as="textarea"
/>
</BulkUpdateFormGroup>
<Form.Group controlId="organized">
<Form.Check
type="checkbox"
<IndeterminateCheckbox
label={intl.formatMessage({ id: "organized" })}
checked={organized}
ref={checkboxRef}
onChange={() => cycleOrganized()}
setChecked={(checked) => setUpdateField({ organized: checked })}
checked={updateInput.organized ?? undefined}
/>
</Form.Group>
</Form>

View file

@ -1,26 +1,26 @@
import React, { useEffect, useState } from "react";
import { Form, Col, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import React, { useEffect, useMemo, useState } from "react";
import { Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import { useBulkGroupUpdate } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql";
import { ModalComponent } from "../Shared/Modal";
import { StudioSelect } from "../Shared/Select";
import { ModalComponent } from "../Shared/Modal";
import { MultiSet } from "../Shared/MultiSet";
import { useToast } from "src/hooks/Toast";
import * as FormUtils from "src/utils/form";
import { RatingSystem } from "../Shared/Rating/RatingSystem";
import {
getAggregateIds,
getAggregateInputIDs,
getAggregateInputValue,
getAggregateRating,
getAggregateStudioId,
getAggregateStateObject,
getAggregateTagIds,
getAggregateStudioId,
getAggregateIds,
} from "src/utils/bulkUpdate";
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
import { isEqual } from "lodash-es";
import { MultiSet } from "../Shared/MultiSet";
import { ContainingGroupsMultiSet } from "./ContainingGroupsMultiSet";
import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate";
import { BulkUpdateDateInput } from "../Shared/DateInput";
import { IRelatedGroupEntry } from "./GroupDetails/RelatedGroupTable";
import { ContainingGroupsMultiSet } from "./ContainingGroupsMultiSet";
import { getDateError } from "src/utils/yup";
interface IListOperationProps {
selected: GQL.ListGroupDataFragment[];
@ -67,50 +67,86 @@ function getAggregateContainingGroupInput(
return undefined;
}
const groupFields = ["rating100", "synopsis", "director", "date"];
export const EditGroupsDialog: React.FC<IListOperationProps> = (
props: IListOperationProps
) => {
const intl = useIntl();
const Toast = useToast();
const [rating100, setRating] = useState<number | undefined>();
const [studioId, setStudioId] = useState<string | undefined>();
const [director, setDirector] = useState<string | undefined>();
const [tagMode, setTagMode] = React.useState<GQL.BulkUpdateIdMode>(
GQL.BulkUpdateIdMode.Add
);
const [tagIds, setTagIds] = useState<string[]>();
const [existingTagIds, setExistingTagIds] = useState<string[]>();
const [updateInput, setUpdateInput] = useState<GQL.BulkGroupUpdateInput>({
ids: props.selected.map((group) => {
return group.id;
}),
});
const [tagIds, setTagIds] = useState<GQL.BulkUpdateIds>({
mode: GQL.BulkUpdateIdMode.Add,
});
const [containingGroupsMode, setGroupMode] =
React.useState<GQL.BulkUpdateIdMode>(GQL.BulkUpdateIdMode.Add);
const [containingGroups, setGroups] = useState<IRelatedGroupEntry[]>();
const [existingContainingGroups, setExistingContainingGroups] =
useState<IRelatedGroupEntry[]>();
const [updateGroups] = useBulkGroupUpdate(getGroupInput());
const unsetDisabled = props.selected.length < 2;
const [updateGroups] = useBulkGroupUpdate();
const [dateError, setDateError] = useState<string | undefined>();
// Network state
const [isUpdating, setIsUpdating] = useState(false);
function getGroupInput(): GQL.BulkGroupUpdateInput {
const aggregateRating = getAggregateRating(props.selected);
const aggregateStudioId = getAggregateStudioId(props.selected);
const aggregateTagIds = getAggregateTagIds(props.selected);
const aggregateState = useMemo(() => {
const updateState: Partial<GQL.BulkGroupUpdateInput> = {};
const state = props.selected;
updateState.studio_id = getAggregateStudioId(props.selected);
const updateTagIds = getAggregateTagIds(props.selected);
const aggregateGroups = getAggregateContainingGroups(props.selected);
let first = true;
state.forEach((group: GQL.ListGroupDataFragment) => {
getAggregateStateObject(updateState, group, groupFields, first);
first = false;
});
return {
state: updateState,
tagIds: updateTagIds,
containingGroups: aggregateGroups,
};
}, [props.selected]);
// update initial state from aggregate
useEffect(() => {
setUpdateInput((current) => ({ ...current, ...aggregateState.state }));
}, [aggregateState]);
useEffect(() => {
setDateError(getDateError(updateInput.date ?? "", intl));
}, [updateInput.date, intl]);
function setUpdateField(input: Partial<GQL.BulkGroupUpdateInput>) {
setUpdateInput((current) => ({ ...current, ...input }));
}
function getGroupInput(): GQL.BulkGroupUpdateInput {
const groupInput: GQL.BulkGroupUpdateInput = {
ids: props.selected.map((group) => group.id),
director,
...updateInput,
tag_ids: tagIds,
};
groupInput.rating100 = getAggregateInputValue(rating100, aggregateRating);
groupInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId);
groupInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds);
// we don't have unset functionality for the rating star control
// so need to determine if we are setting a rating or not
groupInput.rating100 = getAggregateInputValue(
updateInput.rating100,
aggregateState.state.rating100
);
groupInput.containing_groups = getAggregateContainingGroupInput(
containingGroupsMode,
containingGroups,
aggregateGroups
aggregateState.containingGroups
);
return groupInput;
@ -119,13 +155,11 @@ export const EditGroupsDialog: React.FC<IListOperationProps> = (
async function onSave() {
setIsUpdating(true);
try {
await updateGroups();
await updateGroups({ variables: { input: getGroupInput() } });
Toast.success(
intl.formatMessage(
{ id: "toast.updated_entity" },
{
entity: intl.formatMessage({ id: "groups" }).toLocaleLowerCase(),
}
{ entity: intl.formatMessage({ id: "groups" }).toLocaleLowerCase() }
)
);
props.onClose(true);
@ -135,67 +169,24 @@ export const EditGroupsDialog: React.FC<IListOperationProps> = (
setIsUpdating(false);
}
useEffect(() => {
const state = props.selected;
let updateRating: number | undefined;
let updateStudioId: string | undefined;
let updateTagIds: string[] = [];
let updateContainingGroupIds: IRelatedGroupEntry[] = [];
let updateDirector: string | undefined;
let first = true;
state.forEach((group: GQL.ListGroupDataFragment) => {
const groupTagIDs = (group.tags ?? []).map((p) => p.id).sort();
const groupContainingGroupIDs = (group.containing_groups ?? []).sort(
(a, b) => a.group.id.localeCompare(b.group.id)
);
if (first) {
first = false;
updateRating = group.rating100 ?? undefined;
updateStudioId = group.studio?.id ?? undefined;
updateTagIds = groupTagIDs;
updateContainingGroupIds = groupContainingGroupIDs;
updateDirector = group.director ?? undefined;
} else {
if (group.rating100 !== updateRating) {
updateRating = undefined;
}
if (group.studio?.id !== updateStudioId) {
updateStudioId = undefined;
}
if (group.director !== updateDirector) {
updateDirector = undefined;
}
if (!isEqual(groupTagIDs, updateTagIds)) {
updateTagIds = [];
}
if (!isEqual(groupContainingGroupIDs, updateContainingGroupIds)) {
updateTagIds = [];
}
}
});
setRating(updateRating);
setStudioId(updateStudioId);
setExistingTagIds(updateTagIds);
setExistingContainingGroups(updateContainingGroupIds);
setDirector(updateDirector);
}, [props.selected]);
function render() {
return (
<ModalComponent
show
icon={faPencilAlt}
header={intl.formatMessage(
{ id: "actions.edit_entity" },
{ entityType: intl.formatMessage({ id: "groups" }) }
{ id: "dialogs.edit_entity_count_title" },
{
count: props?.selected?.length ?? 1,
singularEntity: intl.formatMessage({ id: "group" }),
pluralEntity: intl.formatMessage({ id: "groups" }),
}
)}
accept={{
onClick: onSave,
text: intl.formatMessage({ id: "actions.apply" }),
}}
disabled={isUpdating || !!dateError}
cancel={{
onClick: () => props.onClose(false),
text: intl.formatMessage({ id: "actions.cancel" }),
@ -204,74 +195,90 @@ export const EditGroupsDialog: React.FC<IListOperationProps> = (
isRunning={isUpdating}
>
<Form>
<Form.Group controlId="rating" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
<RatingSystem
value={rating100}
onSetRating={(value) => setRating(value ?? undefined)}
disabled={isUpdating}
/>
</Col>
</Form.Group>
<Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "studio" }),
})}
<Col xs={9}>
<StudioSelect
onSelect={(items) =>
setStudioId(items.length > 0 ? items[0]?.id : undefined)
}
ids={studioId ? [studioId] : []}
isDisabled={isUpdating}
menuPortalTarget={document.body}
/>
</Col>
</Form.Group>
<Form.Group controlId="containing-groups">
<Form.Label>
<FormattedMessage id="containing_groups" />
</Form.Label>
<BulkUpdateFormGroup name="rating">
<RatingSystem
value={updateInput.rating100}
onSetRating={(value) =>
setUpdateField({ rating100: value ?? undefined })
}
disabled={isUpdating}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="date">
<BulkUpdateDateInput
value={updateInput.date}
valueChanged={(newValue) => setUpdateField({ date: newValue })}
unsetDisabled={unsetDisabled}
error={dateError}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="director">
<BulkUpdateTextInput
value={updateInput.director}
valueChanged={(newValue) =>
setUpdateField({ director: newValue })
}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="studio">
<StudioSelect
onSelect={(items) =>
setUpdateField({
studio_id: items.length > 0 ? items[0]?.id : undefined,
})
}
ids={updateInput.studio_id ? [updateInput.studio_id] : []}
isDisabled={isUpdating}
menuPortalTarget={document.body}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup
name="containing-groups"
messageId="containing_groups"
inline={false}
>
<ContainingGroupsMultiSet
disabled={isUpdating}
onUpdate={(v) => setGroups(v)}
onSetMode={(newMode) => setGroupMode(newMode)}
existingValue={existingContainingGroups ?? []}
existingValue={aggregateState.containingGroups ?? []}
value={containingGroups ?? []}
mode={containingGroupsMode}
menuPortalTarget={document.body}
/>
</Form.Group>
<Form.Group controlId="director">
<Form.Label>
<FormattedMessage id="director" />
</Form.Label>
<Form.Control
className="input-control"
type="text"
value={director}
onChange={(event) => setDirector(event.currentTarget.value)}
placeholder={intl.formatMessage({ id: "director" })}
/>
</Form.Group>
<Form.Group controlId="tags">
<Form.Label>
<FormattedMessage id="tags" />
</Form.Label>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="tags" inline={false}>
<MultiSet
type="tags"
type={"tags"}
disabled={isUpdating}
onUpdate={(itemIDs) => setTagIds(itemIDs)}
onSetMode={(newMode) => setTagMode(newMode)}
existingIds={existingTagIds ?? []}
ids={tagIds ?? []}
mode={tagMode}
onUpdate={(itemIDs) => {
setTagIds((c) => ({ ...c, ids: itemIDs }));
}}
onSetMode={(newMode) => {
setTagIds((c) => ({ ...c, mode: newMode }));
}}
ids={tagIds.ids ?? []}
existingIds={aggregateState.tagIds}
mode={tagIds.mode}
menuPortalTarget={document.body}
/>
</Form.Group>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="synopsis" inline={false}>
<BulkUpdateTextInput
value={updateInput.synopsis}
valueChanged={(newValue) =>
setUpdateField({ synopsis: newValue })
}
unsetDisabled={unsetDisabled}
as="textarea"
/>
</BulkUpdateFormGroup>
</Form>
</ModalComponent>
);

View file

@ -1,96 +1,121 @@
import React, { useEffect, useState } from "react";
import { Form, Col, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import isEqual from "lodash-es/isEqual";
import React, { useEffect, useMemo, useState } from "react";
import { Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import { useBulkImageUpdate } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql";
import { StudioSelect } from "src/components/Shared/Select";
import { ModalComponent } from "src/components/Shared/Modal";
import { useToast } from "src/hooks/Toast";
import * as FormUtils from "src/utils/form";
import { StudioSelect } from "../Shared/Select";
import { ModalComponent } from "../Shared/Modal";
import { MultiSet } from "../Shared/MultiSet";
import { useToast } from "src/hooks/Toast";
import { RatingSystem } from "../Shared/Rating/RatingSystem";
import {
getAggregateGalleryIds,
getAggregateInputIDs,
getAggregateInputValue,
getAggregatePerformerIds,
getAggregateRating,
getAggregateStudioId,
getAggregateStateObject,
getAggregateTagIds,
getAggregateStudioId,
getAggregateGalleryIds,
} from "src/utils/bulkUpdate";
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox";
import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate";
import { BulkUpdateDateInput } from "../Shared/DateInput";
import { getDateError } from "src/utils/yup";
interface IListOperationProps {
selected: GQL.SlimImageDataFragment[];
onClose: (applied: boolean) => void;
}
const imageFields = [
"code",
"rating100",
"details",
"organized",
"photographer",
"date",
];
export const EditImagesDialog: React.FC<IListOperationProps> = (
props: IListOperationProps
) => {
const intl = useIntl();
const Toast = useToast();
const [rating100, setRating] = useState<number>();
const [studioId, setStudioId] = useState<string>();
const [performerMode, setPerformerMode] =
React.useState<GQL.BulkUpdateIdMode>(GQL.BulkUpdateIdMode.Add);
const [performerIds, setPerformerIds] = useState<string[]>();
const [existingPerformerIds, setExistingPerformerIds] = useState<string[]>();
const [tagMode, setTagMode] = React.useState<GQL.BulkUpdateIdMode>(
GQL.BulkUpdateIdMode.Add
);
const [tagIds, setTagIds] = useState<string[]>();
const [existingTagIds, setExistingTagIds] = useState<string[]>();
const [updateInput, setUpdateInput] = useState<GQL.BulkImageUpdateInput>({
ids: props.selected.map((image) => {
return image.id;
}),
});
const [galleryMode, setGalleryMode] = React.useState<GQL.BulkUpdateIdMode>(
GQL.BulkUpdateIdMode.Add
);
const [galleryIds, setGalleryIds] = useState<string[]>();
const [existingGalleryIds, setExistingGalleryIds] = useState<string[]>();
const [performerIds, setPerformerIds] = useState<GQL.BulkUpdateIds>({
mode: GQL.BulkUpdateIdMode.Add,
});
const [tagIds, setTagIds] = useState<GQL.BulkUpdateIds>({
mode: GQL.BulkUpdateIdMode.Add,
});
const [galleryIds, setGalleryIds] = useState<GQL.BulkUpdateIds>({
mode: GQL.BulkUpdateIdMode.Add,
});
const [organized, setOrganized] = useState<boolean | undefined>();
const unsetDisabled = props.selected.length < 2;
const [dateError, setDateError] = useState<string | undefined>();
const [updateImages] = useBulkImageUpdate();
// Network state
const [isUpdating, setIsUpdating] = useState(false);
const checkboxRef = React.createRef<HTMLInputElement>();
const aggregateState = useMemo(() => {
const updateState: Partial<GQL.BulkImageUpdateInput> = {};
const state = props.selected;
updateState.studio_id = getAggregateStudioId(props.selected);
const updateTagIds = getAggregateTagIds(props.selected);
const updatePerformerIds = getAggregatePerformerIds(props.selected);
const updateGalleryIds = getAggregateGalleryIds(props.selected);
let first = true;
state.forEach((image: GQL.SlimImageDataFragment) => {
getAggregateStateObject(updateState, image, imageFields, first);
first = false;
});
return {
state: updateState,
tagIds: updateTagIds,
performerIds: updatePerformerIds,
galleryIds: updateGalleryIds,
};
}, [props.selected]);
// update initial state from aggregate
useEffect(() => {
setUpdateInput((current) => ({ ...current, ...aggregateState.state }));
}, [aggregateState]);
useEffect(() => {
setDateError(getDateError(updateInput.date ?? "", intl));
}, [updateInput.date, intl]);
function setUpdateField(input: Partial<GQL.BulkImageUpdateInput>) {
setUpdateInput((current) => ({ ...current, ...input }));
}
function getImageInput(): GQL.BulkImageUpdateInput {
// need to determine what we are actually setting on each image
const aggregateRating = getAggregateRating(props.selected);
const aggregateStudioId = getAggregateStudioId(props.selected);
const aggregatePerformerIds = getAggregatePerformerIds(props.selected);
const aggregateTagIds = getAggregateTagIds(props.selected);
const aggregateGalleryIds = getAggregateGalleryIds(props.selected);
const imageInput: GQL.BulkImageUpdateInput = {
ids: props.selected.map((image) => {
return image.id;
}),
...updateInput,
tag_ids: tagIds,
performer_ids: performerIds,
gallery_ids: galleryIds,
};
imageInput.rating100 = getAggregateInputValue(rating100, aggregateRating);
imageInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId);
imageInput.performer_ids = getAggregateInputIDs(
performerMode,
performerIds,
aggregatePerformerIds
// we don't have unset functionality for the rating star control
// so need to determine if we are setting a rating or not
imageInput.rating100 = getAggregateInputValue(
updateInput.rating100,
aggregateState.state.rating100
);
imageInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds);
imageInput.gallery_ids = getAggregateInputIDs(
galleryMode,
galleryIds,
aggregateGalleryIds
);
if (organized !== undefined) {
imageInput.organized = organized;
}
return imageInput;
}
@ -98,11 +123,7 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
async function onSave() {
setIsUpdating(true);
try {
await updateImages({
variables: {
input: getImageInput(),
},
});
await updateImages({ variables: { input: getImageInput() } });
Toast.success(
intl.formatMessage(
{ id: "toast.updated_entity" },
@ -116,86 +137,13 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
setIsUpdating(false);
}
useEffect(() => {
const state = props.selected;
let updateRating: number | undefined;
let updateStudioID: string | undefined;
let updatePerformerIds: string[] = [];
let updateTagIds: string[] = [];
let updateGalleryIds: string[] = [];
let updateOrganized: boolean | undefined;
let first = true;
state.forEach((image: GQL.SlimImageDataFragment) => {
const imageRating = image.rating100;
const imageStudioID = image?.studio?.id;
const imagePerformerIDs = (image.performers ?? [])
.map((p) => p.id)
.sort();
const imageTagIDs = (image.tags ?? []).map((p) => p.id).sort();
const imageGalleryIDs = (image.galleries ?? []).map((p) => p.id).sort();
if (first) {
updateRating = imageRating ?? undefined;
updateStudioID = imageStudioID;
updatePerformerIds = imagePerformerIDs;
updateTagIds = imageTagIDs;
updateGalleryIds = imageGalleryIDs;
updateOrganized = image.organized;
first = false;
} else {
if (imageRating !== updateRating) {
updateRating = undefined;
}
if (imageStudioID !== updateStudioID) {
updateStudioID = undefined;
}
if (!isEqual(imagePerformerIDs, updatePerformerIds)) {
updatePerformerIds = [];
}
if (!isEqual(imageTagIDs, updateTagIds)) {
updateTagIds = [];
}
if (!isEqual(imageGalleryIDs, updateGalleryIds)) {
updateGalleryIds = [];
}
if (image.organized !== updateOrganized) {
updateOrganized = undefined;
}
}
});
setRating(updateRating);
setStudioId(updateStudioID);
setExistingPerformerIds(updatePerformerIds);
setExistingTagIds(updateTagIds);
setExistingGalleryIds(updateGalleryIds);
setOrganized(updateOrganized);
}, [props.selected]);
useEffect(() => {
if (checkboxRef.current) {
checkboxRef.current.indeterminate = organized === undefined;
}
}, [organized, checkboxRef]);
function cycleOrganized() {
if (organized) {
setOrganized(undefined);
} else if (organized === undefined) {
setOrganized(false);
} else {
setOrganized(true);
}
}
function render() {
return (
<ModalComponent
show
icon={faPencilAlt}
header={intl.formatMessage(
{ id: "dialogs.edit_entity_title" },
{ id: "dialogs.edit_entity_count_title" },
{
count: props?.selected?.length ?? 1,
singularEntity: intl.formatMessage({ id: "image" }),
@ -206,6 +154,7 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
onClick: onSave,
text: intl.formatMessage({ id: "actions.apply" }),
}}
disabled={isUpdating || !!dateError}
cancel={{
onClick: () => props.onClose(false),
text: intl.formatMessage({ id: "actions.cancel" }),
@ -214,89 +163,120 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
isRunning={isUpdating}
>
<Form>
<Form.Group controlId="rating" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
<RatingSystem
value={rating100}
onSetRating={(value) => setRating(value ?? undefined)}
disabled={isUpdating}
/>
</Col>
</Form.Group>
<Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "studio" }),
})}
<Col xs={9}>
<StudioSelect
onSelect={(items) =>
setStudioId(items.length > 0 ? items[0]?.id : undefined)
}
ids={studioId ? [studioId] : []}
isDisabled={isUpdating}
menuPortalTarget={document.body}
/>
</Col>
</Form.Group>
<Form.Group controlId="performers">
<Form.Label>
<FormattedMessage id="performers" />
</Form.Label>
<MultiSet
type="performers"
<BulkUpdateFormGroup name="rating">
<RatingSystem
value={updateInput.rating100}
onSetRating={(value) =>
setUpdateField({ rating100: value ?? undefined })
}
disabled={isUpdating}
onUpdate={(itemIDs) => setPerformerIds(itemIDs)}
onSetMode={(newMode) => setPerformerMode(newMode)}
existingIds={existingPerformerIds ?? []}
ids={performerIds ?? []}
mode={performerMode}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="scene_code">
<BulkUpdateTextInput
value={updateInput.code}
valueChanged={(newValue) => setUpdateField({ code: newValue })}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="date">
<BulkUpdateDateInput
value={updateInput.date}
valueChanged={(newValue) => setUpdateField({ date: newValue })}
unsetDisabled={unsetDisabled}
error={dateError}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="photographer">
<BulkUpdateTextInput
value={updateInput.photographer}
valueChanged={(newValue) =>
setUpdateField({ photographer: newValue })
}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="studio">
<StudioSelect
onSelect={(items) =>
setUpdateField({
studio_id: items.length > 0 ? items[0]?.id : undefined,
})
}
ids={updateInput.studio_id ? [updateInput.studio_id] : []}
isDisabled={isUpdating}
menuPortalTarget={document.body}
/>
</Form.Group>
</BulkUpdateFormGroup>
<Form.Group controlId="tags">
<Form.Label>
<FormattedMessage id="tags" />
</Form.Label>
<BulkUpdateFormGroup name="performers" inline={false}>
<MultiSet
type="tags"
type={"performers"}
disabled={isUpdating}
onUpdate={(itemIDs) => setTagIds(itemIDs)}
onSetMode={(newMode) => setTagMode(newMode)}
existingIds={existingTagIds ?? []}
ids={tagIds ?? []}
mode={tagMode}
onUpdate={(itemIDs) => {
setPerformerIds((c) => ({ ...c, ids: itemIDs }));
}}
onSetMode={(newMode) => {
setPerformerIds((c) => ({ ...c, mode: newMode }));
}}
ids={performerIds.ids ?? []}
existingIds={aggregateState.performerIds}
mode={performerIds.mode}
menuPortalTarget={document.body}
/>
</Form.Group>
</BulkUpdateFormGroup>
<Form.Group controlId="galleries">
<Form.Label>
<FormattedMessage id="galleries" />
</Form.Label>
<BulkUpdateFormGroup name="galleries" inline={false}>
<MultiSet
type="galleries"
disabled={isUpdating}
onUpdate={(itemIDs) => setGalleryIds(itemIDs)}
onSetMode={(newMode) => setGalleryMode(newMode)}
existingIds={existingGalleryIds ?? []}
ids={galleryIds ?? []}
mode={galleryMode}
onUpdate={(itemIDs) => {
setGalleryIds((c) => ({ ...c, ids: itemIDs }));
}}
onSetMode={(newMode) => {
setGalleryIds((c) => ({ ...c, mode: newMode }));
}}
ids={galleryIds.ids ?? []}
existingIds={aggregateState.galleryIds}
mode={galleryIds.mode}
menuPortalTarget={document.body}
/>
</Form.Group>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="tags" inline={false}>
<MultiSet
type={"tags"}
disabled={isUpdating}
onUpdate={(itemIDs) => {
setTagIds((c) => ({ ...c, ids: itemIDs }));
}}
onSetMode={(newMode) => {
setTagIds((c) => ({ ...c, mode: newMode }));
}}
ids={tagIds.ids ?? []}
existingIds={aggregateState.tagIds}
mode={tagIds.mode}
menuPortalTarget={document.body}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="details" inline={false}>
<BulkUpdateTextInput
value={updateInput.details}
valueChanged={(newValue) => setUpdateField({ details: newValue })}
unsetDisabled={unsetDisabled}
as="textarea"
/>
</BulkUpdateFormGroup>
<Form.Group controlId="organized">
<Form.Check
type="checkbox"
<IndeterminateCheckbox
label={intl.formatMessage({ id: "organized" })}
checked={organized}
ref={checkboxRef}
onChange={() => cycleOrganized()}
setChecked={(checked) => setUpdateField({ organized: checked })}
checked={updateInput.organized ?? undefined}
/>
</Form.Group>
</Form>

View file

@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import { Col, Form, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import { useBulkPerformerUpdate } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql";
import { ModalComponent } from "../Shared/Modal";
@ -23,12 +23,13 @@ import {
stringToCircumcised,
} from "src/utils/circumcised";
import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox";
import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput";
import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate";
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
import * as FormUtils from "src/utils/form";
import { CountrySelect } from "../Shared/CountrySelect";
import { useConfigurationContext } from "src/hooks/Config";
import cx from "classnames";
import { BulkUpdateDateInput } from "../Shared/DateInput";
import { getDateError } from "src/utils/yup";
interface IListOperationProps {
selected: GQL.SlimPerformerDataFragment[];
@ -75,17 +76,30 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
const [aggregateState, setAggregateState] =
useState<GQL.BulkPerformerUpdateInput>({});
// height and weight needs conversion to/from number
const [height, setHeight] = useState<string | undefined>();
const [weight, setWeight] = useState<string | undefined>();
const [penis_length, setPenisLength] = useState<string | undefined>();
const [height, setHeight] = useState<string | undefined | null>();
const [weight, setWeight] = useState<string | undefined | null>();
const [penis_length, setPenisLength] = useState<string | undefined | null>();
const [updateInput, setUpdateInput] = useState<GQL.BulkPerformerUpdateInput>(
{}
);
const genderOptions = [""].concat(genderStrings);
const circumcisedOptions = [""].concat(circumcisedStrings);
const unsetDisabled = props.selected.length < 2;
const [updatePerformers] = useBulkPerformerUpdate(getPerformerInput());
const [birthdateError, setBirthdateError] = useState<string | undefined>();
const [deathDateError, setDeathDateError] = useState<string | undefined>();
useEffect(() => {
setBirthdateError(getDateError(updateInput.birthdate ?? "", intl));
}, [updateInput.birthdate, intl]);
useEffect(() => {
setDeathDateError(getDateError(updateInput.death_date ?? "", intl));
}, [updateInput.death_date, intl]);
// Network state
const [isUpdating, setIsUpdating] = useState(false);
@ -121,14 +135,14 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
);
if (height !== undefined) {
performerInput.height_cm = parseFloat(height);
performerInput.height_cm = height === null ? null : parseFloat(height);
}
if (weight !== undefined) {
performerInput.weight = parseFloat(weight);
performerInput.weight = weight === null ? null : parseFloat(weight);
}
if (penis_length !== undefined) {
performerInput.penis_length = parseFloat(penis_length);
performerInput.penis_length =
penis_length === null ? null : parseFloat(penis_length);
}
return performerInput;
@ -205,25 +219,6 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
setUpdateInput(updateState);
}, [props.selected]);
function renderTextField(
name: string,
value: string | undefined | null,
setter: (newValue: string | undefined) => void
) {
return (
<Form.Group controlId={name} data-field={name}>
<Form.Label>
<FormattedMessage id={name} />
</Form.Label>
<BulkUpdateTextInput
value={value === null ? "" : value ?? undefined}
valueChanged={(newValue) => setter(newValue)}
unsetDisabled={props.selected.length < 2}
/>
</Form.Group>
);
}
function render() {
// sfw class needs to be set because it is outside body
@ -235,13 +230,18 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
show
icon={faPencilAlt}
header={intl.formatMessage(
{ id: "actions.edit_entity" },
{ entityType: intl.formatMessage({ id: "performers" }) }
{ id: "dialogs.edit_entity_count_title" },
{
count: props?.selected?.length ?? 1,
singularEntity: intl.formatMessage({ id: "performer" }),
pluralEntity: intl.formatMessage({ id: "performers" }),
}
)}
accept={{
onClick: onSave,
text: intl.formatMessage({ id: "actions.apply" }),
}}
disabled={isUpdating || !!birthdateError || !!deathDateError}
cancel={{
onClick: () => props.onClose(false),
text: intl.formatMessage({ id: "actions.cancel" }),
@ -249,11 +249,8 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
}}
isRunning={isUpdating}
>
<Form.Group controlId="rating" as={Row} data-field={name}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
<Form>
<BulkUpdateFormGroup name="rating">
<RatingSystem
value={updateInput.rating100}
onSetRating={(value) =>
@ -261,9 +258,8 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
}
disabled={isUpdating}
/>
</Col>
</Form.Group>
<Form>
</BulkUpdateFormGroup>
<Form.Group controlId="favorite">
<IndeterminateCheckbox
setChecked={(checked) => setUpdateField({ favorite: checked })}
@ -272,10 +268,7 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
/>
</Form.Group>
<Form.Group>
<Form.Label>
<FormattedMessage id="gender" />
</Form.Label>
<BulkUpdateFormGroup name="gender">
<Form.Control
as="select"
className="input-control"
@ -292,51 +285,105 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
</option>
))}
</Form.Control>
</Form.Group>
</BulkUpdateFormGroup>
{renderTextField("disambiguation", updateInput.disambiguation, (v) =>
setUpdateField({ disambiguation: v })
)}
{renderTextField("birthdate", updateInput.birthdate, (v) =>
setUpdateField({ birthdate: v })
)}
{renderTextField("death_date", updateInput.death_date, (v) =>
setUpdateField({ death_date: v })
)}
<BulkUpdateFormGroup name="disambiguation">
<BulkUpdateTextInput
value={updateInput.disambiguation}
valueChanged={(newValue) =>
setUpdateField({ disambiguation: newValue })
}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<Form.Group>
<Form.Label>
<FormattedMessage id="country" />
</Form.Label>
<BulkUpdateFormGroup name="birthdate">
<BulkUpdateDateInput
value={updateInput.birthdate}
valueChanged={(newValue) =>
setUpdateField({ birthdate: newValue })
}
unsetDisabled={unsetDisabled}
error={birthdateError}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="death_date">
<BulkUpdateDateInput
value={updateInput.death_date}
valueChanged={(newValue) =>
setUpdateField({ death_date: newValue })
}
unsetDisabled={unsetDisabled}
error={deathDateError}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="country">
<CountrySelect
value={updateInput.country ?? ""}
onChange={(v) => setUpdateField({ country: v })}
showFlag
/>
</Form.Group>
</BulkUpdateFormGroup>
{renderTextField("ethnicity", updateInput.ethnicity, (v) =>
setUpdateField({ ethnicity: v })
)}
{renderTextField("hair_color", updateInput.hair_color, (v) =>
setUpdateField({ hair_color: v })
)}
{renderTextField("eye_color", updateInput.eye_color, (v) =>
setUpdateField({ eye_color: v })
)}
{renderTextField("height", height, (v) => setHeight(v))}
{renderTextField("weight", weight, (v) => setWeight(v))}
{renderTextField("measurements", updateInput.measurements, (v) =>
setUpdateField({ measurements: v })
)}
{renderTextField("penis_length", penis_length, (v) =>
setPenisLength(v)
)}
<BulkUpdateFormGroup name="ethnicity">
<BulkUpdateTextInput
value={updateInput.ethnicity}
valueChanged={(newValue) =>
setUpdateField({ ethnicity: newValue })
}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="hair_color">
<BulkUpdateTextInput
value={updateInput.hair_color}
valueChanged={(newValue) =>
setUpdateField({ hair_color: newValue })
}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="eye_color">
<BulkUpdateTextInput
value={updateInput.eye_color}
valueChanged={(newValue) =>
setUpdateField({ eye_color: newValue })
}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="height">
<BulkUpdateTextInput
value={height}
valueChanged={(newValue) => setHeight(newValue)}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="weight">
<BulkUpdateTextInput
value={weight}
valueChanged={(newValue) => setWeight(newValue)}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="measurements">
<BulkUpdateTextInput
value={updateInput.measurements}
valueChanged={(newValue) =>
setUpdateField({ measurements: newValue })
}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="penis_length">
<BulkUpdateTextInput
value={penis_length}
valueChanged={(newValue) => setPenisLength(newValue)}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<Form.Group data-field="circumcised">
<Form.Label>
<FormattedMessage id="circumcised" />
</Form.Label>
<BulkUpdateFormGroup name="circumcised">
<Form.Control
as="select"
className="input-control"
@ -353,43 +400,68 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
</option>
))}
</Form.Control>
</Form.Group>
</BulkUpdateFormGroup>
{renderTextField("fake_tits", updateInput.fake_tits, (v) =>
setUpdateField({ fake_tits: v })
)}
{renderTextField("tattoos", updateInput.tattoos, (v) =>
setUpdateField({ tattoos: v })
)}
{renderTextField("piercings", updateInput.piercings, (v) =>
setUpdateField({ piercings: v })
)}
{renderTextField(
"career_start",
updateInput.career_start?.toString(),
(v) => setUpdateField({ career_start: v ? parseInt(v) : undefined })
)}
{renderTextField(
"career_end",
updateInput.career_end?.toString(),
(v) => setUpdateField({ career_end: v ? parseInt(v) : undefined })
)}
<BulkUpdateFormGroup name="fake_tits">
<BulkUpdateTextInput
value={updateInput.fake_tits}
valueChanged={(newValue) =>
setUpdateField({ fake_tits: newValue })
}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="tattoos">
<BulkUpdateTextInput
value={updateInput.tattoos}
valueChanged={(newValue) => setUpdateField({ tattoos: newValue })}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="piercings">
<BulkUpdateTextInput
value={updateInput.piercings}
valueChanged={(newValue) =>
setUpdateField({ piercings: newValue })
}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="career_start">
<BulkUpdateTextInput
value={updateInput.career_start?.toString()}
valueChanged={(v) =>
setUpdateField({ career_start: v ? parseInt(v) : undefined })
}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="career_end">
<BulkUpdateTextInput
value={updateInput.career_end?.toString()}
valueChanged={(v) =>
setUpdateField({ career_end: v ? parseInt(v) : undefined })
}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<Form.Group controlId="tags">
<Form.Label>
<FormattedMessage id="tags" />
</Form.Label>
<BulkUpdateFormGroup name="tags" inline={false}>
<MultiSet
type="tags"
type={"tags"}
disabled={isUpdating}
onUpdate={(itemIDs) => setTagIds({ ...tagIds, ids: itemIDs })}
onSetMode={(newMode) => setTagIds({ ...tagIds, mode: newMode })}
existingIds={existingTagIds ?? []}
onUpdate={(itemIDs) => {
setTagIds((c) => ({ ...c, ids: itemIDs }));
}}
onSetMode={(newMode) => {
setTagIds((c) => ({ ...c, mode: newMode }));
}}
ids={tagIds.ids ?? []}
existingIds={existingTagIds}
mode={tagIds.mode}
menuPortalTarget={document.body}
/>
</Form.Group>
</BulkUpdateFormGroup>
<Form.Group controlId="ignore-auto-tags">
<IndeterminateCheckbox

View file

@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from "react";
import { Form } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { useIntl } from "react-intl";
import { useBulkSceneMarkerUpdate } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql";
import { ModalComponent } from "../Shared/Modal";
@ -10,7 +10,7 @@ import {
getAggregateState,
getAggregateStateObject,
} from "src/utils/bulkUpdate";
import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput";
import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate";
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
import { TagSelect } from "../Shared/Select";
@ -38,6 +38,8 @@ export const EditSceneMarkersDialog: React.FC<IListOperationProps> = (
mode: GQL.BulkUpdateIdMode.Add,
});
const unsetDisabled = props.selected.length < 2;
const [updateSceneMarkers] = useBulkSceneMarkerUpdate();
// Network state
@ -115,27 +117,6 @@ export const EditSceneMarkersDialog: React.FC<IListOperationProps> = (
setIsUpdating(false);
}
function renderTextField(
name: string,
value: string | undefined | null,
setter: (newValue: string | undefined) => void,
area: boolean = false
) {
return (
<Form.Group controlId={name}>
<Form.Label>
<FormattedMessage id={name} />
</Form.Label>
<BulkUpdateTextInput
value={value === null ? "" : value ?? undefined}
valueChanged={(newValue) => setter(newValue)}
unsetDisabled={props.selected.length < 2}
as={area ? "textarea" : undefined}
/>
</Form.Group>
);
}
function render() {
return (
<ModalComponent
@ -143,8 +124,12 @@ export const EditSceneMarkersDialog: React.FC<IListOperationProps> = (
show
icon={faPencilAlt}
header={intl.formatMessage(
{ id: "actions.edit_entity" },
{ entityType: intl.formatMessage({ id: "markers" }) }
{ id: "dialogs.edit_entity_count_title" },
{
count: props?.selected?.length ?? 1,
singularEntity: intl.formatMessage({ id: "marker" }),
pluralEntity: intl.formatMessage({ id: "markers" }),
}
)}
accept={{
onClick: onSave,
@ -158,39 +143,39 @@ export const EditSceneMarkersDialog: React.FC<IListOperationProps> = (
isRunning={isUpdating}
>
<Form>
{renderTextField("title", updateInput.title, (newValue) =>
setUpdateField({ title: newValue })
)}
<BulkUpdateFormGroup name="title">
<BulkUpdateTextInput
value={updateInput.title}
valueChanged={(newValue) => setUpdateField({ title: newValue })}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<Form.Group controlId="primary-tag">
<Form.Label>
<FormattedMessage id="primary_tag" />
</Form.Label>
<BulkUpdateFormGroup name="primary-tag" messageId="primary_tag">
<TagSelect
onSelect={(t) => setUpdateField({ primary_tag_id: t[0]?.id })}
ids={
updateInput.primary_tag_id ? [updateInput.primary_tag_id] : []
}
/>
</Form.Group>
</BulkUpdateFormGroup>
<Form.Group controlId="tags">
<Form.Label>
<FormattedMessage id="tags" />
</Form.Label>
<BulkUpdateFormGroup name="tags" inline={false}>
<MultiSet
type="tags"
type={"tags"}
disabled={isUpdating}
onUpdate={(itemIDs) => setTagIds((v) => ({ ...v, ids: itemIDs }))}
onSetMode={(newMode) =>
setTagIds((v) => ({ ...v, mode: newMode }))
}
existingIds={aggregateState.tagIds ?? []}
onUpdate={(itemIDs) => {
setTagIds((c) => ({ ...c, ids: itemIDs }));
}}
onSetMode={(newMode) => {
setTagIds((c) => ({ ...c, mode: newMode }));
}}
ids={tagIds.ids ?? []}
existingIds={aggregateState.tagIds ?? []}
mode={tagIds.mode}
menuPortalTarget={document.body}
/>
</Form.Group>
</BulkUpdateFormGroup>
</Form>
</ModalComponent>
);

View file

@ -1,93 +1,121 @@
import React, { useEffect, useState } from "react";
import { Form, Col, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import isEqual from "lodash-es/isEqual";
import React, { useEffect, useMemo, useState } from "react";
import { Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import { useBulkSceneUpdate } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql";
import { StudioSelect } from "../Shared/Select";
import { ModalComponent } from "../Shared/Modal";
import { MultiSet } from "../Shared/MultiSet";
import { useToast } from "src/hooks/Toast";
import * as FormUtils from "src/utils/form";
import { RatingSystem } from "../Shared/Rating/RatingSystem";
import {
getAggregateInputIDs,
getAggregateInputValue,
getAggregateGroupIds,
getAggregatePerformerIds,
getAggregateRating,
getAggregateStudioId,
getAggregateStateObject,
getAggregateTagIds,
getAggregateStudioId,
} from "src/utils/bulkUpdate";
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox";
import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate";
import { BulkUpdateDateInput } from "../Shared/DateInput";
import { getDateError } from "src/utils/yup";
interface IListOperationProps {
selected: GQL.SlimSceneDataFragment[];
onClose: (applied: boolean) => void;
}
const sceneFields = [
"code",
"rating100",
"details",
"organized",
"director",
"date",
];
export const EditScenesDialog: React.FC<IListOperationProps> = (
props: IListOperationProps
) => {
const intl = useIntl();
const Toast = useToast();
const [rating100, setRating] = useState<number>();
const [studioId, setStudioId] = useState<string>();
const [performerMode, setPerformerMode] =
React.useState<GQL.BulkUpdateIdMode>(GQL.BulkUpdateIdMode.Add);
const [performerIds, setPerformerIds] = useState<string[]>();
const [existingPerformerIds, setExistingPerformerIds] = useState<string[]>();
const [tagMode, setTagMode] = React.useState<GQL.BulkUpdateIdMode>(
GQL.BulkUpdateIdMode.Add
);
const [tagIds, setTagIds] = useState<string[]>();
const [existingTagIds, setExistingTagIds] = useState<string[]>();
const [groupMode, setGroupMode] = React.useState<GQL.BulkUpdateIdMode>(
GQL.BulkUpdateIdMode.Add
);
const [groupIds, setGroupIds] = useState<string[]>();
const [existingGroupIds, setExistingGroupIds] = useState<string[]>();
const [organized, setOrganized] = useState<boolean | undefined>();
const [updateScenes] = useBulkSceneUpdate(getSceneInput());
const [updateInput, setUpdateInput] = useState<GQL.BulkSceneUpdateInput>({
ids: props.selected.map((scene) => {
return scene.id;
}),
});
const [dateError, setDateError] = useState<string | undefined>();
const [performerIds, setPerformerIds] = useState<GQL.BulkUpdateIds>({
mode: GQL.BulkUpdateIdMode.Add,
});
const [tagIds, setTagIds] = useState<GQL.BulkUpdateIds>({
mode: GQL.BulkUpdateIdMode.Add,
});
const [groupIds, setGroupIds] = useState<GQL.BulkUpdateIds>({
mode: GQL.BulkUpdateIdMode.Add,
});
const unsetDisabled = props.selected.length < 2;
const [updateScenes] = useBulkSceneUpdate();
// Network state
const [isUpdating, setIsUpdating] = useState(false);
const checkboxRef = React.createRef<HTMLInputElement>();
const aggregateState = useMemo(() => {
const updateState: Partial<GQL.BulkSceneUpdateInput> = {};
const state = props.selected;
updateState.studio_id = getAggregateStudioId(props.selected);
const updateTagIds = getAggregateTagIds(props.selected);
const updatePerformerIds = getAggregatePerformerIds(props.selected);
const updateGroupIds = getAggregateGroupIds(props.selected);
let first = true;
state.forEach((scene: GQL.SlimSceneDataFragment) => {
getAggregateStateObject(updateState, scene, sceneFields, first);
first = false;
});
return {
state: updateState,
tagIds: updateTagIds,
performerIds: updatePerformerIds,
groupIds: updateGroupIds,
};
}, [props.selected]);
// update initial state from aggregate
useEffect(() => {
setUpdateInput((current) => ({ ...current, ...aggregateState.state }));
}, [aggregateState]);
useEffect(() => {
setDateError(getDateError(updateInput.date ?? "", intl));
}, [updateInput.date, intl]);
function setUpdateField(input: Partial<GQL.BulkSceneUpdateInput>) {
setUpdateInput((current) => ({ ...current, ...input }));
}
function getSceneInput(): GQL.BulkSceneUpdateInput {
// need to determine what we are actually setting on each scene
const aggregateRating = getAggregateRating(props.selected);
const aggregateStudioId = getAggregateStudioId(props.selected);
const aggregatePerformerIds = getAggregatePerformerIds(props.selected);
const aggregateTagIds = getAggregateTagIds(props.selected);
const aggregateGroupIds = getAggregateGroupIds(props.selected);
const sceneInput: GQL.BulkSceneUpdateInput = {
ids: props.selected.map((scene) => {
return scene.id;
}),
...updateInput,
tag_ids: tagIds,
performer_ids: performerIds,
group_ids: groupIds,
};
sceneInput.rating100 = getAggregateInputValue(rating100, aggregateRating);
sceneInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId);
sceneInput.performer_ids = getAggregateInputIDs(
performerMode,
performerIds,
aggregatePerformerIds
// we don't have unset functionality for the rating star control
// so need to determine if we are setting a rating or not
sceneInput.rating100 = getAggregateInputValue(
updateInput.rating100,
aggregateState.state.rating100
);
sceneInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds);
sceneInput.group_ids = getAggregateInputIDs(
groupMode,
groupIds,
aggregateGroupIds
);
if (organized !== undefined) {
sceneInput.organized = organized;
}
return sceneInput;
}
@ -95,7 +123,7 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
async function onSave() {
setIsUpdating(true);
try {
await updateScenes();
await updateScenes({ variables: { input: getSceneInput() } });
Toast.success(
intl.formatMessage(
{ id: "toast.updated_entity" },
@ -109,145 +137,13 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
setIsUpdating(false);
}
useEffect(() => {
const state = props.selected;
let updateRating: number | undefined;
let updateStudioID: string | undefined;
let updatePerformerIds: string[] = [];
let updateTagIds: string[] = [];
let updateGroupIds: string[] = [];
let updateOrganized: boolean | undefined;
let first = true;
state.forEach((scene: GQL.SlimSceneDataFragment) => {
const sceneRating = scene.rating100;
const sceneStudioID = scene?.studio?.id;
const scenePerformerIDs = (scene.performers ?? [])
.map((p) => p.id)
.sort();
const sceneTagIDs = (scene.tags ?? []).map((p) => p.id).sort();
const sceneGroupIDs = (scene.groups ?? []).map((m) => m.group.id).sort();
if (first) {
updateRating = sceneRating ?? undefined;
updateStudioID = sceneStudioID;
updatePerformerIds = scenePerformerIDs;
updateTagIds = sceneTagIDs;
updateGroupIds = sceneGroupIDs;
first = false;
updateOrganized = scene.organized;
} else {
if (sceneRating !== updateRating) {
updateRating = undefined;
}
if (sceneStudioID !== updateStudioID) {
updateStudioID = undefined;
}
if (!isEqual(scenePerformerIDs, updatePerformerIds)) {
updatePerformerIds = [];
}
if (!isEqual(sceneTagIDs, updateTagIds)) {
updateTagIds = [];
}
if (!isEqual(sceneGroupIDs, updateGroupIds)) {
updateGroupIds = [];
}
if (scene.organized !== updateOrganized) {
updateOrganized = undefined;
}
}
});
setRating(updateRating);
setStudioId(updateStudioID);
setExistingPerformerIds(updatePerformerIds);
setExistingTagIds(updateTagIds);
setExistingGroupIds(updateGroupIds);
setOrganized(updateOrganized);
}, [props.selected]);
useEffect(() => {
if (checkboxRef.current) {
checkboxRef.current.indeterminate = organized === undefined;
}
}, [organized, checkboxRef]);
function renderMultiSelect(
type: "performers" | "tags" | "groups",
ids: string[] | undefined
) {
let mode = GQL.BulkUpdateIdMode.Add;
let existingIds: string[] | undefined = [];
switch (type) {
case "performers":
mode = performerMode;
existingIds = existingPerformerIds;
break;
case "tags":
mode = tagMode;
existingIds = existingTagIds;
break;
case "groups":
mode = groupMode;
existingIds = existingGroupIds;
break;
}
return (
<MultiSet
type={type}
disabled={isUpdating}
onUpdate={(itemIDs) => {
switch (type) {
case "performers":
setPerformerIds(itemIDs);
break;
case "tags":
setTagIds(itemIDs);
break;
case "groups":
setGroupIds(itemIDs);
break;
}
}}
onSetMode={(newMode) => {
switch (type) {
case "performers":
setPerformerMode(newMode);
break;
case "tags":
setTagMode(newMode);
break;
case "groups":
setGroupMode(newMode);
break;
}
}}
ids={ids ?? []}
existingIds={existingIds ?? []}
mode={mode}
menuPortalTarget={document.body}
/>
);
}
function cycleOrganized() {
if (organized) {
setOrganized(undefined);
} else if (organized === undefined) {
setOrganized(false);
} else {
setOrganized(true);
}
}
function render() {
return (
<ModalComponent
show
icon={faPencilAlt}
header={intl.formatMessage(
{ id: "dialogs.edit_entity_title" },
{ id: "dialogs.edit_entity_count_title" },
{
count: props?.selected?.length ?? 1,
singularEntity: intl.formatMessage({ id: "scene" }),
@ -258,6 +154,7 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
onClick: onSave,
text: intl.formatMessage({ id: "actions.apply" }),
}}
disabled={isUpdating || !!dateError}
cancel={{
onClick: () => props.onClose(false),
text: intl.formatMessage({ id: "actions.cancel" }),
@ -266,62 +163,121 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
isRunning={isUpdating}
>
<Form>
<Form.Group controlId="rating" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
<RatingSystem
value={rating100}
onSetRating={(value) => setRating(value ?? undefined)}
disabled={isUpdating}
/>
</Col>
</Form.Group>
<Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "studio" }),
})}
<Col xs={9}>
<StudioSelect
onSelect={(items) =>
setStudioId(items.length > 0 ? items[0]?.id : undefined)
}
ids={studioId ? [studioId] : []}
isDisabled={isUpdating}
menuPortalTarget={document.body}
/>
</Col>
</Form.Group>
<BulkUpdateFormGroup name="rating">
<RatingSystem
value={updateInput.rating100}
onSetRating={(value) =>
setUpdateField({ rating100: value ?? undefined })
}
disabled={isUpdating}
/>
</BulkUpdateFormGroup>
<Form.Group controlId="performers">
<Form.Label>
<FormattedMessage id="performers" />
</Form.Label>
{renderMultiSelect("performers", performerIds)}
</Form.Group>
<BulkUpdateFormGroup name="scene_code">
<BulkUpdateTextInput
value={updateInput.code}
valueChanged={(newValue) => setUpdateField({ code: newValue })}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<Form.Group controlId="tags">
<Form.Label>
<FormattedMessage id="tags" />
</Form.Label>
{renderMultiSelect("tags", tagIds)}
</Form.Group>
<BulkUpdateFormGroup name="date">
<BulkUpdateDateInput
value={updateInput.date}
valueChanged={(newValue) => setUpdateField({ date: newValue })}
unsetDisabled={unsetDisabled}
error={dateError}
/>
</BulkUpdateFormGroup>
<Form.Group controlId="groups">
<Form.Label>
<FormattedMessage id="groups" />
</Form.Label>
{renderMultiSelect("groups", groupIds)}
</Form.Group>
<BulkUpdateFormGroup name="director">
<BulkUpdateTextInput
value={updateInput.director}
valueChanged={(newValue) =>
setUpdateField({ director: newValue })
}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="studio">
<StudioSelect
onSelect={(items) =>
setUpdateField({
studio_id: items.length > 0 ? items[0]?.id : undefined,
})
}
ids={updateInput.studio_id ? [updateInput.studio_id] : []}
isDisabled={isUpdating}
menuPortalTarget={document.body}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="performers" inline={false}>
<MultiSet
type={"performers"}
disabled={isUpdating}
onUpdate={(itemIDs) => {
setPerformerIds((c) => ({ ...c, ids: itemIDs }));
}}
onSetMode={(newMode) => {
setPerformerIds((c) => ({ ...c, mode: newMode }));
}}
ids={performerIds.ids ?? []}
existingIds={aggregateState.performerIds}
mode={performerIds.mode}
menuPortalTarget={document.body}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="groups" inline={false}>
<MultiSet
type={"groups"}
disabled={isUpdating}
onUpdate={(itemIDs) => {
setGroupIds((c) => ({ ...c, ids: itemIDs }));
}}
onSetMode={(newMode) => {
setGroupIds((c) => ({ ...c, mode: newMode }));
}}
ids={groupIds.ids ?? []}
existingIds={aggregateState.groupIds}
mode={groupIds.mode}
menuPortalTarget={document.body}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="tags" inline={false}>
<MultiSet
type={"tags"}
disabled={isUpdating}
onUpdate={(itemIDs) => {
setTagIds((c) => ({ ...c, ids: itemIDs }));
}}
onSetMode={(newMode) => {
setTagIds((c) => ({ ...c, mode: newMode }));
}}
ids={tagIds.ids ?? []}
existingIds={aggregateState.tagIds}
mode={tagIds.mode}
menuPortalTarget={document.body}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="details" inline={false}>
<BulkUpdateTextInput
value={updateInput.details}
valueChanged={(newValue) => setUpdateField({ details: newValue })}
unsetDisabled={unsetDisabled}
as="textarea"
/>
</BulkUpdateFormGroup>
<Form.Group controlId="organized">
<Form.Check
type="checkbox"
<IndeterminateCheckbox
label={intl.formatMessage({ id: "organized" })}
checked={organized}
ref={checkboxRef}
onChange={() => cycleOrganized()}
setChecked={(checked) => setUpdateField({ organized: checked })}
checked={updateInput.organized ?? undefined}
/>
</Form.Group>
</Form>

View file

@ -0,0 +1,89 @@
import { faBan } from "@fortawesome/free-solid-svg-icons";
import React from "react";
import {
Button,
Col,
Form,
FormControlProps,
InputGroup,
Row,
} from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { Icon } from "./Icon";
import * as FormUtils from "src/utils/form";
interface IBulkUpdateTextInputProps extends Omit<FormControlProps, "value"> {
valueChanged: (value: string | null | undefined) => void;
value: string | null | undefined;
unsetDisabled?: boolean;
as?: React.ElementType;
}
export const BulkUpdateTextInput: React.FC<IBulkUpdateTextInputProps> = ({
valueChanged,
unsetDisabled,
...props
}) => {
const intl = useIntl();
const value = props.value === null ? "" : props.value ?? undefined;
const unset = value === undefined;
const placeholderValue = unset
? `<${intl.formatMessage({ id: "existing_value" })}>`
: value === ""
? `<${intl.formatMessage({ id: "empty_value" })}>`
: undefined;
return (
<InputGroup className="bulk-update-text-input">
<Form.Control
{...props}
className="text-input"
type="text"
as={props.as}
value={value ?? ""}
placeholder={placeholderValue}
onChange={(event) => valueChanged(event.currentTarget.value)}
/>
<InputGroup.Append>
{!unsetDisabled ? (
<Button
variant="secondary"
onClick={() => valueChanged(undefined)}
title={intl.formatMessage({ id: "actions.unset" })}
disabled={unset}
>
<Icon icon={faBan} />
</Button>
) : undefined}
</InputGroup.Append>
</InputGroup>
);
};
export const BulkUpdateFormGroup: React.FC<{
name: string;
messageId?: string;
inline?: boolean;
}> = ({ name, messageId = name, inline = true, children }) => {
if (inline) {
return (
<Form.Group controlId={name} data-field={name} as={Row}>
{FormUtils.renderLabel({
title: <FormattedMessage id={messageId} />,
})}
<Col xs={9}>{children}</Col>
</Form.Group>
);
}
return (
<Form.Group controlId={name} data-field={name}>
<Form.Label>
<FormattedMessage id={messageId} />
</Form.Label>
{children}
</Form.Group>
);
};

View file

@ -1,48 +0,0 @@
import { faBan } from "@fortawesome/free-solid-svg-icons";
import React from "react";
import { Button, Form, FormControlProps, InputGroup } from "react-bootstrap";
import { useIntl } from "react-intl";
import { Icon } from "./Icon";
interface IBulkUpdateTextInputProps extends FormControlProps {
valueChanged: (value: string | undefined) => void;
unsetDisabled?: boolean;
as?: React.ElementType;
}
export const BulkUpdateTextInput: React.FC<IBulkUpdateTextInputProps> = ({
valueChanged,
unsetDisabled,
...props
}) => {
const intl = useIntl();
const unsetClassName = props.value === undefined ? "unset" : "";
return (
<InputGroup className={`bulk-update-text-input ${unsetClassName}`}>
<Form.Control
{...props}
className="input-control"
type="text"
as={props.as}
value={props.value ?? ""}
placeholder={
props.value === undefined
? `<${intl.formatMessage({ id: "existing_value" })}>`
: undefined
}
onChange={(event) => valueChanged(event.currentTarget.value)}
/>
{!unsetDisabled ? (
<Button
variant="secondary"
onClick={() => valueChanged(undefined)}
title={intl.formatMessage({ id: "actions.unset" })}
>
<Icon icon={faBan} />
</Button>
) : undefined}
</InputGroup>
);
};

View file

@ -8,14 +8,20 @@ import { Icon } from "./Icon";
import "react-datepicker/dist/react-datepicker.css";
import { useIntl } from "react-intl";
import { PatchComponent } from "src/patch";
import { faBan, faTimes } from "@fortawesome/free-solid-svg-icons";
interface IProps {
groupClassName?: string;
className?: string;
disabled?: boolean;
value: string;
isTime?: boolean;
onValueChange(value: string): void;
placeholder?: string;
placeholderOverride?: string;
error?: string;
appendBefore?: React.ReactNode;
appendAfter?: React.ReactNode;
}
const ShowPickerButton = forwardRef<
@ -32,6 +38,11 @@ const ShowPickerButton = forwardRef<
const _DateInput: React.FC<IProps> = (props: IProps) => {
const intl = useIntl();
const {
groupClassName = "date-input-group",
className = "date-input text-input",
} = props;
const date = useMemo(() => {
const toDate = props.isTime
? TextUtils.stringToFuzzyDateTime
@ -70,34 +81,108 @@ const _DateInput: React.FC<IProps> = (props: IProps) => {
}
}
const placeholderText = intl.formatMessage({
const formatHint = intl.formatMessage({
id: props.isTime ? "datetime_format" : "date_format",
});
const placeholderText = props.placeholder
? `${props.placeholder} (${formatHint})`
: formatHint;
return (
<div>
<InputGroup hasValidation>
<Form.Control
className="date-input text-input"
disabled={props.disabled}
value={props.value}
onChange={(e) => 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>
<InputGroup hasValidation className={groupClassName}>
<Form.Control
className={className}
disabled={props.disabled}
value={props.value}
onChange={(e) => props.onValueChange(e.currentTarget.value)}
placeholder={
!props.disabled
? props.placeholderOverride ?? placeholderText
: undefined
}
isInvalid={!!props.error}
/>
<InputGroup.Append>
{props.appendBefore}
{maybeRenderButton()}
{props.appendAfter}
</InputGroup.Append>
<Form.Control.Feedback type="invalid">
{props.error}
</Form.Control.Feedback>
</InputGroup>
);
};
export const DateInput = PatchComponent("DateInput", _DateInput);
interface IBulkUpdateDateInputProps
extends Omit<IProps, "onValueChange" | "value"> {
value: string | null | undefined;
valueChanged: (value: string | null | undefined) => void;
unsetDisabled?: boolean;
as?: React.ElementType;
error?: string;
}
export const BulkUpdateDateInput: React.FC<IBulkUpdateDateInputProps> = ({
valueChanged,
unsetDisabled,
...props
}) => {
const intl = useIntl();
const unset = props.value === undefined;
const unsetButton = !unsetDisabled ? (
<Button
variant="secondary"
onClick={() => valueChanged(undefined)}
title={intl.formatMessage({ id: "actions.unset" })}
disabled={unset}
>
<Icon icon={faBan} />
</Button>
) : undefined;
const clearButton =
props.value !== null ? (
<Button
className="minimal"
variant="secondary"
onClick={() => valueChanged(null)}
title={intl.formatMessage({ id: "actions.clear" })}
>
<Icon icon={faTimes} />
</Button>
) : undefined;
const placeholderValue =
props.value === null
? `<${intl.formatMessage({ id: "empty_value" })}>`
: props.value === undefined
? `<${intl.formatMessage({ id: "existing_value" })}>`
: undefined;
function outValue(v: string | undefined) {
if (v === "") {
return null;
}
return v;
}
return (
<DateInput
{...props}
value={props.value ?? ""}
placeholderOverride={placeholderValue}
onValueChange={(v) => valueChanged(outValue(v))}
groupClassName="bulk-update-date-input"
className="date-input text-input"
appendBefore={clearButton}
appendAfter={unsetButton}
/>
);
};

View file

@ -12,9 +12,10 @@ import { PerformerIDSelect } from "../Performers/PerformerSelect";
import { StudioIDSelect } from "../Studios/StudioSelect";
import { TagIDSelect } from "../Tags/TagSelect";
import { GroupIDSelect } from "../Groups/GroupSelect";
import { SceneIDSelect } from "../Scenes/SceneSelect";
interface IMultiSetProps {
type: "performers" | "studios" | "tags" | "groups" | "galleries";
type: "performers" | "studios" | "tags" | "groups" | "galleries" | "scenes";
existingIds?: string[];
ids?: string[];
mode: GQL.BulkUpdateIdMode;
@ -89,6 +90,17 @@ const Select: React.FC<IMultiSetProps> = (props) => {
menuPortalTarget={props.menuPortalTarget}
/>
);
case "scenes":
return (
<SceneIDSelect
isDisabled={disabled}
isMulti
isClearable={false}
onSelect={onUpdate}
ids={props.ids ?? []}
menuPortalTarget={props.menuPortalTarget}
/>
);
default:
return (
<FilterSelect

View file

@ -494,30 +494,10 @@ button.collapse-button {
}
}
.bulk-update-text-input {
button {
background-color: $secondary;
color: $text-muted;
font-size: $btn-font-size-sm;
margin: $btn-padding-y $btn-padding-x;
padding: 0;
position: absolute;
right: 0;
z-index: 4;
&:hover,
&:focus,
&:active,
&:not(:disabled):not(.disabled):active,
&:not(:disabled):not(.disabled):active:focus {
background-color: $secondary;
border-color: transparent;
box-shadow: none;
}
}
&.unset button {
visibility: hidden;
.bulk-update-date-input {
.react-datepicker-wrapper .btn {
border-bottom-right-radius: 0;
border-top-right-radius: 0;
}
}
@ -1268,3 +1248,8 @@ input[type="range"].double-range-slider-max {
margin-left: 0.25rem;
padding: 0 0.25rem;
}
// general styling for appended minimal button to input group
.text-input + .input-group-append .btn.minimal {
background-color: $textfield-bg;
}

View file

@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from "react";
import { Col, Form, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import { useBulkStudioUpdate } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql";
import { ModalComponent } from "../Shared/Modal";
@ -13,9 +13,8 @@ import {
getAggregateStateObject,
} from "src/utils/bulkUpdate";
import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox";
import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput";
import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate";
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
import * as FormUtils from "src/utils/form";
import { StudioSelect } from "../Shared/Select";
interface IListOperationProps {
@ -47,6 +46,8 @@ export const EditStudiosDialog: React.FC<IListOperationProps> = (
mode: GQL.BulkUpdateIdMode.Add,
});
const unsetDisabled = props.selected.length < 2;
const [updateStudios] = useBulkStudioUpdate();
// Network state
@ -126,27 +127,6 @@ export const EditStudiosDialog: React.FC<IListOperationProps> = (
setIsUpdating(false);
}
function renderTextField(
name: string,
value: string | undefined | null,
setter: (newValue: string | undefined) => void,
area: boolean = false
) {
return (
<Form.Group controlId={name}>
<Form.Label>
<FormattedMessage id={name} />
</Form.Label>
<BulkUpdateTextInput
value={value === null ? "" : value ?? undefined}
valueChanged={(newValue) => setter(newValue)}
unsetDisabled={props.selected.length < 2}
as={area ? "textarea" : undefined}
/>
</Form.Group>
);
}
function render() {
return (
<ModalComponent
@ -154,8 +134,12 @@ export const EditStudiosDialog: React.FC<IListOperationProps> = (
show
icon={faPencilAlt}
header={intl.formatMessage(
{ id: "actions.edit_entity" },
{ entityType: intl.formatMessage({ id: "studios" }) }
{ id: "dialogs.edit_entity_count_title" },
{
count: props?.selected?.length ?? 1,
singularEntity: intl.formatMessage({ id: "studio" }),
pluralEntity: intl.formatMessage({ id: "studios" }),
}
)}
accept={{
onClick: onSave,
@ -168,11 +152,8 @@ export const EditStudiosDialog: React.FC<IListOperationProps> = (
}}
isRunning={isUpdating}
>
<Form.Group controlId="parent-studio" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "parent_studio" }),
})}
<Col xs={9}>
<Form>
<BulkUpdateFormGroup name="parent-studio" messageId="parent_studio">
<StudioSelect
onSelect={(items) =>
setUpdateField({
@ -183,13 +164,8 @@ export const EditStudiosDialog: React.FC<IListOperationProps> = (
isDisabled={isUpdating}
menuPortalTarget={document.body}
/>
</Col>
</Form.Group>
<Form.Group controlId="rating" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="rating">
<RatingSystem
value={updateInput.rating100}
onSetRating={(value) =>
@ -197,9 +173,8 @@ export const EditStudiosDialog: React.FC<IListOperationProps> = (
}
disabled={isUpdating}
/>
</Col>
</Form.Group>
<Form>
</BulkUpdateFormGroup>
<Form.Group controlId="favorite">
<IndeterminateCheckbox
setChecked={(checked) => setUpdateField({ favorite: checked })}
@ -208,30 +183,31 @@ export const EditStudiosDialog: React.FC<IListOperationProps> = (
/>
</Form.Group>
<Form.Group controlId="tags">
<Form.Label>
<FormattedMessage id="tags" />
</Form.Label>
<BulkUpdateFormGroup name="tags" inline={false}>
<MultiSet
type="tags"
type={"tags"}
disabled={isUpdating}
onUpdate={(itemIDs) => setTagIds((v) => ({ ...v, ids: itemIDs }))}
onSetMode={(newMode) =>
setTagIds((v) => ({ ...v, mode: newMode }))
}
existingIds={aggregateState.tagIds ?? []}
onUpdate={(itemIDs) => {
setTagIds((c) => ({ ...c, ids: itemIDs }));
}}
onSetMode={(newMode) => {
setTagIds((c) => ({ ...c, mode: newMode }));
}}
ids={tagIds.ids ?? []}
existingIds={aggregateState.tagIds}
mode={tagIds.mode}
menuPortalTarget={document.body}
/>
</Form.Group>
</BulkUpdateFormGroup>
{renderTextField(
"details",
updateInput.details,
(newValue) => setUpdateField({ details: newValue }),
true
)}
<BulkUpdateFormGroup name="details" inline={false}>
<BulkUpdateTextInput
value={updateInput.details}
valueChanged={(newValue) => setUpdateField({ details: newValue })}
unsetDisabled={unsetDisabled}
as="textarea"
/>
</BulkUpdateFormGroup>
<Form.Group controlId="ignore-auto-tags">
<IndeterminateCheckbox

View file

@ -11,7 +11,7 @@ import {
getAggregateStateObject,
} from "src/utils/bulkUpdate";
import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox";
import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput";
import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate";
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
function Tags(props: {
@ -85,6 +85,8 @@ export const EditTagsDialog: React.FC<IListOperationProps> = (
const [updateInput, setUpdateInput] = useState<GQL.BulkTagUpdateInput>({});
const unsetDisabled = props.selected.length < 2;
const [updateTags] = useBulkTagUpdate(getTagInput());
// Network state
@ -153,33 +155,18 @@ export const EditTagsDialog: React.FC<IListOperationProps> = (
setUpdateInput(updateState);
}, [props.selected]);
function renderTextField(
name: string,
value: string | undefined | null,
setter: (newValue: string | undefined) => void
) {
return (
<Form.Group controlId={name}>
<Form.Label>
<FormattedMessage id={name} />
</Form.Label>
<BulkUpdateTextInput
value={value === null ? "" : value ?? undefined}
valueChanged={(newValue) => setter(newValue)}
unsetDisabled={props.selected.length < 2}
/>
</Form.Group>
);
}
return (
<ModalComponent
dialogClassName="edit-tags-dialog"
show
icon={faPencilAlt}
header={intl.formatMessage(
{ id: "actions.edit_entity" },
{ entityType: intl.formatMessage({ id: "tags" }) }
{ id: "dialogs.edit_entity_count_title" },
{
count: props?.selected?.length ?? 1,
singularEntity: intl.formatMessage({ id: "tag" }),
pluralEntity: intl.formatMessage({ id: "tags" }),
}
)}
accept={{
onClick: onSave,
@ -201,9 +188,16 @@ export const EditTagsDialog: React.FC<IListOperationProps> = (
/>
</Form.Group>
{renderTextField("description", updateInput.description, (v) =>
setUpdateField({ description: v })
)}
<BulkUpdateFormGroup name="description" inline={false}>
<BulkUpdateTextInput
value={updateInput.description}
valueChanged={(newValue) =>
setUpdateField({ description: newValue })
}
unsetDisabled={unsetDisabled}
as="textarea"
/>
</BulkUpdateFormGroup>
<Tags
isUpdating={isUpdating}

View file

@ -625,9 +625,8 @@ export const useSceneUpdate = () =>
},
});
export const useBulkSceneUpdate = (input: GQL.BulkSceneUpdateInput) =>
export const useBulkSceneUpdate = () =>
GQL.useBulkSceneUpdateMutation({
variables: { input },
update(cache, result) {
if (!result.data?.bulkSceneUpdate) return;
@ -1403,9 +1402,8 @@ export const useGroupUpdate = () =>
},
});
export const useBulkGroupUpdate = (input: GQL.BulkGroupUpdateInput) =>
export const useBulkGroupUpdate = () =>
GQL.useBulkGroupUpdateMutation({
variables: { input },
update(cache, result) {
if (!result.data?.bulkGroupUpdate) return;

View file

@ -985,6 +985,7 @@
"delete_object_title": "Delete {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
"dont_show_until_updated": "Don't show until next update",
"edit_entity_title": "Edit {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
"edit_entity_count_title": "Edit {count} {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
"export_include_related_objects": "Include related objects in export",
"export_title": "Export",
"imagewall": {
@ -1147,6 +1148,7 @@
"warmth": "Warmth"
},
"empty_server": "Add some scenes to your server to view recommendations on this page.",
"empty_value": "empty",
"errors": {
"custom_fields": {
"duplicate_field": "Field name must be unique",

View file

@ -81,6 +81,11 @@ export function getAggregateTagIds(state: { tags: IHasID[] }[]) {
return getAggregateIds(sortedLists);
}
export function getAggregateSceneIds(state: { scenes: IHasID[] }[]) {
const sortedLists = state.map((o) => o.scenes.map((oo) => oo.id).sort());
return getAggregateIds(sortedLists);
}
interface IGroup {
group: IHasID;
}

View file

@ -33,7 +33,7 @@ function getLabelProps(labelProps?: FormLabelProps) {
}
export function renderLabel(options: {
title: string;
title: React.ReactNode;
labelProps?: FormLabelProps;
}) {
return (

View file

@ -92,6 +92,37 @@ export function yupUniqueStringList(intl: IntlShape) {
});
}
export function validateDateString(value?: string) {
if (!value) return true;
// Allow YYYY, YYYY-MM, or YYYY-MM-DD formats
if (!value.match(/^\d{4}(-\d{2}(-\d{2})?)?$/)) return false;
// Validate the date components
const parts = value.split("-");
const year = parseInt(parts[0], 10);
if (year < 1 || year > 9999) return false;
if (parts.length >= 2) {
const month = parseInt(parts[1], 10);
if (month < 1 || month > 12) return false;
}
if (parts.length === 3) {
const day = parseInt(parts[2], 10);
if (day < 1 || day > 31) return false;
// Full date - validate it parses correctly
if (Number.isNaN(Date.parse(value))) return false;
}
return true;
}
export function getDateError(
value: string | undefined | null,
intl: IntlShape
) {
if (validateDateString(value ?? "")) return undefined;
return intl
.formatMessage({ id: "validation.date_invalid_form" })
.replace("${path}", intl.formatMessage({ id: "date" }));
}
export function yupDateString(intl: IntlShape) {
return yup
.string()
@ -99,24 +130,7 @@ export function yupDateString(intl: IntlShape) {
.test({
name: "date",
test(value) {
if (!value) return true;
// Allow YYYY, YYYY-MM, or YYYY-MM-DD formats
if (!value.match(/^\d{4}(-\d{2}(-\d{2})?)?$/)) return false;
// Validate the date components
const parts = value.split("-");
const year = parseInt(parts[0], 10);
if (year < 1 || year > 9999) return false;
if (parts.length >= 2) {
const month = parseInt(parts[1], 10);
if (month < 1 || month > 12) return false;
}
if (parts.length === 3) {
const day = parseInt(parts[2], 10);
if (day < 1 || day > 31) return false;
// Full date - validate it parses correctly
if (Number.isNaN(Date.parse(value))) return false;
}
return true;
return validateDateString(value);
},
message: intl.formatMessage({ id: "validation.date_invalid_form" }),
});