From 1fd3fcc6a8a8ad8777aa37ae0da4d5be46f9755e Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Sat, 22 Aug 2020 18:12:39 +1000 Subject: [PATCH] Show and allow creation of unknown performers/tags/studios/movies from scraper dialog (#741) * Fix toast container z-index * Make scrape operations network only * Add CollapseButton component --- graphql/documents/data/scrapers.graphql | 8 +- graphql/schema/types/scraper.graphql | 8 +- pkg/api/resolver.go | 20 ++ pkg/api/resolver_model_scraper.go | 23 ++ pkg/models/model_scraped_item.go | 2 +- .../components/Changelog/versions/v030.tsx | 1 + .../Scenes/SceneDetails/SceneEditPanel.tsx | 16 +- .../Scenes/SceneDetails/SceneScrapeDialog.tsx | 250 ++++++++++++++++-- .../src/components/Shared/CollapseButton.tsx | 28 ++ .../src/components/Shared/ScrapeDialog.tsx | 60 ++++- ui/v2.5/src/components/Shared/index.ts | 1 + ui/v2.5/src/components/Shared/styles.scss | 9 + ui/v2.5/src/core/StashService.ts | 6 + ui/v2.5/src/index.scss | 2 +- 14 files changed, 388 insertions(+), 46 deletions(-) create mode 100644 pkg/api/resolver_model_scraper.go create mode 100644 ui/v2.5/src/components/Shared/CollapseButton.tsx diff --git a/graphql/documents/data/scrapers.graphql b/graphql/documents/data/scrapers.graphql index 9270b8c8e..eb10e6a5c 100644 --- a/graphql/documents/data/scrapers.graphql +++ b/graphql/documents/data/scrapers.graphql @@ -19,7 +19,7 @@ fragment ScrapedPerformerData on ScrapedPerformer { } fragment ScrapedScenePerformerData on ScrapedScenePerformer { - id + stored_id name gender url @@ -62,7 +62,7 @@ fragment ScrapedMovieData on ScrapedMovie { } fragment ScrapedSceneMovieData on ScrapedSceneMovie { - id + stored_id name aliases duration @@ -74,13 +74,13 @@ fragment ScrapedSceneMovieData on ScrapedSceneMovie { } fragment ScrapedSceneStudioData on ScrapedSceneStudio { - id + stored_id name url } fragment ScrapedSceneTagData on ScrapedSceneTag { - id + stored_id name } diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index 8cb0383ba..6a7dcaa0b 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -27,7 +27,7 @@ type Scraper { type ScrapedScenePerformer { """Set if performer matched""" - id: ID + stored_id: ID name: String! gender: String url: String @@ -48,7 +48,7 @@ type ScrapedScenePerformer { type ScrapedSceneMovie { """Set if movie matched""" - id: ID + stored_id: ID name: String! aliases: String duration: String @@ -61,14 +61,14 @@ type ScrapedSceneMovie { type ScrapedSceneStudio { """Set if studio matched""" - id: ID + stored_id: ID name: String! url: String } type ScrapedSceneTag { """Set if tag matched""" - id: ID + stored_id: ID name: String! } diff --git a/pkg/api/resolver.go b/pkg/api/resolver.go index 5713e0b22..63ef59e87 100644 --- a/pkg/api/resolver.go +++ b/pkg/api/resolver.go @@ -43,6 +43,22 @@ func (r *Resolver) Tag() models.TagResolver { return &tagResolver{r} } +func (r *Resolver) ScrapedSceneTag() models.ScrapedSceneTagResolver { + return &scrapedSceneTagResolver{r} +} + +func (r *Resolver) ScrapedSceneMovie() models.ScrapedSceneMovieResolver { + return &scrapedSceneMovieResolver{r} +} + +func (r *Resolver) ScrapedScenePerformer() models.ScrapedScenePerformerResolver { + return &scrapedScenePerformerResolver{r} +} + +func (r *Resolver) ScrapedSceneStudio() models.ScrapedSceneStudioResolver { + return &scrapedSceneStudioResolver{r} +} + type mutationResolver struct{ *Resolver } type queryResolver struct{ *Resolver } type subscriptionResolver struct{ *Resolver } @@ -54,6 +70,10 @@ type sceneMarkerResolver struct{ *Resolver } type studioResolver struct{ *Resolver } type movieResolver struct{ *Resolver } type tagResolver struct{ *Resolver } +type scrapedSceneTagResolver struct{ *Resolver } +type scrapedSceneMovieResolver struct{ *Resolver } +type scrapedScenePerformerResolver struct{ *Resolver } +type scrapedSceneStudioResolver struct{ *Resolver } func (r *queryResolver) MarkerWall(ctx context.Context, q *string) ([]*models.SceneMarker, error) { qb := models.NewSceneMarkerQueryBuilder() diff --git a/pkg/api/resolver_model_scraper.go b/pkg/api/resolver_model_scraper.go new file mode 100644 index 000000000..583194496 --- /dev/null +++ b/pkg/api/resolver_model_scraper.go @@ -0,0 +1,23 @@ +package api + +import ( + "context" + + "github.com/stashapp/stash/pkg/models" +) + +func (r *scrapedSceneTagResolver) StoredID(ctx context.Context, obj *models.ScrapedSceneTag) (*string, error) { + return obj.ID, nil +} + +func (r *scrapedSceneMovieResolver) StoredID(ctx context.Context, obj *models.ScrapedSceneMovie) (*string, error) { + return obj.ID, nil +} + +func (r *scrapedScenePerformerResolver) StoredID(ctx context.Context, obj *models.ScrapedScenePerformer) (*string, error) { + return obj.ID, nil +} + +func (r *scrapedSceneStudioResolver) StoredID(ctx context.Context, obj *models.ScrapedSceneStudio) (*string, error) { + return obj.ID, nil +} diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index ab0799f9d..fd3314197 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -132,7 +132,7 @@ type ScrapedSceneMovie struct { type ScrapedSceneTag struct { // Set if tag matched - ID *string `graphql:"id" json:"id"` + ID *string `graphql:"stored_id" json:"stored_id"` Name string `graphql:"name" json:"name"` } diff --git a/ui/v2.5/src/components/Changelog/versions/v030.tsx b/ui/v2.5/src/components/Changelog/versions/v030.tsx index ba2d10f69..76edccdf8 100644 --- a/ui/v2.5/src/components/Changelog/versions/v030.tsx +++ b/ui/v2.5/src/components/Changelog/versions/v030.tsx @@ -5,6 +5,7 @@ const markup = ` #### 💥 **Note: After upgrading, the next scan will populate all scenes with oshash hashes. MD5 calculation can be disabled after populating the oshash for all scenes. See \`Hashing Algorithms\` in the \`Configuration\` section of the manual for details. ** ### ✨ New Features +* Show and allow creation of unknown performers/tags/studios/movies in the scraper dialog. * Add support for scraping movie details. * Add support for JSON scrapers. * Add support for plugin tasks. diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index aba0e169a..78cb7f32b 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -351,39 +351,39 @@ export const SceneEditPanel: React.FC = (props: IProps) => { setUrl(scene.url); } - if (scene.studio && scene.studio.id) { - setStudioId(scene.studio.id); + if (scene.studio && scene.studio.stored_id) { + setStudioId(scene.studio.stored_id); } if (scene.performers && scene.performers.length > 0) { const idPerfs = scene.performers.filter((p) => { - return p.id !== undefined && p.id !== null; + return p.stored_id !== undefined && p.stored_id !== null; }); if (idPerfs.length > 0) { - const newIds = idPerfs.map((p) => p.id); + const newIds = idPerfs.map((p) => p.stored_id); setPerformerIds(newIds as string[]); } } if (scene.movies && scene.movies.length > 0) { const idMovis = scene.movies.filter((p) => { - return p.id !== undefined && p.id !== null; + return p.stored_id !== undefined && p.stored_id !== null; }); if (idMovis.length > 0) { - const newIds = idMovis.map((p) => p.id); + const newIds = idMovis.map((p) => p.stored_id); setMovieIds(newIds as string[]); } } if (scene?.tags?.length) { const idTags = scene.tags.filter((p) => { - return p.id !== undefined && p.id !== null; + return p.stored_id !== undefined && p.stored_id !== null; }); if (idTags.length > 0) { - const newIds = idTags.map((p) => p.id); + const newIds = idTags.map((p) => p.stored_id); setTagIds(newIds as string[]); } } diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx index 700129e74..699fd8689 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx @@ -11,6 +11,13 @@ import { ScrapedImageRow, } from "src/components/Shared/ScrapeDialog"; import _ from "lodash"; +import { + useStudioCreate, + usePerformerCreate, + useMovieCreate, + useTagCreate, +} from "src/core/StashService"; +import { useToast } from "src/hooks"; function renderScrapedStudio( result: ScrapeResult, @@ -36,7 +43,9 @@ function renderScrapedStudio( function renderScrapedStudioRow( result: ScrapeResult, - onChange: (value: ScrapeResult) => void + onChange: (value: ScrapeResult) => void, + newStudio?: GQL.ScrapedSceneStudio, + onCreateNew?: (value: GQL.ScrapedSceneStudio) => void ) { return ( ); } @@ -78,7 +89,9 @@ function renderScrapedPerformers( function renderScrapedPerformersRow( result: ScrapeResult, - onChange: (value: ScrapeResult) => void + onChange: (value: ScrapeResult) => void, + newPerformers: GQL.ScrapedScenePerformer[], + onCreateNew?: (value: GQL.ScrapedScenePerformer) => void ) { return ( ); } @@ -120,7 +135,9 @@ function renderScrapedMovies( function renderScrapedMoviesRow( result: ScrapeResult, - onChange: (value: ScrapeResult) => void + onChange: (value: ScrapeResult) => void, + newMovies: GQL.ScrapedSceneMovie[], + onCreateNew?: (value: GQL.ScrapedSceneMovie) => void ) { return ( ); } @@ -162,7 +181,9 @@ function renderScrapedTags( function renderScrapedTagsRow( result: ScrapeResult, - onChange: (value: ScrapeResult) => void + onChange: (value: ScrapeResult) => void, + newTags: GQL.ScrapedSceneTag[], + onCreateNew?: (value: GQL.ScrapedSceneTag) => void ) { return ( ); } @@ -186,8 +209,8 @@ interface ISceneScrapeDialogProps { onClose: (scrapedScene?: GQL.ScrapedScene) => void; } -interface IHasID { - id?: string | null; +interface IHasStoredID { + stored_id?: string | null; } export const SceneScrapeDialog: React.FC = ( @@ -203,15 +226,27 @@ export const SceneScrapeDialog: React.FC = ( new ScrapeResult(props.scene.date, props.scraped.date) ); const [studio, setStudio] = useState>( - new ScrapeResult(props.scene.studio_id, props.scraped.studio?.id) + new ScrapeResult( + props.scene.studio_id, + props.scraped.studio?.stored_id + ) + ); + const [newStudio, setNewStudio] = useState< + GQL.ScrapedSceneStudio | undefined + >( + props.scraped.studio && !props.scraped.studio.stored_id + ? props.scraped.studio + : undefined ); - function mapIdObjects(scrapedObjects?: IHasID[]): string[] | undefined { + function mapStoredIdObjects( + scrapedObjects?: IHasStoredID[] + ): string[] | undefined { if (!scrapedObjects) { return undefined; } const ret = scrapedObjects - .map((p) => p.id) + .map((p) => p.stored_id) .filter((p) => { return p !== undefined && p !== null; }) as string[]; @@ -245,21 +280,33 @@ export const SceneScrapeDialog: React.FC = ( const [performers, setPerformers] = useState>( new ScrapeResult( sortIdList(props.scene.performer_ids), - mapIdObjects(props.scraped.performers ?? undefined) + mapStoredIdObjects(props.scraped.performers ?? undefined) ) ); + const [newPerformers, setNewPerformers] = useState< + GQL.ScrapedScenePerformer[] + >(props.scraped.performers?.filter((t) => !t.stored_id) ?? []); + const [movies, setMovies] = useState>( new ScrapeResult( sortIdList(props.scene.movies?.map((p) => p.movie_id)), - mapIdObjects(props.scraped.movies ?? undefined) + mapStoredIdObjects(props.scraped.movies ?? undefined) ) ); + const [newMovies, setNewMovies] = useState( + props.scraped.movies?.filter((t) => !t.stored_id) ?? [] + ); + const [tags, setTags] = useState>( new ScrapeResult( sortIdList(props.scene.tag_ids), - mapIdObjects(props.scraped.tags ?? undefined) + mapStoredIdObjects(props.scraped.tags ?? undefined) ) ); + const [newTags, setNewTags] = useState( + props.scraped.tags?.filter((t) => !t.stored_id) ?? [] + ); + const [details, setDetails] = useState>( new ScrapeResult(props.scene.details, props.scraped.details) ); @@ -267,6 +314,13 @@ export const SceneScrapeDialog: React.FC = ( new ScrapeResult(props.scene.cover_image, props.scraped.image) ); + const [createStudio] = useStudioCreate({ name: "" }); + const [createPerformer] = usePerformerCreate(); + const [createMovie] = useMovieCreate({ name: "" }); + const [createTag] = useTagCreate({ name: "" }); + + const Toast = useToast(); + // don't show the dialog if nothing was scraped if ( [title, url, date, studio, performers, movies, tags, details, image].every( @@ -277,34 +331,164 @@ export const SceneScrapeDialog: React.FC = ( return <>; } - function makeNewScrapedItem() { - const newStudio = studio.getNewValue(); + async function createNewStudio(toCreate: GQL.ScrapedSceneStudio) { + try { + const result = await createStudio({ + variables: { + name: toCreate.name, + url: toCreate.url, + }, + }); + + // set the new studio as the value + setStudio(studio.cloneWithValue(result.data!.studioCreate!.id)); + setNewStudio(undefined); + + Toast.success({ + content: ( + + Created studio: {toCreate.name} + + ), + }); + } catch (e) { + Toast.error(e); + } + } + + async function createNewPerformer(toCreate: GQL.ScrapedScenePerformer) { + let performerInput: GQL.PerformerCreateInput = {}; + try { + performerInput = Object.assign(performerInput, toCreate); + const result = await createPerformer({ + variables: performerInput, + }); + + // add the new performer to the new performers value + const performerClone = performers.cloneWithValue(performers.newValue); + if (!performerClone.newValue) { + performerClone.newValue = []; + } + performerClone.newValue.push(result.data!.performerCreate!.id); + setPerformers(performerClone); + + // remove the performer from the list + const newPerformersClone = newPerformers.concat(); + const pIndex = newPerformersClone.indexOf(toCreate); + newPerformersClone.splice(pIndex, 1); + + setNewPerformers(newPerformersClone); + + Toast.success({ + content: ( + + Created performer: {toCreate.name} + + ), + }); + } catch (e) { + Toast.error(e); + } + } + + async function createNewMovie(toCreate: GQL.ScrapedSceneMovie) { + let movieInput: GQL.MovieCreateInput = { name: "" }; + try { + movieInput = Object.assign(movieInput, toCreate); + const result = await createMovie({ + variables: movieInput, + }); + + // add the new movie to the new movies value + const movieClone = movies.cloneWithValue(movies.newValue); + if (!movieClone.newValue) { + movieClone.newValue = []; + } + movieClone.newValue.push(result.data!.movieCreate!.id); + setMovies(movieClone); + + // remove the movie from the list + const newMoviesClone = newMovies.concat(); + const pIndex = newMoviesClone.indexOf(toCreate); + newMoviesClone.splice(pIndex, 1); + + setNewMovies(newMoviesClone); + + Toast.success({ + content: ( + + Created movie: {toCreate.name} + + ), + }); + } catch (e) { + Toast.error(e); + } + } + + async function createNewTag(toCreate: GQL.ScrapedSceneTag) { + let tagInput: GQL.TagCreateInput = { name: "" }; + try { + tagInput = Object.assign(tagInput, toCreate); + const result = await createTag({ + variables: tagInput, + }); + + // add the new tag to the new tags value + const tagClone = tags.cloneWithValue(tags.newValue); + if (!tagClone.newValue) { + tagClone.newValue = []; + } + tagClone.newValue.push(result.data!.tagCreate!.id); + setTags(tagClone); + + // remove the tag from the list + const newTagsClone = newTags.concat(); + const pIndex = newTagsClone.indexOf(toCreate); + newTagsClone.splice(pIndex, 1); + + setNewTags(newTagsClone); + + Toast.success({ + content: ( + + Created tag: {toCreate.name} + + ), + }); + } catch (e) { + Toast.error(e); + } + } + + function makeNewScrapedItem(): GQL.ScrapedSceneDataFragment { + const newStudioValue = studio.getNewValue(); return { title: title.getNewValue(), url: url.getNewValue(), date: date.getNewValue(), - studio: newStudio + studio: newStudioValue ? { - id: newStudio, + stored_id: newStudioValue, name: "", } : undefined, performers: performers.getNewValue()?.map((p) => { return { - id: p, + stored_id: p, name: "", }; }), movies: movies.getNewValue()?.map((m) => { return { - id: m, + stored_id: m, name: "", }; }), tags: tags.getNewValue()?.map((m) => { return { - id: m, + stored_id: m, name: "", }; }), @@ -332,12 +516,30 @@ export const SceneScrapeDialog: React.FC = ( result={date} onChange={(value) => setDate(value)} /> - {renderScrapedStudioRow(studio, (value) => setStudio(value))} - {renderScrapedPerformersRow(performers, (value) => - setPerformers(value) + {renderScrapedStudioRow( + studio, + (value) => setStudio(value), + newStudio, + createNewStudio + )} + {renderScrapedPerformersRow( + performers, + (value) => setPerformers(value), + newPerformers, + createNewPerformer + )} + {renderScrapedMoviesRow( + movies, + (value) => setMovies(value), + newMovies, + createNewMovie + )} + {renderScrapedTagsRow( + tags, + (value) => setTags(value), + newTags, + createNewTag )} - {renderScrapedMoviesRow(movies, (value) => setMovies(value))} - {renderScrapedTagsRow(tags, (value) => setTags(value))} > = ( + props: React.PropsWithChildren +) => { + const [open, setOpen] = useState(false); + + return ( +
+ + +
{props.children}
+
+
+ ); +}; diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog.tsx index e480295f8..28ffa0e1c 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog.tsx @@ -6,8 +6,9 @@ import { InputGroup, Button, FormControl, + Badge, } from "react-bootstrap"; -import { Icon, Modal } from "src/components/Shared"; +import { CollapseButton, Icon, Modal } from "src/components/Shared"; import _ from "lodash"; export class ScrapeResult { @@ -35,6 +36,7 @@ export class ScrapeResult { ret.newValue = value; ret.useNewValue = !_.isEqual(ret.newValue, ret.originalValue); + ret.scraped = ret.useNewValue; return ret; } @@ -46,15 +48,22 @@ export class ScrapeResult { } } +interface IHasName { + name: string; +} + interface IScrapedFieldProps { result: ScrapeResult; } -interface IScrapedRowProps extends IScrapedFieldProps { +interface IScrapedRowProps + extends IScrapedFieldProps { title: string; renderOriginalField: (result: ScrapeResult) => JSX.Element | undefined; renderNewField: (result: ScrapeResult) => JSX.Element | undefined; onChange: (value: ScrapeResult) => void; + newValues?: V[]; + onCreateNew?: (newValue: V) => void; } function renderButtonIcon(selected: boolean) { @@ -68,17 +77,59 @@ function renderButtonIcon(selected: boolean) { ); } -export const ScrapeDialogRow = (props: IScrapedRowProps) => { +export const ScrapeDialogRow = ( + props: IScrapedRowProps +) => { function handleSelectClick(isNew: boolean) { const ret = _.clone(props.result); ret.useNewValue = isNew; props.onChange(ret); } - if (!props.result.scraped) { + function hasNewValues() { + return props.newValues && props.newValues.length > 0 && props.onCreateNew; + } + + if (!props.result.scraped && !hasNewValues()) { return <>; } + function renderNewValues() { + if (!hasNewValues()) { + return; + } + + const ret = ( + <> + {props.newValues!.map((t) => ( + props.onCreateNew!(t)} + > + {t.name} + + + ))} + + ); + + const minCollapseLength = 10; + + if (props.newValues!.length >= minCollapseLength) { + return ( + + {ret} + + ); + } + + return ret; + } + return ( @@ -112,6 +163,7 @@ export const ScrapeDialogRow = (props: IScrapedRowProps) => { {props.renderNewField(props.result)} + {renderNewValues()} diff --git a/ui/v2.5/src/components/Shared/index.ts b/ui/v2.5/src/components/Shared/index.ts index cd947a32e..d6f679769 100644 --- a/ui/v2.5/src/components/Shared/index.ts +++ b/ui/v2.5/src/components/Shared/index.ts @@ -10,6 +10,7 @@ export { export { default as Icon } from "./Icon"; export { default as Modal } from "./Modal"; +export { CollapseButton } from "./CollapseButton"; export { DetailsEditNavbar } from "./DetailsEditNavbar"; export { DurationInput } from "./DurationInput"; export { TagLink } from "./TagLink"; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 47d992741..e5f9d0961 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -119,3 +119,12 @@ overflow-y: auto; padding-right: 15px; } + +button.collapse-button.btn-primary:not(:disabled):not(.disabled):hover, +button.collapse-button.btn-primary:not(:disabled):not(.disabled):focus, +button.collapse-button.btn-primary:not(:disabled):not(.disabled):active { + background: none; + border: none; + box-shadow: none; + color: #f5f8fa; +} diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index ce3f00c8d..535f49b77 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -405,6 +405,7 @@ export const queryScrapeFreeones = (performerName: string) => variables: { performer_name: performerName, }, + fetchPolicy: "network-only", }); export const queryScrapePerformer = ( @@ -417,6 +418,7 @@ export const queryScrapePerformer = ( scraper_id: scraperId, scraped_performer: scrapedPerformer, }, + fetchPolicy: "network-only", }); export const queryScrapePerformerURL = (url: string) => @@ -425,6 +427,7 @@ export const queryScrapePerformerURL = (url: string) => variables: { url, }, + fetchPolicy: "network-only", }); export const queryScrapeSceneURL = (url: string) => @@ -433,6 +436,7 @@ export const queryScrapeSceneURL = (url: string) => variables: { url, }, + fetchPolicy: "network-only", }); export const queryScrapeMovieURL = (url: string) => @@ -441,6 +445,7 @@ export const queryScrapeMovieURL = (url: string) => variables: { url, }, + fetchPolicy: "network-only", }); export const queryScrapeScene = ( @@ -453,6 +458,7 @@ export const queryScrapeScene = ( scraper_id: scraperId, scene, }, + fetchPolicy: "network-only", }); export const mutateReloadScrapers = () => diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index ef865a03e..5ddfc0e93 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -343,7 +343,7 @@ div.dropdown-menu { max-width: 350px; position: fixed; top: 2rem; - z-index: 1031; + z-index: 1051; .success { background-color: $success;