optimize query builder

This commit is contained in:
jeffvli
2025-11-29 06:50:28 -08:00
parent bb1705a774
commit 5e12a666e3
4 changed files with 593 additions and 555 deletions
@@ -117,11 +117,10 @@ export const QueryBuilder = ({
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
<Select <Select
data={FILTER_GROUP_OPTIONS_DATA} data={FILTER_GROUP_OPTIONS_DATA}
maxWidth={250} maxWidth={170}
onChange={handleChangeType} onChange={handleChangeType}
size="sm" size="sm"
value={data.type} value={data.type}
width={200}
/> />
<ActionIcon icon="add" onClick={handleAddRule} size="sm" variant="subtle" /> <ActionIcon icon="add" onClick={handleAddRule} size="sm" variant="subtle" />
<DropdownMenu position="bottom-start"> <DropdownMenu position="bottom-start">
@@ -39,7 +39,7 @@ export const PlaylistDetailSongListHeader = ({
return ( return (
<Stack gap={0}> <Stack gap={0}>
<PageHeader> <PageHeader>
<LibraryHeaderBar> <LibraryHeaderBar ignoreMaxWidth>
<LibraryHeaderBar.PlayButton <LibraryHeaderBar.PlayButton
ids={[playlistId]} ids={[playlistId]}
itemType={LibraryItem.PLAYLIST} itemType={LibraryItem.PLAYLIST}
@@ -4,7 +4,15 @@ import clone from 'lodash/clone';
import get from 'lodash/get'; import get from 'lodash/get';
import setWith from 'lodash/setWith'; import setWith from 'lodash/setWith';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { forwardRef, Ref, useImperativeHandle, useMemo, useState } from 'react'; import {
forwardRef,
Ref,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { QueryBuilder } from '/@/renderer/components/query-builder'; import { QueryBuilder } from '/@/renderer/components/query-builder';
@@ -53,7 +61,7 @@ type SortEntry = {
order: 'asc' | 'desc'; order: 'asc' | 'desc';
}; };
const DEFAULT_QUERY = { const DEFAULT_QUERY: QueryBuilderGroup = {
group: [], group: [],
rules: [ rules: [
{ {
@@ -63,68 +71,38 @@ const DEFAULT_QUERY = {
value: '', value: '',
}, },
], ],
type: 'all' as 'all' | 'any', type: 'all',
uniqueId: nanoid(), uniqueId: nanoid(),
}; };
export type PlaylistQueryBuilderRef = { // Utility functions for path building
getFilters: () => { const getGroupPath = (level: number, groupIndex: number[]): string => {
extraFilters: { if (level === 0) return 'group';
limit?: number; return `${groupIndex.map((idx) => `group[${idx}]`).join('.')}.group`;
sortBy?: string[];
sortOrder?: string;
};
filters: QueryBuilderGroup;
};
}; };
export const PlaylistQueryBuilder = forwardRef( const getTypePath = (groupIndex: number[]): string => {
( return groupIndex.map((idx) => `group[${idx}]`).join('.');
{ limit, playlistId, query, sortBy, sortOrder }: PlaylistQueryBuilderProps, };
ref: Ref<PlaylistQueryBuilderRef>,
) => {
const { t } = useTranslation();
const server = useCurrentServer();
const [filters, setFilters] = useState<QueryBuilderGroup>(
query ? convertNDQueryToQueryGroup(query) : DEFAULT_QUERY,
);
const { data: playlists } = useQuery( const getRulePath = (level: number, groupIndex: number[]): string => {
playlistsQueries.list({ if (level === 0) return 'rules';
query: { sortBy: PlaylistListSort.NAME, sortOrder: SortOrder.ASC, startIndex: 0 }, return `${groupIndex.map((idx) => `group[${idx}]`).join('.')}.rules`;
serverId: server?.id, };
}),
);
const playlistData = useMemo(() => {
if (!playlists) return [];
return playlists.items
.filter((p) => {
if (!playlistId) return true;
return p.id !== playlistId;
})
.map((p) => ({
label: p.name,
value: p.id,
}));
}, [playlistId, playlists]);
// Parse sortBy and sortOrder into array of sort entries // Parse sortBy and sortOrder into array of sort entries
// Handle new syntax: comma-separated fields with +/- prefix (e.g., "+album,-year") const parseSortEntries = (
// Or old syntax: sortBy array + single sortOrder sortBy: SongListSort | SongListSort[],
const parseSortEntries = (): SortEntry[] => { sortOrder: 'asc' | 'desc',
): SortEntry[] => {
if (Array.isArray(sortBy) && sortBy.length > 0) { if (Array.isArray(sortBy) && sortBy.length > 0) {
const firstSort = sortBy[0]; const firstSort = sortBy[0];
// Check if first entry is a string with commas (new syntax as single string) // Check if first entry is a string with commas (new syntax as single string)
if (typeof firstSort === 'string' && firstSort.includes(',')) { if (typeof firstSort === 'string' && firstSort.includes(',')) {
// Split the comma-separated string and parse each field
return firstSort.split(',').map((s) => { return firstSort.split(',').map((s) => {
const trimmed = s.trim(); const trimmed = s.trim();
const field = const field =
trimmed.startsWith('+') || trimmed.startsWith('-') trimmed.startsWith('+') || trimmed.startsWith('-') ? trimmed.slice(1) : trimmed;
? trimmed.slice(1)
: trimmed;
const order = trimmed.startsWith('-') ? 'desc' : 'asc'; const order = trimmed.startsWith('-') ? 'desc' : 'asc';
return { field, order }; return { field, order };
}); });
@@ -149,9 +127,7 @@ export const PlaylistQueryBuilder = forwardRef(
return sortBy.split(',').map((s) => { return sortBy.split(',').map((s) => {
const trimmed = s.trim(); const trimmed = s.trim();
const field = const field =
trimmed.startsWith('+') || trimmed.startsWith('-') trimmed.startsWith('+') || trimmed.startsWith('-') ? trimmed.slice(1) : trimmed;
? trimmed.slice(1)
: trimmed;
const order = trimmed.startsWith('-') ? 'desc' : 'asc'; const order = trimmed.startsWith('-') ? 'desc' : 'asc';
return { field, order }; return { field, order };
}); });
@@ -163,17 +139,10 @@ export const PlaylistQueryBuilder = forwardRef(
return [{ field: 'dateAdded', order: 'asc' }]; return [{ field: 'dateAdded', order: 'asc' }];
}; };
const extraFiltersForm = useForm({
initialValues: {
limit,
sortEntries: parseSortEntries(),
},
});
// Convert sort entries to new syntax: comma-separated with +/- prefix // Convert sort entries to new syntax: comma-separated with +/- prefix
const convertSortEntriesToSortString = (entries: SortEntry[]): string => { const convertSortEntriesToSortString = (entries: SortEntry[]): string => {
return entries return entries
.filter((entry) => entry.field) // Filter out empty fields .filter((entry) => entry.field)
.map((entry) => { .map((entry) => {
const prefix = entry.order === 'desc' ? '-' : '+'; const prefix = entry.order === 'desc' ? '-' : '+';
return `${prefix}${entry.field}`; return `${prefix}${entry.field}`;
@@ -181,7 +150,74 @@ export const PlaylistQueryBuilder = forwardRef(
.join(','); .join(',');
}; };
useImperativeHandle(ref, () => ({ export type PlaylistQueryBuilderRef = {
getFilters: () => {
extraFilters: {
limit?: number;
sortBy?: string[];
sortOrder?: string;
};
filters: QueryBuilderGroup;
};
};
export const PlaylistQueryBuilder = forwardRef(
(
{ limit, playlistId, query, sortBy, sortOrder }: PlaylistQueryBuilderProps,
ref: Ref<PlaylistQueryBuilderRef>,
) => {
const { t } = useTranslation();
const server = useCurrentServer();
// Memoize initial filters to avoid recalculation
const initialFilters = useMemo(
() => (query ? convertNDQueryToQueryGroup(query) : DEFAULT_QUERY),
[query],
);
const [filters, setFilters] = useState<QueryBuilderGroup>(initialFilters);
// Update filters when query changes
useEffect(() => {
if (query) {
setFilters(convertNDQueryToQueryGroup(query));
}
}, [query]);
const { data: playlists } = useQuery(
playlistsQueries.list({
query: { sortBy: PlaylistListSort.NAME, sortOrder: SortOrder.ASC, startIndex: 0 },
serverId: server?.id,
}),
);
const playlistData = useMemo(() => {
if (!playlists) return [];
return playlists.items
.filter((p) => !playlistId || p.id !== playlistId)
.map((p) => ({
label: p.name,
value: p.id,
}));
}, [playlistId, playlists]);
// Memoize parsed sort entries
const initialSortEntries = useMemo(
() => parseSortEntries(sortBy, sortOrder),
[sortBy, sortOrder],
);
const extraFiltersForm = useForm({
initialValues: {
limit,
sortEntries: initialSortEntries,
},
});
useImperativeHandle(
ref,
() => ({
getFilters: () => { getFilters: () => {
const sortString = convertSortEntriesToSortString( const sortString = convertSortEntriesToSortString(
extraFiltersForm.values.sortEntries, extraFiltersForm.values.sortEntries,
@@ -190,50 +226,33 @@ export const PlaylistQueryBuilder = forwardRef(
extraFilters: { extraFilters: {
limit: extraFiltersForm.values.limit, limit: extraFiltersForm.values.limit,
sortBy: sortString ? [sortString] : undefined, sortBy: sortString ? [sortString] : undefined,
// sortOrder is now optional and embedded in sortBy
}, },
filters, filters,
}; };
}, },
})); }),
[extraFiltersForm.values.sortEntries, extraFiltersForm.values.limit, filters],
);
const handleResetFilters = () => { const handleResetFilters = useCallback(() => {
if (query) { setFilters(query ? convertNDQueryToQueryGroup(query) : DEFAULT_QUERY);
setFilters(convertNDQueryToQueryGroup(query)); }, [query]);
} else {
const handleClearFilters = useCallback(() => {
setFilters(DEFAULT_QUERY); setFilters(DEFAULT_QUERY);
} }, []);
};
const handleClearFilters = () => { const handleAddRuleGroup = useCallback((args: AddArgs) => {
setFilters(DEFAULT_QUERY);
};
const setFilterHandler = (newFilters: QueryBuilderGroup) => {
setFilters(newFilters);
};
const handleAddRuleGroup = (args: AddArgs) => {
const { groupIndex, level } = args; const { groupIndex, level } = args;
const filtersCopy = clone(filters); const path = getGroupPath(level, groupIndex);
const getPath = (level: number) => { setFilters((prev) => {
if (level === 0) return 'group'; const currentGroups = get(prev, path) || [];
return setWith(
const str: string[] = []; clone(prev),
for (const index of groupIndex) {
str.push(`group[${index}]`);
}
return `${str.join('.')}.group`;
};
const path = getPath(level);
const updatedFilters = setWith(
filtersCopy,
path, path,
[ [
...get(filtersCopy, path), ...currentGroups,
{ {
group: [], group: [],
rules: [ rules: [
@@ -250,66 +269,35 @@ export const PlaylistQueryBuilder = forwardRef(
], ],
clone, clone,
); );
});
}, []);
setFilterHandler(updatedFilters); const handleDeleteRuleGroup = useCallback((args: DeleteArgs) => {
};
const handleDeleteRuleGroup = (args: DeleteArgs) => {
const { groupIndex, level, uniqueId } = args; const { groupIndex, level, uniqueId } = args;
const filtersCopy = clone(filters); const path = level === 0 ? 'group' : getTypePath(groupIndex);
const getPath = (level: number) => { setFilters((prev) => {
if (level === 0) return 'group'; const currentGroups = get(prev, path) || [];
return setWith(
const str: string[] = []; clone(prev),
for (let i = 0; i < groupIndex.length; i += 1) {
if (i !== groupIndex.length - 1) {
str.push(`group[${groupIndex[i]}]`);
} else {
str.push(`group`);
}
}
return `${str.join('.')}`;
};
const path = getPath(level);
const updatedFilters = setWith(
filtersCopy,
path, path,
[ currentGroups.filter((group: QueryBuilderGroup) => group.uniqueId !== uniqueId),
...get(filtersCopy, path).filter(
(group: QueryBuilderGroup) => group.uniqueId !== uniqueId,
),
],
clone, clone,
); );
});
}, []);
setFilterHandler(updatedFilters); const handleAddRule = useCallback((args: AddArgs) => {
};
const getRulePath = (level: number, groupIndex: number[]) => {
if (level === 0) return 'rules';
const str: string[] = [];
for (const index of groupIndex) {
str.push(`group[${index}]`);
}
return `${str.join('.')}.rules`;
};
const handleAddRule = (args: AddArgs) => {
const { groupIndex, level } = args; const { groupIndex, level } = args;
const filtersCopy = clone(filters);
const path = getRulePath(level, groupIndex); const path = getRulePath(level, groupIndex);
const updatedFilters = setWith(
filtersCopy, setFilters((prev) => {
const currentRules = get(prev, path) || [];
return setWith(
clone(prev),
path, path,
[ [
...get(filtersCopy, path), ...currentRules,
{ {
field: '', field: '',
operator: '', operator: '',
@@ -319,36 +307,34 @@ export const PlaylistQueryBuilder = forwardRef(
], ],
clone, clone,
); );
});
}, []);
setFilterHandler(updatedFilters); const handleDeleteRule = useCallback((args: DeleteArgs) => {
};
const handleDeleteRule = (args: DeleteArgs) => {
const { groupIndex, level, uniqueId } = args; const { groupIndex, level, uniqueId } = args;
const filtersCopy = clone(filters);
const path = getRulePath(level, groupIndex); const path = getRulePath(level, groupIndex);
const updatedFilters = setWith(
filtersCopy, setFilters((prev) => {
const currentRules = get(prev, path) || [];
return setWith(
clone(prev),
path, path,
get(filtersCopy, path).filter( currentRules.filter((rule: QueryBuilderRule) => rule.uniqueId !== uniqueId),
(rule: QueryBuilderRule) => rule.uniqueId !== uniqueId,
),
clone, clone,
); );
});
}, []);
setFilterHandler(updatedFilters); const handleChangeField = useCallback((args: any) => {
};
const handleChangeField = (args: any) => {
const { groupIndex, level, uniqueId, value } = args; const { groupIndex, level, uniqueId, value } = args;
const filtersCopy = clone(filters);
const path = getRulePath(level, groupIndex); const path = getRulePath(level, groupIndex);
const updatedFilters = setWith(
filtersCopy, setFilters((prev) => {
const currentRules = get(prev, path) || [];
return setWith(
clone(prev),
path, path,
get(filtersCopy, path).map((rule: QueryBuilderGroup) => { currentRules.map((rule: QueryBuilderRule) => {
if (rule.uniqueId !== uniqueId) return rule; if (rule.uniqueId !== uniqueId) return rule;
return { return {
...rule, ...rule,
@@ -359,51 +345,41 @@ export const PlaylistQueryBuilder = forwardRef(
}), }),
clone, clone,
); );
});
}, []);
setFilterHandler(updatedFilters); const handleChangeType = useCallback((args: any) => {
};
const handleChangeType = (args: any) => {
const { groupIndex, level, value } = args; const { groupIndex, level, value } = args;
const filtersCopy = clone(filters);
if (level === 0) { if (level === 0) {
return setFilterHandler({ ...filtersCopy, type: value }); setFilters((prev) => ({ ...prev, type: value }));
return;
} }
const getTypePath = () => { const path = getTypePath(groupIndex);
const str: string[] = []; setFilters((prev) =>
for (let i = 0; i < groupIndex.length; i += 1) { setWith(
str.push(`group[${groupIndex[i]}]`); clone(prev),
}
return `${str.join('.')}`;
};
const path = getTypePath();
const updatedFilters = setWith(
filtersCopy,
path, path,
{ {
...get(filtersCopy, path), ...get(prev, path),
type: value, type: value,
}, },
clone, clone,
),
); );
}, []);
return setFilterHandler(updatedFilters); const handleChangeOperator = useCallback((args: any) => {
};
const handleChangeOperator = (args: any) => {
const { groupIndex, level, uniqueId, value } = args; const { groupIndex, level, uniqueId, value } = args;
const filtersCopy = clone(filters);
const path = getRulePath(level, groupIndex); const path = getRulePath(level, groupIndex);
const updatedFilters = setWith(
filtersCopy, setFilters((prev) => {
const currentRules = get(prev, path) || [];
return setWith(
clone(prev),
path, path,
get(filtersCopy, path).map((rule: QueryBuilderRule) => { currentRules.map((rule: QueryBuilderRule) => {
if (rule.uniqueId !== uniqueId) return rule; if (rule.uniqueId !== uniqueId) return rule;
return { return {
...rule, ...rule,
@@ -412,19 +388,19 @@ export const PlaylistQueryBuilder = forwardRef(
}), }),
clone, clone,
); );
});
}, []);
setFilterHandler(updatedFilters); const handleChangeValue = useCallback((args: any) => {
};
const handleChangeValue = (args: any) => {
const { groupIndex, level, uniqueId, value } = args; const { groupIndex, level, uniqueId, value } = args;
const filtersCopy = clone(filters);
const path = getRulePath(level, groupIndex); const path = getRulePath(level, groupIndex);
const updatedFilters = setWith(
filtersCopy, setFilters((prev) => {
const currentRules = get(prev, path) || [];
return setWith(
clone(prev),
path, path,
get(filtersCopy, path).map((rule: QueryBuilderRule) => { currentRules.map((rule: QueryBuilderRule) => {
if (rule.uniqueId !== uniqueId) return rule; if (rule.uniqueId !== uniqueId) return rule;
return { return {
...rule, ...rule,
@@ -433,39 +409,78 @@ export const PlaylistQueryBuilder = forwardRef(
}), }),
clone, clone,
); );
});
}, []);
setFilterHandler(updatedFilters); // Memoize sort options
}; const sortOptions = useMemo(
() => [
const sortOptions = [
{ {
label: t('filter.random', { postProcess: 'titleCase' }), label: t('filter.random', { postProcess: 'titleCase' }),
type: 'string', type: 'string',
value: 'random', value: 'random',
}, },
...NDSongQueryFields, ...NDSongQueryFields,
]; ],
[t],
);
const handleAddSortEntry = () => { // Memoize order select data
const orderSelectData = useMemo(
() => [
{
label: t('common.ascending', { postProcess: 'sentenceCase' }),
value: 'asc',
},
{
label: t('common.descending', { postProcess: 'sentenceCase' }),
value: 'desc',
},
],
[t],
);
// Memoize operators object
const operators = useMemo(
() => ({
boolean: NDSongQueryBooleanOperators,
date: NDSongQueryDateOperators,
number: NDSongQueryNumberOperators,
playlist: NDSongQueryPlaylistOperators,
string: NDSongQueryStringOperators,
}),
[],
);
const handleAddSortEntry = useCallback(() => {
extraFiltersForm.insertListItem('sortEntries', { field: '', order: 'asc' }); extraFiltersForm.insertListItem('sortEntries', { field: '', order: 'asc' });
}; }, [extraFiltersForm]);
const handleRemoveSortEntry = (index: number) => { const handleRemoveSortEntry = useCallback(
(index: number) => {
extraFiltersForm.removeListItem('sortEntries', index); extraFiltersForm.removeListItem('sortEntries', index);
}; },
[extraFiltersForm],
);
const handleSortFieldChange = (index: number, value: string) => { const handleSortFieldChange = useCallback(
(index: number, value: string) => {
extraFiltersForm.setFieldValue(`sortEntries.${index}.field`, value); extraFiltersForm.setFieldValue(`sortEntries.${index}.field`, value);
}; },
[extraFiltersForm],
);
const handleSortOrderChange = (index: number, value: 'asc' | 'desc') => { const handleSortOrderChange = useCallback(
(index: number, value: 'asc' | 'desc') => {
extraFiltersForm.setFieldValue(`sortEntries.${index}.order`, value); extraFiltersForm.setFieldValue(`sortEntries.${index}.order`, value);
}; },
[extraFiltersForm],
);
return ( return (
<Flex direction="column" h="calc(100% - 2rem)" justify="space-between"> <Flex direction="column" h="100%" w="100%">
<ScrollArea> <ScrollArea style={{ height: '100%' }}>
<Stack gap="md" p="1rem"> <Stack gap="md" h="100%" p="1rem">
<QueryBuilder <QueryBuilder
data={filters} data={filters}
filters={NDSongQueryFields} filters={NDSongQueryFields}
@@ -481,13 +496,7 @@ export const PlaylistQueryBuilder = forwardRef(
onDeleteRule={handleDeleteRule} onDeleteRule={handleDeleteRule}
onDeleteRuleGroup={handleDeleteRuleGroup} onDeleteRuleGroup={handleDeleteRuleGroup}
onResetFilters={handleResetFilters} onResetFilters={handleResetFilters}
operators={{ operators={operators}
boolean: NDSongQueryBooleanOperators,
date: NDSongQueryDateOperators,
number: NDSongQueryNumberOperators,
playlist: NDSongQueryPlaylistOperators,
string: NDSongQueryStringOperators,
}}
playlists={playlistData} playlists={playlistData}
uniqueId={filters.uniqueId} uniqueId={filters.uniqueId}
/> />
@@ -510,20 +519,7 @@ export const PlaylistQueryBuilder = forwardRef(
width={200} width={200}
/> />
<Select <Select
data={[ data={orderSelectData}
{
label: t('common.ascending', {
postProcess: 'sentenceCase',
}),
value: 'asc',
},
{
label: t('common.descending', {
postProcess: 'sentenceCase',
}),
value: 'desc',
},
]}
label={ label={
index === 0 index === 0
? t('common.sortOrder', { ? t('common.sortOrder', {
@@ -1,7 +1,7 @@
import { closeAllModals, openModal } from '@mantine/modals'; import { closeAllModals, openModal } from '@mantine/modals';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { generatePath, useNavigate, useParams } from 'react-router'; import { generatePath, useNavigate, useParams } from 'react-router';
@@ -19,21 +19,228 @@ import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/dele
import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils'; import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils';
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page'; import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import { JsonPreview } from '/@/renderer/features/shared/components/json-preview'; import { JsonPreview } from '/@/renderer/features/shared/components/json-preview';
import { LibraryContainer } from '/@/renderer/features/shared/components/library-container';
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary'; import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store'; import { useCurrentServer } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Box } from '/@/shared/components/box/box';
import { Button } from '/@/shared/components/button/button'; import { Button } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon'; import { Icon } from '/@/shared/components/icon/icon';
import { ConfirmModal } from '/@/shared/components/modal/modal'; import { ConfirmModal } from '/@/shared/components/modal/modal';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { ServerType } from '/@/shared/types/domain-types'; import { ServerType, SongListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
interface PlaylistQueryEditorProps {
createPlaylistMutation: ReturnType<typeof useCreatePlaylist>;
detailQuery: ReturnType<typeof useQuery<any>>;
handleSave: (
filter: Record<string, any>,
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },
) => void;
handleSaveAs: (
filter: Record<string, any>,
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },
) => void;
isQueryBuilderExpanded: boolean;
onToggleExpand: () => void;
playlistId: string;
queryBuilderRef: React.RefObject<null | PlaylistQueryBuilderRef>;
}
const PlaylistQueryEditor = ({
createPlaylistMutation,
detailQuery,
handleSave,
handleSaveAs,
isQueryBuilderExpanded,
onToggleExpand,
playlistId,
queryBuilderRef,
}: PlaylistQueryEditorProps) => {
const { t } = useTranslation();
const openPreviewModal = useCallback(() => {
if (!isQueryBuilderExpanded) {
return;
}
const filters = queryBuilderRef.current?.getFilters();
if (!filters) {
return;
}
const queryValue = convertQueryGroupToNDQuery(filters.filters);
const sortString = filters.extraFilters.sortBy?.[0];
const previewValue = {
...queryValue,
...(filters.extraFilters.limit && { limit: filters.extraFilters.limit }),
...(sortString && { sort: sortString }),
};
openModal({
children: <JsonPreview value={previewValue} />,
size: 'xl',
title: t('common.preview', { postProcess: 'titleCase' }),
});
}, [isQueryBuilderExpanded, queryBuilderRef, t]);
const openSaveAndReplaceModal = useCallback(() => {
if (!isQueryBuilderExpanded) {
return;
}
const filters = queryBuilderRef.current?.getFilters();
if (!filters) {
return;
}
openModal({
children: (
<ConfirmModal
onConfirm={() => {
handleSave(
convertQueryGroupToNDQuery(filters.filters),
filters.extraFilters,
);
closeAllModals();
}}
>
<Text>{t('common.areYouSure', { postProcess: 'sentenceCase' })}</Text>
</ConfirmModal>
),
title: t('common.saveAndReplace', { postProcess: 'sentenceCase' }),
});
}, [isQueryBuilderExpanded, queryBuilderRef, handleSave, t]);
const parseSortBy = useCallback((): string[] => {
const sort = detailQuery?.data?.rules?.sort;
// Handle new syntax: comma-separated with +/- prefix
// e.g., "+album,-year" -> return as single string in array
if (typeof sort === 'string') {
// Check if it's new syntax (has +/- prefix or commas)
if (sort.includes(',') || sort.startsWith('+') || sort.startsWith('-')) {
return [sort];
}
// Old syntax: single field, convert to new format with default order
const order = detailQuery?.data?.rules?.order || 'asc';
const prefix = order === 'desc' ? '-' : '+';
return [`${prefix}${sort}`];
}
if (Array.isArray(sort)) {
// If array, check if first item has +/- prefix
if (
sort.length > 0 &&
typeof sort[0] === 'string' &&
(sort[0].startsWith('+') || sort[0].startsWith('-'))
) {
return sort;
}
// Old array format, convert to new format
const order = detailQuery?.data?.rules?.order || 'asc';
const prefix = order === 'desc' ? '-' : '+';
return sort.map((s) => `${prefix}${s}`);
}
return ['+dateAdded'];
}, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]);
const parseSortOrder = useCallback((): 'asc' | 'desc' => {
const sort = detailQuery?.data?.rules?.sort;
if (typeof sort === 'string' && sort.startsWith('-')) {
return 'desc';
}
// Fall back to old order field or default
return detailQuery?.data?.rules?.order || 'asc';
}, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]);
return (
<motion.div>
<Stack gap={0} h="100%" mah="50dvh" p="md" w="100%">
<Group justify="space-between" pb="md" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ActionIcon
icon={isQueryBuilderExpanded ? 'arrowUpS' : 'arrowDownS'}
iconProps={{
size: 'md',
}}
onClick={onToggleExpand}
size="xs"
/>
<Text>
{t('form.queryEditor.title', {
postProcess: 'titleCase',
})}
</Text>
</Group>
<Group gap="xs">
<Button
disabled={!isQueryBuilderExpanded}
onClick={openPreviewModal}
size="sm"
variant="subtle"
>
{t('common.preview', { postProcess: 'titleCase' })}
</Button>
<Button
disabled={!isQueryBuilderExpanded}
leftSection={<Icon icon="save" />}
loading={createPlaylistMutation?.isPending}
onClick={() => {
if (!isQueryBuilderExpanded) return;
const filters = queryBuilderRef.current?.getFilters();
if (filters) {
handleSaveAs(
convertQueryGroupToNDQuery(filters.filters),
filters.extraFilters,
);
}
}}
size="sm"
variant="subtle"
>
{t('common.saveAs', { postProcess: 'titleCase' })}
</Button>
<Button
disabled={!isQueryBuilderExpanded}
leftSection={<Icon color="error" icon="save" />}
onClick={openSaveAndReplaceModal}
size="sm"
variant="subtle"
>
{t('common.saveAndReplace', {
postProcess: 'titleCase',
})}
</Button>
</Group>
</Group>
<div
style={{
display: isQueryBuilderExpanded ? 'flex' : 'none',
flex: 1,
minHeight: 0,
overflow: 'hidden',
}}
>
<PlaylistQueryBuilder
key={JSON.stringify(detailQuery?.data?.rules)}
limit={detailQuery?.data?.rules?.limit}
playlistId={playlistId}
query={detailQuery?.data?.rules}
ref={queryBuilderRef}
sortBy={parseSortBy() as SongListSort | SongListSort[]}
sortOrder={parseSortOrder()}
/>
</div>
</Stack>
</motion.div>
);
};
const PlaylistDetailSongListRoute = () => { const PlaylistDetailSongListRoute = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -186,35 +393,6 @@ const PlaylistDetailSongListRoute = () => {
}); });
}; };
const openSaveAndReplaceModal = () => {
if (!isQueryBuilderExpanded) {
return;
}
const filters = queryBuilderRef.current?.getFilters();
if (!filters) {
return;
}
openModal({
children: (
<ConfirmModal
onConfirm={() => {
handleSave(
convertQueryGroupToNDQuery(filters.filters),
filters.extraFilters,
);
closeAllModals();
}}
>
<Text>{t('common.areYouSure', { postProcess: 'sentenceCase' })}</Text>
</ConfirmModal>
),
title: t('common.saveAndReplace', { postProcess: 'sentenceCase' }),
});
};
const isSmartPlaylist = const isSmartPlaylist =
!detailQuery?.isLoading && !detailQuery?.isLoading &&
detailQuery?.data?.rules && detailQuery?.data?.rules &&
@@ -233,34 +411,6 @@ const PlaylistDetailSongListRoute = () => {
setIsQueryBuilderExpanded(true); setIsQueryBuilderExpanded(true);
}; };
const openPreviewModal = () => {
if (!isQueryBuilderExpanded) return;
const filters = queryBuilderRef.current?.getFilters();
if (!filters) {
toast.error({
message:
t('error.queryBuilderNotReady', { postProcess: 'sentenceCase' }) ||
'Query builder is not ready. Please expand it first.',
});
return;
}
const queryValue = convertQueryGroupToNDQuery(filters.filters);
const sortString = filters.extraFilters.sortBy?.[0];
const previewValue = {
...queryValue,
...(filters.extraFilters.limit && { limit: filters.extraFilters.limit }),
...(sortString && { sort: sortString }),
};
openModal({
children: <JsonPreview value={previewValue} />,
size: 'xl',
title: t('common.preview', { postProcess: 'titleCase' }),
});
};
const playlistSongs = useQuery( const playlistSongs = useQuery(
playlistsQueries.songList({ playlistsQueries.songList({
query: { query: {
@@ -294,7 +444,6 @@ const PlaylistDetailSongListRoute = () => {
return ( return (
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}> <AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
<ListContext.Provider value={providerValue}> <ListContext.Provider value={providerValue}>
<LibraryContainer>
<PlaylistDetailSongListHeader <PlaylistDetailSongListHeader
isSmartPlaylist={!!isSmartPlaylist} isSmartPlaylist={!!isSmartPlaylist}
onConvertToSmart={() => { onConvertToSmart={() => {
@@ -307,126 +456,20 @@ const PlaylistDetailSongListRoute = () => {
onToggleQueryBuilder={handleToggleShowQueryBuilder} onToggleQueryBuilder={handleToggleShowQueryBuilder}
/> />
{(isSmartPlaylist || showQueryBuilder) && ( {(isSmartPlaylist || showQueryBuilder) && (
<motion.div> <AnimatePresence>
<Box h="100%" mah="50dvh" p="md" w="100%"> <PlaylistQueryEditor
<Group justify="space-between" pb="md" wrap="nowrap"> createPlaylistMutation={createPlaylistMutation}
<Group gap="sm" wrap="nowrap"> detailQuery={detailQuery}
<ActionIcon handleSave={handleSave}
icon={ handleSaveAs={handleSaveAs}
isQueryBuilderExpanded ? 'arrowUpS' : 'arrowDownS' isQueryBuilderExpanded={isQueryBuilderExpanded}
} onToggleExpand={handleToggleExpand}
iconProps={{
size: 'md',
}}
onClick={handleToggleExpand}
size="xs"
/>
<Text>
{t('form.queryEditor.title', {
postProcess: 'titleCase',
})}
</Text>
</Group>
<Group gap="xs">
<Button
disabled={!isQueryBuilderExpanded}
onClick={openPreviewModal}
size="sm"
variant="subtle"
>
{t('common.preview', { postProcess: 'titleCase' })}
</Button>
<Button
disabled={!isQueryBuilderExpanded}
leftSection={<Icon icon="save" />}
loading={createPlaylistMutation?.isPending}
onClick={() => {
if (!isQueryBuilderExpanded) return;
const filters =
queryBuilderRef.current?.getFilters();
if (filters) {
handleSaveAs(
convertQueryGroupToNDQuery(filters.filters),
filters.extraFilters,
);
}
}}
size="sm"
>
{t('common.saveAs', { postProcess: 'titleCase' })}
</Button>
<Button
disabled={!isQueryBuilderExpanded}
leftSection={<Icon color="error" icon="save" />}
onClick={openSaveAndReplaceModal}
size="sm"
variant="default"
>
{t('common.saveAndReplace', {
postProcess: 'titleCase',
})}
</Button>
</Group>
</Group>
<div style={{ display: isQueryBuilderExpanded ? 'block' : 'none' }}>
<PlaylistQueryBuilder
key={JSON.stringify(detailQuery?.data?.rules)}
limit={detailQuery?.data?.rules?.limit}
playlistId={playlistId} playlistId={playlistId}
query={detailQuery?.data?.rules} queryBuilderRef={queryBuilderRef}
ref={queryBuilderRef}
sortBy={(() => {
const sort = detailQuery?.data?.rules?.sort;
// Handle new syntax: comma-separated with +/- prefix
// e.g., "+album,-year" -> return as single string in array
if (typeof sort === 'string') {
// Check if it's new syntax (has +/- prefix or commas)
if (
sort.includes(',') ||
sort.startsWith('+') ||
sort.startsWith('-')
) {
return [sort];
}
// Old syntax: single field, convert to new format with default order
const order =
detailQuery?.data?.rules?.order || 'asc';
const prefix = order === 'desc' ? '-' : '+';
return [`${prefix}${sort}`];
}
if (Array.isArray(sort)) {
// If array, check if first item has +/- prefix
if (
sort.length > 0 &&
typeof sort[0] === 'string' &&
(sort[0].startsWith('+') ||
sort[0].startsWith('-'))
) {
return sort;
}
// Old array format, convert to new format
const order =
detailQuery?.data?.rules?.order || 'asc';
const prefix = order === 'desc' ? '-' : '+';
return sort.map((s) => `${prefix}${s}`);
}
return ['+dateAdded'];
})()}
sortOrder={(() => {
const sort = detailQuery?.data?.rules?.sort;
if (typeof sort === 'string' && sort.startsWith('-')) {
return 'desc';
}
// Fall back to old order field or default
return detailQuery?.data?.rules?.order || 'asc';
})()}
/> />
</div> </AnimatePresence>
</Box>
</motion.div>
)} )}
<PlaylistDetailSongListContent /> <PlaylistDetailSongListContent />
</LibraryContainer>
</ListContext.Provider> </ListContext.Provider>
</AnimatedPage> </AnimatedPage>
); );