Tag select refactor (#4478)

* Add interface to load tags by id
* Use minimal data for tag select queries
* Center image/text in select list
* Overhaul tag select
* Support excludeIds. Comment out image in dropdown
* Replace existing selects
* Remove unused code
* Fix styling of aliases
This commit is contained in:
WithoutPants 2024-01-24 20:24:13 +11:00 committed by GitHub
parent dd8da7f339
commit 723211a620
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 543 additions and 249 deletions

View file

@ -24,3 +24,16 @@ fragment TagData on Tag {
...SlimTagData
}
}
fragment SelectTagData on Tag {
id
name
description
aliases
image_path
parents {
id
name
}
}

View file

@ -21,14 +21,6 @@ query AllMoviesForFilter {
}
}
query AllTagsForFilter {
allTags {
id
name
aliases
}
}
query Stats {
stats {
scene_count

View file

@ -12,3 +12,16 @@ query FindTag($id: ID!) {
...TagData
}
}
query FindTagsForSelect(
$filter: FindFilterType
$tag_filter: TagFilterType
$ids: [Int!]
) {
findTags(filter: $filter, tag_filter: $tag_filter, ids: $ids) {
count
tags {
...SelectTagData
}
}
}

View file

@ -89,6 +89,7 @@ type Query {
findTags(
tag_filter: TagFilterType
filter: FindFilterType
ids: [Int!]
): FindTagsResultType!
"Retrieve random scene markers for the wall"
@ -203,9 +204,9 @@ type Query {
allGalleries: [Gallery!]!
allStudios: [Studio!]!
allMovies: [Movie!]!
allTags: [Tag!]!
allPerformers: [Performer!]! @deprecated(reason: "Use findPerformers instead")
allTags: [Tag!]! @deprecated(reason: "Use findTags instead")
# Get everything with minimal metadata

View file

@ -23,9 +23,19 @@ func (r *queryResolver) FindTag(ctx context.Context, id string) (ret *models.Tag
return ret, nil
}
func (r *queryResolver) FindTags(ctx context.Context, tagFilter *models.TagFilterType, filter *models.FindFilterType) (ret *FindTagsResultType, err error) {
func (r *queryResolver) FindTags(ctx context.Context, tagFilter *models.TagFilterType, filter *models.FindFilterType, ids []int) (ret *FindTagsResultType, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
tags, total, err := r.repository.Tag.Query(ctx, tagFilter, filter)
var tags []*models.Tag
var err error
var total int
if len(ids) > 0 {
tags, err = r.repository.Tag.FindMany(ctx, ids)
total = len(tags)
} else {
tags, total, err = r.repository.Tag.Query(ctx, tagFilter, filter)
}
if err != nil {
return err
}

View file

@ -18,11 +18,7 @@ import {
useListGalleryScrapers,
mutateReloadScrapers,
} from "src/core/StashService";
import {
TagSelect,
SceneSelect,
StudioSelect,
} from "src/components/Shared/Select";
import { SceneSelect, StudioSelect } from "src/components/Shared/Select";
import { Icon } from "src/components/Shared/Icon";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { useToast } from "src/hooks/Toast";
@ -44,6 +40,7 @@ import {
yupUniqueStringList,
} from "src/utils/yup";
import { formikUtils } from "src/utils/form";
import { Tag, TagSelect } from "src/components/Tags/TagSelect";
interface IProps {
gallery: Partial<GQL.GalleryDataFragment>;
@ -68,6 +65,7 @@ export const GalleryEditPanel: React.FC<IProps> = ({
);
const [performers, setPerformers] = useState<Performer[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
const isNew = gallery.id === undefined;
const { configuration: stashConfig } = React.useContext(ConfigurationContext);
@ -146,6 +144,14 @@ export const GalleryEditPanel: React.FC<IProps> = ({
);
}
function onSetTags(items: Tag[]) {
setTags(items);
formik.setFieldValue(
"tag_ids",
items.map((item) => item.id)
);
}
useRatingKeybinds(
isVisible,
stashConfig?.ui?.ratingSystemOptions?.type,
@ -156,6 +162,10 @@ export const GalleryEditPanel: React.FC<IProps> = ({
setPerformers(gallery.performers ?? []);
}, [gallery.performers]);
useEffect(() => {
setTags(gallery.tags ?? []);
}, [gallery.tags]);
useEffect(() => {
if (isVisible) {
Mousetrap.bind("s s", () => {
@ -340,8 +350,15 @@ export const GalleryEditPanel: React.FC<IProps> = ({
});
if (idTags.length > 0) {
const newIds = idTags.map((t) => t.stored_id);
formik.setFieldValue("tag_ids", newIds as string[]);
onSetTags(
idTags.map((p) => {
return {
id: p.stored_id!,
name: p.name ?? "",
aliases: [],
};
})
);
}
}
}
@ -438,13 +455,8 @@ export const GalleryEditPanel: React.FC<IProps> = ({
const control = (
<TagSelect
isMulti
onSelect={(items) =>
formik.setFieldValue(
"tag_ids",
items.map((item) => item.id)
)
}
ids={formik.values.tag_ids}
onSelect={onSetTags}
values={tags}
hoverPlacement="right"
/>
);

View file

@ -4,7 +4,7 @@ import { FormattedMessage, useIntl } from "react-intl";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql";
import * as yup from "yup";
import { TagSelect, StudioSelect } from "src/components/Shared/Select";
import { StudioSelect } from "src/components/Shared/Select";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { useToast } from "src/hooks/Toast";
import { useFormik } from "formik";
@ -22,6 +22,7 @@ import {
PerformerSelect,
} from "src/components/Performers/PerformerSelect";
import { formikUtils } from "src/utils/form";
import { Tag, TagSelect } from "src/components/Tags/TagSelect";
interface IProps {
image: GQL.ImageDataFragment;
@ -45,6 +46,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
const { configuration } = React.useContext(ConfigurationContext);
const [performers, setPerformers] = useState<Performer[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
const schema = yup.object({
title: yup.string().ensure(),
@ -93,6 +95,14 @@ export const ImageEditPanel: React.FC<IProps> = ({
);
}
function onSetTags(items: Tag[]) {
setTags(items);
formik.setFieldValue(
"tag_ids",
items.map((item) => item.id)
);
}
useRatingKeybinds(
true,
configuration?.ui?.ratingSystemOptions?.type,
@ -103,6 +113,10 @@ export const ImageEditPanel: React.FC<IProps> = ({
setPerformers(image.performers ?? []);
}, [image.performers]);
useEffect(() => {
setTags(image.tags ?? []);
}, [image.tags]);
useEffect(() => {
if (isVisible) {
Mousetrap.bind("s s", () => {
@ -196,13 +210,8 @@ export const ImageEditPanel: React.FC<IProps> = ({
const control = (
<TagSelect
isMulti
onSelect={(items) =>
formik.setFieldValue(
"tag_ids",
items.map((item) => item.id)
)
}
ids={formik.values.tag_ids}
onSelect={onSetTags}
values={tags}
hoverPlacement="right"
/>
);

View file

@ -15,7 +15,6 @@ import { Icon } from "src/components/Shared/Icon";
import { ImageInput } from "src/components/Shared/ImageInput";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { CollapseButton } from "src/components/Shared/CollapseButton";
import { TagSelect } from "src/components/Shared/Select";
import { CountrySelect } from "src/components/Shared/CountrySelect";
import { URLField } from "src/components/Shared/URLField";
import ImageUtils from "src/utils/image";
@ -49,6 +48,7 @@ import {
yupDateString,
yupUniqueAliases,
} from "src/utils/yup";
import { Tag, TagSelect } from "src/components/Tags/TagSelect";
const isScraper = (
scraper: GQL.Scraper | GQL.StashBox
@ -83,6 +83,8 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
// Network state
const [isLoading, setIsLoading] = useState(false);
const [tags, setTags] = useState<Tag[]>([]);
const Scrapers = useListPerformerScrapers();
const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]);
@ -161,6 +163,18 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
onSubmit: (values) => onSave(schema.cast(values)),
});
function onSetTags(items: Tag[]) {
setTags(items);
formik.setFieldValue(
"tag_ids",
items.map((item) => item.id)
);
}
useEffect(() => {
setTags(performer.tags ?? []);
}, [performer.tags]);
function translateScrapedGender(scrapedGender?: string) {
if (!scrapedGender) {
return;
@ -300,8 +314,15 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
}
if (state.tags) {
// map tags to their ids and filter out those not found
const newTagIds = state.tags.map((t) => t.stored_id).filter((t) => t);
formik.setFieldValue("tag_ids", newTagIds);
onSetTags(
state.tags.map((p) => {
return {
id: p.stored_id!,
name: p.name ?? "",
aliases: [],
};
})
);
setNewTags(state.tags.filter((t) => !t.stored_id));
}
@ -725,13 +746,8 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
<TagSelect
menuPortalTarget={document.body}
isMulti
onSelect={(items) =>
formik.setFieldValue(
"tag_ids",
items.map((item) => item.id)
)
}
ids={formik.values.tag_ids}
onSelect={onSetTags}
values={tags}
/>
{renderNewTags()}
</>

View file

@ -85,7 +85,7 @@ export const PerformerSelect: React.FC<
thisOptionProps = {
...optionProps,
children: (
<span>
<span className="react-select-image-option">
<a
href={`/performers/${object.id}`}
target="_blank"

View file

@ -235,6 +235,7 @@
.performer-select {
.alias {
font-weight: bold;
white-space: pre;
}
.performer-select-image {

View file

@ -20,7 +20,6 @@ import {
queryScrapeSceneQueryFragment,
} from "src/core/StashService";
import {
TagSelect,
StudioSelect,
GallerySelect,
MovieSelect,
@ -52,6 +51,7 @@ import {
PerformerSelect,
} from "src/components/Performers/PerformerSelect";
import { formikUtils } from "src/utils/form";
import { Tag, TagSelect } from "src/components/Tags/TagSelect";
const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog"));
const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal"));
@ -80,6 +80,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
[]
);
const [performers, setPerformers] = useState<Performer[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
const Scrapers = useListSceneScrapers();
const [fragmentScrapers, setFragmentScrapers] = useState<GQL.Scraper[]>([]);
@ -104,6 +105,10 @@ export const SceneEditPanel: React.FC<IProps> = ({
setPerformers(scene.performers ?? []);
}, [scene.performers]);
useEffect(() => {
setTags(scene.tags ?? []);
}, [scene.tags]);
const { configuration: stashConfig } = React.useContext(ConfigurationContext);
// Network state
@ -202,6 +207,14 @@ export const SceneEditPanel: React.FC<IProps> = ({
);
}
function onSetTags(items: Tag[]) {
setTags(items);
formik.setFieldValue(
"tag_ids",
items.map((item) => item.id)
);
}
useRatingKeybinds(
isVisible,
stashConfig?.ui?.ratingSystemOptions?.type,
@ -578,8 +591,15 @@ export const SceneEditPanel: React.FC<IProps> = ({
});
if (idTags.length > 0) {
const newIds = idTags.map((p) => p.stored_id);
formik.setFieldValue("tag_ids", newIds as string[]);
onSetTags(
idTags.map((p) => {
return {
id: p.stored_id!,
name: p.name ?? "",
aliases: [],
};
})
);
}
}
@ -748,13 +768,8 @@ export const SceneEditPanel: React.FC<IProps> = ({
const control = (
<TagSelect
isMulti
onSelect={(items) =>
formik.setFieldValue(
"tag_ids",
items.map((item) => item.id)
)
}
ids={formik.values.tag_ids}
onSelect={onSetTags}
values={tags}
hoverPlacement="right"
/>
);

View file

@ -1,4 +1,4 @@
import React, { useMemo } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { Button, Form } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { useFormik } from "formik";
@ -10,16 +10,13 @@ import {
useSceneMarkerDestroy,
} from "src/core/StashService";
import { DurationInput } from "src/components/Shared/DurationInput";
import {
TagSelect,
MarkerTitleSuggest,
SelectObject,
} from "src/components/Shared/Select";
import { MarkerTitleSuggest } from "src/components/Shared/Select";
import { getPlayerPosition } from "src/components/ScenePlayer/util";
import { useToast } from "src/hooks/Toast";
import isEqual from "lodash-es/isEqual";
import { formikUtils } from "src/utils/form";
import { yupFormikValidate } from "src/utils/yup";
import { Tag, TagSelect } from "src/components/Tags/TagSelect";
interface ISceneMarkerForm {
sceneID: string;
@ -39,6 +36,9 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
const [sceneMarkerDestroy] = useSceneMarkerDestroy();
const Toast = useToast();
const [primaryTag, setPrimaryTag] = useState<Tag>();
const [tags, setTags] = useState<Tag[]>([]);
const isNew = marker === undefined;
const schema = yup.object({
@ -68,6 +68,34 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
onSubmit: (values) => onSave(schema.cast(values)),
});
function onSetPrimaryTag(item: Tag) {
setPrimaryTag(item);
formik.setFieldValue("primary_tag_id", item.id);
}
function onSetTags(items: Tag[]) {
setTags(items);
formik.setFieldValue(
"tag_ids",
items.map((item) => item.id)
);
}
useEffect(() => {
setPrimaryTag(
marker?.primary_tag ? { ...marker.primary_tag, aliases: [] } : undefined
);
}, [marker?.primary_tag]);
useEffect(() => {
setTags(
marker?.tags.map((t) => ({
...t,
aliases: [],
})) ?? []
);
}, [marker?.tags]);
async function onSave(input: InputValues) {
try {
if (isNew) {
@ -105,11 +133,6 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
}
}
async function onSetPrimaryTagID(tags: SelectObject[]) {
await formik.setFieldValue("primary_tag_id", tags[0]?.id);
await formik.setFieldTouched("primary_tag_id", true);
}
const splitProps = {
labelProps: {
column: true,
@ -145,14 +168,12 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
}
function renderPrimaryTagField() {
const primaryTagId = formik.values.primary_tag_id;
const title = intl.formatMessage({ id: "primary_tag" });
const control = (
<>
<TagSelect
onSelect={onSetPrimaryTagID}
ids={primaryTagId ? [primaryTagId] : []}
onSelect={(t) => onSetPrimaryTag(t[0])}
values={primaryTag ? [primaryTag] : []}
hoverPlacement="right"
/>
{formik.touched.primary_tag_id && (
@ -189,13 +210,8 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
const control = (
<TagSelect
isMulti
onSelect={(items) =>
formik.setFieldValue(
"tag_ids",
items.map((item) => item.id)
)
}
ids={formik.values.tag_ids}
onSelect={onSetTags}
values={tags}
hoverPlacement="right"
/>
);

View file

@ -9,6 +9,7 @@ interface IHoverPopover {
placement?: OverlayProps["placement"];
onOpen?: () => void;
onClose?: () => void;
target?: React.RefObject<HTMLElement>;
}
export const HoverPopover: React.FC<IHoverPopover> = ({
@ -20,6 +21,7 @@ export const HoverPopover: React.FC<IHoverPopover> = ({
placement = "top",
onOpen,
onClose,
target,
}) => {
const [show, setShow] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
@ -61,7 +63,11 @@ export const HoverPopover: React.FC<IHoverPopover> = ({
{children}
</div>
{triggerRef.current && (
<Overlay show={show} placement={placement} target={triggerRef.current}>
<Overlay
show={show}
placement={placement}
target={target?.current ?? triggerRef.current}
>
<Popover
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}

View file

@ -14,11 +14,9 @@ import CreatableSelect from "react-select/creatable";
import * as GQL from "src/core/generated-graphql";
import {
useAllTagsForFilter,
useAllMoviesForFilter,
useAllStudiosForFilter,
useMarkerStrings,
useTagCreate,
useStudioCreate,
useMovieCreate,
} from "src/core/StashService";
@ -28,13 +26,13 @@ import { ConfigurationContext } from "src/hooks/Config";
import { useIntl } from "react-intl";
import { objectTitle } from "src/core/files";
import { galleryTitle } from "src/core/galleries";
import { TagPopover } from "../Tags/TagPopover";
import { defaultMaxOptionsShown, IUIConfig } from "src/core/config";
import { useDebounce } from "src/hooks/debounce";
import { Placement } from "react-bootstrap/esm/Overlay";
import { PerformerIDSelect } from "../Performers/PerformerSelect";
import { Icon } from "./Icon";
import { faTableColumns } from "@fortawesome/free-solid-svg-icons";
import { TagIDSelect } from "../Tags/TagSelect";
export type SelectObject = {
id: string;
@ -726,146 +724,7 @@ export const MovieSelect: React.FC<IFilterProps> = (props) => {
export const TagSelect: React.FC<
IFilterProps & { excludeIds?: string[]; hoverPlacement?: Placement }
> = (props) => {
const [tagAliases, setTagAliases] = useState<Record<string, string[]>>({});
const [allAliases, setAllAliases] = useState<string[]>([]);
const { data, loading } = useAllTagsForFilter();
const [createTag] = useTagCreate();
const intl = useIntl();
const placeholder =
props.noSelectionString ??
intl.formatMessage(
{ id: "actions.select_entity" },
{ entityType: intl.formatMessage({ id: props.isMulti ? "tags" : "tag" }) }
);
const { configuration } = React.useContext(ConfigurationContext);
const defaultCreatable =
!configuration?.interface.disableDropdownCreate.tag ?? true;
const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]);
const tags = useMemo(
() => (data?.allTags ?? []).filter((tag) => !exclude.includes(tag.id)),
[data?.allTags, exclude]
);
useEffect(() => {
// build the tag aliases map
const newAliases: Record<string, string[]> = {};
const newAll: string[] = [];
tags.forEach((t) => {
newAliases[t.id] = t.aliases;
newAll.push(...t.aliases);
});
setTagAliases(newAliases);
setAllAliases(newAll);
}, [tags]);
const TagOption: React.FC<OptionProps<Option, boolean>> = (optionProps) => {
const { inputValue } = optionProps.selectProps;
let thisOptionProps = optionProps;
if (
inputValue &&
!optionProps.label.toLowerCase().includes(inputValue.toLowerCase())
) {
// must be alias
const newLabel = `${optionProps.data.label} (alias)`;
thisOptionProps = {
...optionProps,
children: newLabel,
};
}
const id = optionProps.data.value;
const hide = (optionProps.data as Option & { __isNew__: boolean })
.__isNew__;
return (
<TagPopover id={id} hide={hide} placement={props.hoverPlacement}>
<reactSelectComponents.Option {...thisOptionProps} />
</TagPopover>
);
};
const filterOption = (option: Option, rawInput: string): boolean => {
if (!rawInput) {
return true;
}
const input = rawInput.toLowerCase();
const optionVal = option.label.toLowerCase();
if (optionVal.includes(input)) {
return true;
}
// search for tag aliases
const aliases = tagAliases[option.value];
// only match on alias if exact
if (aliases && aliases.some((a) => a.toLowerCase() === input)) {
return true;
}
return false;
};
const onCreate = async (name: string) => {
const result = await createTag({
variables: {
input: {
name,
},
},
});
return {
item: result.data!.tagCreate!,
message: intl.formatMessage(
{ id: "toast.created_entity" },
{ entity: intl.formatMessage({ id: "tag" }).toLocaleLowerCase() }
),
};
};
const isValidNewOption = (
inputValue: string,
value: OnChangeValue<Option, boolean>,
options: OptionsOrGroups<Option, GroupBase<Option>>
) => {
if (!inputValue) {
return false;
}
if (
(options as Options<Option>).some((o: Option) => {
return o.label.toLowerCase() === inputValue.toLowerCase();
})
) {
return false;
}
if (allAliases.some((a) => a.toLowerCase() === inputValue.toLowerCase())) {
return false;
}
return true;
};
return (
<FilterSelectComponent
{...props}
filterOption={filterOption}
isValidNewOption={isValidNewOption}
components={{ Option: TagOption }}
isMulti={props.isMulti ?? false}
items={tags}
creatable={props.creatable ?? defaultCreatable}
type="tags"
placeholder={placeholder}
isLoading={loading}
onCreate={onCreate}
closeMenuOnSelect={!props.isMulti}
/>
);
return <TagIDSelect {...props} />;
};
export const FilterSelect: React.FC<IFilterProps & ITypeProps> = (props) => {

View file

@ -516,3 +516,8 @@ div.react-datepicker {
border-radius: 0 0.25rem 0.25rem 0;
}
}
.react-select-image-option {
align-items: center;
display: flex;
}

View file

@ -3,7 +3,6 @@ import { FormattedMessage, useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import * as yup from "yup";
import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar";
import { TagSelect } from "src/components/Shared/Select";
import { Form } from "react-bootstrap";
import ImageUtils from "src/utils/image";
import { useFormik } from "formik";
@ -15,6 +14,7 @@ import { useToast } from "src/hooks/Toast";
import { handleUnsavedChanges } from "src/utils/navigation";
import { formikUtils } from "src/utils/form";
import { yupFormikValidate, yupUniqueAliases } from "src/utils/yup";
import { Tag, TagSelect } from "../TagSelect";
interface ITagEditPanel {
tag: Partial<GQL.TagDataFragment>;
@ -41,6 +41,9 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
// Network state
const [isLoading, setIsLoading] = useState(false);
const [childTags, setChildTags] = useState<Tag[]>([]);
const [parentTags, setParentTags] = useState<Tag[]>([]);
const schema = yup.object({
name: yup.string().required(),
aliases: yupUniqueAliases(intl, "name"),
@ -69,6 +72,30 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
onSubmit: (values) => onSave(schema.cast(values)),
});
function onSetParentTags(items: Tag[]) {
setParentTags(items);
formik.setFieldValue(
"parent_ids",
items.map((item) => item.id)
);
}
function onSetChildTags(items: Tag[]) {
setChildTags(items);
formik.setFieldValue(
"child_ids",
items.map((item) => item.id)
);
}
useEffect(() => {
setParentTags(tag.parents ?? []);
}, [tag.parents]);
useEffect(() => {
setChildTags(tag.children ?? []);
}, [tag.children]);
// set up hotkeys
useEffect(() => {
Mousetrap.bind("s s", () => {
@ -121,13 +148,8 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
const control = (
<TagSelect
isMulti
onSelect={(items) =>
formik.setFieldValue(
"parent_ids",
items.map((item) => item.id)
)
}
ids={formik.values.parent_ids}
onSelect={onSetParentTags}
values={parentTags}
excludeIds={[...(tag?.id ? [tag.id] : []), ...formik.values.child_ids]}
creatable={false}
hoverPlacement="right"
@ -142,13 +164,8 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
const control = (
<TagSelect
isMulti
onSelect={(items) =>
formik.setFieldValue(
"child_ids",
items.map((item) => item.id)
)
}
ids={formik.values.child_ids}
onSelect={onSetChildTags}
values={childTags}
excludeIds={[...(tag?.id ? [tag.id] : []), ...formik.values.parent_ids]}
creatable={false}
hoverPlacement="right"

View file

@ -2,13 +2,13 @@ import { Form, Col, Row } from "react-bootstrap";
import React, { useState } from "react";
import * as GQL from "src/core/generated-graphql";
import { ModalComponent } from "src/components/Shared/Modal";
import { TagSelect } from "src/components/Shared/Select";
import * as FormUtils from "src/utils/form";
import { useTagsMerge } from "src/core/StashService";
import { useIntl } from "react-intl";
import { useToast } from "src/hooks/Toast";
import { useHistory } from "react-router-dom";
import { faSignInAlt, faSignOutAlt } from "@fortawesome/free-solid-svg-icons";
import { Tag, TagSelect } from "../TagSelect";
interface ITagMergeModalProps {
show: boolean;
@ -23,8 +23,9 @@ export const TagMergeModal: React.FC<ITagMergeModalProps> = ({
tag,
mergeType,
}) => {
const [srcIds, setSrcIds] = useState<string[]>([]);
const [destId, setDestId] = useState<string | null>(null);
const [src, setSrc] = useState<Tag[]>([]);
const [dest, setDest] = useState<Tag | null>(null);
const [running, setRunning] = useState(false);
const [mergeTags] = useTagsMerge();
@ -38,8 +39,8 @@ export const TagMergeModal: React.FC<ITagMergeModalProps> = ({
});
async function onMerge() {
const source = mergeType === "from" ? srcIds : [tag.id];
const destination = mergeType === "from" ? tag.id : destId;
const source = mergeType === "from" ? src.map((s) => s.id) : [tag.id];
const destination = mergeType === "from" ? tag.id : dest?.id ?? null;
if (!destination) return;
@ -65,8 +66,8 @@ export const TagMergeModal: React.FC<ITagMergeModalProps> = ({
function canMerge() {
return (
(mergeType === "from" && srcIds.length > 0) ||
(mergeType === "into" && destId)
(mergeType === "from" && src.length > 0) ||
(mergeType === "into" && dest !== null)
);
}
@ -102,8 +103,8 @@ export const TagMergeModal: React.FC<ITagMergeModalProps> = ({
<TagSelect
isMulti
creatable={false}
onSelect={(items) => setSrcIds(items.map((item) => item.id))}
ids={srcIds}
onSelect={(items) => setSrc(items)}
values={src}
excludeIds={tag?.id ? [tag.id] : []}
/>
</Col>
@ -125,8 +126,8 @@ export const TagMergeModal: React.FC<ITagMergeModalProps> = ({
<TagSelect
isMulti={false}
creatable={false}
onSelect={(items) => setDestId(items[0]?.id)}
ids={destId ? [destId] : undefined}
onSelect={(items) => setDest(items[0])}
values={dest ? [dest] : undefined}
excludeIds={tag?.id ? [tag.id] : []}
/>
</Col>

View file

@ -38,6 +38,7 @@ interface ITagPopoverProps {
id: string;
hide?: boolean;
placement?: Placement;
target?: React.RefObject<HTMLElement>;
}
export const TagPopover: React.FC<ITagPopoverProps> = ({
@ -45,6 +46,7 @@ export const TagPopover: React.FC<ITagPopoverProps> = ({
hide,
children,
placement = "top",
target,
}) => {
const { configuration: config } = React.useContext(ConfigurationContext);
@ -57,6 +59,7 @@ export const TagPopover: React.FC<ITagPopoverProps> = ({
return (
<HoverPopover
target={target}
placement={placement}
enterDelay={500}
leaveDelay={100}

View file

@ -0,0 +1,279 @@
import React, { useEffect, useMemo, useState } from "react";
import {
OptionProps,
components as reactSelectComponents,
MultiValueGenericProps,
SingleValueProps,
} from "react-select";
import cx from "classnames";
import * as GQL from "src/core/generated-graphql";
import {
useTagCreate,
queryFindTagsByIDForSelect,
queryFindTagsForSelect,
} from "src/core/StashService";
import { ConfigurationContext } from "src/hooks/Config";
import { useIntl } from "react-intl";
import { defaultMaxOptionsShown, IUIConfig } from "src/core/config";
import { ListFilterModel } from "src/models/list-filter/filter";
import {
FilterSelectComponent,
IFilterIDProps,
IFilterProps,
IFilterValueProps,
Option as SelectOption,
} from "../Shared/FilterSelect";
import { useCompare } from "src/hooks/state";
import { TagPopover } from "./TagPopover";
import { Placement } from "react-bootstrap/esm/Overlay";
export type SelectObject = {
id: string;
name?: string | null;
title?: string | null;
};
export type Tag = Pick<GQL.Tag, "id" | "name" | "aliases" | "image_path">;
type Option = SelectOption<Tag>;
export const TagSelect: React.FC<
IFilterProps &
IFilterValueProps<Tag> & {
hoverPlacement?: Placement;
excludeIds?: string[];
}
> = (props) => {
const [createTag] = useTagCreate();
const { configuration } = React.useContext(ConfigurationContext);
const intl = useIntl();
const maxOptionsShown =
(configuration?.ui as IUIConfig).maxOptionsShown ?? defaultMaxOptionsShown;
const defaultCreatable =
!configuration?.interface.disableDropdownCreate.tag ?? true;
const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]);
async function loadTags(input: string): Promise<Option[]> {
const filter = new ListFilterModel(GQL.FilterMode.Tags);
filter.searchTerm = input;
filter.currentPage = 1;
filter.itemsPerPage = maxOptionsShown;
filter.sortBy = "name";
filter.sortDirection = GQL.SortDirectionEnum.Asc;
const query = await queryFindTagsForSelect(filter);
return query.data.findTags.tags
.filter((tag) => {
// HACK - we should probably exclude these in the backend query, but
// this will do in the short-term
return !exclude.includes(tag.id.toString());
})
.map((tag) => ({
value: tag.id,
object: tag,
}));
}
const TagOption: React.FC<OptionProps<Option, boolean>> = (optionProps) => {
let thisOptionProps = optionProps;
const { object } = optionProps.data;
let { name } = object;
// if name does not match the input value but an alias does, show the alias
const { inputValue } = optionProps.selectProps;
let alias: string | undefined = "";
if (!name.toLowerCase().includes(inputValue.toLowerCase())) {
alias = object.aliases?.find((a) =>
a.toLowerCase().includes(inputValue.toLowerCase())
);
}
thisOptionProps = {
...optionProps,
children: (
<TagPopover id={object.id} placement={props.hoverPlacement}>
<span className="react-select-image-option">
{/* the following code causes re-rendering issues when selecting tags */}
{/* <TagPopover
id={object.id}
placement={props.hoverPlacement}
target={targetRef}
>
<a
href={`/tags/${object.id}`}
target="_blank"
rel="noreferrer"
className="tag-select-image-link"
>
<img
className="tag-select-image"
src={object.image_path ?? ""}
loading="lazy"
/>
</a>
</TagPopover> */}
<span>{name}</span>
{alias && <span className="alias">{` (${alias})`}</span>}
</span>
</TagPopover>
),
};
return <reactSelectComponents.Option {...thisOptionProps} />;
};
const TagMultiValueLabel: React.FC<
MultiValueGenericProps<Option, boolean>
> = (optionProps) => {
let thisOptionProps = optionProps;
const { object } = optionProps.data;
thisOptionProps = {
...optionProps,
children: object.name,
};
return <reactSelectComponents.MultiValueLabel {...thisOptionProps} />;
};
const TagValueLabel: React.FC<SingleValueProps<Option, boolean>> = (
optionProps
) => {
let thisOptionProps = optionProps;
const { object } = optionProps.data;
thisOptionProps = {
...optionProps,
children: <>{object.name}</>,
};
return <reactSelectComponents.SingleValue {...thisOptionProps} />;
};
const onCreate = async (name: string) => {
const result = await createTag({
variables: { input: { name } },
});
return {
value: result.data!.tagCreate!.id,
item: result.data!.tagCreate!,
message: "Created tag",
};
};
const getNamedObject = (id: string, name: string) => {
return {
id,
name,
aliases: [],
};
};
const isValidNewOption = (inputValue: string, options: Tag[]) => {
if (!inputValue) {
return false;
}
if (
options.some((o) => {
return (
o.name.toLowerCase() === inputValue.toLowerCase() ||
o.aliases?.some((a) => a.toLowerCase() === inputValue.toLowerCase())
);
})
) {
return false;
}
return true;
};
return (
<FilterSelectComponent<Tag, boolean>
{...props}
className={cx(
"tag-select",
{
"tag-select-active": props.active,
},
props.className
)}
loadOptions={loadTags}
getNamedObject={getNamedObject}
isValidNewOption={isValidNewOption}
components={{
Option: TagOption,
MultiValueLabel: TagMultiValueLabel,
SingleValue: TagValueLabel,
}}
isMulti={props.isMulti ?? false}
creatable={props.creatable ?? defaultCreatable}
onCreate={onCreate}
placeholder={
props.noSelectionString ??
intl.formatMessage(
{ id: "actions.select_entity" },
{
entityType: intl.formatMessage({
id: props.isMulti ? "tags" : "tag",
}),
}
)
}
closeMenuOnSelect={!props.isMulti}
/>
);
};
export const TagIDSelect: React.FC<IFilterProps & IFilterIDProps<Tag>> = (
props
) => {
const { ids, onSelect: onSelectValues } = props;
const [values, setValues] = useState<Tag[]>([]);
const idsChanged = useCompare(ids);
function onSelect(items: Tag[]) {
setValues(items);
onSelectValues?.(items);
}
async function loadObjectsByID(idsToLoad: string[]): Promise<Tag[]> {
const tagIDs = idsToLoad.map((id) => parseInt(id));
const query = await queryFindTagsByIDForSelect(tagIDs);
const { tags: loadedTags } = query.data.findTags;
return loadedTags;
}
useEffect(() => {
if (!idsChanged) {
return;
}
if (!ids || ids?.length === 0) {
setValues([]);
return;
}
// load the values if we have ids and they haven't been loaded yet
const filteredValues = values.filter((v) => ids.includes(v.id.toString()));
if (filteredValues.length === ids.length) {
return;
}
const load = async () => {
const items = await loadObjectsByID(ids);
setValues(items);
};
load();
}, [ids, idsChanged, values]);
return <TagSelect {...props} values={values} onSelect={onSelect} />;
};

View file

@ -90,3 +90,16 @@
transform: scale(0.7);
}
}
.tag-select {
.alias {
font-weight: bold;
white-space: pre;
}
}
.tag-select-image {
height: 25px;
margin-right: 0.5em;
width: 25px;
}

View file

@ -344,7 +344,22 @@ export const queryFindTags = (filter: ListFilterModel) =>
},
});
export const useAllTagsForFilter = () => GQL.useAllTagsForFilterQuery();
export const queryFindTagsByIDForSelect = (tagIDs: number[]) =>
client.query<GQL.FindTagsForSelectQuery>({
query: GQL.FindTagsForSelectDocument,
variables: {
ids: tagIDs,
},
});
export const queryFindTagsForSelect = (filter: ListFilterModel) =>
client.query<GQL.FindTagsForSelectQuery>({
query: GQL.FindTagsForSelectDocument,
variables: {
filter: filter.makeFindFilter(),
tag_filter: filter.makeFilter(),
},
});
export const useFindSavedFilter = (id: string) =>
GQL.useFindSavedFilterQuery({
@ -1598,8 +1613,6 @@ export const useTagCreate = () =>
const tag = result.data?.tagCreate;
if (!tag) return;
appendObject(cache, tag, GQL.AllTagsForFilterDocument);
// update stats
updateStats(cache, "tag_count", 1);