Add initial nd smart playlist ui

This commit is contained in:
jeffvli 2023-01-04 15:54:25 -08:00
parent 65974dbf28
commit 75ef43dffb
6 changed files with 604 additions and 407 deletions

View file

@ -339,3 +339,40 @@ export type NDPlaylistSongList = {
startIndex: number; startIndex: number;
totalRecordCount: number; totalRecordCount: number;
}; };
export const NDSongQueryFields = [
{ label: 'Title', value: 'title' },
{ label: 'Album', value: 'album' },
{ label: 'Artist', value: 'artist' },
{ label: 'Album artist', value: 'albumartist' },
{ label: 'Has cover art', value: 'hascoverart' },
{ label: 'Track number', value: 'tracknumber' },
{ label: 'Disc number', value: 'discnumber' },
{ label: 'Year', value: 'year' },
{ label: 'Size', value: 'size' },
{ label: 'Is compilation', value: 'compilation' },
{ label: 'Date added', value: 'dateadded' },
{ label: 'Date modified', value: 'datemodified' },
{ label: 'Disc subtitle', value: 'discsubtitle' },
{ label: 'Comment', value: 'comment' },
{ label: 'Lyrics', value: 'lyrics' },
{ label: 'Sort title', value: 'sorttitle' },
{ label: 'Sort album', value: 'sortalbum' },
{ label: 'Sort artist', value: 'sortartist' },
{ label: 'Sort album artist', value: 'sortalbumartist' },
{ label: 'Album type', value: 'albumtype' },
{ label: 'Album comment', value: 'albumcomment' },
{ label: 'Catalog number', value: 'catalognumber' },
{ label: 'File path', value: 'filepath' },
{ label: 'File type', value: 'filetype' },
{ label: 'Duration', value: 'duration' },
{ label: 'Bitrate', value: 'bitrate' },
{ label: 'BPM', value: 'bpm' },
{ label: 'Channels', value: 'channels' },
{ label: 'Genre', value: 'genre' },
{ label: 'Is favorite', value: 'loved' },
{ label: 'Date favorited', value: 'dateloved' },
{ label: 'Last played', value: 'lastplayed' },
{ label: 'Play count', value: 'playcount' },
{ label: 'Rating', value: 'rating' },
];

View file

