mirror of
https://github.com/stashapp/stash.git
synced 2026-04-19 13:31:15 +02:00
Add folder criteria to scenes, images and galleries and sidebars (#6636)
* Add useDebouncedState hook * Add basename to folder sort whitelist * Add parent_folder criterion to gallery * Add selection on enter if single result
This commit is contained in:
parent
697c66ae62
commit
717f968a2c
27 changed files with 1025 additions and 70 deletions
|
|
@ -603,6 +603,8 @@ input GalleryFilterType {
|
|||
files_filter: FileFilterType
|
||||
"Filter by related folders that meet this criteria"
|
||||
folders_filter: FolderFilterType
|
||||
"Filter by parent folder of the zip or folder the gallery is in"
|
||||
parent_folder: HierarchicalMultiCriterionInput
|
||||
|
||||
custom_fields: [CustomFieldCriterionInput!]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ type GalleryFilterType struct {
|
|||
Checksum *StringCriterionInput `json:"checksum"`
|
||||
// Filter by path
|
||||
Path *StringCriterionInput `json:"path"`
|
||||
// Filter by parent folder
|
||||
ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder,omitempty"`
|
||||
// Filter by zip file count
|
||||
FileCount *IntCriterionInput `json:"file_count"`
|
||||
// Filter to only include galleries missing this property
|
||||
|
|
|
|||
|
|
@ -614,6 +614,7 @@ var folderSortOptions = sortOptions{
|
|||
"created_at",
|
||||
"id",
|
||||
"path",
|
||||
"basename",
|
||||
"random",
|
||||
"updated_at",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ func (qb *galleryFilterHandler) criterionHandler() criterionHandler {
|
|||
}),
|
||||
|
||||
qb.pathCriterionHandler(filter.Path),
|
||||
qb.parentFolderCriterionHandler(filter.ParentFolder),
|
||||
qb.fileCountCriterionHandler(filter.FileCount),
|
||||
intCriterionHandler(filter.Rating100, "galleries.rating", nil),
|
||||
qb.urlsCriterionHandler(filter.URL),
|
||||
|
|
@ -278,6 +279,65 @@ func (qb *galleryFilterHandler) pathCriterionHandler(c *models.StringCriterionIn
|
|||
}
|
||||
}
|
||||
|
||||
func (qb *galleryFilterHandler) parentFolderCriterionHandler(folder *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if folder == nil {
|
||||
return
|
||||
}
|
||||
|
||||
galleryRepository.addFoldersTable(f)
|
||||
f.addLeftJoin(folderTable, "gallery_folder", "galleries.folder_id = gallery_folder.id")
|
||||
|
||||
criterion := *folder
|
||||
switch criterion.Modifier {
|
||||
case models.CriterionModifierEquals:
|
||||
criterion.Modifier = models.CriterionModifierIncludes
|
||||
case models.CriterionModifierNotEquals:
|
||||
criterion.Modifier = models.CriterionModifierExcludes
|
||||
}
|
||||
|
||||
// only allow includes or excludes filters
|
||||
if criterion.Modifier != models.CriterionModifierIncludes && criterion.Modifier != models.CriterionModifierExcludes {
|
||||
f.setError(fmt.Errorf("invalid modifier for parent folder criterion: %s", criterion.Modifier))
|
||||
}
|
||||
|
||||
if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// combine excludes if excludes modifier is selected
|
||||
if criterion.Modifier == models.CriterionModifierExcludes {
|
||||
criterion.Modifier = models.CriterionModifierIncludes
|
||||
criterion.Excludes = append(criterion.Excludes, criterion.Value...)
|
||||
criterion.Value = nil
|
||||
}
|
||||
|
||||
if len(criterion.Value) > 0 {
|
||||
valuesClause, err := getHierarchicalValues(ctx, criterion.Value, "folders", "", "parent_folder_id", "parent_folder_id", criterion.Depth)
|
||||
if err != nil {
|
||||
f.setError(err)
|
||||
return
|
||||
}
|
||||
|
||||
// combine clauses with OR to handle zip file or folder
|
||||
c1 := makeClause(fmt.Sprintf("folders.parent_folder_id IN (SELECT column2 FROM (%s))", valuesClause))
|
||||
c2 := makeClause(fmt.Sprintf("gallery_folder.parent_folder_id IN (SELECT column2 FROM (%s))", valuesClause))
|
||||
f.whereClauses = append(f.whereClauses, orClauses(c1, c2))
|
||||
}
|
||||
|
||||
if len(criterion.Excludes) > 0 {
|
||||
valuesClause, err := getHierarchicalValues(ctx, criterion.Excludes, "folders", "", "parent_folder_id", "parent_folder_id", criterion.Depth)
|
||||
if err != nil {
|
||||
f.setError(err)
|
||||
return
|
||||
}
|
||||
|
||||
f.addWhere(fmt.Sprintf("folders.parent_folder_id NOT IN (SELECT column2 FROM (%s)) OR folders.parent_folder_id IS NULL", valuesClause))
|
||||
f.addWhere(fmt.Sprintf("gallery_folder.parent_folder_id NOT IN (SELECT column2 FROM (%s)) OR gallery_folder.parent_folder_id IS NULL", valuesClause))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *galleryFilterHandler) fileCountCriterionHandler(fileCount *models.IntCriterionInput) criterionHandlerFunc {
|
||||
h := countCriterionHandlerBuilder{
|
||||
primaryTable: galleryTable,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
fragment FolderData on Folder {
|
||||
id
|
||||
basename
|
||||
path
|
||||
}
|
||||
|
||||
|
|
@ -86,3 +87,17 @@ fragment VisualFileData on VisualFile {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment SelectFolderData on Folder {
|
||||
id
|
||||
path
|
||||
basename
|
||||
}
|
||||
|
||||
fragment RecursiveFolderData on Folder {
|
||||
...SelectFolderData
|
||||
|
||||
parent_folders {
|
||||
...SelectFolderData
|
||||
}
|
||||
}
|
||||
|
|
|
|||
24
ui/v2.5/graphql/queries/folder.graphql
Normal file
24
ui/v2.5/graphql/queries/folder.graphql
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
query FindRootFoldersForSelect {
|
||||
findFolders(
|
||||
filter: { per_page: -1, sort: "path", direction: ASC }
|
||||
folder_filter: { parent_folder: { modifier: IS_NULL } }
|
||||
) {
|
||||
count
|
||||
folders {
|
||||
...SelectFolderData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query FindFoldersForQuery(
|
||||
$filter: FindFilterType
|
||||
$folder_filter: FolderFilterType
|
||||
$ids: [ID!]
|
||||
) {
|
||||
findFolders(filter: $filter, folder_filter: $folder_filter, ids: $ids) {
|
||||
count
|
||||
folders {
|
||||
...RecursiveFolderData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -51,6 +51,8 @@ import {
|
|||
import { FilterTags } from "../List/FilterTags";
|
||||
import { SidebarAgeFilter } from "../List/Filters/SidebarAgeFilter";
|
||||
import { PerformerAgeCriterionOption } from "src/models/list-filter/galleries";
|
||||
import { SidebarFolderFilter } from "../List/Filters/FolderFilter";
|
||||
import { ParentFolderCriterionOption } from "src/models/list-filter/criteria/folder";
|
||||
|
||||
const GalleryList: React.FC<{
|
||||
galleries: GQL.SlimGalleryDataFragment[];
|
||||
|
|
@ -165,6 +167,13 @@ const SidebarContent: React.FC<{
|
|||
filterHook={filterHook}
|
||||
/>
|
||||
<SidebarRatingFilter filter={filter} setFilter={setFilter} />
|
||||
<SidebarFolderFilter
|
||||
text={<FormattedMessage id="parent_folder" />}
|
||||
criterionOption={ParentFolderCriterionOption}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
sectionID="parent_folder"
|
||||
/>
|
||||
<SidebarBooleanFilter
|
||||
title={<FormattedMessage id="organized" />}
|
||||
data-type={OrganizedCriterionOption.type}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ import { Button } from "react-bootstrap";
|
|||
import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organized";
|
||||
import { SidebarAgeFilter } from "../List/Filters/SidebarAgeFilter";
|
||||
import { PerformerAgeCriterionOption } from "src/models/list-filter/images";
|
||||
import { SidebarFolderFilter } from "../List/Filters/FolderFilter";
|
||||
|
||||
interface IImageWallProps {
|
||||
images: GQL.SlimImageDataFragment[];
|
||||
|
|
@ -430,6 +431,12 @@ const SidebarContent: React.FC<{
|
|||
filterHook={filterHook}
|
||||
/>
|
||||
<SidebarRatingFilter filter={filter} setFilter={setFilter} />
|
||||
<SidebarFolderFilter
|
||||
text={<FormattedMessage id="folder" />}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
sectionID="folder"
|
||||
/>
|
||||
<SidebarBooleanFilter
|
||||
title={<FormattedMessage id="organized" />}
|
||||
data-type={OrganizedCriterionOption.type}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,11 @@ import { PathCriterion } from "src/models/list-filter/criteria/path";
|
|||
import { ModifierSelectorButtons } from "./ModifierSelect";
|
||||
import { CustomFieldsCriterion } from "src/models/list-filter/criteria/custom-fields";
|
||||
import { CustomFieldsFilter } from "./Filters/CustomFieldsFilter";
|
||||
import { FolderFilter } from "./Filters/FolderFilter";
|
||||
import {
|
||||
FolderCriterion,
|
||||
ParentFolderCriterion,
|
||||
} from "src/models/list-filter/criteria/folder";
|
||||
|
||||
interface IGenericCriterionEditor {
|
||||
criterion: ModifierCriterion<CriterionValue>;
|
||||
|
|
@ -68,7 +73,9 @@ const GenericCriterionEditor: React.FC<IGenericCriterionEditor> = ({
|
|||
if (
|
||||
criterion instanceof PerformersCriterion ||
|
||||
criterion instanceof StudiosCriterion ||
|
||||
criterion instanceof TagsCriterion
|
||||
criterion instanceof TagsCriterion ||
|
||||
criterion instanceof FolderCriterion ||
|
||||
criterion instanceof ParentFolderCriterion
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -163,6 +170,18 @@ const GenericCriterionEditor: React.FC<IGenericCriterionEditor> = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (
|
||||
criterion instanceof FolderCriterion ||
|
||||
criterion instanceof ParentFolderCriterion
|
||||
) {
|
||||
return (
|
||||
<FolderFilter
|
||||
criterion={criterion}
|
||||
setCriterion={(c) => setCriterion(c)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (criterion instanceof ILabeledIdCriterion) {
|
||||
return (
|
||||
<LabeledIdFilter
|
||||
|
|
|
|||
612
ui/v2.5/src/components/List/Filters/FolderFilter.tsx
Normal file
612
ui/v2.5/src/components/List/Filters/FolderFilter.tsx
Normal file
|
|
@ -0,0 +1,612 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
FolderDataFragment,
|
||||
useFindFoldersForQueryQuery,
|
||||
useFindRootFoldersForSelectQuery,
|
||||
} from "src/core/generated-graphql";
|
||||
import {
|
||||
ISidebarSectionProps,
|
||||
SidebarSection,
|
||||
} from "src/components/Shared/Sidebar";
|
||||
import {
|
||||
faChevronDown,
|
||||
faChevronRight,
|
||||
faMinus,
|
||||
faPlus,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { ExpandCollapseButton } from "src/components/Shared/CollapseButton";
|
||||
import cx from "classnames";
|
||||
import { queryFindSubFolders } from "src/core/StashService";
|
||||
import { keyboardClickHandler } from "src/utils/keyboard";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import {
|
||||
FolderCriterion,
|
||||
FolderCriterionOption,
|
||||
} from "src/models/list-filter/criteria/folder";
|
||||
import { Option, SelectedList } from "./SidebarListFilter";
|
||||
import {
|
||||
defineMessages,
|
||||
FormattedMessage,
|
||||
MessageDescriptor,
|
||||
useIntl,
|
||||
} from "react-intl";
|
||||
import { Icon } from "src/components/Shared/Icon";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import { DepthSelector } from "./SelectableFilter";
|
||||
import ClearableInput from "src/components/Shared/ClearableInput";
|
||||
import { useDebouncedState } from "src/hooks/debounce";
|
||||
import { ModifierCriterionOption } from "src/models/list-filter/criteria/criterion";
|
||||
|
||||
interface IFolder extends FolderDataFragment {
|
||||
children?: IFolder[];
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
const FolderRow: React.FC<{
|
||||
folder: IFolder;
|
||||
level?: number;
|
||||
canExclude?: boolean;
|
||||
toggleExpanded: (folder: IFolder) => void;
|
||||
onSelect: (folder: IFolder, exclude?: boolean) => void;
|
||||
}> = ({ folder, level, toggleExpanded, onSelect, canExclude }) => {
|
||||
return (
|
||||
<>
|
||||
<li
|
||||
className="folder-row unselected-object"
|
||||
style={{ paddingLeft: (level ?? 0) * 5 }}
|
||||
>
|
||||
<a
|
||||
onClick={() => onSelect(folder)}
|
||||
onKeyDown={keyboardClickHandler(() => onSelect(folder))}
|
||||
tabIndex={0}
|
||||
>
|
||||
<span>
|
||||
<span
|
||||
className={cx({
|
||||
empty: folder.children && folder.children.length === 0,
|
||||
})}
|
||||
>
|
||||
<ExpandCollapseButton
|
||||
collapsed={!folder.expanded}
|
||||
setCollapsed={() => toggleExpanded(folder)}
|
||||
collapsedIcon={faChevronRight}
|
||||
notCollapsedIcon={faChevronDown}
|
||||
/>
|
||||
</span>
|
||||
{folder.basename}
|
||||
</span>
|
||||
{canExclude && (
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(folder, true);
|
||||
}}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="minimal exclude-button"
|
||||
>
|
||||
<span className="exclude-button-text">
|
||||
<FormattedMessage id="actions.exclude_lowercase" />
|
||||
</span>
|
||||
<Icon className="fa-fw exclude-icon" icon={faMinus} />
|
||||
</Button>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
{folder.expanded &&
|
||||
folder.children?.map((child) => (
|
||||
<FolderRow
|
||||
key={child.id}
|
||||
folder={child}
|
||||
level={(level ?? 0) + 1}
|
||||
toggleExpanded={toggleExpanded}
|
||||
onSelect={onSelect}
|
||||
canExclude={canExclude}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function toggleExpandedFn(object: IFolder): (f: IFolder) => IFolder {
|
||||
return (f: IFolder) => {
|
||||
if (f.id === object.id) {
|
||||
return { ...f, expanded: !f.expanded };
|
||||
}
|
||||
|
||||
if (f.children) {
|
||||
return {
|
||||
...f,
|
||||
children: f.children.map(toggleExpandedFn(object)),
|
||||
};
|
||||
}
|
||||
|
||||
return f;
|
||||
};
|
||||
}
|
||||
|
||||
function replaceFolder(folder: IFolder): (f: IFolder) => IFolder {
|
||||
return (f: IFolder) => {
|
||||
if (f.id === folder.id) {
|
||||
return folder;
|
||||
}
|
||||
|
||||
if (f.children) {
|
||||
return {
|
||||
...f,
|
||||
children: f.children.map(replaceFolder(folder)),
|
||||
};
|
||||
}
|
||||
|
||||
return f;
|
||||
};
|
||||
}
|
||||
|
||||
function useFolderMap(query: string, skip?: boolean) {
|
||||
const { data: rootFoldersResult } = useFindRootFoldersForSelectQuery({
|
||||
skip,
|
||||
});
|
||||
|
||||
const { data: queryFoldersResult } = useFindFoldersForQueryQuery({
|
||||
skip: !query,
|
||||
variables: {
|
||||
filter: { q: query, per_page: 200 },
|
||||
},
|
||||
});
|
||||
|
||||
const rootFolders: IFolder[] = useMemo(() => {
|
||||
const ret = rootFoldersResult?.findFolders.folders ?? [];
|
||||
return ret.map((f) => ({ ...f, expanded: false, children: undefined }));
|
||||
}, [rootFoldersResult]);
|
||||
|
||||
const queryFolders: IFolder[] = useMemo(() => {
|
||||
// construct the folder list from the query result
|
||||
const ret: IFolder[] = [];
|
||||
|
||||
(queryFoldersResult?.findFolders.folders ?? []).forEach((folder) => {
|
||||
if (!folder.parent_folders.length) {
|
||||
// no parents, just add it if not present
|
||||
if (!ret.find((f) => f.id === folder.id)) {
|
||||
ret.push({ ...folder, expanded: true, children: [] });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// expand the parent folders
|
||||
let currentParent: IFolder | undefined;
|
||||
for (let i = folder.parent_folders.length - 1; i >= 0; i--) {
|
||||
const thisFolder = folder.parent_folders[i];
|
||||
let existing: IFolder | undefined;
|
||||
|
||||
if (i === folder.parent_folders.length - 1) {
|
||||
// last parent, add the folder as root
|
||||
existing = ret.find((f) => f.id === thisFolder.id);
|
||||
if (!existing) {
|
||||
existing = {
|
||||
...folder.parent_folders[i],
|
||||
expanded: true,
|
||||
children: [],
|
||||
};
|
||||
ret.push(existing);
|
||||
}
|
||||
currentParent = existing;
|
||||
continue;
|
||||
}
|
||||
|
||||
// find folder in current parent's children
|
||||
// currentParent is guaranteed to be defined here
|
||||
existing = currentParent!.children?.find((f) => f.id === thisFolder.id);
|
||||
if (!existing) {
|
||||
// add to current parent's children
|
||||
existing = {
|
||||
...thisFolder,
|
||||
expanded: true,
|
||||
children: [],
|
||||
};
|
||||
currentParent!.children!.push(existing);
|
||||
}
|
||||
currentParent = existing;
|
||||
}
|
||||
|
||||
if (!currentParent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentParent.children) {
|
||||
currentParent.children = [];
|
||||
}
|
||||
|
||||
// currentParent is now the immediate parent folder
|
||||
currentParent!.children!.push({
|
||||
...folder,
|
||||
expanded: false,
|
||||
children: undefined,
|
||||
});
|
||||
});
|
||||
return ret;
|
||||
}, [queryFoldersResult]);
|
||||
|
||||
const [folderMap, setFolderMap] = React.useState<IFolder[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!query) {
|
||||
setFolderMap(rootFolders);
|
||||
} else {
|
||||
setFolderMap(queryFolders);
|
||||
}
|
||||
}, [query, rootFolders, queryFolders]);
|
||||
|
||||
async function onToggleExpanded(folder: IFolder) {
|
||||
setFolderMap(folderMap.map(toggleExpandedFn(folder)));
|
||||
|
||||
// query children folders if not already loaded
|
||||
if (folder.children === undefined) {
|
||||
const subFolderResult = await queryFindSubFolders(folder.id);
|
||||
setFolderMap((current) =>
|
||||
current.map(
|
||||
replaceFolder({
|
||||
...folder,
|
||||
expanded: true,
|
||||
children: subFolderResult.data.findFolders.folders.map((f) => ({
|
||||
...f,
|
||||
expanded: false,
|
||||
})),
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { folderMap, onToggleExpanded };
|
||||
}
|
||||
|
||||
function getMatchingFolders(folders: IFolder[], query: string): IFolder[] {
|
||||
let matches: IFolder[] = [];
|
||||
|
||||
const queryLower = query.toLowerCase();
|
||||
|
||||
folders.forEach((folder) => {
|
||||
if (
|
||||
folder.basename.toLowerCase().includes(queryLower) ||
|
||||
folder.path.toLowerCase() === queryLower
|
||||
) {
|
||||
matches.push(folder);
|
||||
}
|
||||
|
||||
if (folder.children) {
|
||||
matches = matches.concat(getMatchingFolders(folder.children, query));
|
||||
}
|
||||
});
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export const FolderSelector: React.FC<{
|
||||
onSelect: (folder: IFolder, exclude?: boolean) => void;
|
||||
canExclude?: boolean;
|
||||
preListContent?: React.ReactNode;
|
||||
folderMap: IFolder[];
|
||||
onToggleExpanded: (folder: IFolder) => void;
|
||||
}> = ({
|
||||
onSelect,
|
||||
preListContent,
|
||||
canExclude = false,
|
||||
folderMap,
|
||||
onToggleExpanded,
|
||||
}) => {
|
||||
return (
|
||||
<ul className="selectable-list">
|
||||
{preListContent}
|
||||
{folderMap.map((folder) => (
|
||||
<FolderRow
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
onSelect={(f, exclude) => onSelect(f, exclude)}
|
||||
toggleExpanded={onToggleExpanded}
|
||||
canExclude={canExclude}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
interface IInputFilterProps {
|
||||
criterion: FolderCriterion;
|
||||
setCriterion: (c: FolderCriterion) => void;
|
||||
}
|
||||
|
||||
export const FolderFilter: React.FC<IInputFilterProps> = ({
|
||||
criterion,
|
||||
setCriterion,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [query, setQuery] = useState("");
|
||||
const [displayQuery, onQueryChange] = useDebouncedState(query, setQuery, 250);
|
||||
|
||||
const { folderMap, onToggleExpanded } = useFolderMap(query);
|
||||
|
||||
const messages = defineMessages({
|
||||
sub_folder_depth: {
|
||||
id: "sub_folder_depth",
|
||||
defaultMessage: "Levels (empty for all)",
|
||||
},
|
||||
});
|
||||
|
||||
function criterionOptionTypeToIncludeID(): string {
|
||||
return "include-sub-folders";
|
||||
}
|
||||
|
||||
function criterionOptionTypeToIncludeUIString(): MessageDescriptor {
|
||||
const optionType = "include_sub_folders";
|
||||
|
||||
return {
|
||||
id: optionType,
|
||||
};
|
||||
}
|
||||
|
||||
function onDepthChanged(depth: number) {
|
||||
// this could be ParentFolderCriterion, but the types are the same
|
||||
const newValue = criterion.clone() as FolderCriterion;
|
||||
newValue.value.depth = depth;
|
||||
setCriterion(newValue);
|
||||
}
|
||||
|
||||
function onSelect(folder: IFolder, exclude: boolean = false) {
|
||||
// toggle selection
|
||||
const newValue = criterion.clone() as FolderCriterion;
|
||||
|
||||
if (!exclude) {
|
||||
if (newValue.value.items.find((i) => i.id === folder.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
newValue.value.items.push({ id: folder.id, label: folder.path });
|
||||
} else {
|
||||
if (newValue.value.excluded.find((i) => i.id === folder.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
newValue.value.excluded.push({ id: folder.id, label: folder.path });
|
||||
}
|
||||
|
||||
setCriterion(newValue);
|
||||
}
|
||||
|
||||
const onUnselect = useCallback(
|
||||
(i: Option, excluded?: boolean) => {
|
||||
const newValue = criterion.clone() as FolderCriterion;
|
||||
|
||||
if (!excluded) {
|
||||
newValue.value.items = newValue.value.items.filter(
|
||||
(item) => item.id !== i.id
|
||||
);
|
||||
} else {
|
||||
newValue.value.excluded = newValue.value.excluded.filter(
|
||||
(item) => item.id !== i.id
|
||||
);
|
||||
}
|
||||
setCriterion(newValue);
|
||||
},
|
||||
[criterion, setCriterion]
|
||||
);
|
||||
|
||||
function onEnter() {
|
||||
if (!query) return;
|
||||
|
||||
// if there is a single folder that matches the query, select it
|
||||
const matchingFolders = getMatchingFolders(folderMap, query);
|
||||
if (matchingFolders.length === 1) {
|
||||
onSelect(matchingFolders[0]);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedList = useMemo(() => {
|
||||
const selected: Option[] =
|
||||
criterion.value?.items.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
})) ?? [];
|
||||
|
||||
return <SelectedList items={selected} onUnselect={onUnselect} />;
|
||||
}, [criterion, onUnselect]);
|
||||
|
||||
const excludedList = useMemo(() => {
|
||||
const selected: Option[] =
|
||||
criterion.value?.excluded.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
})) ?? [];
|
||||
|
||||
return (
|
||||
<SelectedList
|
||||
excluded
|
||||
items={selected}
|
||||
onUnselect={(i) => onUnselect(i, true)}
|
||||
/>
|
||||
);
|
||||
}, [criterion, onUnselect]);
|
||||
|
||||
return (
|
||||
<div className="folder-filter">
|
||||
<DepthSelector
|
||||
depth={criterion.value.depth}
|
||||
onDepthChanged={onDepthChanged}
|
||||
id={criterionOptionTypeToIncludeID()}
|
||||
label={intl.formatMessage(criterionOptionTypeToIncludeUIString())}
|
||||
placeholder={intl.formatMessage(messages.sub_folder_depth)}
|
||||
/>
|
||||
|
||||
<Form.Group>
|
||||
{selectedList}
|
||||
{excludedList}
|
||||
<ClearableInput
|
||||
value={displayQuery}
|
||||
setValue={(v) => onQueryChange(v)}
|
||||
placeholder={`${intl.formatMessage({ id: "actions.search" })}…`}
|
||||
onEnter={onEnter}
|
||||
/>
|
||||
<FolderSelector
|
||||
folderMap={folderMap}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
onSelect={onSelect}
|
||||
canExclude
|
||||
/>
|
||||
</Form.Group>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SidebarFolderFilter: React.FC<
|
||||
ISidebarSectionProps & {
|
||||
filter: ListFilterModel;
|
||||
setFilter: (f: ListFilterModel) => void;
|
||||
criterionOption?: ModifierCriterionOption;
|
||||
}
|
||||
> = (props) => {
|
||||
const intl = useIntl();
|
||||
const [skip, setSkip] = useState(true);
|
||||
const [query, setQuery] = useState("");
|
||||
const [displayQuery, onQueryChange] = useDebouncedState(query, setQuery, 250);
|
||||
|
||||
function onOpen() {
|
||||
setSkip(false);
|
||||
props.onOpen?.();
|
||||
}
|
||||
|
||||
const { folderMap, onToggleExpanded } = useFolderMap(query, skip);
|
||||
|
||||
const option = props.criterionOption ?? FolderCriterionOption;
|
||||
const { filter, setFilter } = props;
|
||||
|
||||
const criterion = useMemo(() => {
|
||||
const ret = filter.criteria.find(
|
||||
(c) => c.criterionOption.type === option.type
|
||||
);
|
||||
if (ret) return ret as FolderCriterion;
|
||||
|
||||
const newCriterion = filter.makeCriterion(option.type) as FolderCriterion;
|
||||
return newCriterion;
|
||||
}, [option.type, filter]);
|
||||
|
||||
// if there are multiple values or excluded values, then we show none of the
|
||||
// current values
|
||||
const multipleSelected =
|
||||
criterion.value.items.length > 1 || criterion.value.excluded.length > 0;
|
||||
|
||||
function onSelect(folder: IFolder) {
|
||||
const c = criterion.clone() as FolderCriterion;
|
||||
c.value = {
|
||||
items: [{ id: folder.id, label: folder.path }],
|
||||
depth: 0,
|
||||
excluded: [],
|
||||
};
|
||||
|
||||
const newCriteria = props.filter.criteria.filter(
|
||||
(cc) => cc.criterionOption.type !== option.type
|
||||
);
|
||||
|
||||
if (c.isValid()) newCriteria.push(c);
|
||||
|
||||
setFilter(props.filter.setCriteria(newCriteria));
|
||||
}
|
||||
|
||||
function onSelectSubfolders() {
|
||||
const c = criterion.clone() as FolderCriterion;
|
||||
c.value = {
|
||||
items: c.value?.items ?? [],
|
||||
depth: -1,
|
||||
excluded: c.value?.excluded ?? [],
|
||||
};
|
||||
|
||||
setFilter(props.filter.replaceCriteria(option.type, [c]));
|
||||
}
|
||||
|
||||
const onUnselect = useCallback(
|
||||
(i: Option) => {
|
||||
if (i.className === "modifier-object") {
|
||||
// subfolders option
|
||||
const c = criterion.clone() as FolderCriterion;
|
||||
c.value = {
|
||||
items: c.value?.items ?? [],
|
||||
depth: 0,
|
||||
excluded: c.value?.excluded ?? [],
|
||||
};
|
||||
|
||||
setFilter(props.filter.replaceCriteria(option.type, [c]));
|
||||
return;
|
||||
}
|
||||
|
||||
setFilter(props.filter.removeCriterion(option.type));
|
||||
},
|
||||
[props.filter, setFilter, option.type, criterion]
|
||||
);
|
||||
|
||||
function onEnter() {
|
||||
if (!query) return;
|
||||
|
||||
// if there is a single folder that matches the query, select it
|
||||
const matchingFolders = getMatchingFolders(folderMap, query);
|
||||
if (matchingFolders.length === 1) {
|
||||
onSelect(matchingFolders[0]);
|
||||
}
|
||||
}
|
||||
|
||||
const subDirsSelected = criterion.value?.depth === -1;
|
||||
|
||||
const selectedList = useMemo(() => {
|
||||
if (multipleSelected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selected: Option[] =
|
||||
criterion.value?.items.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
})) ?? [];
|
||||
|
||||
if (subDirsSelected) {
|
||||
selected.push({
|
||||
id: "subfolders",
|
||||
label: "(" + intl.formatMessage({ id: "sub_folders" }) + ")",
|
||||
className: "modifier-object",
|
||||
});
|
||||
}
|
||||
|
||||
return <SelectedList items={selected} onUnselect={onUnselect} />;
|
||||
}, [intl, multipleSelected, subDirsSelected, criterion, onUnselect]);
|
||||
|
||||
const modifierItem = criterion.value.items.length > 0 &&
|
||||
!multipleSelected &&
|
||||
!subDirsSelected && (
|
||||
<li className="unselected-object modifier-object">
|
||||
<a onClick={onSelectSubfolders}>
|
||||
<span>
|
||||
<Icon className={`fa-fw include-button`} icon={faPlus} />
|
||||
(<FormattedMessage id="sub_folders" />)
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarSection
|
||||
{...props}
|
||||
outsideCollapse={selectedList}
|
||||
onOpen={onOpen}
|
||||
className="sidebar-list-filter sidebar-folder-filter"
|
||||
>
|
||||
<ClearableInput
|
||||
value={displayQuery}
|
||||
setValue={(v) => onQueryChange(v)}
|
||||
placeholder={`${intl.formatMessage({ id: "actions.search" })}…`}
|
||||
onEnter={onEnter}
|
||||
/>
|
||||
|
||||
<FolderSelector
|
||||
folderMap={folderMap}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
preListContent={modifierItem}
|
||||
onSelect={(f) => onSelect(f)}
|
||||
/>
|
||||
</SidebarSection>
|
||||
);
|
||||
};
|
||||
|
|
@ -391,10 +391,17 @@ export function useCandidates(props: {
|
|||
const defaultModifier = getDefaultModifier(singleValue);
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
return (results ?? []).map((r) => ({
|
||||
id: r.id,
|
||||
label: r.label,
|
||||
}));
|
||||
}, [results]);
|
||||
|
||||
const modifierCandidates = useMemo(() => {
|
||||
const hierarchicalCandidate =
|
||||
hierarchical && (criterion.value as IHierarchicalLabelValue).depth !== -1;
|
||||
|
||||
const modifierCandidates: Option[] = getModifierCandidates({
|
||||
return getModifierCandidates({
|
||||
modifier,
|
||||
defaultModifier,
|
||||
hasSelected: selected.length > 0,
|
||||
|
|
@ -416,19 +423,11 @@ export function useCandidates(props: {
|
|||
canExclude: false,
|
||||
};
|
||||
});
|
||||
|
||||
return modifierCandidates.concat(
|
||||
(results ?? []).map((r) => ({
|
||||
id: r.id,
|
||||
label: r.label,
|
||||
}))
|
||||
);
|
||||
}, [
|
||||
defaultModifier,
|
||||
intl,
|
||||
modifier,
|
||||
singleValue,
|
||||
results,
|
||||
selected,
|
||||
excluded,
|
||||
criterion.value,
|
||||
|
|
@ -436,7 +435,7 @@ export function useCandidates(props: {
|
|||
includeSubMessageID,
|
||||
]);
|
||||
|
||||
return candidates;
|
||||
return { candidates, modifierCandidates };
|
||||
}
|
||||
|
||||
export function useLabeledIdFilterState(props: {
|
||||
|
|
@ -481,7 +480,7 @@ export function useLabeledIdFilterState(props: {
|
|||
includeSubMessageID,
|
||||
});
|
||||
|
||||
const candidates = useCandidates({
|
||||
const { candidates, modifierCandidates } = useCandidates({
|
||||
criterion,
|
||||
queryResults,
|
||||
selected,
|
||||
|
|
@ -497,6 +496,7 @@ export function useLabeledIdFilterState(props: {
|
|||
|
||||
return {
|
||||
candidates,
|
||||
modifierCandidates,
|
||||
onSelect,
|
||||
onUnselect,
|
||||
selected,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,12 @@ import {
|
|||
ModifierCriterion,
|
||||
IHierarchicalLabeledIdCriterion,
|
||||
} from "src/models/list-filter/criteria/criterion";
|
||||
import { defineMessages, MessageDescriptor, useIntl } from "react-intl";
|
||||
import {
|
||||
defineMessages,
|
||||
FormattedMessage,
|
||||
MessageDescriptor,
|
||||
useIntl,
|
||||
} from "react-intl";
|
||||
import { CriterionModifier } from "src/core/generated-graphql";
|
||||
import { keyboardClickHandler } from "src/utils/keyboard";
|
||||
import { useDebounce } from "src/hooks/debounce";
|
||||
|
|
@ -118,7 +123,9 @@ const UnselectedItem: React.FC<{
|
|||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="minimal exclude-button"
|
||||
>
|
||||
<span className="exclude-button-text">exclude</span>
|
||||
<span className="exclude-button-text">
|
||||
<FormattedMessage id="actions.exclude_lowercase" />
|
||||
</span>
|
||||
{excludeIcon}
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -240,12 +247,19 @@ const SelectableFilter: React.FC<ISelectableFilter> = ({
|
|||
onSetModifier(defaultModifier);
|
||||
}
|
||||
|
||||
function onEnter() {
|
||||
if (objects.length === 1) {
|
||||
onSelect(objects[0], false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="selectable-filter">
|
||||
<ClearableInput
|
||||
focus={inputFocus}
|
||||
value={query}
|
||||
setValue={(v) => onQueryChange(v)}
|
||||
onEnter={onEnter}
|
||||
placeholder={`${intl.formatMessage({ id: "actions.search" })}…`}
|
||||
/>
|
||||
<ul>
|
||||
|
|
@ -450,6 +464,42 @@ export const ObjectsFilter = <
|
|||
);
|
||||
};
|
||||
|
||||
export const DepthSelector: React.FC<{
|
||||
depth: number | undefined;
|
||||
onDepthChanged: (depth: number) => void;
|
||||
id: string;
|
||||
label?: React.ReactNode;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}> = ({ depth, onDepthChanged, id, label, disabled, placeholder }) => {
|
||||
return (
|
||||
<Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Check
|
||||
id={id}
|
||||
checked={depth !== 0}
|
||||
label={label}
|
||||
onChange={() => onDepthChanged(depth !== 0 ? 0 : -1)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Form.Group>
|
||||
{depth !== 0 && (
|
||||
<Form.Group>
|
||||
<NumberField
|
||||
className="btn-secondary"
|
||||
placeholder={placeholder}
|
||||
onChange={(e) =>
|
||||
onDepthChanged(e.target.value ? parseInt(e.target.value, 10) : -1)
|
||||
}
|
||||
defaultValue={depth !== -1 ? depth : ""}
|
||||
min="1"
|
||||
/>
|
||||
</Form.Group>
|
||||
)}
|
||||
</Form.Group>
|
||||
);
|
||||
};
|
||||
|
||||
interface IHierarchicalObjectsFilter<T extends IHierarchicalLabeledIdCriterion>
|
||||
extends IObjectsFilter<T> {}
|
||||
|
||||
|
|
@ -497,38 +547,15 @@ export const HierarchicalObjectsFilter = <
|
|||
}
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<Form.Group>
|
||||
<Form.Check
|
||||
id={criterionOptionTypeToIncludeID()}
|
||||
checked={
|
||||
criterion.modifier !== CriterionModifier.Equals &&
|
||||
criterion.value.depth !== 0
|
||||
}
|
||||
label={intl.formatMessage(criterionOptionTypeToIncludeUIString())}
|
||||
onChange={() => onDepthChanged(criterion.value.depth !== 0 ? 0 : -1)}
|
||||
disabled={criterion.modifier === CriterionModifier.Equals}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
{criterion.value.depth !== 0 && (
|
||||
<Form.Group>
|
||||
<NumberField
|
||||
className="btn-secondary"
|
||||
placeholder={intl.formatMessage(messages.studio_depth)}
|
||||
onChange={(e) =>
|
||||
onDepthChanged(e.target.value ? parseInt(e.target.value, 10) : -1)
|
||||
}
|
||||
defaultValue={
|
||||
criterion.value && criterion.value.depth !== -1
|
||||
? criterion.value.depth
|
||||
: ""
|
||||
}
|
||||
min="1"
|
||||
/>
|
||||
</Form.Group>
|
||||
)}
|
||||
<div>
|
||||
<DepthSelector
|
||||
depth={criterion.value.depth}
|
||||
onDepthChanged={onDepthChanged}
|
||||
id={criterionOptionTypeToIncludeID()}
|
||||
label={intl.formatMessage(criterionOptionTypeToIncludeUIString())}
|
||||
placeholder={intl.formatMessage(messages.studio_depth)}
|
||||
/>
|
||||
<ObjectsFilter {...props} />
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -182,7 +182,8 @@ const QueryField: React.FC<{
|
|||
focus: ReturnType<typeof useFocus>;
|
||||
value: string;
|
||||
setValue: (query: string) => void;
|
||||
}> = ({ focus, value, setValue }) => {
|
||||
onEnter?: () => void;
|
||||
}> = ({ focus, value, setValue, onEnter }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [displayQuery, setDisplayQuery] = useState(value);
|
||||
|
|
@ -206,6 +207,7 @@ const QueryField: React.FC<{
|
|||
value={displayQuery}
|
||||
setValue={(v) => onQueryChange(v)}
|
||||
placeholder={`${intl.formatMessage({ id: "actions.search" })}…`}
|
||||
onEnter={onEnter}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -214,6 +216,7 @@ interface IQueryableProps {
|
|||
inputFocus?: ReturnType<typeof useFocus>;
|
||||
query?: string;
|
||||
setQuery?: (query: string) => void;
|
||||
onEnter?: () => void;
|
||||
}
|
||||
|
||||
export const CandidateList: React.FC<
|
||||
|
|
@ -227,6 +230,7 @@ export const CandidateList: React.FC<
|
|||
inputFocus,
|
||||
query,
|
||||
setQuery,
|
||||
onEnter,
|
||||
items,
|
||||
onSelect,
|
||||
canExclude,
|
||||
|
|
@ -242,6 +246,7 @@ export const CandidateList: React.FC<
|
|||
focus={inputFocus}
|
||||
value={query}
|
||||
setValue={(v) => setQuery(v)}
|
||||
onEnter={onEnter}
|
||||
/>
|
||||
)}
|
||||
<ul>
|
||||
|
|
@ -265,6 +270,7 @@ export const SidebarListFilter: React.FC<{
|
|||
selected: Option[];
|
||||
excluded?: Option[];
|
||||
candidates: Option[];
|
||||
modifierCandidates?: Option[];
|
||||
singleValue?: boolean;
|
||||
onSelect: (item: Option, exclude: boolean) => void;
|
||||
onUnselect: (item: Option, exclude: boolean) => void;
|
||||
|
|
@ -283,6 +289,7 @@ export const SidebarListFilter: React.FC<{
|
|||
selected,
|
||||
excluded,
|
||||
candidates,
|
||||
modifierCandidates,
|
||||
onSelect,
|
||||
onUnselect,
|
||||
canExclude,
|
||||
|
|
@ -324,6 +331,20 @@ export const SidebarListFilter: React.FC<{
|
|||
}
|
||||
}
|
||||
|
||||
function onEnter() {
|
||||
if (candidates && candidates.length === 1) {
|
||||
selectHook(candidates[0], false);
|
||||
}
|
||||
}
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (!modifierCandidates) {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
return [...modifierCandidates, ...candidates];
|
||||
}, [candidates, modifierCandidates]);
|
||||
|
||||
return (
|
||||
<SidebarSection
|
||||
className="sidebar-list-filter"
|
||||
|
|
@ -350,13 +371,14 @@ export const SidebarListFilter: React.FC<{
|
|||
>
|
||||
{preCandidates ? <div className="extra">{preCandidates}</div> : null}
|
||||
<CandidateList
|
||||
items={candidates}
|
||||
items={items}
|
||||
onSelect={selectHook}
|
||||
canExclude={canExclude}
|
||||
inputFocus={inputFocus}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
singleValue={singleValue}
|
||||
onEnter={onEnter}
|
||||
/>
|
||||
{postCandidates ? <div className="extra">{postCandidates}</div> : null}
|
||||
</SidebarSection>
|
||||
|
|
|
|||
|
|
@ -234,14 +234,14 @@ input[type="range"].zoom-slider {
|
|||
|
||||
.saved-filter-item {
|
||||
cursor: pointer;
|
||||
height: 2em;
|
||||
margin-bottom: 0.25rem;
|
||||
min-height: 2em;
|
||||
|
||||
a {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 2em;
|
||||
justify-content: space-between;
|
||||
min-height: 2em;
|
||||
outline: none;
|
||||
|
||||
&:hover,
|
||||
|
|
@ -507,7 +507,8 @@ input[type="range"].zoom-slider {
|
|||
}
|
||||
}
|
||||
|
||||
.selectable-filter ul {
|
||||
.selectable-filter ul,
|
||||
ul.selectable-list {
|
||||
list-style-type: none;
|
||||
margin-top: 0.5rem;
|
||||
max-height: 300px;
|
||||
|
|
@ -533,14 +534,14 @@ input[type="range"].zoom-slider {
|
|||
.excluded-object,
|
||||
.unselected-object {
|
||||
cursor: pointer;
|
||||
height: 2em;
|
||||
margin-bottom: 0.25rem;
|
||||
min-height: 2em;
|
||||
|
||||
a {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 2em;
|
||||
justify-content: space-between;
|
||||
min-height: 2em;
|
||||
outline: none;
|
||||
|
||||
&:hover,
|
||||
|
|
@ -613,7 +614,8 @@ input[type="range"].zoom-slider {
|
|||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-list-filter ul {
|
||||
.sidebar-list-filter ul,
|
||||
.folder-filter ul {
|
||||
list-style-type: none;
|
||||
margin-bottom: 0.25rem;
|
||||
max-height: 300px;
|
||||
|
|
@ -639,14 +641,14 @@ input[type="range"].zoom-slider {
|
|||
.excluded-object,
|
||||
.unselected-object {
|
||||
cursor: pointer;
|
||||
height: 2em;
|
||||
margin-bottom: 0.25rem;
|
||||
min-height: 2em;
|
||||
|
||||
a {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 2em;
|
||||
justify-content: space-between;
|
||||
min-height: 2em;
|
||||
outline: none;
|
||||
|
||||
&:hover,
|
||||
|
|
@ -687,7 +689,7 @@ input[type="range"].zoom-slider {
|
|||
}
|
||||
|
||||
&:hover {
|
||||
background-color: inherit;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&:hover .exclude-button-text,
|
||||
|
|
@ -748,6 +750,29 @@ input[type="range"].zoom-slider {
|
|||
}
|
||||
}
|
||||
|
||||
.sidebar-folder-filter ul,
|
||||
.folder-filter ul,
|
||||
ul.selectable-list {
|
||||
margin-top: 0.25rem;
|
||||
|
||||
.btn.expand-collapse {
|
||||
font-size: 0.8rem;
|
||||
padding-left: 0;
|
||||
padding-right: 0.25rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.empty .btn.expand-collapse {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.selected-object a .selected-object-label {
|
||||
font-size: 0.8em;
|
||||
overflow-wrap: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.tilted {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ import useFocus from "src/utils/focus";
|
|||
import { useZoomKeybinds } from "../List/ZoomSlider";
|
||||
import { FilteredListToolbar } from "../List/FilteredListToolbar";
|
||||
import { FilterTags } from "../List/FilterTags";
|
||||
import { SidebarFolderFilter } from "../List/Filters/FolderFilter";
|
||||
|
||||
function renderMetadataByline(result: GQL.FindScenesQueryResult) {
|
||||
const duration = result?.data?.findScenes?.duration;
|
||||
|
|
@ -305,6 +306,12 @@ const SidebarContent: React.FC<{
|
|||
/>
|
||||
<SidebarRatingFilter filter={filter} setFilter={setFilter} />
|
||||
<SidebarDurationFilter filter={filter} setFilter={setFilter} />
|
||||
<SidebarFolderFilter
|
||||
text={<FormattedMessage id="folder" />}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
sectionID="folder"
|
||||
/>
|
||||
<SidebarBooleanFilter
|
||||
title={<FormattedMessage id="hasMarkers" />}
|
||||
data-type={HasMarkersCriterionOption.type}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ interface IClearableInput {
|
|||
className?: string;
|
||||
value: string;
|
||||
setValue: (value: string) => void;
|
||||
onEnter?: () => void;
|
||||
focus?: ReturnType<typeof useFocus>;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
|
@ -18,6 +19,7 @@ export const ClearableInput: React.FC<IClearableInput> = ({
|
|||
className,
|
||||
value,
|
||||
setValue,
|
||||
onEnter,
|
||||
focus,
|
||||
placeholder,
|
||||
}) => {
|
||||
|
|
@ -43,6 +45,9 @@ export const ClearableInput: React.FC<IClearableInput> = ({
|
|||
if (e.key === "Escape") {
|
||||
queryRef.current?.blur();
|
||||
}
|
||||
if (e.key === "Enter" && onEnter) {
|
||||
onEnter();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
faChevronDown,
|
||||
faChevronRight,
|
||||
faChevronUp,
|
||||
IconDefinition,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Button, Collapse, CollapseProps } from "react-bootstrap";
|
||||
|
|
@ -55,14 +56,21 @@ export const CollapseButton: React.FC<React.PropsWithChildren<IProps>> = (
|
|||
export const ExpandCollapseButton: React.FC<{
|
||||
collapsed: boolean;
|
||||
setCollapsed: (collapsed: boolean) => void;
|
||||
}> = ({ collapsed, setCollapsed }) => {
|
||||
const buttonIcon = collapsed ? faChevronDown : faChevronUp;
|
||||
collapsedIcon?: IconDefinition;
|
||||
notCollapsedIcon?: IconDefinition;
|
||||
}> = ({ collapsedIcon, notCollapsedIcon, collapsed, setCollapsed }) => {
|
||||
const buttonIcon = collapsed
|
||||
? collapsedIcon ?? faChevronDown
|
||||
: notCollapsedIcon ?? faChevronUp;
|
||||
|
||||
return (
|
||||
<span className="detail-expand-collapse">
|
||||
<Button
|
||||
className="minimal expand-collapse"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
onClick={(e) => {
|
||||
setCollapsed(!collapsed);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Icon icon={buttonIcon} fixedWidth />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -97,15 +97,17 @@ interface IContext {
|
|||
|
||||
export const SidebarStateContext = React.createContext<IContext | null>(null);
|
||||
|
||||
export interface ISidebarSectionProps {
|
||||
text: React.ReactNode;
|
||||
className?: string;
|
||||
outsideCollapse?: React.ReactNode;
|
||||
onOpen?: () => void;
|
||||
// used to store open/closed state in SidebarStateContext
|
||||
sectionID?: string;
|
||||
}
|
||||
|
||||
export const SidebarSection: React.FC<
|
||||
PropsWithChildren<{
|
||||
text: React.ReactNode;
|
||||
className?: string;
|
||||
outsideCollapse?: React.ReactNode;
|
||||
onOpen?: () => void;
|
||||
// used to store open/closed state in SidebarStateContext
|
||||
sectionID?: string;
|
||||
}>
|
||||
PropsWithChildren<ISidebarSectionProps>
|
||||
> = ({
|
||||
className = "",
|
||||
text,
|
||||
|
|
|
|||
|
|
@ -515,6 +515,21 @@ export const useFindSavedFilters = (mode?: GQL.FilterMode) =>
|
|||
variables: { mode },
|
||||
});
|
||||
|
||||
export const queryFindSubFolders = (id: string) =>
|
||||
client.query<GQL.FindFoldersForQueryQuery>({
|
||||
query: GQL.FindFoldersForQueryDocument,
|
||||
variables: {
|
||||
folder_filter: {
|
||||
parent_folder: { value: id, modifier: GQL.CriterionModifier.Equals },
|
||||
},
|
||||
filter: {
|
||||
per_page: -1,
|
||||
sort: "basename",
|
||||
direction: GQL.SortDirectionEnum.Asc,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/// Object Mutations
|
||||
|
||||
// Increases/decreases the given field of the Stats query by diff
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { debounce, DebouncedFunc, DebounceSettings } from "lodash-es";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
export function useDebounce<T extends (...args: any) => any>(
|
||||
fn: T,
|
||||
|
|
@ -21,3 +21,30 @@ export function useDebounce<T extends (...args: any) => any>(
|
|||
[wait, options?.leading, options?.trailing, options?.maxWait]
|
||||
);
|
||||
}
|
||||
|
||||
export function useDebouncedState<T>(
|
||||
initialValue: T,
|
||||
setValue: (v: T) => void,
|
||||
wait?: number
|
||||
): [T, (v: T) => void, (v: T) => void] {
|
||||
const [displayedState, setDisplayedState] = useState(initialValue);
|
||||
|
||||
const debouncedSetValue = useDebounce(setValue, wait);
|
||||
const onChange = useCallback(
|
||||
(input: T) => {
|
||||
setDisplayedState(input);
|
||||
debouncedSetValue(input);
|
||||
},
|
||||
[debouncedSetValue, setDisplayedState]
|
||||
);
|
||||
|
||||
const setInstant = useCallback(
|
||||
(v: T) => {
|
||||
setDisplayedState(v);
|
||||
setValue(v);
|
||||
},
|
||||
[setValue]
|
||||
);
|
||||
|
||||
return [displayedState, onChange, setInstant];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@
|
|||
"edit_entity": "Edit {entityType}",
|
||||
"enable": "Enable",
|
||||
"encoding_image": "Encoding image…",
|
||||
"exclude_lowercase": "exclude",
|
||||
"export": "Export",
|
||||
"export_all": "Export all…",
|
||||
"find": "Find",
|
||||
|
|
@ -1225,6 +1226,7 @@
|
|||
"image_index": "Image #",
|
||||
"images": "Images",
|
||||
"include_parent_tags": "Include parent tags",
|
||||
"include_sub_folders": "Include sub-folders",
|
||||
"include_sub_group_content": "Include sub-group content",
|
||||
"include_sub_groups": "Include sub-groups",
|
||||
"include_sub_studio_content": "Include sub-studio content",
|
||||
|
|
@ -1327,6 +1329,7 @@
|
|||
"next": "Next",
|
||||
"previous": "Previous"
|
||||
},
|
||||
"parent_folder": "Parent Folder",
|
||||
"parent_of": "Parent of {children}",
|
||||
"parent_studio": "Parent Studio",
|
||||
"parent_studios": "Parent Studios",
|
||||
|
|
@ -1578,6 +1581,8 @@
|
|||
},
|
||||
"studio_tags": "Studio Tags",
|
||||
"studios": "Studios",
|
||||
"sub_folder_depth": "Sub folder depth (empty for all)",
|
||||
"sub_folders": "Sub folders",
|
||||
"sub_group": "Sub-Group",
|
||||
"sub_group_count": "Sub-Group Count",
|
||||
"sub_group_of": "Sub-group of {parent}",
|
||||
|
|
|
|||
|
|
@ -251,6 +251,7 @@ export type InputType =
|
|||
| "scene_tags"
|
||||
| "groups"
|
||||
| "galleries"
|
||||
| "folders"
|
||||
| undefined;
|
||||
|
||||
type MakeCriterionFn = (
|
||||
|
|
|
|||
52
ui/v2.5/src/models/list-filter/criteria/folder.ts
Normal file
52
ui/v2.5/src/models/list-filter/criteria/folder.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { CriterionModifier } from "src/core/generated-graphql";
|
||||
import {
|
||||
ModifierCriterionOption,
|
||||
IHierarchicalLabeledIdCriterion,
|
||||
} from "./criterion";
|
||||
|
||||
const modifierOptions = [CriterionModifier.Includes];
|
||||
|
||||
const defaultModifier = CriterionModifier.Includes;
|
||||
const inputType = "folders";
|
||||
|
||||
export const FolderCriterionOption = new ModifierCriterionOption({
|
||||
messageID: "folder",
|
||||
type: "folder",
|
||||
modifierOptions,
|
||||
defaultModifier,
|
||||
inputType,
|
||||
makeCriterion: () => new FolderCriterion(),
|
||||
});
|
||||
|
||||
// for galleries, we should use parent folder to distinguish between gallery folder
|
||||
// and parent folder of the gallery folder
|
||||
export const ParentFolderCriterionOption = new ModifierCriterionOption({
|
||||
messageID: "parent_folder",
|
||||
type: "parent_folder",
|
||||
modifierOptions,
|
||||
defaultModifier,
|
||||
inputType,
|
||||
makeCriterion: () => new ParentFolderCriterion(),
|
||||
});
|
||||
|
||||
export class FolderCriterion extends IHierarchicalLabeledIdCriterion {
|
||||
constructor() {
|
||||
super(FolderCriterionOption);
|
||||
}
|
||||
|
||||
public applyToCriterionInput(input: Record<string, unknown>) {
|
||||
input.files_filter = {
|
||||
parent_folder: this.toCriterionInput(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class ParentFolderCriterion extends IHierarchicalLabeledIdCriterion {
|
||||
constructor() {
|
||||
super(ParentFolderCriterionOption);
|
||||
}
|
||||
|
||||
public applyToCriterionInput(input: Record<string, unknown>) {
|
||||
input.parent_folder = this.toCriterionInput();
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ import { DisplayMode } from "./types";
|
|||
import { RatingCriterionOption } from "./criteria/rating";
|
||||
import { PathCriterionOption } from "./criteria/path";
|
||||
import { CustomFieldsCriterionOption } from "./criteria/custom-fields";
|
||||
import { ParentFolderCriterionOption } from "./criteria/folder";
|
||||
|
||||
const defaultSortBy = "path";
|
||||
|
||||
|
|
@ -53,6 +54,7 @@ const criterionOptions = [
|
|||
createStringCriterionOption("details"),
|
||||
createStringCriterionOption("photographer"),
|
||||
PathCriterionOption,
|
||||
ParentFolderCriterionOption,
|
||||
createStringCriterionOption("checksum", "media_info.md5"),
|
||||
RatingCriterionOption,
|
||||
OrganizedCriterionOption,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { DisplayMode } from "./types";
|
|||
import { GalleriesCriterionOption } from "./criteria/galleries";
|
||||
import { PhashCriterionOption } from "./criteria/phash";
|
||||
import { CustomFieldsCriterionOption } from "./criteria/custom-fields";
|
||||
import { FolderCriterionOption } from "./criteria/folder";
|
||||
|
||||
const defaultSortBy = "path";
|
||||
|
||||
|
|
@ -54,6 +55,7 @@ const criterionOptions = [
|
|||
createMandatoryStringCriterionOption("checksum", "media_info.md5"),
|
||||
PhashCriterionOption,
|
||||
PathCriterionOption,
|
||||
FolderCriterionOption,
|
||||
GalleriesCriterionOption,
|
||||
OrganizedCriterionOption,
|
||||
createMandatoryNumberCriterionOption("o_counter", "o_count", {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import { RatingCriterionOption } from "./criteria/rating";
|
|||
import { PathCriterionOption } from "./criteria/path";
|
||||
import { OrientationCriterionOption } from "./criteria/orientation";
|
||||
import { CustomFieldsCriterionOption } from "./criteria/custom-fields";
|
||||
import { FolderCriterionOption } from "./criteria/folder";
|
||||
|
||||
const defaultSortBy = "date";
|
||||
const sortByOptions = [
|
||||
|
|
@ -96,6 +97,7 @@ const criterionOptions = [
|
|||
createStringCriterionOption("title"),
|
||||
createStringCriterionOption("code", "scene_code"),
|
||||
PathCriterionOption,
|
||||
FolderCriterionOption,
|
||||
createStringCriterionOption("details"),
|
||||
createStringCriterionOption("director"),
|
||||
createMandatoryStringCriterionOption("oshash", "media_info.oshash"),
|
||||
|
|
|
|||
|
|
@ -223,4 +223,6 @@ export type CriterionType =
|
|||
| "disambiguation"
|
||||
| "has_chapters"
|
||||
| "sort_name"
|
||||
| "custom_fields";
|
||||
| "custom_fields"
|
||||
| "folder"
|
||||
| "parent_folder";
|
||||
|
|
|
|||
Loading…
Reference in a new issue