mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
Add search term filter tag to scene list filter tags (#6095)
* Add search term to filter tags on scene list page Clicking on the tag selects all on the search term input. Clicking on the x erases it. * Ensure clear criteria maintains consistent behaviour on other pages * Hide search term tag when input is visible
This commit is contained in:
parent
793a5f826e
commit
3bb771a149
8 changed files with 116 additions and 38 deletions
|
|
@ -9,31 +9,37 @@ import { Badge, BadgeProps, Button, Overlay, Popover } from "react-bootstrap";
|
|||
import { Criterion } from "src/models/list-filter/criteria/criterion";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { Icon } from "../Shared/Icon";
|
||||
import { faTimes } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faMagnifyingGlass, faTimes } from "@fortawesome/free-solid-svg-icons";
|
||||
import { BsPrefixProps, ReplaceProps } from "react-bootstrap/esm/helpers";
|
||||
import { CustomFieldsCriterion } from "src/models/list-filter/criteria/custom-fields";
|
||||
import { useDebounce } from "src/hooks/debounce";
|
||||
import cx from "classnames";
|
||||
|
||||
type TagItemProps = PropsWithChildren<
|
||||
ReplaceProps<"span", BsPrefixProps<"span"> & BadgeProps>
|
||||
>;
|
||||
|
||||
export const TagItem: React.FC<TagItemProps> = (props) => {
|
||||
const { children } = props;
|
||||
const { className, children, ...others } = props;
|
||||
return (
|
||||
<Badge className="tag-item" variant="secondary" {...props}>
|
||||
<Badge
|
||||
className={cx("tag-item", className)}
|
||||
variant="secondary"
|
||||
{...others}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export const FilterTag: React.FC<{
|
||||
className?: string;
|
||||
label: React.ReactNode;
|
||||
onClick: React.MouseEventHandler<HTMLSpanElement>;
|
||||
onRemove: React.MouseEventHandler<HTMLElement>;
|
||||
}> = ({ label, onClick, onRemove }) => {
|
||||
}> = ({ className, label, onClick, onRemove }) => {
|
||||
return (
|
||||
<TagItem onClick={onClick}>
|
||||
<TagItem className={className} onClick={onClick}>
|
||||
{label}
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
|
@ -96,18 +102,24 @@ const MoreFilterTags: React.FC<{
|
|||
};
|
||||
|
||||
interface IFilterTagsProps {
|
||||
searchTerm?: string;
|
||||
criteria: Criterion[];
|
||||
onEditSearchTerm?: () => void;
|
||||
onEditCriterion: (c: Criterion) => void;
|
||||
onRemoveCriterion: (c: Criterion, valueIndex?: number) => void;
|
||||
onRemoveAll: () => void;
|
||||
onRemoveSearchTerm?: () => void;
|
||||
truncateOnOverflow?: boolean;
|
||||
}
|
||||
|
||||
export const FilterTags: React.FC<IFilterTagsProps> = ({
|
||||
searchTerm,
|
||||
criteria,
|
||||
onEditCriterion,
|
||||
onRemoveCriterion,
|
||||
onRemoveAll,
|
||||
onEditSearchTerm,
|
||||
onRemoveSearchTerm,
|
||||
truncateOnOverflow = false,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
|
@ -265,7 +277,7 @@ export const FilterTags: React.FC<IFilterTagsProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (criteria.length === 0) {
|
||||
if (criteria.length === 0 && !searchTerm) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -273,31 +285,31 @@ export const FilterTags: React.FC<IFilterTagsProps> = ({
|
|||
|
||||
const filterTags = criteria.map((c) => getFilterTags(c)).flat();
|
||||
|
||||
if (cutoff && filterTags.length > cutoff) {
|
||||
const visibleCriteria = filterTags.slice(0, cutoff);
|
||||
const hiddenCriteria = filterTags.slice(cutoff);
|
||||
|
||||
return (
|
||||
<div className={className} ref={ref}>
|
||||
{visibleCriteria}
|
||||
<MoreFilterTags tags={hiddenCriteria} />
|
||||
{criteria.length >= 3 && (
|
||||
<Button
|
||||
variant="minimal"
|
||||
className="clear-all-button"
|
||||
onClick={() => onRemoveAll()}
|
||||
>
|
||||
<FormattedMessage id="actions.clear" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
if (searchTerm && searchTerm.length > 0) {
|
||||
filterTags.unshift(
|
||||
<FilterTag
|
||||
key="search-term"
|
||||
className="search-term-filter-tag"
|
||||
label={
|
||||
<span className="search-term">
|
||||
<Icon icon={faMagnifyingGlass} />
|
||||
{searchTerm}
|
||||
</span>
|
||||
}
|
||||
onClick={() => onEditSearchTerm?.()}
|
||||
onRemove={() => onRemoveSearchTerm?.()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const visibleCriteria = cutoff ? filterTags.slice(0, cutoff) : filterTags;
|
||||
const hiddenCriteria = cutoff ? filterTags.slice(cutoff) : [];
|
||||
|
||||
return (
|
||||
<div className={className} ref={ref}>
|
||||
{filterTags}
|
||||
{criteria.length >= 3 && (
|
||||
{visibleCriteria}
|
||||
<MoreFilterTags tags={hiddenCriteria} />
|
||||
{filterTags.length >= 3 && (
|
||||
<Button
|
||||
variant="minimal"
|
||||
className="clear-all-button"
|
||||
|
|
|
|||
|
|
@ -16,8 +16,17 @@ export const FilteredSidebarHeader: React.FC<{
|
|||
filter: ListFilterModel;
|
||||
setFilter: (filter: ListFilterModel) => void;
|
||||
view?: View;
|
||||
}> = ({ sidebarOpen, showEditFilter, filter, setFilter, view }) => {
|
||||
const focus = useFocus();
|
||||
focus?: ReturnType<typeof useFocus>;
|
||||
}> = ({
|
||||
sidebarOpen,
|
||||
showEditFilter,
|
||||
filter,
|
||||
setFilter,
|
||||
view,
|
||||
focus: providedFocus,
|
||||
}) => {
|
||||
const localFocus = useFocus();
|
||||
const focus = providedFocus ?? localFocus;
|
||||
const [, setFocus] = focus;
|
||||
|
||||
// Set the focus on the input field when the sidebar is opened
|
||||
|
|
|
|||
|
|
@ -196,9 +196,12 @@ export function useFilterOperations(props: {
|
|||
[setFilter]
|
||||
);
|
||||
|
||||
const clearAllCriteria = useCallback(() => {
|
||||
setFilter((cv) => cv.clearCriteria());
|
||||
}, [setFilter]);
|
||||
const clearAllCriteria = useCallback(
|
||||
(includeSearchTerm = false) => {
|
||||
setFilter((cv) => cv.clearCriteria(includeSearchTerm));
|
||||
},
|
||||
[setFilter]
|
||||
);
|
||||
|
||||
return {
|
||||
setPage,
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ import {
|
|||
SortBySelect,
|
||||
} from "../List/ListFilter";
|
||||
import { Criterion } from "src/models/list-filter/criteria/criterion";
|
||||
import useFocus from "src/utils/focus";
|
||||
|
||||
function renderMetadataByline(result: GQL.FindScenesQueryResult) {
|
||||
const duration = result?.data?.findScenes?.duration;
|
||||
|
|
@ -251,6 +252,7 @@ const SidebarContent: React.FC<{
|
|||
onClose?: () => void;
|
||||
showEditFilter: (editingCriterion?: string) => void;
|
||||
count?: number;
|
||||
focus?: ReturnType<typeof useFocus>;
|
||||
}> = ({
|
||||
filter,
|
||||
setFilter,
|
||||
|
|
@ -260,6 +262,7 @@ const SidebarContent: React.FC<{
|
|||
sidebarOpen,
|
||||
onClose,
|
||||
count,
|
||||
focus,
|
||||
}) => {
|
||||
const showResultsId =
|
||||
count !== undefined ? "actions.show_count_results" : "actions.show_results";
|
||||
|
|
@ -274,6 +277,7 @@ const SidebarContent: React.FC<{
|
|||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
view={view}
|
||||
focus={focus}
|
||||
/>
|
||||
|
||||
<ScenesFilterSidebarSections>
|
||||
|
|
@ -345,6 +349,8 @@ const ListToolbarContent: React.FC<{
|
|||
onEditCriterion: (c: Criterion) => void;
|
||||
onRemoveCriterion: (criterion: Criterion, valueIndex?: number) => void;
|
||||
onRemoveAllCriterion: () => void;
|
||||
onEditSearchTerm: () => void;
|
||||
onRemoveSearchTerm: () => void;
|
||||
onSelectAll: () => void;
|
||||
onSelectNone: () => void;
|
||||
onEdit: () => void;
|
||||
|
|
@ -361,6 +367,8 @@ const ListToolbarContent: React.FC<{
|
|||
onEditCriterion,
|
||||
onRemoveCriterion,
|
||||
onRemoveAllCriterion,
|
||||
onEditSearchTerm,
|
||||
onRemoveSearchTerm,
|
||||
onSelectAll,
|
||||
onSelectNone,
|
||||
onEdit,
|
||||
|
|
@ -370,7 +378,7 @@ const ListToolbarContent: React.FC<{
|
|||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { criteria } = filter;
|
||||
const { criteria, searchTerm } = filter;
|
||||
const hasSelection = selectedIds.size > 0;
|
||||
|
||||
return (
|
||||
|
|
@ -387,10 +395,13 @@ const ListToolbarContent: React.FC<{
|
|||
title={intl.formatMessage({ id: "actions.sidebar.toggle" })}
|
||||
/>
|
||||
<FilterTags
|
||||
searchTerm={searchTerm}
|
||||
criteria={criteria}
|
||||
onEditCriterion={onEditCriterion}
|
||||
onRemoveCriterion={onRemoveCriterion}
|
||||
onRemoveAll={onRemoveAllCriterion}
|
||||
onEditSearchTerm={onEditSearchTerm}
|
||||
onRemoveSearchTerm={onRemoveSearchTerm}
|
||||
truncateOnOverflow
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -538,6 +549,9 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
|||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
|
||||
const searchFocus = useFocus();
|
||||
const [, setSearchFocus] = searchFocus;
|
||||
|
||||
const { filterHook, defaultSort, view, alterQuery, fromGroupId } = props;
|
||||
|
||||
// States
|
||||
|
|
@ -792,6 +806,7 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
|||
sidebarOpen={showSidebar}
|
||||
onClose={() => setShowSidebar(false)}
|
||||
count={cachedResult.loading ? undefined : totalCount}
|
||||
focus={searchFocus}
|
||||
/>
|
||||
</Sidebar>
|
||||
<div>
|
||||
|
|
@ -809,7 +824,12 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
|||
onToggleSidebar={() => setShowSidebar(!showSidebar)}
|
||||
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
|
||||
onRemoveCriterion={removeCriterion}
|
||||
onRemoveAllCriterion={() => clearAllCriteria()}
|
||||
onRemoveAllCriterion={() => clearAllCriteria(true)}
|
||||
onEditSearchTerm={() => {
|
||||
setShowSidebar(true);
|
||||
setSearchFocus(true);
|
||||
}}
|
||||
onRemoveSearchTerm={() => setFilter(filter.clearSearchTerm())}
|
||||
onSelectAll={() => onSelectAll()}
|
||||
onSelectNone={() => onSelectNone()}
|
||||
onEdit={onEdit}
|
||||
|
|
|
|||
|
|
@ -1121,6 +1121,21 @@ input[type="range"].blue-slider {
|
|||
}
|
||||
}
|
||||
|
||||
// hide the search term tag item when the search box is visible
|
||||
@include media-breakpoint-up(lg) {
|
||||
.scene-list-toolbar .filter-tags .search-term-filter-tag {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@include media-breakpoint-down(md) {
|
||||
.sidebar-pane:not(.hide-sidebar)
|
||||
.scene-list-toolbar
|
||||
.filter-tags
|
||||
.search-term-filter-tag {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.scene-list-header {
|
||||
flex-wrap: wrap-reverse;
|
||||
gap: 0.5rem;
|
||||
|
|
|
|||
|
|
@ -700,8 +700,10 @@ div.dropdown-menu {
|
|||
}
|
||||
|
||||
.tag-item {
|
||||
align-items: center;
|
||||
background-color: $muted-gray;
|
||||
color: $dark-text;
|
||||
display: inline-flex;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
|
|
@ -712,17 +714,20 @@ div.dropdown-menu {
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-term svg {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: none;
|
||||
border: none;
|
||||
bottom: 2px;
|
||||
color: $dark-text;
|
||||
font-size: 12px;
|
||||
line-height: 1rem;
|
||||
line-height: 16px;
|
||||
margin-right: -0.5rem;
|
||||
opacity: 0.5;
|
||||
padding: 0 0.5rem;
|
||||
position: relative;
|
||||
|
||||
&:active,
|
||||
&:hover {
|
||||
|
|
|
|||
|
|
@ -476,13 +476,23 @@ export class ListFilterModel {
|
|||
return this.setCriteria(criteria);
|
||||
}
|
||||
|
||||
public clearCriteria() {
|
||||
public clearCriteria(clearSearchTerm = false) {
|
||||
const ret = this.clone();
|
||||
if (clearSearchTerm) {
|
||||
ret.searchTerm = "";
|
||||
}
|
||||
ret.criteria = [];
|
||||
ret.currentPage = 1;
|
||||
return ret;
|
||||
}
|
||||
|
||||
public clearSearchTerm() {
|
||||
const ret = this.clone();
|
||||
ret.searchTerm = "";
|
||||
ret.currentPage = 1; // reset to first page
|
||||
return ret;
|
||||
}
|
||||
|
||||
public setCriteria(criteria: Criterion[]) {
|
||||
const ret = this.clone();
|
||||
ret.criteria = criteria;
|
||||
|
|
|
|||
|
|
@ -2,10 +2,14 @@ import { useRef, useEffect, useCallback } from "react";
|
|||
|
||||
const useFocus = () => {
|
||||
const htmlElRef = useRef<HTMLInputElement | null>(null);
|
||||
const setFocus = useCallback(() => {
|
||||
const setFocus = useCallback((selectAll?: boolean) => {
|
||||
const currentEl = htmlElRef.current;
|
||||
if (currentEl) {
|
||||
currentEl.focus();
|
||||
if (selectAll) {
|
||||
currentEl.select();
|
||||
} else {
|
||||
currentEl.focus();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue