mirror of
https://github.com/stashapp/stash.git
synced 2026-03-30 10:01:21 +02:00
Merge 23a9b26e64 into fd480c5a3e
This commit is contained in:
commit
32372b1340
17 changed files with 325 additions and 5 deletions
|
|
@ -640,3 +640,134 @@ func TestStringCriterionHandlerNotNull(t *testing.T) {
|
|||
assert.Equal(fmt.Sprintf("(%[1]s IS NOT NULL AND TRIM(%[1]s) != '')", column), f.whereClauses[0].sql)
|
||||
assert.Len(f.whereClauses[0].args, 0)
|
||||
}
|
||||
|
||||
func TestValidateSortFavoritesFirstPrefix(t *testing.T) {
|
||||
opts := sortOptions{"name", "rating", "random", "scenes_count"}
|
||||
assert := assert.New(t)
|
||||
|
||||
// valid: known sorts with the favorites_first_ prefix pass
|
||||
assert.NoError(opts.validateSort("favorites_first_name"))
|
||||
assert.NoError(opts.validateSort("favorites_first_rating"))
|
||||
assert.NoError(opts.validateSort("favorites_first_scenes_count"))
|
||||
|
||||
// valid: random with prefix passes (random itself is valid)
|
||||
assert.NoError(opts.validateSort("favorites_first_random"))
|
||||
assert.NoError(opts.validateSort("favorites_first_random_42"))
|
||||
|
||||
// invalid: prefixed random with invalid seed
|
||||
assert.Error(opts.validateSort("favorites_first_random_not-a-number"))
|
||||
|
||||
// invalid: unknown sort after prefix
|
||||
assert.Error(opts.validateSort("favorites_first_unknown_sort"))
|
||||
|
||||
// invalid: nothing after prefix
|
||||
assert.Error(opts.validateSort("favorites_first_"))
|
||||
|
||||
// plain sorts still work unchanged
|
||||
assert.NoError(opts.validateSort("name"))
|
||||
assert.NoError(opts.validateSort("random_42"))
|
||||
assert.Error(opts.validateSort("unknown"))
|
||||
}
|
||||
|
||||
func TestGetPerformerSortFavoritesFirstPrefix(t *testing.T) {
|
||||
qb := &PerformerStore{}
|
||||
assert := assert.New(t)
|
||||
|
||||
favNameSort := "favorites_first_name"
|
||||
favRatingSort := "favorites_first_rating"
|
||||
favRandomSort := "favorites_first_random_42"
|
||||
plainSort := "name"
|
||||
invalidSort := "favorites_first_not_a_real_sort"
|
||||
|
||||
// favorites_first_name → ORDER BY performers.favorite DESC, performers.name …
|
||||
sort, err := qb.getPerformerSort(&models.FindFilterType{Sort: &favNameSort})
|
||||
assert.NoError(err)
|
||||
assert.Contains(sort, "performers.favorite DESC")
|
||||
assert.Contains(sort, "performers.name")
|
||||
|
||||
// favorites_first_rating → ORDER BY performers.favorite DESC, performers.rating …
|
||||
sort, err = qb.getPerformerSort(&models.FindFilterType{Sort: &favRatingSort})
|
||||
assert.NoError(err)
|
||||
assert.Contains(sort, "performers.favorite DESC")
|
||||
assert.Contains(sort, "performers.rating")
|
||||
|
||||
// favorites_first_random_<seed> keeps favorites first and randomizes within groups
|
||||
sort, err = qb.getPerformerSort(&models.FindFilterType{Sort: &favRandomSort})
|
||||
assert.NoError(err)
|
||||
assert.Contains(sort, "performers.favorite DESC")
|
||||
assert.Contains(sort, "mod((performers.id + 42)")
|
||||
|
||||
// plain name sort must NOT inject favorite
|
||||
sort, err = qb.getPerformerSort(&models.FindFilterType{Sort: &plainSort})
|
||||
assert.NoError(err)
|
||||
assert.NotContains(sort, "favorite")
|
||||
|
||||
// unknown sort after prefix must error
|
||||
_, err = qb.getPerformerSort(&models.FindFilterType{Sort: &invalidSort})
|
||||
assert.Error(err)
|
||||
}
|
||||
|
||||
func TestGetStudioSortFavoritesFirstPrefix(t *testing.T) {
|
||||
qb := &StudioStore{}
|
||||
assert := assert.New(t)
|
||||
|
||||
favNameSort := "favorites_first_name"
|
||||
favRandomSort := "favorites_first_random_42"
|
||||
plainSort := "name"
|
||||
invalidSort := "favorites_first_not_a_real_sort"
|
||||
|
||||
// favorites_first_name → ORDER BY studios.favorite DESC, studios.name …
|
||||
sort, err := qb.getStudioSort(&models.FindFilterType{Sort: &favNameSort})
|
||||
assert.NoError(err)
|
||||
assert.Contains(sort, "studios.favorite DESC")
|
||||
assert.Contains(sort, "studios.name")
|
||||
|
||||
// favorites_first_random_<seed> keeps favorites first and randomizes within groups
|
||||
sort, err = qb.getStudioSort(&models.FindFilterType{Sort: &favRandomSort})
|
||||
assert.NoError(err)
|
||||
assert.Contains(sort, "studios.favorite DESC")
|
||||
assert.Contains(sort, "mod((studios.id + 42)")
|
||||
|
||||
// plain name sort must NOT inject favorite
|
||||
sort, err = qb.getStudioSort(&models.FindFilterType{Sort: &plainSort})
|
||||
assert.NoError(err)
|
||||
assert.NotContains(sort, "favorite")
|
||||
|
||||
// unknown sort after prefix must error
|
||||
_, err = qb.getStudioSort(&models.FindFilterType{Sort: &invalidSort})
|
||||
assert.Error(err)
|
||||
}
|
||||
|
||||
func TestGetTagSortFavoritesFirstPrefix(t *testing.T) {
|
||||
qb := &TagStore{}
|
||||
assert := assert.New(t)
|
||||
|
||||
favNameSort := "favorites_first_name"
|
||||
favRandomSort := "favorites_first_random_42"
|
||||
plainSort := "name"
|
||||
invalidSort := "favorites_first_not_a_real_sort"
|
||||
|
||||
query := &queryBuilder{}
|
||||
|
||||
// favorites_first_name → ORDER BY tags.favorite DESC, COALESCE(tags.sort_name, tags.name) …
|
||||
sort, err := qb.getTagSort(query, &models.FindFilterType{Sort: &favNameSort})
|
||||
assert.NoError(err)
|
||||
assert.Contains(sort, "tags.favorite DESC")
|
||||
assert.Contains(sort, "tags.sort_name") // tag name uses COALESCE(sort_name, name)
|
||||
|
||||
// favorites_first_random_<seed> keeps favorites first and randomizes within groups
|
||||
sort, err = qb.getTagSort(query, &models.FindFilterType{Sort: &favRandomSort})
|
||||
assert.NoError(err)
|
||||
assert.Contains(sort, "tags.favorite DESC")
|
||||
assert.Contains(sort, "mod((tags.id + 42)")
|
||||
|
||||
// plain name sort must NOT inject favorite
|
||||
sort, err = qb.getTagSort(query, &models.FindFilterType{Sort: &plainSort})
|
||||
assert.NoError(err)
|
||||
assert.NotContains(sort, "favorite")
|
||||
|
||||
// unknown sort after prefix must error
|
||||
_, err = qb.getTagSort(query, &models.FindFilterType{Sort: &invalidSort})
|
||||
assert.Error(err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
|
|
@ -842,6 +843,12 @@ func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) (s
|
|||
direction = findFilter.GetDirection()
|
||||
}
|
||||
|
||||
// check for favorites-first prefix and strip it before further processing
|
||||
favoritesFirst := strings.HasPrefix(sort, FavoritesFirstPrefix)
|
||||
if favoritesFirst {
|
||||
sort = sort[len(FavoritesFirstPrefix):]
|
||||
}
|
||||
|
||||
// CVE-2024-32231 - ensure sort is in the list of allowed sorts
|
||||
if err := performerSortOptions.validateSort(sort); err != nil {
|
||||
return "", err
|
||||
|
|
@ -877,6 +884,11 @@ func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) (s
|
|||
|
||||
// Whatever the sorting, always use name/id as a final sort
|
||||
sortQuery += ", COALESCE(performers.name, performers.id) COLLATE NATURAL_CI ASC"
|
||||
|
||||
if favoritesFirst {
|
||||
sortQuery = strings.Replace(sortQuery, " ORDER BY ", " ORDER BY performers.favorite DESC, ", 1)
|
||||
}
|
||||
|
||||
return sortQuery, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,11 +47,17 @@ func getPaginationSQL(page int, perPage int) string {
|
|||
return " LIMIT " + strconv.Itoa(perPage) + " OFFSET " + strconv.Itoa(page) + " "
|
||||
}
|
||||
|
||||
const randomSeedPrefix = "random_" // prefix for random sort
|
||||
const randomSeedPrefix = "random_" // prefix for random sort
|
||||
const FavoritesFirstPrefix = "favorites_first_" // prefix for favorites-first sort
|
||||
|
||||
type sortOptions []string
|
||||
|
||||
func (o sortOptions) validateSort(sort string) error {
|
||||
// strip favorites-first prefix before validating the underlying sort
|
||||
if strings.HasPrefix(sort, FavoritesFirstPrefix) {
|
||||
sort = sort[len(FavoritesFirstPrefix):]
|
||||
}
|
||||
|
||||
if strings.HasPrefix(sort, randomSeedPrefix) {
|
||||
// seed as a parameter from the UI
|
||||
seedStr := sort[len(randomSeedPrefix):]
|
||||
|
|
@ -62,6 +68,7 @@ func (o sortOptions) validateSort(sort string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
|
||||
for _, v := range o {
|
||||
if v == sort {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
|
|
@ -686,6 +687,12 @@ func (qb *StudioStore) getStudioSort(findFilter *models.FindFilterType) (string,
|
|||
direction = findFilter.GetDirection()
|
||||
}
|
||||
|
||||
// check for favorites-first prefix and strip it before further processing
|
||||
favoritesFirst := strings.HasPrefix(sort, FavoritesFirstPrefix)
|
||||
if favoritesFirst {
|
||||
sort = sort[len(FavoritesFirstPrefix):]
|
||||
}
|
||||
|
||||
// CVE-2024-32231 - ensure sort is in the list of allowed sorts
|
||||
if err := studioSortOptions.validateSort(sort); err != nil {
|
||||
return "", err
|
||||
|
|
@ -715,6 +722,11 @@ func (qb *StudioStore) getStudioSort(findFilter *models.FindFilterType) (string,
|
|||
|
||||
// Whatever the sorting, always use name/id as a final sort
|
||||
sortQuery += ", COALESCE(studios.name, studios.id) COLLATE NATURAL_CI ASC"
|
||||
|
||||
if favoritesFirst {
|
||||
sortQuery = strings.Replace(sortQuery, " ORDER BY ", " ORDER BY studios.favorite DESC, ", 1)
|
||||
}
|
||||
|
||||
return sortQuery, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1955,3 +1955,4 @@ func TestStudioQueryCustomFields(t *testing.T) {
|
|||
// TODO All
|
||||
// TODO AllSlim
|
||||
// TODO Query
|
||||
|
||||
|
|
|
|||
|
|
@ -811,6 +811,12 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte
|
|||
direction = findFilter.GetDirection()
|
||||
}
|
||||
|
||||
// check for favorites-first prefix and strip it before further processing
|
||||
favoritesFirst := strings.HasPrefix(sort, FavoritesFirstPrefix)
|
||||
if favoritesFirst {
|
||||
sort = sort[len(FavoritesFirstPrefix):]
|
||||
}
|
||||
|
||||
// CVE-2024-32231 - ensure sort is in the list of allowed sorts
|
||||
if err := tagSortOptions.validateSort(sort); err != nil {
|
||||
return "", err
|
||||
|
|
@ -844,6 +850,11 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte
|
|||
|
||||
// Whatever the sorting, always use sort_name/name/id as a final sort
|
||||
sortQuery += ", COALESCE(tags.sort_name, tags.name, tags.id) COLLATE NATURAL_CI ASC"
|
||||
|
||||
if favoritesFirst {
|
||||
sortQuery = strings.Replace(sortQuery, " ORDER BY ", " ORDER BY tags.favorite DESC, ", 1)
|
||||
}
|
||||
|
||||
return sortQuery, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1958,3 +1958,4 @@ func TestTagQueryCustomFields(t *testing.T) {
|
|||
// TODO All
|
||||
// TODO AllSlim
|
||||
// TODO Query
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export enum View {
|
|||
TagScenes = "tag_scenes",
|
||||
TagImages = "tag_images",
|
||||
TagPerformers = "tag_performers",
|
||||
TagStudios = "tag_studios",
|
||||
TagGroups = "tag_groups",
|
||||
|
||||
PerformerScenes = "performer_scenes",
|
||||
|
|
|
|||
|
|
@ -58,6 +58,9 @@ import { FavoritePerformerCriterionOption } from "src/models/list-filter/criteri
|
|||
import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter";
|
||||
import { SidebarOptionFilter } from "../List/Filters/OptionFilter";
|
||||
import { GenderCriterionOption } from "src/models/list-filter/criteria/gender";
|
||||
import { useConfigurationContext } from "src/hooks/Config";
|
||||
import { useFavoritesFirstFilterHook } from "src/hooks/useFavoritesFirstFilterHook";
|
||||
import { IUIConfig } from "src/core/config";
|
||||
|
||||
export const FormatHeight = (height?: number | null) => {
|
||||
const intl = useIntl();
|
||||
|
|
@ -377,6 +380,22 @@ export const FilteredPerformerList = PatchComponent(
|
|||
extraOperations = [],
|
||||
} = props;
|
||||
|
||||
// favorites-first: applies on the main Performers page and related performer tabs
|
||||
const { configuration } = useConfigurationContext();
|
||||
const uiConfig = configuration?.ui as IUIConfig;
|
||||
const favoritesFirstApplicable =
|
||||
view === View.Performers ||
|
||||
view === View.TagPerformers ||
|
||||
view === View.StudioPerformers;
|
||||
const showFavoritesFirst =
|
||||
favoritesFirstApplicable &&
|
||||
(uiConfig?.showFavoritesFirstPerformers ?? false);
|
||||
|
||||
const combinedFilterHook = useFavoritesFirstFilterHook(
|
||||
showFavoritesFirst,
|
||||
filterHook
|
||||
);
|
||||
|
||||
// States
|
||||
const {
|
||||
showSidebar,
|
||||
|
|
@ -397,7 +416,7 @@ export const FilteredPerformerList = PatchComponent(
|
|||
useResult: useFindPerformers,
|
||||
getCount: (r) => r.data?.findPerformers.count ?? 0,
|
||||
getItems: (r) => r.data?.findPerformers.performers ?? [],
|
||||
filterHook,
|
||||
filterHook: combinedFilterHook,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -702,6 +702,39 @@ export const SettingsInterfacePanel: React.FC = PatchComponent(
|
|||
/>
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection headingID="config.ui.favorites_first.heading">
|
||||
<div className="setting-group">
|
||||
<div className="setting">
|
||||
<div>
|
||||
<div className="sub-heading">
|
||||
{intl.formatMessage({
|
||||
id: "config.ui.favorites_first.description",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
<BooleanSetting
|
||||
id="showFavoritesFirstPerformers"
|
||||
headingID="performer"
|
||||
checked={ui.showFavoritesFirstPerformers ?? undefined}
|
||||
onChange={(v) => saveUI({ showFavoritesFirstPerformers: v })}
|
||||
/>
|
||||
<BooleanSetting
|
||||
id="showFavoritesFirstStudios"
|
||||
headingID="studio"
|
||||
checked={ui.showFavoritesFirstStudios ?? undefined}
|
||||
onChange={(v) => saveUI({ showFavoritesFirstStudios: v })}
|
||||
/>
|
||||
<BooleanSetting
|
||||
id="showFavoritesFirstTags"
|
||||
headingID="tag"
|
||||
checked={ui.showFavoritesFirstTags ?? undefined}
|
||||
onChange={(v) => saveUI({ showFavoritesFirstTags: v })}
|
||||
/>
|
||||
</div>
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection headingID="config.ui.editing.heading">
|
||||
<div className="setting-group">
|
||||
<div className="setting">
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter";
|
|||
import { FavoriteStudioCriterionOption } from "src/models/list-filter/criteria/favorite";
|
||||
import { Button } from "react-bootstrap";
|
||||
import cx from "classnames";
|
||||
import { useConfigurationContext } from "src/hooks/Config";
|
||||
import { useFavoritesFirstFilterHook } from "src/hooks/useFavoritesFirstFilterHook";
|
||||
import { IUIConfig } from "src/core/config";
|
||||
|
||||
const StudioList: React.FC<{
|
||||
studios: GQL.StudioDataFragment[];
|
||||
|
|
@ -204,6 +207,20 @@ export const FilteredStudioList = PatchComponent(
|
|||
|
||||
const { filterHook, view, alterQuery, extraOperations = [] } = props;
|
||||
|
||||
// favorites-first: applies on the main Studios page and the Tag → Studios tab
|
||||
const { configuration } = useConfigurationContext();
|
||||
const uiConfig = configuration?.ui as IUIConfig;
|
||||
const favoritesFirstApplicable =
|
||||
view === View.Studios || view === View.TagStudios;
|
||||
const showFavoritesFirst =
|
||||
favoritesFirstApplicable &&
|
||||
(uiConfig?.showFavoritesFirstStudios ?? false);
|
||||
|
||||
const combinedFilterHook = useFavoritesFirstFilterHook(
|
||||
showFavoritesFirst,
|
||||
filterHook
|
||||
);
|
||||
|
||||
// States
|
||||
const {
|
||||
showSidebar,
|
||||
|
|
@ -224,7 +241,7 @@ export const FilteredStudioList = PatchComponent(
|
|||
useResult: useFindStudios,
|
||||
getCount: (r) => r.data?.findStudios.count ?? 0,
|
||||
getItems: (r) => r.data?.findStudios.studios ?? [],
|
||||
filterHook,
|
||||
filterHook: combinedFilterHook,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from "react";
|
|||
import * as GQL from "src/core/generated-graphql";
|
||||
import { useTagFilterHook } from "src/core/tags";
|
||||
import { FilteredStudioList } from "src/components/Studios/StudioList";
|
||||
import { View } from "src/components/List/views";
|
||||
|
||||
interface ITagStudiosPanel {
|
||||
active: boolean;
|
||||
|
|
@ -15,5 +16,11 @@ export const TagStudiosPanel: React.FC<ITagStudiosPanel> = ({
|
|||
showSubTagContent,
|
||||
}) => {
|
||||
const filterHook = useTagFilterHook(tag, showSubTagContent);
|
||||
return <FilteredStudioList filterHook={filterHook} alterQuery={active} />;
|
||||
return (
|
||||
<FilteredStudioList
|
||||
filterHook={filterHook}
|
||||
alterQuery={active}
|
||||
view={View.TagStudios}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ import { LoadedContent } from "../List/PagedList";
|
|||
import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter";
|
||||
import { FavoriteTagCriterionOption } from "src/models/list-filter/criteria/favorite";
|
||||
import { TagListTable } from "./TagListTable";
|
||||
import { useConfigurationContext } from "src/hooks/Config";
|
||||
import { useFavoritesFirstFilterHook } from "src/hooks/useFavoritesFirstFilterHook";
|
||||
import { IUIConfig } from "src/core/config";
|
||||
|
||||
const TagList: React.FC<{
|
||||
tags: GQL.TagListDataFragment[];
|
||||
|
|
@ -206,6 +209,16 @@ export const FilteredTagList = PatchComponent(
|
|||
|
||||
const view = View.Tags;
|
||||
|
||||
// favorites-first: applies on the main Tags page
|
||||
const { configuration } = useConfigurationContext();
|
||||
const uiConfig = configuration?.ui as IUIConfig;
|
||||
const showFavoritesFirst = uiConfig?.showFavoritesFirstTags ?? false;
|
||||
|
||||
const combinedFilterHook = useFavoritesFirstFilterHook(
|
||||
showFavoritesFirst,
|
||||
filterHook
|
||||
);
|
||||
|
||||
// States
|
||||
const {
|
||||
showSidebar,
|
||||
|
|
@ -226,7 +239,7 @@ export const FilteredTagList = PatchComponent(
|
|||
useResult: useFindTagsForList,
|
||||
getCount: (r) => r.data?.findTags.count ?? 0,
|
||||
getItems: (r) => r.data?.findTags.tags ?? [],
|
||||
filterHook,
|
||||
filterHook: combinedFilterHook,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,13 @@ export interface IUIConfig {
|
|||
// if true show all content details by default
|
||||
showAllDetails?: boolean;
|
||||
|
||||
// if true show favorites first on the performers list page and tag performers tab
|
||||
showFavoritesFirstPerformers?: boolean;
|
||||
// if true show favorites first on the studios list page and tag studios tab
|
||||
showFavoritesFirstStudios?: boolean;
|
||||
// if true show favorites first on the tags list page
|
||||
showFavoritesFirstTags?: boolean;
|
||||
|
||||
// if true the chromecast option will enabled
|
||||
enableChromecast?: boolean;
|
||||
|
||||
|
|
|
|||
40
ui/v2.5/src/hooks/useFavoritesFirstFilterHook.ts
Normal file
40
ui/v2.5/src/hooks/useFavoritesFirstFilterHook.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { useCallback, useMemo } from "react";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
|
||||
type ListFilterHook = (filter: ListFilterModel) => ListFilterModel;
|
||||
|
||||
export const useFavoritesFirstFilterHook = (
|
||||
showFavoritesFirst: boolean,
|
||||
filterHook?: ListFilterHook
|
||||
) => {
|
||||
const favoritesFirstHook = useCallback((f: ListFilterModel) => {
|
||||
const sortBy = f.sortBy ?? "name";
|
||||
|
||||
if (sortBy.startsWith("favorites_first_")) {
|
||||
return f;
|
||||
}
|
||||
|
||||
if (sortBy === "random") {
|
||||
if (f.randomSeed === -1) {
|
||||
// Match ListFilterModel random seed generation for stable paging.
|
||||
f.randomSeed = Math.floor(Math.random() * 10 ** 8);
|
||||
}
|
||||
f.sortBy = `favorites_first_random_${f.randomSeed.toString()}`;
|
||||
return f;
|
||||
}
|
||||
|
||||
f.sortBy = `favorites_first_${sortBy}`;
|
||||
return f;
|
||||
}, []);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!showFavoritesFirst) {
|
||||
return filterHook;
|
||||
}
|
||||
if (!filterHook) {
|
||||
return favoritesFirstHook;
|
||||
}
|
||||
|
||||
return (f: ListFilterModel) => favoritesFirstHook(filterHook(f));
|
||||
}, [showFavoritesFirst, filterHook, favoritesFirstHook]);
|
||||
};
|
||||
|
|
@ -718,6 +718,10 @@
|
|||
"heading": "Show studio as text"
|
||||
}
|
||||
},
|
||||
"favorites_first": {
|
||||
"description": "When enabled, favourites will appear at the top of the list while respecting the current sort order within each group.",
|
||||
"heading": "Show Favourites First"
|
||||
},
|
||||
"editing": {
|
||||
"disable_dropdown_create": {
|
||||
"description": "Remove the ability to create new objects from the dropdown selectors.",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@
|
|||
"custom_locales": {
|
||||
"heading": "Custom localization",
|
||||
"option_label": "Custom localization enabled"
|
||||
},
|
||||
"favorites_first": {
|
||||
"description": "When enabled, favorites will appear at the top of the list while respecting the current sort order within each group.",
|
||||
"heading": "Show Favorites First"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue