Progress on advanced filters

This commit is contained in:
jeffvli
2022-11-02 22:04:46 -07:00
parent 73fff64a75
commit 94b40178aa
7 changed files with 764 additions and 148 deletions
+7 -1
View File
@@ -24,10 +24,15 @@ const getList = async (
res: Response res: Response
) => { ) => {
const { serverId } = req.params; const { serverId } = req.params;
const { take, skip, serverUrlId } = req.query; const { take, skip, serverUrlId, advancedFilters } = req.query;
const decodedAdvancedFilters = advancedFilters && decodeURI(advancedFilters);
const jsonAdvancedFilters =
decodedAdvancedFilters && JSON.parse(decodedAdvancedFilters);
const albums = await service.albums.findMany({ const albums = await service.albums.findMany({
...req.query, ...req.query,
advancedFilters: jsonAdvancedFilters,
serverId, serverId,
skip: Number(skip), skip: Number(skip),
take: Number(take), take: Number(take),
@@ -66,6 +71,7 @@ const getDetailSongList = async (
const albums = await service.albums.findMany({ const albums = await service.albums.findMany({
...req.query, ...req.query,
advancedFilters: undefined,
serverId, serverId,
skip: Number(skip), skip: Number(skip),
take: Number(take), take: Number(take),
+198 -1
View File
@@ -57,7 +57,7 @@ const sort = (sortBy: AlbumSort, orderBy: SortOrder) => {
break; break;
case AlbumSort.DATE_RELEASED: case AlbumSort.DATE_RELEASED:
order = { releaseDate: orderBy, year: orderBy }; order = { releaseDate: orderBy };
break; break;
case AlbumSort.DATE_RELEASED_YEAR: case AlbumSort.DATE_RELEASED_YEAR:
@@ -80,7 +80,204 @@ const sort = (sortBy: AlbumSort, orderBy: SortOrder) => {
return order; return order;
}; };
export enum FilterGroupType {
AND = 'AND',
OR = 'OR',
}
export type AdvancedFilterRule = {
field: string | null;
operator: string | null;
uniqueId: string;
value: string | number | Date | undefined | null | any;
};
export type AdvancedFilterGroup = {
group: AdvancedFilterGroup[];
rules: AdvancedFilterRule[];
type: FilterGroupType;
uniqueId: string;
};
const advancedFilterGroup = (
groups: AdvancedFilterGroup[],
user: AuthUser,
data: any[]
) => {
if (groups.length === 0) {
return data;
}
const filterGroups: any[] = [];
for (const group of groups) {
const rootType = group.type.toUpperCase();
const query: any = {
[rootType]: [],
};
for (const rule of group.rules) {
if (rule.field && rule.operator) {
const [table, field, relationField] = rule.field.split('.');
if (field === 'ratings') {
if (table === 'albums') {
query[rootType].push({
[field]: {
some: {
[relationField]: {
[rule.operator]: rule.value,
},
userId: user.id,
},
},
});
} else {
query[rootType].push({
[table]: {
some: {
[field]: {
some: {
[relationField]: {
[rule.operator]: rule.value,
},
userId: user.id,
},
},
},
},
});
}
} else if (table === 'albums') {
const obj = {
[field]: {
[rule.operator]: rule.value,
mode: 'insensitive',
},
};
query[rootType].push(obj);
} else {
const obj = {
[table]: {
some: {
[field]: {
[rule.operator]: rule.value,
mode: 'insensitive',
},
},
},
};
query[rootType].push(obj);
}
}
}
if (group.group.length > 0) {
const b = advancedFilterGroup(group.group, user, data);
b.forEach((c) => query[rootType].push(c));
}
data.push(query);
filterGroups.push(query);
}
return filterGroups;
};
const advancedFilter = (filter: AdvancedFilterGroup, user: AuthUser) => {
const rootQueryType = filter.type.toUpperCase();
const rootQuery = {
[rootQueryType]: [] as any[],
};
const operatorMap = {
'!=': 'not',
'!~': 'contains',
$: 'endsWith',
'<': 'lt',
'<=': 'lte',
'=': 'equals',
'>': 'gt',
'>=': 'gte',
'^': 'startsWith',
'~': 'contains',
};
for (const rule of filter.rules) {
if (rule.field && rule.operator) {
let [table, field, relationField] = rule.field.split('.');
const condition = rule.operator === '!~' ? 'none' : 'some';
const op = operatorMap[rule.operator as keyof typeof operatorMap];
switch (table) {
case 'albums':
if (field === 'ratings') {
rootQuery[rootQueryType].push({
[field]: {
[condition]: {
[relationField]: {
[op]: rule.value,
},
userId: user.id,
},
},
});
break;
}
rootQuery[rootQueryType].push({
[field]: {
mode: 'insensitive',
[op]: rule.value,
},
});
break;
default:
if (field === 'ratings') {
rootQuery[rootQueryType].push({
[table]: {
some: {
[field]: {
some: {
[relationField]: {
[op]: rule.value,
},
userId: user.id,
},
},
},
},
});
break;
}
rootQuery[rootQueryType].push({
[table]: {
[condition]: {
[field]: {
mode: 'insensitive',
[op]: rule.value,
},
},
},
});
break;
}
}
}
const groups = advancedFilterGroup(filter.group, user, []);
for (const group of groups) {
rootQuery[rootQueryType].push(group);
}
return rootQuery;
};
export const albumHelpers = { export const albumHelpers = {
advancedFilter,
include, include,
sort, sort,
}; };
+28 -5
View File
@@ -1,7 +1,8 @@
import { Prisma } from '@prisma/client';
import { AuthUser } from '@/middleware'; import { AuthUser } from '@/middleware';
import { OffsetPagination, SortOrder } from '@/types/types'; import { OffsetPagination, SortOrder } from '@/types/types';
import { ApiError } from '@/utils'; import { ApiError } from '@/utils';
import { AlbumSort } from '@helpers/albums.helpers'; import { AdvancedFilterGroup, AlbumSort } from '@helpers/albums.helpers';
import { helpers } from '@helpers/index'; import { helpers } from '@helpers/index';
import { prisma } from '@lib/prisma'; import { prisma } from '@lib/prisma';
@@ -24,6 +25,7 @@ const findById = async (user: AuthUser, options: { id: string }) => {
}; };
export type AlbumFindManyOptions = { export type AlbumFindManyOptions = {
advancedFilters?: AdvancedFilterGroup;
orderBy: SortOrder; orderBy: SortOrder;
serverFolderId?: string[]; serverFolderId?: string[];
serverId: string; serverId: string;
@@ -32,8 +34,16 @@ export type AlbumFindManyOptions = {
} & OffsetPagination; } & OffsetPagination;
const findMany = async (options: AlbumFindManyOptions) => { const findMany = async (options: AlbumFindManyOptions) => {
const { take, serverFolderId, skip, sortBy, orderBy, user, serverId } = const {
options; take,
serverFolderId,
skip,
sortBy,
orderBy,
user,
serverId,
advancedFilters,
} = options;
const serverFolderIds = const serverFolderIds =
serverFolderId || serverFolderId ||
@@ -42,6 +52,9 @@ const findMany = async (options: AlbumFindManyOptions) => {
let totalEntries = 0; let totalEntries = 0;
let albums; let albums;
const advancedFiltersQuery =
advancedFilters && helpers.albums.advancedFilter(advancedFilters, user);
if (sortBy === AlbumSort.RATING) { if (sortBy === AlbumSort.RATING) {
const [count, result] = await prisma.$transaction([ const [count, result] = await prisma.$transaction([
prisma.albumRating.count({ prisma.albumRating.count({
@@ -92,14 +105,24 @@ const findMany = async (options: AlbumFindManyOptions) => {
} else { } else {
[totalEntries, albums] = await prisma.$transaction([ [totalEntries, albums] = await prisma.$transaction([
prisma.album.count({ prisma.album.count({
where: { OR: helpers.shared.serverFolderFilter(serverFolderIds) }, where: {
AND: [
helpers.shared.serverFolderFilter(serverFolderIds),
advancedFiltersQuery as Prisma.AlbumWhereInput,
],
},
}), }),
prisma.album.findMany({ prisma.album.findMany({
include: helpers.albums.include(user, { songs: false }), include: helpers.albums.include(user, { songs: false }),
orderBy: [helpers.albums.sort(sortBy, orderBy)], orderBy: [helpers.albums.sort(sortBy, orderBy)],
skip, skip,
take, take,
where: { OR: helpers.shared.serverFolderFilter(serverFolderIds) }, where: {
AND: [
helpers.shared.serverFolderFilter(serverFolderIds),
advancedFiltersQuery as Prisma.AlbumWhereInput,
],
},
}), }),
]); ]);
} }
+1
View File
@@ -16,6 +16,7 @@ const list = {
...serverFolderIdValidation, ...serverFolderIdValidation,
...orderByValidation, ...orderByValidation,
...serverUrlIdValidation, ...serverUrlIdValidation,
advancedFilters: z.optional(z.string()),
sortBy: z.nativeEnum(AlbumSort), sortBy: z.nativeEnum(AlbumSort),
}), }),
}; };
+1
View File
@@ -18,6 +18,7 @@ export enum AlbumSort {
} }
export type AlbumListParams = PaginationParams & { export type AlbumListParams = PaginationParams & {
advancedFilters?: string;
orderBy: SortOrder; orderBy: SortOrder;
serverFolderId?: string[]; serverFolderId?: string[];
serverUrlId?: string; serverUrlId?: string;
@@ -1,29 +1,31 @@
import { useState } from 'react';
import { Box, Stack, Group } from '@mantine/core'; import { Box, Stack, Group } from '@mantine/core';
import _ from 'lodash'; import dayjs from 'dayjs';
import get from 'lodash/get';
import set from 'lodash/set';
import { nanoid } from 'nanoid/non-secure'; import { nanoid } from 'nanoid/non-secure';
import { RiAddLine, RiMore2Line, RiSubtractLine } from 'react-icons/ri'; import { RiAddLine, RiMore2Line, RiSubtractLine } from 'react-icons/ri';
import { import {
Button, Button,
DatePicker,
DropdownMenu, DropdownMenu,
NumberInput, NumberInput,
Select, Select,
TextInput, TextInput,
} from '@/renderer/components'; } from '@/renderer/components';
enum FilterGroupType { export enum FilterGroupType {
AND = 'and', AND = 'AND',
OR = 'or', OR = 'OR',
} }
type AdvancedFilterRule = { export type AdvancedFilterRule = {
field: string; field: string | null;
operator: string; operator: string | null;
uniqueId: string; uniqueId: string;
value: string; value: string | number | Date | undefined | null | any;
}; };
type AdvancedFilterGroup = { export type AdvancedFilterGroup = {
group: AdvancedFilterGroup[]; group: AdvancedFilterGroup[];
rules: AdvancedFilterRule[]; rules: AdvancedFilterRule[];
type: FilterGroupType; type: FilterGroupType;
@@ -33,6 +35,8 @@ type AdvancedFilterGroup = {
const DATE_FILTER_OPTIONS_DATA = [ const DATE_FILTER_OPTIONS_DATA = [
{ label: 'is before', value: '<' }, { label: 'is before', value: '<' },
{ label: 'is after', value: '>' }, { label: 'is after', value: '>' },
{ label: 'is before or equal to', value: '<=' },
{ label: 'is after or equal to', value: '>=' },
]; ];
const STRING_FILTER_OPTIONS_DATA = [ const STRING_FILTER_OPTIONS_DATA = [
@@ -49,164 +53,413 @@ const NUMBER_FILTER_OPTIONS_DATA = [
{ label: 'is not', value: '!=' }, { label: 'is not', value: '!=' },
{ label: 'is greater than', value: '>' }, { label: 'is greater than', value: '>' },
{ label: 'is less than', value: '<' }, { label: 'is less than', value: '<' },
{ label: 'is greater than or equal to', value: '>=' },
{ label: 'is less than or equal to', value: '<=' },
];
const ID_FILTER_OPTIONS_DATA = [
{ label: 'is', value: 'equals' },
{ label: 'is not', value: 'not' },
]; ];
const FILTER_GROUP_OPTIONS_DATA = [ const FILTER_GROUP_OPTIONS_DATA = [
{ {
label: 'Match ALL', label: 'Match all',
value: FilterGroupType.AND, value: FilterGroupType.AND,
}, },
{ {
label: 'Match ANY', label: 'Match any',
value: FilterGroupType.OR, value: FilterGroupType.OR,
}, },
]; ];
const FILTER_OPTIONS_DATA = [ const FILTER_OPTIONS_DATA = [
{ {
label: 'Artist Title', label: 'Artist Name',
value: 'artist.title', value: 'artists.name',
}, },
{ {
label: 'Artist Rating', label: 'Artist Rating',
value: 'artist.rating', value: 'artists.ratings.value',
}, },
{ {
label: 'Artist Genre', label: 'Artist Genre',
value: 'artist.genre', value: 'artists.genre',
}, },
{ {
label: 'Album Title', label: 'Album Artist Name',
value: 'album.title', value: 'albumArtists.name',
},
{
label: 'Album Artist Rating',
value: 'albumArtists.ratings.value',
},
{
label: 'Album Artist Genre',
value: 'albumArtists.genre',
},
{
label: 'Album Name',
value: 'albums.name',
}, },
{ {
label: 'Album Genre', label: 'Album Genre',
value: 'album.genre', value: 'albums.genre',
}, },
{ {
label: 'Album Rating', label: 'Album Rating',
value: 'album.rating', value: 'albums.ratings.value',
}, },
{ {
label: 'Album Year', label: 'Album Year',
value: 'album.year', value: 'albums.year',
}, },
{ {
label: 'Album Release Date', label: 'Album Release Date',
value: 'album.releaseDate', value: 'albums.releaseDate',
}, },
{ {
label: 'Album Plays', label: 'Album Plays',
value: 'album.playCount', value: 'albums.playCount',
}, },
{ {
label: 'Album Date Added', label: 'Album Date Added',
value: 'album.dateAdded', value: 'albums.dateAdded',
}, },
{ {
label: 'Track Title', label: 'Track Name',
value: 'track.title', value: 'songs.name',
}, },
{ {
label: 'Track Plays', label: 'Track Plays',
value: 'track.plays', value: 'songs.playCount',
}, },
{ {
label: 'Track Rating', label: 'Track Rating',
value: 'track.rating', value: 'songs.ratings.value',
}, },
]; ];
const OPTIONS_MAP = { const OPTIONS_MAP = {
'album.dateAdded': { 'albumArtists.genre': {
type: 'id',
},
'albumArtists.name': {
type: 'string',
},
'albumArtists.ratings.value': {
type: 'number',
},
'albums.dateAdded': {
type: 'date', type: 'date',
}, },
'album.favorite': { 'albums.favorite': {
type: 'boolean', type: 'boolean',
}, },
'album.genre': { 'albums.genre': {
type: 'id',
},
'albums.name': {
type: 'string', type: 'string',
}, },
'album.playCount': { 'albums.playCount': {
type: 'number', type: 'number',
}, },
'album.rating': { 'albums.ratings.value': {
type: 'number', type: 'number',
}, },
'album.releaseDate': { 'albums.releaseDate': {
type: 'date', type: 'date',
}, },
'album.title': { 'albums.year': {
type: 'string',
},
'album.year': {
type: 'number', type: 'number',
}, },
'artist.genre': { 'artists.genre': {
type: 'id',
},
'artists.name': {
type: 'string', type: 'string',
}, },
'artist.rating': { 'artists.ratings.value': {
type: 'number', type: 'number',
}, },
'artist.title': { 'songs.name': {
type: 'string', type: 'string',
}, },
'track.plays': { 'songs.playCount': {
type: 'number', type: 'number',
}, },
'track.rating': { 'songs.ratings.value': {
type: 'number', type: 'number',
}, },
'track.title': {
type: 'string',
},
}; };
const FilterOption = ({ level, onDeleteRule, uniqueId, groupIndex }: any) => { interface FilterOptionProps {
const [selectedOption, setSelectedOption] = useState< data: AdvancedFilterRule;
string | null | typeof OPTIONS_MAP groupIndex: number[];
>(FILTER_OPTIONS_DATA[0].value); level: number;
onChangeField: (args: any) => void;
onChangeOperator: (args: any) => void;
onChangeValue: (args: any) => void;
onDeleteRule: (args: DeleteArgs) => void;
}
const FilterOption = ({
data,
level,
onDeleteRule,
groupIndex,
onChangeField,
onChangeOperator,
onChangeValue,
}: FilterOptionProps) => {
const { field, operator, uniqueId, value } = data;
const handleDeleteRule = () => { const handleDeleteRule = () => {
onDeleteRule({ groupIndex, level, uniqueId }); onDeleteRule({ groupIndex, level, uniqueId });
}; };
const filterMap = { const handleChangeField = (e: any) => {
date: <Select data={DATE_FILTER_OPTIONS_DATA} size="xs" width={150} />, onChangeField({ groupIndex, level, uniqueId, value: e });
number: <Select data={NUMBER_FILTER_OPTIONS_DATA} size="xs" width={150} />,
string: <Select data={STRING_FILTER_OPTIONS_DATA} size="xs" width={150} />,
}; };
const filterInputMap = { const handleChangeOperator = (e: any) => {
'album.dateAdded': <TextInput size="xs" width={150} />, onChangeOperator({ groupIndex, level, uniqueId, value: e });
'album.genre': <Select searchable data={['hello']} size="xs" width={150} />, };
'album.playCount': <NumberInput size="xs" width={150} />,
'album.rating': <NumberInput size="xs" width={150} />, const handleChangeValue = (e: any) => {
'album.releaseDate': <TextInput size="xs" width={150} />, const isDirectValue =
'album.title': <TextInput size="xs" width={150} />, typeof e === 'string' ||
'album.year': <NumberInput size="xs" width={150} />, typeof e === 'number' ||
'artist.genre': <Select searchable data={[]} size="xs" width={150} />, typeof e === 'undefined' ||
'artist.rating': <NumberInput size="xs" width={150} />, typeof e === null;
'artist.title': <TextInput size="xs" width={150} />,
'track.plays': <NumberInput size="xs" width={150} />, if (isDirectValue) {
'track.rating': <NumberInput size="xs" width={150} />, return onChangeValue({
'track.title': <TextInput size="xs" width={150} />, groupIndex,
level,
uniqueId,
value: e,
});
}
return onChangeValue({
groupIndex,
level,
uniqueId,
value: e.currentTarget.value,
});
};
const filterOperatorMap = {
date: (
<Select
data={DATE_FILTER_OPTIONS_DATA}
size="xs"
value={operator}
width={150}
onChange={handleChangeOperator}
/>
),
id: (
<Select
data={ID_FILTER_OPTIONS_DATA}
size="xs"
value={operator}
width={150}
onChange={handleChangeOperator}
/>
),
number: (
<Select
data={NUMBER_FILTER_OPTIONS_DATA}
size="xs"
value={operator}
width={150}
onChange={handleChangeOperator}
/>
),
string: (
<Select
data={STRING_FILTER_OPTIONS_DATA}
size="xs"
value={operator}
width={150}
onChange={handleChangeOperator}
/>
),
};
const filterInputValueMap = {
'albumArtists.genre': (
<Select
searchable
data={[]}
size="xs"
value={value}
width={150}
onChange={handleChangeValue}
/>
),
'albumArtists.name': (
<TextInput
size="xs"
value={value}
width={150}
onChange={handleChangeValue}
/>
),
'albumArtists.ratings.value': (
<NumberInput
max={5}
min={0}
size="xs"
value={value}
width={150}
onChange={handleChangeValue}
/>
),
'albums.dateAdded': (
<DatePicker
initialLevel="year"
maxDate={dayjs(new Date()).year(3000).toDate()}
minDate={dayjs(new Date()).year(1950).toDate()}
size="xs"
value={value}
width={150}
onChange={handleChangeValue}
/>
),
'albums.genre': (
<Select
searchable
data={[]}
size="xs"
value={value}
width={150}
onChange={handleChangeValue}
/>
),
'albums.name': (
<TextInput
size="xs"
value={value}
width={150}
onChange={handleChangeValue}
/>
),
'albums.playCount': (
<NumberInput
min={0}
size="xs"
value={value}
width={150}
onChange={(e) => handleChangeValue(e)}
/>
),
'albums.ratings.value': (
<NumberInput
max={5}
min={0}
size="xs"
value={value}
width={150}
onChange={handleChangeValue}
/>
),
'albums.releaseDate': (
<DatePicker
initialLevel="year"
maxDate={dayjs(new Date()).year(3000).toDate()}
minDate={dayjs(new Date()).year(1950).toDate()}
size="xs"
value={value}
width={150}
onChange={handleChangeValue}
/>
),
'albums.year': (
<NumberInput
min={0}
size="xs"
value={value}
width={150}
onChange={handleChangeValue}
/>
),
'artists.genre': (
<Select
searchable
data={[]}
size="xs"
value={value}
width={150}
onChange={handleChangeValue}
/>
),
'artists.name': (
<TextInput
size="xs"
value={value}
width={150}
onChange={handleChangeValue}
/>
),
'artists.ratings.value': (
<NumberInput
max={5}
min={0}
size="xs"
value={value}
width={150}
onChange={handleChangeValue}
/>
),
'songs.name': (
<TextInput size="xs" width={150} onChange={handleChangeValue} />
),
'songs.playCount': (
<NumberInput
min={0}
size="xs"
value={value}
width={150}
onChange={handleChangeValue}
/>
),
'songs.ratings.value': (
<NumberInput
max={5}
min={0}
size="xs"
value={value}
width={150}
onChange={handleChangeValue}
/>
),
}; };
return ( return (
<Group ml={level === 0 ? '10px' : `${level * 10}px`}> <Group ml={`${(level + 1) * 10}px`}>
<Select <Select
data={FILTER_OPTIONS_DATA} data={FILTER_OPTIONS_DATA}
size="xs" size="xs"
onChange={setSelectedOption} value={field}
onChange={handleChangeField}
/> />
{selectedOption && {field ? (
filterMap[ filterOperatorMap[
OPTIONS_MAP[selectedOption as keyof typeof OPTIONS_MAP] OPTIONS_MAP[field as keyof typeof OPTIONS_MAP]
.type as keyof typeof filterMap .type as keyof typeof filterOperatorMap
]} ]
{selectedOption && ) : (
filterInputMap[selectedOption as keyof typeof filterInputMap]} <TextInput disabled size="xs" width={150} />
)}
{field ? (
filterInputValueMap[field as keyof typeof filterInputValueMap]
) : (
<TextInput disabled size="xs" width={150} />
)}
<Button <Button
px={5} px={5}
size="xs" size="xs"
@@ -237,6 +490,10 @@ interface FilterGroupProps {
level: number; level: number;
onAddRule: (args: AddArgs) => void; onAddRule: (args: AddArgs) => void;
onAddRuleGroup: (args: AddArgs) => void; onAddRuleGroup: (args: AddArgs) => void;
onChangeField: (args: any) => void;
onChangeOperator: (args: any) => void;
onChangeType: (args: any) => void;
onChangeValue: (args: any) => void;
onDeleteRule: (args: DeleteArgs) => void; onDeleteRule: (args: DeleteArgs) => void;
onDeleteRuleGroup: (args: DeleteArgs) => void; onDeleteRuleGroup: (args: DeleteArgs) => void;
uniqueId: string; uniqueId: string;
@@ -249,6 +506,10 @@ const FilterGroup = ({
onDeleteRuleGroup, onDeleteRuleGroup,
onDeleteRule, onDeleteRule,
onAddRuleGroup, onAddRuleGroup,
onChangeType,
onChangeField,
onChangeOperator,
onChangeValue,
groupIndex, groupIndex,
uniqueId, uniqueId,
}: FilterGroupProps) => { }: FilterGroupProps) => {
@@ -264,13 +525,18 @@ const FilterGroup = ({
onDeleteRuleGroup({ groupIndex, level, uniqueId }); onDeleteRuleGroup({ groupIndex, level, uniqueId });
}; };
const handleChangeType = (value: string | null) => {
onChangeType({ groupIndex, level, value });
};
return ( return (
<Stack ml={`${level * 10}px`}> <Stack ml={`${level * 10}px`}>
<Group> <Group>
<Select <Select
data={FILTER_GROUP_OPTIONS_DATA} data={FILTER_GROUP_OPTIONS_DATA}
defaultValue={FILTER_GROUP_OPTIONS_DATA[0].value}
size="xs" size="xs"
value={data.type}
onChange={handleChangeType}
/> />
<Button <Button
px={5} px={5}
@@ -300,16 +566,16 @@ const FilterGroup = ({
</DropdownMenu> </DropdownMenu>
</Group> </Group>
{data.rules.map((rule: AdvancedFilterRule) => ( {data.rules.map((rule: AdvancedFilterRule) => (
<> <FilterOption
<FilterOption key={rule.uniqueId}
key={rule.uniqueId} data={rule}
groupIndex={groupIndex || []} groupIndex={groupIndex || []}
level={level} level={level}
uniqueId={rule.uniqueId} onChangeField={onChangeField}
onAddRule={handleAddRule} onChangeOperator={onChangeOperator}
onDeleteRule={onDeleteRule} onChangeValue={onChangeValue}
/> onDeleteRule={onDeleteRule}
</> />
))} ))}
{data.group && ( {data.group && (
<> <>
@@ -322,6 +588,10 @@ const FilterGroup = ({
uniqueId={group.uniqueId} uniqueId={group.uniqueId}
onAddRule={onAddRule} onAddRule={onAddRule}
onAddRuleGroup={onAddRuleGroup} onAddRuleGroup={onAddRuleGroup}
onChangeField={onChangeField}
onChangeOperator={onChangeOperator}
onChangeType={onChangeType}
onChangeValue={onChangeValue}
onDeleteRule={onDeleteRule} onDeleteRule={onDeleteRule}
onDeleteRuleGroup={onDeleteRuleGroup} onDeleteRuleGroup={onDeleteRuleGroup}
/> />
@@ -332,14 +602,7 @@ const FilterGroup = ({
); );
}; };
export const AdvancedFilters = () => { export const AdvancedFilters = ({ filters, setFilters }: any) => {
const [filters, setFilters] = useState<AdvancedFilterGroup>({
group: [],
rules: [],
type: FilterGroupType.AND,
uniqueId: nanoid(),
});
const handleAddRuleGroup = (args: AddArgs) => { const handleAddRuleGroup = (args: AddArgs) => {
const { level, groupIndex } = args; const { level, groupIndex } = args;
const filtersCopy = { ...filters }; const filtersCopy = { ...filters };
@@ -356,12 +619,19 @@ export const AdvancedFilters = () => {
}; };
const path = getPath(level); const path = getPath(level);
const updatedFilters = _.set(filtersCopy, path, [ const updatedFilters = set(filtersCopy, path, [
..._.get(filtersCopy, path), ...get(filtersCopy, path),
{ {
group: [], group: [],
rules: [], rules: [
type: 'push', {
field: undefined,
operator: undefined,
uniqueId: nanoid(),
value: undefined,
},
],
type: FilterGroupType.AND,
uniqueId: nanoid(), uniqueId: nanoid(),
}, },
]); ]);
@@ -390,8 +660,8 @@ export const AdvancedFilters = () => {
const path = getPath(level); const path = getPath(level);
const updatedFilters = _.set(filtersCopy, path, [ const updatedFilters = set(filtersCopy, path, [
..._.get(filtersCopy, path).filter( ...get(filtersCopy, path).filter(
(group: AdvancedFilterGroup) => group.uniqueId !== uniqueId (group: AdvancedFilterGroup) => group.uniqueId !== uniqueId
), ),
]); ]);
@@ -399,25 +669,30 @@ export const AdvancedFilters = () => {
setFilters(updatedFilters); setFilters(updatedFilters);
}; };
const getRulePath = (level: number, groupIndex: number[]) => {
if (level === 0) return 'rules';
const str = [];
for (const index of groupIndex) {
str.push(`group[${index}]`);
}
return `${str.join('.')}.rules`;
};
const handleAddRule = (args: AddArgs) => { const handleAddRule = (args: AddArgs) => {
const { level, groupIndex } = args; const { level, groupIndex } = args;
const filtersCopy = { ...filters }; const filtersCopy = { ...filters };
const getPath = (level: number) => { const path = getRulePath(level, groupIndex);
if (level === 0) return 'rules'; const updatedFilters = set(filtersCopy, path, [
...get(filtersCopy, path),
const str = []; {
for (const index of groupIndex) { field: null,
str.push(`group[${index}]`); operator: null,
} uniqueId: nanoid(),
value: null,
return `${str.join('.')}.rules`; },
};
const path = getPath(level);
const updatedFilters = _.set(filtersCopy, path, [
..._.get(filtersCopy, path),
{ filter: 'newrule', uniqueId: nanoid() },
]); ]);
setFilters(updatedFilters); setFilters(updatedFilters);
@@ -427,22 +702,11 @@ export const AdvancedFilters = () => {
const { uniqueId, level, groupIndex } = args; const { uniqueId, level, groupIndex } = args;
const filtersCopy = { ...filters }; const filtersCopy = { ...filters };
const getPath = (level: number) => { const path = getRulePath(level, groupIndex);
if (level === 0) return 'rules'; const updatedFilters = set(
const str = [];
for (const index of groupIndex) {
str.push(`group[${index}]`);
}
return `${str.join('.')}.rules`;
};
const path = getPath(level);
const updatedFilters = _.set(
filtersCopy, filtersCopy,
path, path,
_.get(filtersCopy, path).filter( get(filtersCopy, path).filter(
(rule: AdvancedFilterRule) => rule.uniqueId !== uniqueId (rule: AdvancedFilterRule) => rule.uniqueId !== uniqueId
) )
); );
@@ -450,6 +714,95 @@ export const AdvancedFilters = () => {
setFilters(updatedFilters); setFilters(updatedFilters);
}; };
const handleChangeField = (args: any) => {
const { uniqueId, level, groupIndex, value } = args;
const filtersCopy = { ...filters };
const path = getRulePath(level, groupIndex);
const updatedFilters = set(
filtersCopy,
path,
get(filtersCopy, path).map((rule: AdvancedFilterRule) => {
if (rule.uniqueId !== uniqueId) return rule;
return {
...rule,
field: value,
operator: null,
value: null,
};
})
);
setFilters(updatedFilters);
};
const handleChangeType = (args: any) => {
const { level, groupIndex, value } = args;
const filtersCopy = { ...filters };
if (level === 0) {
return setFilters({ ...filtersCopy, type: value });
}
const getTypePath = () => {
const str = [];
for (let i = 0; i < groupIndex.length; i += 1) {
str.push(`group[${groupIndex[i]}]`);
}
return `${str.join('.')}`;
};
const path = getTypePath();
const updatedFilters = set(filtersCopy, path, {
...get(filtersCopy, path),
type: value,
});
return setFilters(updatedFilters);
};
const handleChangeOperator = (args: any) => {
const { uniqueId, level, groupIndex, value } = args;
const filtersCopy = { ...filters };
const path = getRulePath(level, groupIndex);
const updatedFilters = set(
filtersCopy,
path,
get(filtersCopy, path).map((rule: AdvancedFilterRule) => {
if (rule.uniqueId !== uniqueId) return rule;
return {
...rule,
operator: value,
};
})
);
setFilters(updatedFilters);
};
const handleChangeValue = (args: any) => {
const { uniqueId, level, groupIndex, value } = args;
const filtersCopy = { ...filters };
const path = getRulePath(level, groupIndex);
const updatedFilters = set(
filtersCopy,
path,
get(filtersCopy, path).map((rule: AdvancedFilterRule) => {
if (rule.uniqueId !== uniqueId) return rule;
return {
...rule,
value,
};
})
);
setFilters(updatedFilters);
};
return ( return (
<Box m={10}> <Box m={10}>
<FilterGroup <FilterGroup
@@ -459,6 +812,10 @@ export const AdvancedFilters = () => {
uniqueId={filters.uniqueId} uniqueId={filters.uniqueId}
onAddRule={handleAddRule} onAddRule={handleAddRule}
onAddRuleGroup={handleAddRuleGroup} onAddRuleGroup={handleAddRuleGroup}
onChangeField={handleChangeField}
onChangeOperator={handleChangeOperator}
onChangeType={handleChangeType}
onChangeValue={handleChangeValue}
onDeleteRule={handleDeleteRule} onDeleteRule={handleDeleteRule}
onDeleteRuleGroup={handleDeleteRuleGroup} onDeleteRuleGroup={handleDeleteRuleGroup}
/> />
@@ -1,8 +1,9 @@
/* eslint-disable no-plusplus */ /* eslint-disable no-plusplus */
import { useState, useCallback, useMemo } from 'react'; import { useState, useCallback, useMemo } from 'react';
import { Group, Checkbox } from '@mantine/core'; import { Group, Checkbox } from '@mantine/core';
import { useSetState } from '@mantine/hooks'; import { useDebouncedValue, useSetState } from '@mantine/hooks';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { nanoid } from 'nanoid';
import { RiArrowDownSLine } from 'react-icons/ri'; import { RiArrowDownSLine } from 'react-icons/ri';
import AutoSizer from 'react-virtualized-auto-sizer'; import AutoSizer from 'react-virtualized-auto-sizer';
import { api } from '@/renderer/api'; import { api } from '@/renderer/api';
@@ -17,6 +18,11 @@ import {
VirtualGridContainer, VirtualGridContainer,
VirtualInfiniteGrid, VirtualInfiniteGrid,
} from '@/renderer/components'; } from '@/renderer/components';
import {
AdvancedFilterGroup,
AdvancedFilters,
FilterGroupType,
} from '@/renderer/features/albums/components/advanced-filters';
import { useAlbumList } from '@/renderer/features/albums/queries/use-album-list'; import { useAlbumList } from '@/renderer/features/albums/queries/use-album-list';
import { useServerList } from '@/renderer/features/servers'; import { useServerList } from '@/renderer/features/servers';
import { AnimatedPage, useServerCredential } from '@/renderer/features/shared'; import { AnimatedPage, useServerCredential } from '@/renderer/features/shared';
@@ -60,12 +66,23 @@ export const AlbumListRoute = () => {
sortBy: AlbumSort.NAME, sortBy: AlbumSort.NAME,
}); });
const [advancedFilters, setAdvancedFilters] = useState<AdvancedFilterGroup>({
group: [],
rules: [{ field: null, operator: null, uniqueId: nanoid(), value: null }],
type: FilterGroupType.AND,
uniqueId: nanoid(),
});
const [debouncedFilters] = useDebouncedValue(advancedFilters, 300);
const encoded = encodeURI(JSON.stringify(debouncedFilters));
const serverFolders = useMemo(() => { const serverFolders = useMemo(() => {
const server = servers?.data.find((server) => server.id === serverId); const server = servers?.data.find((server) => server.id === serverId);
return server?.serverFolders; return server?.serverFolders;
}, [serverId, servers]); }, [serverId, servers]);
const { data: albums } = useAlbumList({ const { data: albums } = useAlbumList({
advancedFilters: encoded,
orderBy: filters.orderBy, orderBy: filters.orderBy,
serverFolderId: filters.serverFolderId, serverFolderId: filters.serverFolderId,
skip: 0, skip: 0,
@@ -76,9 +93,17 @@ export const AlbumListRoute = () => {
const fetch = useCallback( const fetch = useCallback(
async ({ skip, take }) => { async ({ skip, take }) => {
const albums = await queryClient.fetchQuery( const albums = await queryClient.fetchQuery(
queryKeys.albums.list(serverId, { skip, take, ...filters }), queryKeys.albums.list(serverId, {
skip,
take,
...filters,
advancedFilters: encoded,
}),
async () => async () =>
api.albums.getAlbumList({ serverId }, { skip, take, ...filters }) api.albums.getAlbumList(
{ serverId },
{ skip, take, ...filters, advancedFilters: encoded }
)
); );
// * Adds server token // * Adds server token
@@ -95,7 +120,7 @@ export const AlbumListRoute = () => {
return albums; return albums;
}, },
[filters, isImageTokenRequired, queryClient, serverId, serverToken] [encoded, filters, isImageTokenRequired, queryClient, serverId, serverToken]
); );
return ( return (
@@ -171,12 +196,18 @@ export const AlbumListRoute = () => {
</DropdownMenu.Dropdown> </DropdownMenu.Dropdown>
</DropdownMenu> </DropdownMenu>
</Group> </Group>
<ViewTypeButton <Group position="right">
handler={setViewType} <ViewTypeButton
menuProps={{ position: 'bottom-end' }} handler={setViewType}
type={viewType} menuProps={{ position: 'bottom-end' }}
/> type={viewType}
/>
</Group>
</Group> </Group>
<AdvancedFilters
filters={advancedFilters}
setFilters={setAdvancedFilters}
/>
<VirtualGridAutoSizerContainer> <VirtualGridAutoSizerContainer>
<AutoSizer> <AutoSizer>
{({ height, width }) => ( {({ height, width }) => (
@@ -201,7 +232,7 @@ export const AlbumListRoute = () => {
itemGap={20} itemGap={20}
itemSize={200} itemSize={200}
itemType={LibraryItem.ALBUM} itemType={LibraryItem.ALBUM}
minimumBatchSize={100} minimumBatchSize={40}
width={width} width={width}
/> />
)} )}