From 01d351c85d57b4a580cefd4482c1499afa829c05 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Tue, 24 Feb 2026 19:56:24 -0800 Subject: [PATCH] FR: Custom Fields Frontend (#6601) * Add "custom-field-" prefix to custom field detail item ids --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- ui/v2.5/graphql/data/gallery.graphql | 2 + ui/v2.5/graphql/data/group.graphql | 2 + ui/v2.5/graphql/data/image.graphql | 2 + ui/v2.5/graphql/data/scene.graphql | 2 + ui/v2.5/graphql/data/studio.graphql | 1 + ui/v2.5/graphql/queries/scene.graphql | 8 ++ .../GalleryDetails/GalleryDetailPanel.tsx | 2 + .../GalleryDetails/GalleryEditPanel.tsx | 39 ++++++- ui/v2.5/src/components/Galleries/styles.scss | 14 +++ .../Groups/GroupDetails/GroupDetailsPanel.tsx | 2 + .../Groups/GroupDetails/GroupEditPanel.tsx | 37 +++++- .../Images/ImageDetails/ImageDetailPanel.tsx | 2 + .../Images/ImageDetails/ImageEditPanel.tsx | 30 ++++- ui/v2.5/src/components/Images/styles.scss | 14 +++ .../PerformerDetails/PerformerEditPanel.tsx | 19 +-- ui/v2.5/src/components/Performers/styles.scss | 5 - .../Scenes/SceneDetails/SceneDetailPanel.tsx | 2 + .../Scenes/SceneDetails/SceneEditPanel.tsx | 39 ++++++- .../components/Scenes/SceneMergeDialog.tsx | 109 ++++++++++++++---- ui/v2.5/src/components/Scenes/styles.scss | 14 +++ .../src/components/Shared/CustomFields.tsx | 25 ++-- ui/v2.5/src/components/Shared/styles.scss | 35 ++++++ .../StudioDetails/StudioDetailsPanel.tsx | 2 + .../Studios/StudioDetails/StudioEditPanel.tsx | 38 +++++- .../Tags/TagDetails/TagDetailsPanel.tsx | 2 + .../Tags/TagDetails/TagEditPanel.tsx | 36 +++++- ui/v2.5/src/core/StashService.ts | 8 ++ ui/v2.5/src/models/list-filter/galleries.ts | 2 + ui/v2.5/src/models/list-filter/groups.ts | 2 + ui/v2.5/src/models/list-filter/images.ts | 2 + ui/v2.5/src/models/list-filter/scenes.ts | 2 + ui/v2.5/src/models/list-filter/studios.ts | 2 + ui/v2.5/src/models/list-filter/tags.ts | 2 + 33 files changed, 434 insertions(+), 69 deletions(-) diff --git a/ui/v2.5/graphql/data/gallery.graphql b/ui/v2.5/graphql/data/gallery.graphql index 89f3ed44c..349a52ad7 100644 --- a/ui/v2.5/graphql/data/gallery.graphql +++ b/ui/v2.5/graphql/data/gallery.graphql @@ -39,6 +39,8 @@ fragment GalleryData on Gallery { scenes { ...SlimSceneData } + + custom_fields } fragment SelectGalleryData on Gallery { diff --git a/ui/v2.5/graphql/data/group.graphql b/ui/v2.5/graphql/data/group.graphql index 440c420da..a9968bbae 100644 --- a/ui/v2.5/graphql/data/group.graphql +++ b/ui/v2.5/graphql/data/group.graphql @@ -39,6 +39,8 @@ fragment GroupData on Group { id title } + + custom_fields } # Lightweight fragment for list views - excludes expensive recursive counts diff --git a/ui/v2.5/graphql/data/image.graphql b/ui/v2.5/graphql/data/image.graphql index 52163b007..63ce5b458 100644 --- a/ui/v2.5/graphql/data/image.graphql +++ b/ui/v2.5/graphql/data/image.graphql @@ -37,4 +37,6 @@ fragment ImageData on Image { visual_files { ...VisualFileData } + + custom_fields } diff --git a/ui/v2.5/graphql/data/scene.graphql b/ui/v2.5/graphql/data/scene.graphql index e4a6e5cc6..b7378c1da 100644 --- a/ui/v2.5/graphql/data/scene.graphql +++ b/ui/v2.5/graphql/data/scene.graphql @@ -79,6 +79,8 @@ fragment SceneData on Scene { mime_type label } + + custom_fields } fragment SelectSceneData on Scene { diff --git a/ui/v2.5/graphql/data/studio.graphql b/ui/v2.5/graphql/data/studio.graphql index 8347b4739..0e23a885e 100644 --- a/ui/v2.5/graphql/data/studio.graphql +++ b/ui/v2.5/graphql/data/studio.graphql @@ -41,6 +41,7 @@ fragment StudioData on Studio { ...SlimTagData } o_counter + custom_fields } fragment SelectStudioData on Studio { diff --git a/ui/v2.5/graphql/queries/scene.graphql b/ui/v2.5/graphql/queries/scene.graphql index d6a3afd47..0e1a9fa11 100644 --- a/ui/v2.5/graphql/queries/scene.graphql +++ b/ui/v2.5/graphql/queries/scene.graphql @@ -40,6 +40,14 @@ query FindScene($id: ID!, $checksum: String) { } } +query FindFullScenes($ids: [Int!]) { + findScenes(scene_ids: $ids) { + scenes { + ...SceneData + } + } +} + query FindSceneMarkerTags($id: ID!) { sceneMarkerTags(scene_id: $id) { tag { diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx index 597a57b15..ead882ec0 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx @@ -6,6 +6,7 @@ import { TagLink } from "src/components/Shared/TagLink"; import { PerformerCard } from "src/components/Performers/PerformerCard"; import { sortPerformers } from "src/core/performers"; import { PhotographerLink } from "src/components/Shared/Link"; +import { CustomFields } from "src/components/Shared/CustomFields"; interface IGalleryDetailProps { gallery: GQL.GalleryDataFragment; @@ -108,6 +109,7 @@ export const GalleryDetailPanel: React.FC = ({ {renderDetails()} {renderTags()} {renderPerformers()} + diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx index 04b802784..14b5d6aad 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx @@ -31,6 +31,11 @@ import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { Scene, SceneSelect } from "src/components/Scenes/SceneSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { ScraperMenu } from "src/components/Shared/ScraperMenu"; +import { + CustomFieldsInput, + formatCustomFieldInput, +} from "src/components/Shared/CustomFields"; +import { cloneDeep } from "@apollo/client/utilities"; interface IProps { gallery: Partial; @@ -76,6 +81,7 @@ export const GalleryEditPanel: React.FC = ({ tag_ids: yup.array(yup.string().required()).defined(), scene_ids: yup.array(yup.string().required()).defined(), details: yup.string().ensure(), + custom_fields: yup.object().required().defined(), }); const initialValues = { @@ -89,15 +95,26 @@ export const GalleryEditPanel: React.FC = ({ tag_ids: (gallery?.tags ?? []).map((t) => t.id), scene_ids: (gallery?.scenes ?? []).map((s) => s.id), details: gallery?.details ?? "", + custom_fields: cloneDeep(gallery?.custom_fields ?? {}), }; type InputValues = yup.InferType; + const [customFieldsError, setCustomFieldsError] = useState(); + + function submit(values: InputValues) { + const input = { + ...schema.cast(values), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), + }; + onSave(input); + } + const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), - onSubmit: (values) => onSave(schema.cast(values)), + onSubmit: submit, }); const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( @@ -189,7 +206,10 @@ export const GalleryEditPanel: React.FC = ({ } async function onSaveAndNewClick() { - const input = schema.cast(formik.values); + const input = { + ...schema.cast(formik.values), + custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields), + }; onSave(input, true); } @@ -455,7 +475,9 @@ export const GalleryEditPanel: React.FC = ({ id="gallery-save-split-button" className="edit-button" variant="primary" - disabled={!isEqual(formik.errors, {})} + disabled={ + !isEqual(formik.errors, {}) || customFieldsError !== undefined + } title={intl.formatMessage({ id: "actions.save" })} onClick={() => formik.submitForm()} > @@ -468,7 +490,9 @@ export const GalleryEditPanel: React.FC = ({ className="edit-button" variant="primary" disabled={ - (!isNew && !formik.dirty) || !isEqual(formik.errors, {}) + (!isNew && !formik.dirty) || + !isEqual(formik.errors, {}) || + customFieldsError !== undefined } onClick={() => formik.submitForm()} > @@ -523,6 +547,13 @@ export const GalleryEditPanel: React.FC = ({ {cover} + + formik.setFieldValue("custom_fields", v)} + error={customFieldsError} + setError={(e) => setCustomFieldsError(e)} + /> diff --git a/ui/v2.5/src/components/Galleries/styles.scss b/ui/v2.5/src/components/Galleries/styles.scss index c53175313..ac9330e9a 100644 --- a/ui/v2.5/src/components/Galleries/styles.scss +++ b/ui/v2.5/src/components/Galleries/styles.scss @@ -208,6 +208,20 @@ $galleryTabWidth: 450px; .form-group[data-field="urls"] .string-list-input input.form-control { font-size: 0.85em; } + + @include media-breakpoint-up(xl) { + .custom-fields-input { + .custom-fields-field { + flex: 0 0 25%; + max-width: 25%; + } + + .custom-fields-value { + flex: 0 0 75%; + max-width: 75%; + } + } + } } .gallery-cover { diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx index b8e39ffe6..8ae4b16a9 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx @@ -6,6 +6,7 @@ import { DetailItem } from "src/components/Shared/DetailItem"; import { Link } from "react-router-dom"; import { DirectorLink } from "src/components/Shared/Link"; import { GroupLink, TagLink } from "src/components/Shared/TagLink"; +import { CustomFields } from "src/components/Shared/CustomFields"; interface IGroupDescription { group: GQL.SlimGroupDataFragment; @@ -101,6 +102,7 @@ export const GroupDetailsPanel: React.FC = ({ fullWidth={fullWidth} /> )} + ); }; diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx index f0a6f17c1..6401738fa 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx @@ -28,6 +28,11 @@ import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { Group } from "src/components/Groups/GroupSelect"; import { RelatedGroupTable, IRelatedGroupEntry } from "./RelatedGroupTable"; +import { + CustomFieldsInput, + formatCustomFieldInput, +} from "src/components/Shared/CustomFields"; +import { cloneDeep } from "@apollo/client/utilities"; interface IGroupEditPanel { group: Partial; @@ -84,6 +89,7 @@ export const GroupEditPanel: React.FC = ({ synopsis: yup.string().ensure(), front_image: yup.string().nullable().optional(), back_image: yup.string().nullable().optional(), + custom_fields: yup.object().required().defined(), }); const initialValues = { @@ -99,15 +105,26 @@ export const GroupEditPanel: React.FC = ({ director: group?.director ?? "", urls: group?.urls ?? [], synopsis: group?.synopsis ?? "", + custom_fields: cloneDeep(group?.custom_fields ?? {}), }; type InputValues = yup.InferType; + const [customFieldsError, setCustomFieldsError] = useState(); + + function submit(values: InputValues) { + const input = { + ...schema.cast(values), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), + }; + onSave(input); + } + const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), - onSubmit: (values) => onSave(schema.cast(values)), + onSubmit: submit, }); const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( @@ -220,7 +237,10 @@ export const GroupEditPanel: React.FC = ({ } async function onSaveAndNewClick() { - const input = schema.cast(formik.values); + const input = { + ...schema.cast(formik.values), + custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields), + }; onSave(input, true); } @@ -458,6 +478,13 @@ export const GroupEditPanel: React.FC = ({ {renderURLListField("urls", onScrapeGroupURL, urlScrapable)} {renderInputField("synopsis", "textarea")} {renderTagsField()} + + formik.setFieldValue("custom_fields", v)} + error={customFieldsError} + setError={(e) => setCustomFieldsError(e)} + /> = ({ onToggleEdit={onCancel} onSave={formik.handleSubmit} onSaveAndNew={isNew ? onSaveAndNewClick : undefined} - saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})} + saveDisabled={ + (!isNew && !formik.dirty) || + !isEqual(formik.errors, {}) || + customFieldsError !== undefined + } onImageChange={onFrontImageChange} onImageChangeURL={onFrontImageLoad} onClearImage={() => onFrontImageLoad(null)} diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx index a2044fcff..cf33b648b 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx @@ -7,6 +7,7 @@ import { sortPerformers } from "src/core/performers"; import { FormattedMessage, useIntl } from "react-intl"; import { PhotographerLink } from "src/components/Shared/Link"; import { PatchComponent } from "../../../patch"; +import { CustomFields } from "src/components/Shared/CustomFields"; interface IImageDetailProps { image: GQL.ImageDataFragment; } @@ -132,6 +133,7 @@ export const ImageDetailPanel: React.FC = PatchComponent( {renderDetails()} {renderTags()} {renderPerformers()} + diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx index 58b809d41..94dddac4b 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx @@ -35,6 +35,11 @@ import { } from "src/components/Galleries/GallerySelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { ScraperMenu } from "src/components/Shared/ScraperMenu"; +import { + CustomFieldsInput, + formatCustomFieldInput, +} from "src/components/Shared/CustomFields"; +import { cloneDeep } from "@apollo/client/utilities"; interface IProps { image: GQL.ImageDataFragment; @@ -86,6 +91,7 @@ export const ImageEditPanel: React.FC = ({ studio_id: yup.string().required().nullable(), performer_ids: yup.array(yup.string().required()).defined(), tag_ids: yup.array(yup.string().required()).defined(), + custom_fields: yup.object().required().defined(), }); const initialValues = { @@ -99,15 +105,26 @@ export const ImageEditPanel: React.FC = ({ studio_id: image.studio?.id ?? null, performer_ids: (image.performers ?? []).map((p) => p.id), tag_ids: (image.tags ?? []).map((t) => t.id), + custom_fields: cloneDeep(image.custom_fields ?? {}), }; type InputValues = yup.InferType; + const [customFieldsError, setCustomFieldsError] = useState(); + + function submit(values: InputValues) { + const input = { + ...schema.cast(values), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), + }; + onSave(input); + } + const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), - onSubmit: (values) => onSave(schema.cast(values)), + onSubmit: submit, }); const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( @@ -444,7 +461,9 @@ export const ImageEditPanel: React.FC = ({ className="edit-button" variant="primary" disabled={ - (!isNew && !formik.dirty) || !isEqual(formik.errors, {}) + (!isNew && !formik.dirty) || + !isEqual(formik.errors, {}) || + customFieldsError !== undefined } onClick={() => formik.submitForm()} > @@ -492,6 +511,13 @@ export const ImageEditPanel: React.FC = ({ {renderDetailsField()} + + formik.setFieldValue("custom_fields", v)} + error={customFieldsError} + setError={(e) => setCustomFieldsError(e)} + /> diff --git a/ui/v2.5/src/components/Images/styles.scss b/ui/v2.5/src/components/Images/styles.scss index 0050a9434..43ac56590 100644 --- a/ui/v2.5/src/components/Images/styles.scss +++ b/ui/v2.5/src/components/Images/styles.scss @@ -179,6 +179,20 @@ $imageTabWidth: 450px; .form-group[data-field="urls"] .string-list-input input.form-control { font-size: 0.85em; } + + @include media-breakpoint-up(xl) { + .custom-fields-input { + .custom-fields-field { + flex: 0 0 25%; + max-width: 25%; + } + + .custom-fields-value { + flex: 0 0 75%; + max-width: 75%; + } + } + } } .image-file-card.card { diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index 98871bf9a..93b69e7b5 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -48,7 +48,10 @@ import { yupUniqueStringList, } from "src/utils/yup"; import { useTagsEdit } from "src/hooks/tagsEdit"; -import { CustomFieldsInput } from "src/components/Shared/CustomFields"; +import { + CustomFieldsInput, + formatCustomFieldInput, +} from "src/components/Shared/CustomFields"; import { cloneDeep } from "@apollo/client/utilities"; const isScraper = ( @@ -67,16 +70,6 @@ interface IPerformerDetails { setEncodingImage: (loading: boolean) => void; } -function customFieldInput(isNew: boolean, input: {}) { - if (isNew) { - return input; - } else { - return { - full: input, - }; - } -} - export const PerformerEditPanel: React.FC = ({ performer, isVisible, @@ -173,7 +166,7 @@ export const PerformerEditPanel: React.FC = ({ function submit(values: InputValues) { const input = { ...schema.cast(values), - custom_fields: customFieldInput(isNew, values.custom_fields), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), }; onSave(input); } @@ -368,7 +361,7 @@ export const PerformerEditPanel: React.FC = ({ const { values } = formik; const input = { ...schema.cast(values), - custom_fields: customFieldInput(isNew, values.custom_fields), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), }; onSave(input, true); } diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index 54a010e50..49dc27550 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -82,11 +82,6 @@ font-weight: 700; padding-left: 0; } - - .custom-fields .detail-item-title, - .custom-fields .detail-item-value { - font-family: "Courier New", Courier, monospace; - } /* stylelint-enable selector-class-pattern */ } diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx index ad7663e9d..b109016b1 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx @@ -6,6 +6,7 @@ import { TagLink } from "src/components/Shared/TagLink"; import { PerformerCard } from "src/components/Performers/PerformerCard"; import { sortPerformers } from "src/core/performers"; import { DirectorLink } from "src/components/Shared/Link"; +import { CustomFields } from "src/components/Shared/CustomFields"; interface ISceneDetailProps { scene: GQL.SceneDataFragment; @@ -103,6 +104,7 @@ export const SceneDetailPanel: React.FC = (props) => { {renderDetails()} {renderTags()} {renderPerformers()} + diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index 54bf5b573..41293ff78 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -50,6 +50,11 @@ import { Group } from "src/components/Groups/GroupSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { ScraperMenu } from "src/components/Shared/ScraperMenu"; import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal"; +import { + CustomFieldsInput, + formatCustomFieldInput, +} from "src/components/Shared/CustomFields"; +import { cloneDeep } from "@apollo/client/utilities"; const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog")); const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal")); @@ -140,6 +145,7 @@ export const SceneEditPanel: React.FC = ({ stash_ids: yup.mixed().defined(), details: yup.string().ensure(), cover_image: yup.string().nullable().optional(), + custom_fields: yup.object().required().defined(), }); const initialValues = useMemo( @@ -159,17 +165,28 @@ export const SceneEditPanel: React.FC = ({ stash_ids: getStashIDs(scene.stash_ids), details: scene.details ?? "", cover_image: initialCoverImage, + custom_fields: cloneDeep(scene.custom_fields ?? {}), }), [scene, initialCoverImage] ); type InputValues = yup.InferType; + const [customFieldsError, setCustomFieldsError] = useState(); + + function submit(values: InputValues) { + const input = { + ...schema.cast(values), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), + }; + onSave(input); + } + const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), - onSubmit: (values) => onSave(schema.cast(values)), + onSubmit: submit, }); const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( @@ -288,7 +305,10 @@ export const SceneEditPanel: React.FC = ({ } async function onSaveAndNewClick() { - const input = schema.cast(formik.values); + const input = { + ...schema.cast(formik.values), + custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields), + }; onSave(input, true); } @@ -759,7 +779,9 @@ export const SceneEditPanel: React.FC = ({ id="scene-save-split-button" className="edit-button" variant="primary" - disabled={!isEqual(formik.errors, {})} + disabled={ + !isEqual(formik.errors, {}) || customFieldsError !== undefined + } title={intl.formatMessage({ id: "actions.save" })} onClick={() => formik.submitForm()} > @@ -772,7 +794,9 @@ export const SceneEditPanel: React.FC = ({ className="edit-button" variant="primary" disabled={ - (!isNew && !formik.dirty) || !isEqual(formik.errors, {}) + (!isNew && !formik.dirty) || + !isEqual(formik.errors, {}) || + customFieldsError !== undefined } onClick={() => formik.submitForm()} > @@ -863,6 +887,13 @@ export const SceneEditPanel: React.FC = ({ onReset={scene.id ? onResetCover : undefined} /> + + formik.setFieldValue("custom_fields", v)} + error={customFieldsError} + setError={(e) => setCustomFieldsError(e)} + /> diff --git a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx index 9455af186..89d445002 100644 --- a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx @@ -7,12 +7,16 @@ import { StringListSelect, GallerySelect } from "../Shared/Select"; import * as FormUtils from "src/utils/form"; import ImageUtils from "src/utils/image"; import TextUtils from "src/utils/text"; -import { mutateSceneMerge, queryFindScenesByID } from "src/core/StashService"; +import { + mutateSceneMerge, + queryFindFullScenesByID, +} from "src/core/StashService"; import { FormattedMessage, useIntl } from "react-intl"; import { useToast } from "src/hooks/Toast"; import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons"; import { ScrapeDialogRow, + ScrapedCustomFieldRows, ScrapedImageRow, ScrapedInputGroupRow, ScrapedStringListRow, @@ -24,6 +28,7 @@ import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { ModalComponent } from "../Shared/Modal"; import { IHasStoredID, sortStoredIdObjects } from "src/utils/data"; import { + CustomFieldScrapeResults, ObjectListScrapeResult, ScrapeResult, ZeroableScrapeResult, @@ -52,8 +57,8 @@ type MergeOptions = { }; interface ISceneMergeDetailsProps { - sources: GQL.SlimSceneDataFragment[]; - dest: GQL.SlimSceneDataFragment; + sources: GQL.SceneDataFragment[]; + dest: GQL.SceneDataFragment; onClose: (options?: MergeOptions) => void; } @@ -173,6 +178,10 @@ const SceneMergeDetails: React.FC = ({ new ScrapeResult(dest.paths.screenshot) ); + const [customFields, setCustomFields] = useState( + new Map() + ); + // calculate the values for everything // uses the first set value for single value fields, and combines all useEffect(() => { @@ -309,28 +318,64 @@ const SceneMergeDetails: React.FC = ({ ) ); + const customFieldNames = new Set( + Object.keys(dest.custom_fields ?? {}) + ); + + for (const s of sources) { + for (const n of Object.keys(s.custom_fields ?? {})) { + customFieldNames.add(n); + } + } + + setCustomFields( + new Map( + Array.from(customFieldNames) + .sort() + .map((field) => { + return [ + field, + new ScrapeResult( + dest.custom_fields?.[field], + sources.find((s) => s.custom_fields?.[field])?.custom_fields?.[ + field + ], + dest.custom_fields?.[field] === undefined + ), + ]; + }) + ) + ); + loadImages(); }, [sources, dest]); + const hasCustomFieldValues = useMemo(() => { + return hasScrapedValues(Array.from(customFields.values())); + }, [customFields]); + // ensure this is updated if fields are changed const hasValues = useMemo(() => { - return hasScrapedValues([ - title, - code, - url, - date, - rating, - oCounter, - galleries, - studio, - performers, - groups, - tags, - details, - organized, - stashIDs, - image, - ]); + return ( + hasCustomFieldValues || + hasScrapedValues([ + title, + code, + url, + date, + rating, + oCounter, + galleries, + studio, + performers, + groups, + tags, + details, + organized, + stashIDs, + image, + ]) + ); }, [ title, code, @@ -347,6 +392,7 @@ const SceneMergeDetails: React.FC = ({ organized, stashIDs, image, + hasCustomFieldValues, ]); function renderScrapeRows() { @@ -566,6 +612,12 @@ const SceneMergeDetails: React.FC = ({ result={image} onChange={(value) => setImage(value)} /> + {hasCustomFieldValues && ( + setCustomFields(newCustomFields)} + /> + )} ); } @@ -606,6 +658,13 @@ const SceneMergeDetails: React.FC = ({ organized: organized.getNewValue(), stash_ids: stashIDs.getNewValue(), cover_image: coverImage, + custom_fields: { + partial: Object.fromEntries( + Array.from(customFields.entries()).flatMap(([field, v]) => + v.useNewValue ? [[field, v.getNewValue()]] : [] + ) + ), + }, }, includeViewHistory: playCount.getNewValue() !== undefined, includeOHistory: oCounter.getNewValue() !== undefined, @@ -655,10 +714,10 @@ export const SceneMergeModal: React.FC = ({ const [sourceScenes, setSourceScenes] = useState([]); const [destScene, setDestScene] = useState([]); - const [loadedSources, setLoadedSources] = useState< - GQL.SlimSceneDataFragment[] - >([]); - const [loadedDest, setLoadedDest] = useState(); + const [loadedSources, setLoadedSources] = useState( + [] + ); + const [loadedDest, setLoadedDest] = useState(); const [running, setRunning] = useState(false); const [secondStep, setSecondStep] = useState(false); @@ -684,7 +743,7 @@ export const SceneMergeModal: React.FC = ({ async function loadScenes() { const sceneIDs = sourceScenes.map((s) => parseInt(s.id)); sceneIDs.push(parseInt(destScene[0].id)); - const query = await queryFindScenesByID(sceneIDs); + const query = await queryFindFullScenesByID(sceneIDs); const { scenes: loadedScenes } = query.data.findScenes; setLoadedDest(loadedScenes.find((s) => s.id === destScene[0].id)); diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index 78644b4c9..3f142f4bd 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -562,6 +562,20 @@ input[type="range"].blue-slider { .form-group[data-field="urls"] .string-list-input input.form-control { font-size: 0.85em; } + + @include media-breakpoint-up(xl) { + .custom-fields-input { + .custom-fields-field { + flex: 0 0 25%; + max-width: 25%; + } + + .custom-fields-value { + flex: 0 0 75%; + max-width: 75%; + } + } + } } .scene-markers-panel { diff --git a/ui/v2.5/src/components/Shared/CustomFields.tsx b/ui/v2.5/src/components/Shared/CustomFields.tsx index c8d389a17..e6e892f7c 100644 --- a/ui/v2.5/src/components/Shared/CustomFields.tsx +++ b/ui/v2.5/src/components/Shared/CustomFields.tsx @@ -18,6 +18,7 @@ export type CustomFieldMap = { interface ICustomFields { values: CustomFieldMap; + fullWidth?: boolean; } function convertValue(value: unknown): string { @@ -41,7 +42,7 @@ const CustomField: React.FC<{ field: string; value: unknown }> = ({ const valueStr = convertValue(value); // replace spaces with hyphen characters for css id - const id = field.toLowerCase().replace(/ /g, "-"); + const id = `custom-field-${field.toLowerCase().replace(/ /g, "-")}`; return ( = ({ export const CustomFields: React.FC = PatchComponent( "CustomFields", - ({ values }) => { + ({ values, fullWidth }) => { const intl = useIntl(); if (Object.keys(values).length === 0) { return null; @@ -65,7 +66,7 @@ export const CustomFields: React.FC = PatchComponent( return ( // according to linter rule CSS classes shouldn't use underscores -
+
@@ -125,7 +126,7 @@ const CustomFieldInput: React.FC<{ - + {isNew ? ( <> {currentField} )} - + void; } +export function formatCustomFieldInput(isNew: boolean, input: {}) { + if (isNew) { + return input; + } else { + return { + full: input, + }; + } +} + export const CustomFieldsInput: React.FC = PatchComponent( "CustomFieldsInput", ({ values, error, onChange, setError }) => { @@ -282,10 +293,10 @@ export const CustomFieldsInput: React.FC = PatchComponent( - + - + diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 32b222832..97a5c4387 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -795,6 +795,11 @@ button.btn.favorite-button { .detail-item { max-width: 100%; } + + .detail-item-title, + .detail-item-value { + font-family: "Courier New", Courier, monospace; + } } .custom-fields .detail-item .detail-item-title { @@ -816,6 +821,36 @@ button.btn.favorite-button { font-weight: 700; } +.custom-fields-input { + .custom-fields-field { + flex: 0 0 100%; + max-width: 100%; + + @include media-breakpoint-up(sm) { + flex: 0 0 25%; + max-width: 25%; + } + @include media-breakpoint-up(xl) { + flex: 0 0 16.667%; + max-width: 16.667%; + } + } + + .custom-fields-value { + flex: 0 0 100%; + max-width: 100%; + + @include media-breakpoint-up(sm) { + flex: 0 0 75%; + max-width: 75%; + } + @include media-breakpoint-up(xl) { + flex: 0 0 58.33%; + max-width: 58.33%; + } + } +} + .custom-fields-row { align-items: center; font-family: "Courier New", Courier, monospace; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx index 5ad92100f..ae8314fe8 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx @@ -4,6 +4,7 @@ import * as GQL from "src/core/generated-graphql"; import { DetailItem } from "src/components/Shared/DetailItem"; import { StashIDPill } from "src/components/Shared/StashID"; import { PatchComponent } from "src/patch"; +import { CustomFields } from "src/components/Shared/CustomFields"; import { Link } from "react-router-dom"; interface IStudioDetailsPanel { @@ -87,6 +88,7 @@ export const StudioDetailsPanel: React.FC = PatchComponent( value={renderStashIDs()} fullWidth={fullWidth} /> +
); } diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx index f887e5403..490f09a55 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx @@ -21,6 +21,11 @@ import { Studio, StudioSelect } from "../StudioSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { Icon } from "src/components/Shared/Icon"; import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal"; +import { + CustomFieldsInput, + formatCustomFieldInput, +} from "src/components/Shared/CustomFields"; +import { cloneDeep } from "@apollo/client/utilities"; interface IStudioEditPanel { studio: Partial; @@ -63,6 +68,7 @@ export const StudioEditPanel: React.FC = ({ ignore_auto_tag: yup.boolean().defined(), stash_ids: yup.mixed().defined(), image: yup.string().nullable().optional(), + custom_fields: yup.object().required().defined(), }); const initialValues = { @@ -75,15 +81,26 @@ export const StudioEditPanel: React.FC = ({ tag_ids: (studio.tags ?? []).map((t) => t.id), ignore_auto_tag: studio.ignore_auto_tag ?? false, stash_ids: getStashIDs(studio.stash_ids), + custom_fields: cloneDeep(studio.custom_fields ?? {}), }; type InputValues = yup.InferType; + const [customFieldsError, setCustomFieldsError] = useState(); + + function submit(values: InputValues) { + const input = { + ...schema.cast(values), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), + }; + onSave(input); + } + const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), - onSubmit: (values) => onSave(schema.cast(values)), + onSubmit: submit, }); const { tagsControl } = useTagsEdit(studio.tags, (ids) => @@ -144,7 +161,10 @@ export const StudioEditPanel: React.FC = ({ } async function onSaveAndNewClick() { - const input = schema.cast(formik.values); + const input = { + ...schema.cast(formik.values), + custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields), + }; onSave(input, true); } @@ -242,6 +262,14 @@ export const StudioEditPanel: React.FC = ({ )} + + formik.setFieldValue("custom_fields", v)} + error={customFieldsError} + setError={(e) => setCustomFieldsError(e)} + /> +
{renderInputField("ignore_auto_tag", "checkbox")} @@ -254,7 +282,11 @@ export const StudioEditPanel: React.FC = ({ onToggleEdit={onCancel} onSave={formik.handleSubmit} onSaveAndNew={isNew ? onSaveAndNewClick : undefined} - saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})} + saveDisabled={ + (!isNew && !formik.dirty) || + !isEqual(formik.errors, {}) || + customFieldsError !== undefined + } onImageChange={onImageChange} onImageChangeURL={onImageLoad} onClearImage={() => onImageLoad(null)} diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagDetailsPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagDetailsPanel.tsx index 92c92d072..bf2e80c91 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagDetailsPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagDetailsPanel.tsx @@ -3,6 +3,7 @@ import { TagLink } from "src/components/Shared/TagLink"; import { DetailItem } from "src/components/Shared/DetailItem"; import { StashIDPill } from "src/components/Shared/StashID"; import * as GQL from "src/core/generated-graphql"; +import { CustomFields } from "src/components/Shared/CustomFields"; interface ITagDetails { tag: GQL.TagDataFragment; @@ -90,6 +91,7 @@ export const TagDetailsPanel: React.FC = ({ tag, fullWidth }) => { value={renderStashIDs()} fullWidth={fullWidth} /> +
); }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx index 22c99b80e..21cd32c53 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx @@ -20,6 +20,11 @@ import { addUpdateStashID, getStashIDs } from "src/utils/stashIds"; import { Tag, TagSelect } from "../TagSelect"; import { Icon } from "src/components/Shared/Icon"; import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal"; +import { + CustomFieldsInput, + formatCustomFieldInput, +} from "src/components/Shared/CustomFields"; +import { cloneDeep } from "@apollo/client/utilities"; interface ITagEditPanel { tag: Partial; @@ -63,6 +68,7 @@ export const TagEditPanel: React.FC = ({ ignore_auto_tag: yup.boolean().defined(), stash_ids: yup.mixed().defined(), image: yup.string().nullable().optional(), + custom_fields: yup.object().required().defined(), }); const initialValues = { @@ -74,15 +80,26 @@ export const TagEditPanel: React.FC = ({ child_ids: (tag?.children ?? []).map((t) => t.id), ignore_auto_tag: tag?.ignore_auto_tag ?? false, stash_ids: getStashIDs(tag?.stash_ids), + custom_fields: cloneDeep(tag?.custom_fields ?? {}), }; type InputValues = yup.InferType; + const [customFieldsError, setCustomFieldsError] = useState(); + + function submit(values: InputValues) { + const input = { + ...schema.cast(values), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), + }; + onSave(input); + } + const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), - onSubmit: (values) => onSave(schema.cast(values)), + onSubmit: submit, }); function onSetParentTags(items: Tag[]) { @@ -134,7 +151,10 @@ export const TagEditPanel: React.FC = ({ } async function onSaveAndNewClick() { - const input = schema.cast(formik.values); + const input = { + ...schema.cast(formik.values), + custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields), + }; onSave(input, true); } @@ -266,6 +286,14 @@ export const TagEditPanel: React.FC = ({ )} + + formik.setFieldValue("custom_fields", v)} + error={customFieldsError} + setError={(e) => setCustomFieldsError(e)} + /> +
{renderInputField("ignore_auto_tag", "checkbox")} @@ -279,7 +307,9 @@ export const TagEditPanel: React.FC = ({ onSave={formik.handleSubmit} onSaveAndNew={isNew ? onSaveAndNewClick : undefined} saveDisabled={ - (!isNew && !formik.dirty) || !isEqual(formik.errors, {}) + (!isNew && !formik.dirty) || + !isEqual(formik.errors, {}) || + customFieldsError !== undefined } onImageChange={onImageChange} onImageChangeURL={onImageLoad} diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 27186d6e1..535beed65 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -166,6 +166,14 @@ export const queryFindScenesByID = (sceneIDs: number[]) => }, }); +export const queryFindFullScenesByID = (sceneIDs: number[]) => + client.query({ + query: GQL.FindFullScenesDocument, + variables: { + ids: sceneIDs, + }, + }); + export const queryFindScenesForSelect = (filter: ListFilterModel) => client.query({ query: GQL.FindScenesForSelectDocument, diff --git a/ui/v2.5/src/models/list-filter/galleries.ts b/ui/v2.5/src/models/list-filter/galleries.ts index 3d4d40a1c..adac37e3c 100644 --- a/ui/v2.5/src/models/list-filter/galleries.ts +++ b/ui/v2.5/src/models/list-filter/galleries.ts @@ -21,6 +21,7 @@ import { ListFilterOptions, MediaSortByOptions } from "./filter-options"; import { DisplayMode } from "./types"; import { RatingCriterionOption } from "./criteria/rating"; import { PathCriterionOption } from "./criteria/path"; +import { CustomFieldsCriterionOption } from "./criteria/custom-fields"; const defaultSortBy = "path"; @@ -71,6 +72,7 @@ const criterionOptions = [ createDateCriterionOption("date"), createMandatoryTimestampCriterionOption("created_at"), createMandatoryTimestampCriterionOption("updated_at"), + CustomFieldsCriterionOption, ]; export const GalleryListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/groups.ts b/ui/v2.5/src/models/list-filter/groups.ts index ee0c90d73..9c5b3f2d4 100644 --- a/ui/v2.5/src/models/list-filter/groups.ts +++ b/ui/v2.5/src/models/list-filter/groups.ts @@ -17,6 +17,7 @@ import { ContainingGroupsCriterionOption, SubGroupsCriterionOption, } from "./criteria/groups"; +import { CustomFieldsCriterionOption } from "./criteria/custom-fields"; const defaultSortBy = "name"; @@ -66,6 +67,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("scene_count"), createMandatoryTimestampCriterionOption("created_at"), createMandatoryTimestampCriterionOption("updated_at"), + CustomFieldsCriterionOption, ]; export const GroupListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/images.ts b/ui/v2.5/src/models/list-filter/images.ts index 9468e5eaf..eabcbfd26 100644 --- a/ui/v2.5/src/models/list-filter/images.ts +++ b/ui/v2.5/src/models/list-filter/images.ts @@ -23,6 +23,7 @@ import { ListFilterOptions, MediaSortByOptions } from "./filter-options"; import { DisplayMode } from "./types"; import { GalleriesCriterionOption } from "./criteria/galleries"; import { PhashCriterionOption } from "./criteria/phash"; +import { CustomFieldsCriterionOption } from "./criteria/custom-fields"; const defaultSortBy = "path"; @@ -73,6 +74,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("file_count"), createMandatoryTimestampCriterionOption("created_at"), createMandatoryTimestampCriterionOption("updated_at"), + CustomFieldsCriterionOption, ]; export const ImageListFilterOptions = new ListFilterOptions( defaultSortBy, diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index f4f93deeb..c0e4a75a1 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -35,6 +35,7 @@ import { StashIDCriterionOption } from "./criteria/stash-ids"; import { RatingCriterionOption } from "./criteria/rating"; import { PathCriterionOption } from "./criteria/path"; import { OrientationCriterionOption } from "./criteria/orientation"; +import { CustomFieldsCriterionOption } from "./criteria/custom-fields"; const defaultSortBy = "date"; const sortByOptions = [ @@ -141,6 +142,7 @@ const criterionOptions = [ createDateCriterionOption("date"), createMandatoryTimestampCriterionOption("created_at"), createMandatoryTimestampCriterionOption("updated_at"), + CustomFieldsCriterionOption, ]; export const SceneListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/studios.ts b/ui/v2.5/src/models/list-filter/studios.ts index a38540a47..e62d41c7a 100644 --- a/ui/v2.5/src/models/list-filter/studios.ts +++ b/ui/v2.5/src/models/list-filter/studios.ts @@ -13,6 +13,7 @@ import { ParentStudiosCriterionOption } from "./criteria/studios"; import { TagsCriterionOption } from "./criteria/tags"; import { ListFilterOptions } from "./filter-options"; import { DisplayMode } from "./types"; +import { CustomFieldsCriterionOption } from "./criteria/custom-fields"; const defaultSortBy = "name"; const sortByOptions = [ @@ -67,6 +68,7 @@ const criterionOptions = [ ), createMandatoryTimestampCriterionOption("created_at"), createMandatoryTimestampCriterionOption("updated_at"), + CustomFieldsCriterionOption, ]; export const StudioListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/tags.ts b/ui/v2.5/src/models/list-filter/tags.ts index 39ce9ca39..4c8bed69f 100644 --- a/ui/v2.5/src/models/list-filter/tags.ts +++ b/ui/v2.5/src/models/list-filter/tags.ts @@ -15,6 +15,7 @@ import { } from "./criteria/tags"; import { FavoriteTagCriterionOption } from "./criteria/favorite"; import { StashIDCriterionOption } from "./criteria/stash-ids"; +import { CustomFieldsCriterionOption } from "./criteria/custom-fields"; const defaultSortBy = "name"; const sortByOptions = ["name", "random", "scenes_duration"] @@ -77,6 +78,7 @@ const criterionOptions = [ new MandatoryNumberCriterionOption("sub_tag_count", "child_count"), createMandatoryTimestampCriterionOption("created_at"), createMandatoryTimestampCriterionOption("updated_at"), + CustomFieldsCriterionOption, ]; export const TagListFilterOptions = new ListFilterOptions(