mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
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:
parent
dd8da7f339
commit
723211a620
21 changed files with 543 additions and 249 deletions
|
|
@ -24,3 +24,16 @@ fragment TagData on Tag {
|
|||
...SlimTagData
|
||||
}
|
||||
}
|
||||
|
||||
fragment SelectTagData on Tag {
|
||||
id
|
||||
name
|
||||
description
|
||||
aliases
|
||||
image_path
|
||||
|
||||
parents {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,14 +21,6 @@ query AllMoviesForFilter {
|
|||
}
|
||||
}
|
||||
|
||||
query AllTagsForFilter {
|
||||
allTags {
|
||||
id
|
||||
name
|
||||
aliases
|
||||
}
|
||||
}
|
||||
|
||||
query Stats {
|
||||
stats {
|
||||
scene_count
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -235,6 +235,7 @@
|
|||
.performer-select {
|
||||
.alias {
|
||||
font-weight: bold;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.performer-select-image {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -516,3 +516,8 @@ div.react-datepicker {
|
|||
border-radius: 0 0.25rem 0.25rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
.react-select-image-option {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
279
ui/v2.5/src/components/Tags/TagSelect.tsx
Normal file
279
ui/v2.5/src/components/Tags/TagSelect.tsx
Normal 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} />;
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue