Fix scene marker issues (#3955)

* Fix scene marker NOT NULL constraint error
* similar changes to gallery chapters
* Fix NULL conversion error if names are NULL in DB
* Fix scene marker form resetting
This commit is contained in:
DingDongSoLong4
2023-07-28 03:22:43 +02:00
committed by GitHub
parent 7b77b8986f
commit 95a78de3aa
9 changed files with 84 additions and 75 deletions

View File

@@ -14,8 +14,8 @@ type SceneMarker struct {
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
// SceneMarkerPartial represents part of a SceneMarker object. It is used to update // SceneMarkerPartial represents part of a SceneMarker object.
// the database entry. // It is used to update the database entry.
type SceneMarkerPartial struct { type SceneMarkerPartial struct {
Title OptionalString Title OptionalString
Seconds OptionalFloat64 Seconds OptionalFloat64

View File

@@ -20,7 +20,7 @@ const (
type galleryChapterRow struct { type galleryChapterRow struct {
ID int `db:"id" goqu:"skipinsert"` ID int `db:"id" goqu:"skipinsert"`
Title string `db:"title"` Title string `db:"title"` // TODO: make db schema (and gql schema) nullable
ImageIndex int `db:"image_index"` ImageIndex int `db:"image_index"`
GalleryID int `db:"gallery_id"` GalleryID int `db:"gallery_id"`
CreatedAt Timestamp `db:"created_at"` CreatedAt Timestamp `db:"created_at"`
@@ -54,7 +54,12 @@ type galleryChapterRowRecord struct {
} }
func (r *galleryChapterRowRecord) fromPartial(o models.GalleryChapterPartial) { func (r *galleryChapterRowRecord) fromPartial(o models.GalleryChapterPartial) {
r.setString("title", o.Title) // TODO: replace with setNullString after schema is made nullable
// r.setNullString("title", o.Title)
// saves a null input as the empty string
if o.Title.Set {
r.set("title", o.Title.Value)
}
r.setInt("image_index", o.ImageIndex) r.setInt("image_index", o.ImageIndex)
r.setInt("gallery_id", o.GalleryID) r.setInt("gallery_id", o.GalleryID)
r.setTimestamp("created_at", o.CreatedAt) r.setTimestamp("created_at", o.CreatedAt)

View File

@@ -30,7 +30,7 @@ const (
type performerRow struct { type performerRow struct {
ID int `db:"id" goqu:"skipinsert"` ID int `db:"id" goqu:"skipinsert"`
Name string `db:"name"` Name null.String `db:"name"` // TODO: make schema non-nullable
Disambigation zero.String `db:"disambiguation"` Disambigation zero.String `db:"disambiguation"`
Gender zero.String `db:"gender"` Gender zero.String `db:"gender"`
URL zero.String `db:"url"` URL zero.String `db:"url"`
@@ -65,7 +65,7 @@ type performerRow struct {
func (r *performerRow) fromPerformer(o models.Performer) { func (r *performerRow) fromPerformer(o models.Performer) {
r.ID = o.ID r.ID = o.ID
r.Name = o.Name r.Name = null.StringFrom(o.Name)
r.Disambigation = zero.StringFrom(o.Disambiguation) r.Disambigation = zero.StringFrom(o.Disambiguation)
if o.Gender != nil && o.Gender.IsValid() { if o.Gender != nil && o.Gender.IsValid() {
r.Gender = zero.StringFrom(o.Gender.String()) r.Gender = zero.StringFrom(o.Gender.String())
@@ -101,7 +101,7 @@ func (r *performerRow) fromPerformer(o models.Performer) {
func (r *performerRow) resolve() *models.Performer { func (r *performerRow) resolve() *models.Performer {
ret := &models.Performer{ ret := &models.Performer{
ID: r.ID, ID: r.ID,
Name: r.Name, Name: r.Name.String,
Disambiguation: r.Disambigation.String, Disambiguation: r.Disambigation.String,
URL: r.URL.String, URL: r.URL.String,
Twitter: r.Twitter.String, Twitter: r.Twitter.String,

View File

@@ -25,7 +25,7 @@ GROUP BY scene_markers.id
type sceneMarkerRow struct { type sceneMarkerRow struct {
ID int `db:"id" goqu:"skipinsert"` ID int `db:"id" goqu:"skipinsert"`
Title string `db:"title"` Title string `db:"title"` // TODO: make db schema (and gql schema) nullable
Seconds float64 `db:"seconds"` Seconds float64 `db:"seconds"`
PrimaryTagID int `db:"primary_tag_id"` PrimaryTagID int `db:"primary_tag_id"`
SceneID int `db:"scene_id"` SceneID int `db:"scene_id"`
@@ -62,7 +62,12 @@ type sceneMarkerRowRecord struct {
} }
func (r *sceneMarkerRowRecord) fromPartial(o models.SceneMarkerPartial) { func (r *sceneMarkerRowRecord) fromPartial(o models.SceneMarkerPartial) {
r.setNullString("title", o.Title) // TODO: replace with setNullString after schema is made nullable
// r.setNullString("title", o.Title)
// saves a null input as the empty string
if o.Title.Set {
r.set("title", o.Title.Value)
}
r.setFloat64("seconds", o.Seconds) r.setFloat64("seconds", o.Seconds)
r.setInt("primary_tag_id", o.PrimaryTagID) r.setInt("primary_tag_id", o.PrimaryTagID)
r.setInt("scene_id", o.SceneID) r.setInt("scene_id", o.SceneID)

View File

@@ -10,6 +10,7 @@ import (
"github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9"
"github.com/doug-martin/goqu/v9/exp" "github.com/doug-martin/goqu/v9/exp"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"gopkg.in/guregu/null.v4"
"gopkg.in/guregu/null.v4/zero" "gopkg.in/guregu/null.v4/zero"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
@@ -27,7 +28,7 @@ const (
type tagRow struct { type tagRow struct {
ID int `db:"id" goqu:"skipinsert"` ID int `db:"id" goqu:"skipinsert"`
Name string `db:"name"` Name null.String `db:"name"` // TODO: make schema non-nullable
Description zero.String `db:"description"` Description zero.String `db:"description"`
IgnoreAutoTag bool `db:"ignore_auto_tag"` IgnoreAutoTag bool `db:"ignore_auto_tag"`
CreatedAt Timestamp `db:"created_at"` CreatedAt Timestamp `db:"created_at"`
@@ -39,7 +40,7 @@ type tagRow struct {
func (r *tagRow) fromTag(o models.Tag) { func (r *tagRow) fromTag(o models.Tag) {
r.ID = o.ID r.ID = o.ID
r.Name = o.Name r.Name = null.StringFrom(o.Name)
r.Description = zero.StringFrom(o.Description) r.Description = zero.StringFrom(o.Description)
r.IgnoreAutoTag = o.IgnoreAutoTag r.IgnoreAutoTag = o.IgnoreAutoTag
r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} r.CreatedAt = Timestamp{Timestamp: o.CreatedAt}
@@ -49,7 +50,7 @@ func (r *tagRow) fromTag(o models.Tag) {
func (r *tagRow) resolve() *models.Tag { func (r *tagRow) resolve() *models.Tag {
ret := &models.Tag{ ret := &models.Tag{
ID: r.ID, ID: r.ID,
Name: r.Name, Name: r.Name.String,
Description: r.Description.String, Description: r.Description.String,
IgnoreAutoTag: r.IgnoreAutoTag, IgnoreAutoTag: r.IgnoreAutoTag,
CreatedAt: r.CreatedAt.Timestamp, CreatedAt: r.CreatedAt.Timestamp,

View File

@@ -14,13 +14,13 @@ import isEqual from "lodash-es/isEqual";
interface IGalleryChapterForm { interface IGalleryChapterForm {
galleryID: string; galleryID: string;
editingChapter?: GQL.GalleryChapterDataFragment; chapter?: GQL.GalleryChapterDataFragment;
onClose: () => void; onClose: () => void;
} }
export const GalleryChapterForm: React.FC<IGalleryChapterForm> = ({ export const GalleryChapterForm: React.FC<IGalleryChapterForm> = ({
galleryID, galleryID,
editingChapter, chapter,
onClose, onClose,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
@@ -30,6 +30,8 @@ export const GalleryChapterForm: React.FC<IGalleryChapterForm> = ({
const [galleryChapterDestroy] = useGalleryChapterDestroy(); const [galleryChapterDestroy] = useGalleryChapterDestroy();
const Toast = useToast(); const Toast = useToast();
const isNew = chapter === undefined;
const schema = yup.object({ const schema = yup.object({
title: yup.string().ensure(), title: yup.string().ensure(),
image_index: yup image_index: yup
@@ -41,8 +43,8 @@ export const GalleryChapterForm: React.FC<IGalleryChapterForm> = ({
}); });
const initialValues = { const initialValues = {
title: editingChapter?.title ?? "", title: chapter?.title ?? "",
image_index: editingChapter?.image_index ?? 1, image_index: chapter?.image_index ?? 1,
}; };
type InputValues = yup.InferType<typeof schema>; type InputValues = yup.InferType<typeof schema>;
@@ -56,7 +58,7 @@ export const GalleryChapterForm: React.FC<IGalleryChapterForm> = ({
async function onSave(input: InputValues) { async function onSave(input: InputValues) {
try { try {
if (!editingChapter) { if (isNew) {
await galleryChapterCreate({ await galleryChapterCreate({
variables: { variables: {
gallery_id: galleryID, gallery_id: galleryID,
@@ -66,7 +68,7 @@ export const GalleryChapterForm: React.FC<IGalleryChapterForm> = ({
} else { } else {
await galleryChapterUpdate({ await galleryChapterUpdate({
variables: { variables: {
id: editingChapter.id, id: chapter.id,
gallery_id: galleryID, gallery_id: galleryID,
...input, ...input,
}, },
@@ -80,10 +82,10 @@ export const GalleryChapterForm: React.FC<IGalleryChapterForm> = ({
} }
async function onDelete() { async function onDelete() {
if (!editingChapter) return; if (isNew) return;
try { try {
await galleryChapterDestroy({ variables: { id: editingChapter.id } }); await galleryChapterDestroy({ variables: { id: chapter.id } });
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
} finally { } finally {
@@ -130,9 +132,7 @@ export const GalleryChapterForm: React.FC<IGalleryChapterForm> = ({
<div className="col d-flex"> <div className="col d-flex">
<Button <Button
variant="primary" variant="primary"
disabled={ disabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})}
(editingChapter && !formik.dirty) || !isEqual(formik.errors, {})
}
onClick={() => formik.submitForm()} onClick={() => formik.submitForm()}
> >
<FormattedMessage id="actions.save" /> <FormattedMessage id="actions.save" />
@@ -145,7 +145,7 @@ export const GalleryChapterForm: React.FC<IGalleryChapterForm> = ({
> >
<FormattedMessage id="actions.cancel" /> <FormattedMessage id="actions.cancel" />
</Button> </Button>
{editingChapter && ( {!isNew && (
<Button <Button
variant="danger" variant="danger"
className="ml-auto" className="ml-auto"

View File

@@ -12,22 +12,24 @@ interface IGalleryChapterPanelProps {
onClickChapter: (index: number) => void; onClickChapter: (index: number) => void;
} }
export const GalleryChapterPanel: React.FC<IGalleryChapterPanelProps> = ( export const GalleryChapterPanel: React.FC<IGalleryChapterPanelProps> = ({
props: IGalleryChapterPanelProps gallery,
) => { isVisible,
onClickChapter,
}) => {
const [isEditorOpen, setIsEditorOpen] = useState<boolean>(false); const [isEditorOpen, setIsEditorOpen] = useState<boolean>(false);
const [editingChapter, setEditingChapter] = const [editingChapter, setEditingChapter] =
useState<GQL.GalleryChapterDataFragment>(); useState<GQL.GalleryChapterDataFragment>();
// set up hotkeys // set up hotkeys
useEffect(() => { useEffect(() => {
if (props.isVisible) { if (!isVisible) return;
Mousetrap.bind("n", () => onOpenEditor()); Mousetrap.bind("n", () => onOpenEditor());
return () => { return () => {
Mousetrap.unbind("n"); Mousetrap.unbind("n");
}; };
}
}); });
function onOpenEditor(chapter?: GQL.GalleryChapterDataFragment) { function onOpenEditor(chapter?: GQL.GalleryChapterDataFragment) {
@@ -35,10 +37,6 @@ export const GalleryChapterPanel: React.FC<IGalleryChapterPanelProps> = (
setEditingChapter(chapter ?? undefined); setEditingChapter(chapter ?? undefined);
} }
function onClickChapter(image_index: number) {
props.onClickChapter(image_index);
}
const closeEditor = () => { const closeEditor = () => {
setEditingChapter(undefined); setEditingChapter(undefined);
setIsEditorOpen(false); setIsEditorOpen(false);
@@ -47,8 +45,8 @@ export const GalleryChapterPanel: React.FC<IGalleryChapterPanelProps> = (
if (isEditorOpen) if (isEditorOpen)
return ( return (
<GalleryChapterForm <GalleryChapterForm
galleryID={props.gallery.id} galleryID={gallery.id}
editingChapter={editingChapter} chapter={editingChapter}
onClose={closeEditor} onClose={closeEditor}
/> />
); );
@@ -60,7 +58,7 @@ export const GalleryChapterPanel: React.FC<IGalleryChapterPanelProps> = (
</Button> </Button>
<div className="container"> <div className="container">
<ChapterEntries <ChapterEntries
galleryChapters={props.gallery.chapters} galleryChapters={gallery.chapters}
onClickChapter={onClickChapter} onClickChapter={onClickChapter}
onEdit={onOpenEditor} onEdit={onOpenEditor}
/> />

View File

@@ -1,4 +1,4 @@
import React from "react"; import React, { useMemo } from "react";
import { Button, Form } from "react-bootstrap"; import { Button, Form } from "react-bootstrap";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { useFormik } from "formik"; import { useFormik } from "formik";
@@ -17,13 +17,13 @@ import isEqual from "lodash-es/isEqual";
interface ISceneMarkerForm { interface ISceneMarkerForm {
sceneID: string; sceneID: string;
editingMarker?: GQL.SceneMarkerDataFragment; marker?: GQL.SceneMarkerDataFragment;
onClose: () => void; onClose: () => void;
} }
export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
sceneID, sceneID,
editingMarker, marker,
onClose, onClose,
}) => { }) => {
const [sceneMarkerCreate] = useSceneMarkerCreate(); const [sceneMarkerCreate] = useSceneMarkerCreate();
@@ -31,6 +31,8 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
const [sceneMarkerDestroy] = useSceneMarkerDestroy(); const [sceneMarkerDestroy] = useSceneMarkerDestroy();
const Toast = useToast(); const Toast = useToast();
const isNew = marker === undefined;
const schema = yup.object({ const schema = yup.object({
title: yup.string().ensure(), title: yup.string().ensure(),
seconds: yup.number().required().integer(), seconds: yup.number().required().integer(),
@@ -38,12 +40,16 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
tag_ids: yup.array(yup.string().required()).defined(), tag_ids: yup.array(yup.string().required()).defined(),
}); });
const initialValues = { // useMemo to only run getPlayerPosition when the input marker actually changes
title: editingMarker?.title ?? "", const initialValues = useMemo(
seconds: editingMarker?.seconds ?? Math.round(getPlayerPosition() ?? 0), () => ({
primary_tag_id: editingMarker?.primary_tag.id ?? "", title: marker?.title ?? "",
tag_ids: editingMarker?.tags.map((tag) => tag.id) ?? [], seconds: marker?.seconds ?? Math.round(getPlayerPosition() ?? 0),
}; primary_tag_id: marker?.primary_tag.id ?? "",
tag_ids: marker?.tags.map((tag) => tag.id) ?? [],
}),
[marker]
);
type InputValues = yup.InferType<typeof schema>; type InputValues = yup.InferType<typeof schema>;
@@ -56,7 +62,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
async function onSave(input: InputValues) { async function onSave(input: InputValues) {
try { try {
if (!editingMarker) { if (isNew) {
await sceneMarkerCreate({ await sceneMarkerCreate({
variables: { variables: {
scene_id: sceneID, scene_id: sceneID,
@@ -66,7 +72,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
} else { } else {
await sceneMarkerUpdate({ await sceneMarkerUpdate({
variables: { variables: {
id: editingMarker.id, id: marker.id,
scene_id: sceneID, scene_id: sceneID,
...input, ...input,
}, },
@@ -80,10 +86,10 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
} }
async function onDelete() { async function onDelete() {
if (!editingMarker) return; if (isNew) return;
try { try {
await sceneMarkerDestroy({ variables: { id: editingMarker.id } }); await sceneMarkerDestroy({ variables: { id: marker.id } });
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
} finally { } finally {
@@ -169,9 +175,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
<div className="col d-flex"> <div className="col d-flex">
<Button <Button
variant="primary" variant="primary"
disabled={ disabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})}
(editingMarker && !formik.dirty) || !isEqual(formik.errors, {})
}
onClick={() => formik.submitForm()} onClick={() => formik.submitForm()}
> >
<FormattedMessage id="actions.save" /> <FormattedMessage id="actions.save" />
@@ -184,7 +188,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
> >
<FormattedMessage id="actions.cancel" /> <FormattedMessage id="actions.cancel" />
</Button> </Button>
{editingMarker && ( {!isNew && (
<Button <Button
variant="danger" variant="danger"
className="ml-auto" className="ml-auto"

View File

@@ -13,13 +13,13 @@ interface ISceneMarkersPanelProps {
onClickMarker: (marker: GQL.SceneMarkerDataFragment) => void; onClickMarker: (marker: GQL.SceneMarkerDataFragment) => void;
} }
export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = ( export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = ({
props: ISceneMarkersPanelProps sceneId,
) => { isVisible,
onClickMarker,
}) => {
const { data, loading } = GQL.useFindSceneMarkerTagsQuery({ const { data, loading } = GQL.useFindSceneMarkerTagsQuery({
variables: { variables: { id: sceneId },
id: props.sceneId,
},
}); });
const [isEditorOpen, setIsEditorOpen] = useState<boolean>(false); const [isEditorOpen, setIsEditorOpen] = useState<boolean>(false);
const [editingMarker, setEditingMarker] = const [editingMarker, setEditingMarker] =
@@ -27,13 +27,13 @@ export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (
// set up hotkeys // set up hotkeys
useEffect(() => { useEffect(() => {
if (props.isVisible) { if (!isVisible) return;
Mousetrap.bind("n", () => onOpenEditor()); Mousetrap.bind("n", () => onOpenEditor());
return () => { return () => {
Mousetrap.unbind("n"); Mousetrap.unbind("n");
}; };
}
}); });
if (loading) return null; if (loading) return null;
@@ -43,10 +43,6 @@ export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (
setEditingMarker(marker ?? undefined); setEditingMarker(marker ?? undefined);
} }
function onClickMarker(marker: GQL.SceneMarkerDataFragment) {
props.onClickMarker(marker);
}
const closeEditor = () => { const closeEditor = () => {
setEditingMarker(undefined); setEditingMarker(undefined);
setIsEditorOpen(false); setIsEditorOpen(false);
@@ -55,8 +51,8 @@ export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (
if (isEditorOpen) if (isEditorOpen)
return ( return (
<SceneMarkerForm <SceneMarkerForm
sceneID={props.sceneId} sceneID={sceneId}
editingMarker={editingMarker} marker={editingMarker}
onClose={closeEditor} onClose={closeEditor}
/> />
); );