@ -1,25 +1,11 @@
import { Group, Stack } from '@mantine/core'; import { Group, Stack } from '@mantine/core';
import { Select } from '/@/renderer/components/select'; import { Select } from '/@/renderer/components/select';
import { FilterGroupType } from '/@/renderer/types';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { RiAddLine, RiMore2Line } from 'react-icons/ri'; import { RiAddLine, RiMore2Line } from 'react-icons/ri';
import { Button } from '/@/renderer/components/button'; import { Button } from '/@/renderer/components/button';
import { DropdownMenu } from '/@/renderer/components/dropdown-menu'; import { DropdownMenu } from '/@/renderer/components/dropdown-menu';
import { QueryBuilderOption } from '/@/renderer/components/query-builder/query-builder-option'; import { QueryBuilderOption } from '/@/renderer/components/query-builder/query-builder-option';
import { QueryBuilderGroup, QueryBuilderRule } from '/@/renderer/types';
export type AdvancedFilterGroup = {
children: AdvancedFilterGroup[];
rules: AdvancedFilterRule[];
type: FilterGroupType;
uniqueId: string;
};
export type AdvancedFilterRule = {
field?: string | null;
operator?: string | null;
uniqueId: string;
value?: string | number | Date | undefined | null | any;
};
const FILTER_GROUP_OPTIONS_DATA = [ const FILTER_GROUP_OPTIONS_DATA = [
{ {
@ -32,58 +18,16 @@ const FILTER_GROUP_OPTIONS_DATA = [
}, },
]; ];
// const queryJson = [
// {
// any: [{ is: { loved: true } }, { gt: { rating: 3 } }],
// },
// { inTheRange: { year: [1981, 1990] } },
// ];
// const parseQuery = (query: Record<string, any>[]) => {
// // for (const ruleset in query) {
// // // console.log('key', key);
// // // console.log('query[key]', query[key]);
// // // console.log('Object.keys(query[key])', Object.keys(query[key]));
// // // console.log('Object.values(query[key])', Object.values(query[key]));
// // // console.log('Object.entries(query[key])', Object.entries(query[key]));
// // const keys = Object.keys(query[ruleset]);
// // }
// const res = flatMapDeep(query, flatten);
// console.log('res', res);
// return res;
// };
// const OperatorSelect = ({ value, onChange }: any) => {
// const handleChange = (e: any) => {
// onChange(e);
// };
// return (
// <Select
// data={operators}
// label="Operator"
// value={value}
// onChange={handleChange}
// />
// );
// };
type AddArgs = { type AddArgs = {
groupIndex: number[]; groupIndex: number[];
groupValue: string;
level: number; level: number;
}; };
type DeleteArgs = { type DeleteArgs = {
groupIndex: number[]; groupIndex: number[];
groupValue: string;
level: number; level: number;
uniqueId: string; uniqueId: string;
}; };
interface QueryBuilderProps { interface QueryBuilderProps {
data: Record<string, any>; data: Record<string, any>;
filters: { label: string; value: string }[]; filters: { label: string; value: string }[];
@ -115,42 +59,36 @@ export const QueryBuilder = ({
uniqueId, uniqueId,
filters, filters,
}: QueryBuilderProps) => { }: QueryBuilderProps) => {
const groupValue = Object.keys(data)[0];
const rules = data[groupValue].filter((rule: any) => !rule.any && !rule.all);
const group = data[groupValue].filter((rule: any) => rule.any || rule.all);
const handleAddRule = () => { const handleAddRule = () => {
onAddRule({ groupIndex, groupValue, level }); onAddRule({ groupIndex, level });
}; };
const handleAddRuleGroup = () => { const handleAddRuleGroup = () => {
onAddRuleGroup({ groupIndex, groupValue, level }); onAddRuleGroup({ groupIndex, level });
}; };
const handleDeleteRuleGroup = () => { const handleDeleteRuleGroup = () => {
onDeleteRuleGroup({ groupIndex, groupValue, level, uniqueId }); onDeleteRuleGroup({ groupIndex, level, uniqueId });
}; };
const handleChangeType = (value: string | null) => { const handleChangeType = (value: string | null) => {
onChangeType({ groupIndex, level, value }); onChangeType({ groupIndex, level, value });
}; };
console.log('rules :>> ', rules);
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}
maxWidth={175} maxWidth={175}
size="xs" size="sm"
value={groupValue} value={data.type}
width="20%" width="20%"
onChange={handleChangeType} onChange={handleChangeType}
/> />
<Button <Button
px={5} px={5}
size="xs" size="sm"
tooltip={{ label: 'Add rule' }} tooltip={{ label: 'Add rule' }}
variant="default" variant="default"
onClick={handleAddRule} onClick={handleAddRule}
@ -161,7 +99,7 @@ export const QueryBuilder = ({
<DropdownMenu.Target> <DropdownMenu.Target>
<Button <Button
p={0} p={0}
size="xs" size="sm"
variant="subtle" variant="subtle"
> >
<RiMore2Line size={20} /> <RiMore2Line size={20} />
@ -181,9 +119,9 @@ export const QueryBuilder = ({
key="advanced-filter-option" key="advanced-filter-option"
initial={false} initial={false}
> >
{rules.map((rule: AdvancedFilterRule, i: number) => ( {data?.rules?.map((rule: QueryBuilderRule) => (
<motion.div <motion.div
key={`rule-${level}-${Object.keys(rule)[0]}`} key={rule.uniqueId}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -25 }} exit={{ opacity: 0, x: -25 }}
initial={{ opacity: 0, x: -25 }} initial={{ opacity: 0, x: -25 }}
@ -193,7 +131,7 @@ export const QueryBuilder = ({
data={rule} data={rule}
filters={filters} filters={filters}
groupIndex={groupIndex || []} groupIndex={groupIndex || []}
groupValue={groupValue} // groupValue={groupValue}
level={level} level={level}
noRemove={data?.rules?.length === 1} noRemove={data?.rules?.length === 1}
onChangeField={onChangeField} onChangeField={onChangeField}
@ -204,14 +142,14 @@ export const QueryBuilder = ({
</motion.div> </motion.div>
))} ))}
</AnimatePresence> </AnimatePresence>
{group && ( {data?.group && (
<AnimatePresence <AnimatePresence
key="advanced-filter-group" key="advanced-filter-group"
initial={false} initial={false}
> >
{group.map((group: AdvancedFilterGroup, index: number) => ( {data.group?.map((group: QueryBuilderGroup, index: number) => (
<motion.div <motion.div
key={`group-${level}-${index}`} key={group.uniqueId}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -25 }} exit={{ opacity: 0, x: -25 }}
initial={{ opacity: 0, x: -25 }} initial={{ opacity: 0, x: -25 }}

View file

@ -4,7 +4,7 @@ import { RiSubtractLine } from 'react-icons/ri';
import { Button } from '/@/renderer/components/button'; import { Button } from '/@/renderer/components/button';
import { TextInput } from '/@/renderer/components/input'; import { TextInput } from '/@/renderer/components/input';
import { Select } from '/@/renderer/components/select'; import { Select } from '/@/renderer/components/select';
import { AdvancedFilterRule } from '/@/renderer/types'; import { QueryBuilderRule } from '/@/renderer/types';
const operators = [ const operators = [
{ label: 'is', value: 'is' }, { label: 'is', value: 'is' },
@ -24,16 +24,15 @@ const operators = [
type DeleteArgs = { type DeleteArgs = {
groupIndex: number[]; groupIndex: number[];
groupValue: string;
level: number; level: number;
uniqueId: string; uniqueId: string;
}; };
interface QueryOptionProps { interface QueryOptionProps {
data: AdvancedFilterRule; data: QueryBuilderRule;
filters: { label: string; value: string }[]; filters: { label: string; value: string }[];
groupIndex: number[]; groupIndex: number[];
groupValue: string; // groupValue: string;
level: number; level: number;
noRemove: boolean; noRemove: boolean;
onChangeField: (args: any) => void; onChangeField: (args: any) => void;
@ -48,7 +47,6 @@ export const QueryBuilderOption = ({
level, level,
onDeleteRule, onDeleteRule,
groupIndex, groupIndex,
groupValue,
noRemove, noRemove,
onChangeField, onChangeField,
onChangeOperator, onChangeOperator,
@ -57,7 +55,7 @@ export const QueryBuilderOption = ({
const { field, operator, uniqueId, value } = data; const { field, operator, uniqueId, value } = data;
const handleDeleteRule = () => { const handleDeleteRule = () => {
onDeleteRule({ groupIndex, groupValue, level, uniqueId }); onDeleteRule({ groupIndex, level, uniqueId });
}; };
const handleChangeField = (e: any) => { const handleChangeField = (e: any) => {
@ -69,6 +67,8 @@ export const QueryBuilderOption = ({
}; };
const handleChangeValue = (e: any) => { const handleChangeValue = (e: any) => {
console.log('e', e);
const isDirectValue = const isDirectValue =
typeof e === 'string' || typeof e === 'string' ||
typeof e === 'number' || typeof e === 'number' ||
@ -330,7 +330,7 @@ export const QueryBuilderOption = ({
// ), // ),
// }; // };
const ml = (level + 1) * 10 - level * 5; const ml = (level + 1) * 10;
return ( return (
<Group ml={ml}> <Group ml={ml}>
@ -338,7 +338,7 @@ export const QueryBuilderOption = ({
searchable searchable
data={filters} data={filters}
maxWidth={175} maxWidth={175}
size="xs" size="sm"
value={field} value={field}
width="20%" width="20%"
onChange={handleChangeField} onChange={handleChangeField}
@ -346,21 +346,29 @@ export const QueryBuilderOption = ({
<Select <Select
searchable searchable
data={operators} data={operators}
// disabled={!field} disabled={!field}
maxWidth={175} maxWidth={175}
size="xs" size="sm"
value={field} value={operator}
width="20%" width="20%"
onChange={handleChangeField} onChange={handleChangeOperator}
/> />
{field ? ( {field ? (
<></> <TextInput
defaultValue={value}
maxWidth={175}
size="sm"
width="20%"
onChange={handleChangeValue}
/>
) : ( ) : (
<TextInput <TextInput
disabled disabled
defaultValue={value}
maxWidth={175} maxWidth={175}
size="xs" size="sm"
width="20%" width="20%"
onChange={handleChangeValue}
/> />
)} )}
{/* // filterOperatorMap[ // OPTIONS_MAP[field as keyof typeof OPTIONS_MAP].type as keyof typeof {/* // filterOperatorMap[ // OPTIONS_MAP[field as keyof typeof OPTIONS_MAP].type as keyof typeof
@ -368,7 +376,7 @@ export const QueryBuilderOption = ({
<Button <Button
disabled={noRemove} disabled={noRemove}
px={5} px={5}
size="xs" size="sm"
tooltip={{ label: 'Remove rule' }} tooltip={{ label: 'Remove rule' }}
variant="default" variant="default"
onClick={handleDeleteRule} onClick={handleDeleteRule}

View file

@ -1,69 +1,43 @@
import { useState, useImperativeHandle, forwardRef } from 'react'; import { useState, useImperativeHandle, forwardRef } from 'react';
import { uniqueId } from 'lodash'; import { Flex, Group, ScrollArea } from '@mantine/core';
import clone from 'lodash/clone'; import clone from 'lodash/clone';
import setWith from 'lodash/setWith';
import get from 'lodash/get'; import get from 'lodash/get';
import sortBy from 'lodash/sortBy'; import setWith from 'lodash/setWith';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { NDSongQueryFields } from '/@/renderer/api/navidrome.types'; import { NDSongQueryFields } from '/@/renderer/api/navidrome.types';
import { AdvancedFilterGroup, AdvancedFilterRule, QueryBuilder } from '/@/renderer/components'; import { Button, DropdownMenu, NumberInput, QueryBuilder } from '/@/renderer/components';
import { FilterGroupType } from '/@/renderer/types'; import {
convertNDQueryToQueryGroup,
convertQueryGroupToNDQuery,
} from '/@/renderer/features/playlists/utils';
import { QueryBuilderGroup, QueryBuilderRule } from '/@/renderer/types';
import { RiMore2Fill } from 'react-icons/ri';
type AddArgs = { type AddArgs = {
groupIndex: number[]; groupIndex: number[];
groupValue: string;
level: number; level: number;
}; };
type DeleteArgs = { type DeleteArgs = {
groupIndex: number[]; groupIndex: number[];
groupValue: string;
level: number; level: number;
uniqueId: string; uniqueId: string;
}; };
const sortQuery = (query: any) => { interface PlaylistQueryBuilderProps {
let b; onSave: (parsedFilter: any) => void;
onSaveAs: (parsedFilter: any) => void;
if (query.all) { query: any;
b = sortBy(query.all, (item) => {
const key = Object.keys(item)[0];
return key === 'all' || key === 'any' ? 0 : 1;
});
} else {
b = sortBy(query.any, (item) => {
const key = Object.keys(item)[0];
return key === 'all' || key === 'any' ? 0 : 1;
});
} }
return { all: b }; export const PlaylistQueryBuilder = forwardRef(
}; ({ query, onSave, onSaveAs }: PlaylistQueryBuilderProps, ref) => {
const addUniqueId = (query: any) => {
const queryCopy = clone(query);
const addId = (item: any) => {
const key = Object.keys(item)[0];
if (key === 'all' || key === 'any') {
item[key].forEach(addId);
} else {
item[key].uniqueId = nanoid();
}
};
addId(queryCopy);
return queryCopy;
};
export const PlaylistQueryBuilder = forwardRef(({ query, onChange }: any, ref) => {
const [filters, setFilters] = useState<any>( const [filters, setFilters] = useState<any>(
sortQuery(addUniqueId(query)) || { convertNDQueryToQueryGroup(query) || {
all: [], all: [],
}, },
); );
console.log('filters :>> ', JSON.stringify(filters));
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
reset() { reset() {
setFilters({ setFilters({
@ -72,33 +46,54 @@ export const PlaylistQueryBuilder = forwardRef(({ query, onChange }: any, ref) =
}, },
})); }));
const setFilterHandler = (newFilters: AdvancedFilterGroup) => { const setFilterHandler = (newFilters: QueryBuilderGroup) => {
setFilters(newFilters); setFilters(newFilters);
onChange(newFilters); // onSave(newFilters);
};
const handleSave = () => {
onSave(convertQueryGroupToNDQuery(filters));
};
const handleSaveAs = () => {
onSaveAs(convertQueryGroupToNDQuery(filters));
}; };
const handleAddRuleGroup = (args: AddArgs) => { const handleAddRuleGroup = (args: AddArgs) => {
const { level, groupIndex, groupValue } = args; const { level, groupIndex } = args;
const filtersCopy = clone(filters); const filtersCopy = clone(filters);
const getPath = (level: number) => { const getPath = (level: number) => {
const rootKey = Object.keys(filters)[0]; if (level === 0) return 'group';
if (level === 0) return rootKey;
const str = [rootKey]; const str = [];
for (const index of groupIndex) { for (const index of groupIndex) {
str.push(`[${index}].${groupValue}`); str.push(`group[${index}]`);
} }
return `${str.join('.')}`; return `${str.join('.')}.group`;
}; };
const path = getPath(level); const path = getPath(level);
console.log('path', filtersCopy, path);
const updatedFilters = setWith( const updatedFilters = setWith(
filtersCopy, filtersCopy,
path, path,
sortQuery([...get(filtersCopy, path), { any: [{ contains: { title: '' } }] }]), [
...get(filtersCopy, path),
{
group: [],
rules: [
{
field: '',
operator: '',
uniqueId: nanoid(),
value: '',
},
],
type: 'any',
uniqueId: nanoid(),
},
],
clone, clone,
); );
@ -106,26 +101,18 @@ export const PlaylistQueryBuilder = forwardRef(({ query, onChange }: any, ref) =
}; };
const handleDeleteRuleGroup = (args: DeleteArgs) => { const handleDeleteRuleGroup = (args: DeleteArgs) => {
const { uniqueId, level, groupIndex, groupValue } = args; const { uniqueId, level, groupIndex } = args;
const filtersCopy = clone(filters); const filtersCopy = clone(filters);
const getPath = (level: number) => { const getPath = (level: number) => {
const rootKey = Object.keys(filters)[0]; if (level === 0) return 'group';
if (level === 0) return rootKey;
const str = []; const str = [];
for (let i = 0; i < groupIndex.length; i += 1) { for (let i = 0; i < groupIndex.length; i += 1) {
if (groupIndex.length === 1) { if (i !== groupIndex.length - 1) {
str.push(rootKey); str.push(`group[${groupIndex[i]}]`);
break;
}
if (i === 0) {
str.push(`${rootKey}[${groupIndex[i]}]`);
} else if (i !== groupIndex.length - 1) {
str.push(`${groupValue}[${groupIndex[i]}]`);
} else { } else {
str.push(`${groupValue}`); str.push(`group`);
} }
} }
@ -134,56 +121,48 @@ export const PlaylistQueryBuilder = forwardRef(({ query, onChange }: any, ref) =
const path = getPath(level); const path = getPath(level);
const dataAtPath = get(filtersCopy, path); const updatedFilters = setWith(
const lv = groupIndex[level - 1]; filtersCopy,
const removed = [...dataAtPath.slice(0, lv), ...dataAtPath.slice(lv + 1)]; path,
const updatedFilters = setWith(filtersCopy, path, sortQuery(removed), clone); [
...get(filtersCopy, path).filter(
(group: QueryBuilderGroup) => group.uniqueId !== uniqueId,
),
],
clone,
);
setFilterHandler(updatedFilters); setFilterHandler(updatedFilters);
}; };
const getRulePath = (level: number, groupIndex: number[], groupValue: string) => { const getRulePath = (level: number, groupIndex: number[]) => {
if (level === 0) return Object.keys(filters)[0]; if (level === 0) return 'rules';
const str = []; const str = [];
for (const index of groupIndex) { for (const index of groupIndex) {
str.push(`${Object.keys(filters)[0]}[${index}].${groupValue}`); str.push(`group[${index}]`);
} }
return `${str.join('.')}`; return `${str.join('.')}.rules`;
}; };
// const getRulePath = (
// level: number,
// groupIndex: number[],
// groupValue: string,
// uniqueId?: string,
// ) => {
// const rootKey = Object.keys(filters)[0];
// if (level === 0) return rootKey;
// const str = [];
// for (const index of groupIndex) {
// if (uniqueId) {
// str.push(`${rootKey}[${index}].${groupValue}.${uniqueId}`);
// } else {
// str.push(`${rootKey}[${index}].${groupValue}`);
// }
// }
// return `${str.join('.')}`;
// };
const handleAddRule = (args: AddArgs) => { const handleAddRule = (args: AddArgs) => {
const { level, groupValue, groupIndex } = args; const { level, groupIndex } = args;
const filtersCopy = clone(filters); const filtersCopy = clone(filters);
const path = getRulePath(level, groupIndex, groupValue); const path = getRulePath(level, groupIndex);
const updatedFilters = setWith( const updatedFilters = setWith(
filtersCopy, filtersCopy,
path, path,
[...get(filtersCopy, path), { contains: { title: '', uniqueId: nanoid() } }], [
...get(filtersCopy, path),
{
field: 'title',
operator: 'contains',
uniqueId: nanoid(),
value: null,
},
],
clone, clone,
); );
@ -191,18 +170,16 @@ export const PlaylistQueryBuilder = forwardRef(({ query, onChange }: any, ref) =
}; };
const handleDeleteRule = (args: DeleteArgs) => { const handleDeleteRule = (args: DeleteArgs) => {
const { uniqueId, level, groupIndex, groupValue } = args; const { uniqueId, level, groupIndex } = args;
const filtersCopy = clone(filters); const filtersCopy = clone(filters);
const path = getRulePath(level, groupIndex, groupValue); const path = getRulePath(level, groupIndex);
const updatedFilters = setWith(
const dataAtPath = get(filtersCopy, path); filtersCopy,
const lv = groupIndex[level - 1]; path,
const removed = [...dataAtPath.slice(0, lv), ...dataAtPath.slice(lv + 1)]; get(filtersCopy, path).filter((rule: QueryBuilderRule) => rule.uniqueId !== uniqueId),
clone,
console.log('removed :>> ', removed); );
const updatedFilters = setWith(filtersCopy, path, removed, clone);
setFilterHandler(updatedFilters); setFilterHandler(updatedFilters);
}; };
@ -212,13 +189,10 @@ export const PlaylistQueryBuilder = forwardRef(({ query, onChange }: any, ref) =
const filtersCopy = clone(filters); const filtersCopy = clone(filters);
const path = getRulePath(level, groupIndex); const path = getRulePath(level, groupIndex);
console.log('path', path);
const updatedFilters = setWith( const updatedFilters = setWith(
filtersCopy, filtersCopy,
path, path,
get(filtersCopy, path).map((rule: AdvancedFilterRule) => { get(filtersCopy, path).map((rule: QueryBuilderGroup) => {
if (rule.uniqueId !== uniqueId) return rule; if (rule.uniqueId !== uniqueId) return rule;
// const defaultOperator = FILTER_OPTIONS_DATA.find( // const defaultOperator = FILTER_OPTIONS_DATA.find(
// (option) => option.value === value, // (option) => option.value === value,
@ -227,7 +201,6 @@ export const PlaylistQueryBuilder = forwardRef(({ query, onChange }: any, ref) =
return { return {
...rule, ...rule,
field: value, field: value,
// operator: defaultOperator || '',
operator: '', operator: '',
value: '', value: '',
}; };
@ -235,9 +208,7 @@ export const PlaylistQueryBuilder = forwardRef(({ query, onChange }: any, ref) =
clone, clone,
); );
console.log('updatedFilters', updatedFilters); setFilterHandler(updatedFilters);
// setFilterHandler(updatedFilters);
}; };
const handleChangeType = (args: any) => { const handleChangeType = (args: any) => {
@ -280,7 +251,7 @@ export const PlaylistQueryBuilder = forwardRef(({ query, onChange }: any, ref) =
const updatedFilters = setWith( const updatedFilters = setWith(
filtersCopy, filtersCopy,
path, path,
get(filtersCopy, path).map((rule: AdvancedFilterRule) => { get(filtersCopy, path).map((rule: QueryBuilderRule) => {
if (rule.uniqueId !== uniqueId) return rule; if (rule.uniqueId !== uniqueId) return rule;
return { return {
...rule, ...rule,
@ -298,10 +269,11 @@ export const PlaylistQueryBuilder = forwardRef(({ query, onChange }: any, ref) =
const filtersCopy = clone(filters); const filtersCopy = clone(filters);
const path = getRulePath(level, groupIndex); const path = getRulePath(level, groupIndex);
console.log('path', path);
const updatedFilters = setWith( const updatedFilters = setWith(
filtersCopy, filtersCopy,
path, path,
get(filtersCopy, path).map((rule: AdvancedFilterRule) => { get(filtersCopy, path).map((rule: QueryBuilderRule) => {
if (rule.uniqueId !== uniqueId) return rule; if (rule.uniqueId !== uniqueId) return rule;
return { return {
...rule, ...rule,
@ -315,7 +287,12 @@ export const PlaylistQueryBuilder = forwardRef(({ query, onChange }: any, ref) =
}; };
return ( return (
<> <Flex
direction="column"
h="100%"
justify="space-between"
>
<ScrollArea h="100%">
<QueryBuilder <QueryBuilder
data={filters} data={filters}
filters={NDSongQueryFields} filters={NDSongQueryFields}
@ -331,6 +308,39 @@ export const PlaylistQueryBuilder = forwardRef(({ query, onChange }: any, ref) =
onDeleteRule={handleDeleteRule} onDeleteRule={handleDeleteRule}
onDeleteRuleGroup={handleDeleteRuleGroup} onDeleteRuleGroup={handleDeleteRuleGroup}
/> />
</> </ScrollArea>
<Group
align="flex-end"
p="1rem 1rem 0"
position="apart"
>
<NumberInput
label="Limit to"
width={75}
/>
<Group>
<Button
variant="filled"
onClick={handleSave}
>
Save
</Button>
<DropdownMenu position="bottom-end">
<DropdownMenu.Target>
<Button
p="0.5em"
variant="default"
>
<RiMore2Fill size={15} />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item onClick={handleSaveAs}>Save as</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
</Group>
</Flex>
);
},
); );
});

View file

@ -1,21 +1,116 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { useRef } from 'react'; import { useRef } from 'react';
import { useParams } from 'react-router'; import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { VirtualGridContainer } from '/@/renderer/components'; import { Stack } from '@mantine/core';
import { closeAllModals, openModal } from '@mantine/modals';
import { generatePath, useNavigate, useParams } from 'react-router';
import { PlaylistDetailSongListContent } from '../components/playlist-detail-song-list-content'; import { PlaylistDetailSongListContent } from '../components/playlist-detail-song-list-content';
import { PlaylistDetailSongListHeader } from '../components/playlist-detail-song-list-header'; import { PlaylistDetailSongListHeader } from '../components/playlist-detail-song-list-header';
import { AnimatedPage } from '/@/renderer/features/shared'; import { AnimatedPage } from '/@/renderer/features/shared';
import { PlaylistQueryBuilder } from '/@/renderer/features/playlists/components/playlist-query-builder';
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
import { AppRoute } from '/@/renderer/router/routes';
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
import { Paper, toast } from '/@/renderer/components';
import { SaveAsPlaylistForm } from '/@/renderer/features/playlists/components/save-as-playlist-form';
const PlaylistDetailSongListRoute = () => { const PlaylistDetailSongListRoute = () => {
const navigate = useNavigate();
const tableRef = useRef<AgGridReactType | null>(null); const tableRef = useRef<AgGridReactType | null>(null);
const { playlistId } = useParams() as { playlistId: string }; const { playlistId } = useParams() as { playlistId: string };
const detailQuery = usePlaylistDetail({ id: playlistId });
const createPlaylistMutation = useCreatePlaylist();
const deletePlaylistMutation = useDeletePlaylist();
const handleSave = (filter: Record<string, any>) => {
const rules = {
...filter,
order: 'desc',
sort: 'year',
};
if (!detailQuery?.data) return;
createPlaylistMutation.mutate(
{
body: {
comment: detailQuery?.data?.description || '',
name: detailQuery?.data?.name,
ndParams: {
owner: detailQuery?.data?.owner || '',
ownerId: detailQuery?.data?.ownerId || '',
public: detailQuery?.data?.public || false,
rules,
sync: detailQuery?.data?.sync || false,
},
},
},
{
onSuccess: (data) => {
toast.success({ message: 'Smart playlist saved' });
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: data?.id || '' }), {
replace: true,
});
deletePlaylistMutation.mutate({ query: { id: playlistId } });
},
},
);
};
const handleSaveAs = (filter: Record<string, any>) => {
openModal({
children: (
<SaveAsPlaylistForm
body={{
comment: detailQuery?.data?.description || '',
name: detailQuery?.data?.name,
ndParams: {
owner: detailQuery?.data?.owner || '',
ownerId: detailQuery?.data?.ownerId || '',
public: detailQuery?.data?.public || false,
rules: {
...filter,
order: 'desc',
sort: 'year',
},
sync: detailQuery?.data?.sync || false,
},
}}
onCancel={closeAllModals}
onSuccess={(data) =>
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: data?.id || '' }))
}
/>
),
title: 'Save as',
});
};
return ( return (
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}> <AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
<VirtualGridContainer> <Stack
h="100%"
spacing={0}
>
<PlaylistDetailSongListHeader tableRef={tableRef} /> <PlaylistDetailSongListHeader tableRef={tableRef} />
<Paper
sx={{
maxHeight: '35vh',
padding: '1rem',
}}
w="100%"
>
{!detailQuery?.isLoading && (
<PlaylistQueryBuilder
query={detailQuery?.data?.rules || { all: [] }}
onSave={handleSave}
onSaveAs={handleSaveAs}
/>
)}
</Paper>
<PlaylistDetailSongListContent tableRef={tableRef} /> <PlaylistDetailSongListContent tableRef={tableRef} />
</VirtualGridContainer> </Stack>
</AnimatedPage> </AnimatedPage>
); );
}; };

View file

@ -0,0 +1,109 @@
import { nanoid } from 'nanoid/non-secure';
import { QueryBuilderGroup } from '/@/renderer/types';
export const parseQueryBuilderChildren = (groups: QueryBuilderGroup[], data: any[]) => {
if (groups.length === 0) {
return data;
}
const filterGroups: any[] = [];
for (const group of groups) {
const rootType = group.type;
const query: any = {
[rootType]: [],
};
for (const rule of group.rules) {
if (rule.field && rule.operator) {
const [table, field] = rule.field.split('.');
const operator = rule.operator;
const value = field !== 'releaseDate' ? rule.value : new Date(rule.value);
switch (table) {
default:
query[rootType].push({
[operator]: {
[table]: value,
},
});
break;
}
}
}
if (group.group.length > 0) {
const b = parseQueryBuilderChildren(group.group, data);
b.forEach((c) => query[rootType].push(c));
}
data.push(query);
filterGroups.push(query);
}
return filterGroups;
};
// Convert QueryBuilderGroup to default query
export const convertQueryGroupToNDQuery = (filter: QueryBuilderGroup) => {
const rootQueryType = filter.type;
const rootQuery = {
[rootQueryType]: [] as any[],
};
for (const rule of filter.rules) {
if (rule.field && rule.operator) {
const [table] = rule.field.split('.');
const operator = rule.operator;
const value = rule.value;
switch (table) {
default:
rootQuery[rootQueryType].push({
[operator]: {
[table]: value,
},
});
break;
}
}
}
const groups = parseQueryBuilderChildren(filter.group, []);
for (const group of groups) {
rootQuery[rootQueryType].push(group);
}
return rootQuery;
};
// Convert default query to QueryBuilderGroup
export const convertNDQueryToQueryGroup = (query: Record<string, any>) => {
const rootType = Object.keys(query)[0];
const rootGroup: QueryBuilderGroup = {
group: [],
rules: [],
type: rootType as 'any' | 'all',
uniqueId: nanoid(),
};
for (const rule of query[rootType]) {
if (rule.any || rule.all) {
const group = convertNDQueryToQueryGroup(rule);
rootGroup.group.push(group);
} else {
const operator = Object.keys(rule)[0];
const field = Object.keys(rule[operator])[0];
const value = rule[operator][field];
rootGroup.rules.push({
field,
operator,
uniqueId: nanoid(),
value,
});
}
}
return rootGroup;
};