directly replace playlist rules on save and replace

This commit is contained in:
jeffvli
2026-03-07 21:18:55 -08:00
parent c1051956ad
commit 602808c742
6 changed files with 54 additions and 125 deletions
@@ -44,7 +44,13 @@ import { Modal } from '/@/shared/components/modal/modal';
import { Tooltip } from '/@/shared/components/tooltip/tooltip'; import { Tooltip } from '/@/shared/components/tooltip/tooltip';
import { useDisclosure } from '/@/shared/hooks/use-disclosure'; import { useDisclosure } from '/@/shared/hooks/use-disclosure';
import { useLocalStorage } from '/@/shared/hooks/use-local-storage'; import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
import { LibraryItem, SongListSort, SortOrder } from '/@/shared/types/domain-types'; import {
LibraryItem,
Playlist,
SongListSort,
SortOrder,
UpdatePlaylistBody,
} from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
interface PlaylistDetailSongListHeaderFiltersProps { interface PlaylistDetailSongListHeaderFiltersProps {
@@ -193,7 +199,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
<MoreButton onClick={handleMore} /> <MoreButton onClick={handleMore} />
</Group> </Group>
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
{isViewEditMode && <SaveAndReplaceButton mode={mode} />} {isViewEditMode && <SaveAndReplaceButton mode={mode} playlist={detailQuery.data} />}
{isViewEditMode && ( {isViewEditMode && (
<Button <Button
onClick={() => setMode?.(mode === 'edit' ? 'view' : 'edit')} onClick={() => setMode?.(mode === 'edit' ? 'view' : 'edit')}
@@ -242,24 +248,39 @@ export const PlaylistDetailSongListHeaderFilters = ({
); );
}; };
export const openSaveAndReplaceModal = (playlistId: string, listData: unknown[]) => { export const openSaveAndReplaceModal = (playlistId: string, updateBody: UpdatePlaylistBody) => {
openContextModal({ openContextModal({
innerProps: { listData, playlistId }, innerProps: { playlistId, updateBody },
modalKey: 'saveAndReplace', modalKey: 'saveAndReplace',
size: 'sm', size: 'sm',
title: i18n.t('common.saveAndReplace', { postProcess: 'titleCase' }) as string, title: i18n.t('common.saveAndReplace', { postProcess: 'titleCase' }) as string,
}); });
}; };
const SaveAndReplaceButton = ({ mode }: { mode: 'edit' | 'view' | undefined }) => { const SaveAndReplaceButton = ({
mode,
playlist,
}: {
mode: 'edit' | 'view' | undefined;
playlist: Playlist | undefined;
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { playlistId } = useParams() as { playlistId: string }; const { playlistId } = useParams() as { playlistId: string };
const { listData } = useListContext();
const handleOpenModal = useCallback(() => { const handleOpenModal = useCallback(() => {
if (!playlistId || !listData) return; if (!playlistId || !playlist) return;
openSaveAndReplaceModal(playlistId, listData);
}, [playlistId, listData]); const updateBody: UpdatePlaylistBody = {
comment: playlist.description ?? '',
name: playlist.name,
ownerId: playlist.ownerId ?? '',
public: playlist.public ?? false,
queryBuilderRules: playlist.rules ?? undefined,
sync: playlist.sync ?? false,
};
openSaveAndReplaceModal(playlistId, updateBody);
}, [playlistId, playlist]);
if (mode === 'view') { if (mode === 'view') {
return null; return null;
@@ -7,7 +7,7 @@ import {
PlaylistQueryBuilder, PlaylistQueryBuilder,
PlaylistQueryBuilderRef, PlaylistQueryBuilderRef,
} from '/@/renderer/features/playlists/components/playlist-query-builder'; } from '/@/renderer/features/playlists/components/playlist-query-builder';
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation'; import { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation';
import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils'; import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils';
import { JsonPreview } from '/@/renderer/features/shared/components/json-preview'; import { JsonPreview } from '/@/renderer/features/shared/components/json-preview';
import { Box } from '/@/shared/components/box/box'; import { Box } from '/@/shared/components/box/box';
@@ -25,7 +25,6 @@ import { toast } from '/@/shared/components/toast/toast';
import { SongListSort } from '/@/shared/types/domain-types'; import { SongListSort } from '/@/shared/types/domain-types';
export interface PlaylistQueryEditorProps { export interface PlaylistQueryEditorProps {
createPlaylistMutation: ReturnType<typeof useCreatePlaylist>;
detailQuery: ReturnType<typeof useQuery<any>>; detailQuery: ReturnType<typeof useQuery<any>>;
handleSave: ( handleSave: (
filter: Record<string, any>, filter: Record<string, any>,
@@ -39,6 +38,7 @@ export interface PlaylistQueryEditorProps {
onToggleExpand: () => void; onToggleExpand: () => void;
playlistId: string; playlistId: string;
queryBuilderRef: React.RefObject<null | PlaylistQueryBuilderRef>; queryBuilderRef: React.RefObject<null | PlaylistQueryBuilderRef>;
updatePlaylistMutation: ReturnType<typeof useUpdatePlaylist>;
} }
type AppliedJsonState = { type AppliedJsonState = {
@@ -77,7 +77,6 @@ const parseRulesJsonToSaveArgs = (
}; };
export const PlaylistQueryEditor = ({ export const PlaylistQueryEditor = ({
createPlaylistMutation,
detailQuery, detailQuery,
handleSave, handleSave,
handleSaveAs, handleSaveAs,
@@ -85,6 +84,7 @@ export const PlaylistQueryEditor = ({
onToggleExpand, onToggleExpand,
playlistId, playlistId,
queryBuilderRef, queryBuilderRef,
updatePlaylistMutation,
}: PlaylistQueryEditorProps) => { }: PlaylistQueryEditorProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -322,7 +322,7 @@ export const PlaylistQueryEditor = ({
<Button <Button
disabled={!isQueryBuilderExpanded} disabled={!isQueryBuilderExpanded}
leftSection={<Icon icon="save" />} leftSection={<Icon icon="save" />}
loading={createPlaylistMutation?.isPending} loading={updatePlaylistMutation?.isPending}
onClick={() => { onClick={() => {
if (!isQueryBuilderExpanded) return; if (!isQueryBuilderExpanded) return;
const payload = getFiltersForSave(); const payload = getFiltersForSave();
@@ -1,40 +1,22 @@
import { closeAllModals, ContextModalProps } from '@mantine/modals'; import { closeAllModals, ContextModalProps } from '@mantine/modals';
import { useCallback, useMemo } from 'react'; import { useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useReplacePlaylist } from '/@/renderer/features/playlists/mutations/replace-playlist-mutation'; import { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation';
import { useCurrentServerId } from '/@/renderer/store'; import { useCurrentServerId } from '/@/renderer/store';
import { ConfirmModal } from '/@/shared/components/modal/modal'; import { ConfirmModal } from '/@/shared/components/modal/modal';
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 { Song } from '/@/shared/types/domain-types'; import { UpdatePlaylistBody } from '/@/shared/types/domain-types';
export const SaveAndReplaceContextModal = ({ export const SaveAndReplaceContextModal = ({
innerProps, innerProps,
}: ContextModalProps<{ listData: unknown[]; playlistId: string }>) => { }: ContextModalProps<{ playlistId: string; updateBody: UpdatePlaylistBody }>) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { listData, playlistId } = innerProps; const { playlistId, updateBody } = innerProps;
const serverId = useCurrentServerId(); const serverId = useCurrentServerId();
const replacePlaylistMutation = useReplacePlaylist({}); const updatePlaylistMutation = useUpdatePlaylist({});
// Get current songs from list data
const currentSongIds = useMemo(() => {
if (!listData || !Array.isArray(listData)) {
return [];
}
return listData
.filter((item): item is Song => {
return (
typeof item === 'object' &&
item !== null &&
'id' in item &&
typeof (item as any).id === 'string'
);
})
.map((song) => song.id);
}, [listData]);
const handleConfirm = useCallback(() => { const handleConfirm = useCallback(() => {
if (!serverId || !playlistId) { if (!serverId || !playlistId) {
@@ -42,24 +24,11 @@ export const SaveAndReplaceContextModal = ({
return; return;
} }
if (currentSongIds.length === 0) { updatePlaylistMutation.mutate(
console.error('currentSongIds is empty');
toast.error({
message: t('error.genericError', { postProcess: 'sentenceCase' }),
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
return;
}
replacePlaylistMutation.mutate(
{ {
apiClientProps: { serverId }, apiClientProps: { serverId },
body: { body: updateBody,
songId: currentSongIds, query: { id: playlistId },
},
query: {
id: playlistId,
},
}, },
{ {
onError: (err) => { onError: (err) => {
@@ -81,10 +50,10 @@ export const SaveAndReplaceContextModal = ({
}, },
}, },
); );
}, [t, currentSongIds, serverId, playlistId, replacePlaylistMutation]); }, [t, serverId, playlistId, updateBody, updatePlaylistMutation]);
return ( return (
<ConfirmModal loading={replacePlaylistMutation.isPending} onConfirm={handleConfirm}> <ConfirmModal loading={updatePlaylistMutation.isPending} onConfirm={handleConfirm}>
<Text>{t('form.editPlaylist.editNote', { postProcess: 'sentenceCase' })}</Text> <Text>{t('form.editPlaylist.editNote', { postProcess: 'sentenceCase' })}</Text>
</ConfirmModal> </ConfirmModal>
); );
@@ -1,50 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { useRecentPlaylists } from '/@/renderer/features/playlists/hooks/use-recent-playlists';
import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { useCurrentServerId } from '/@/renderer/store';
import { ReplacePlaylistArgs, ReplacePlaylistResponse } from '/@/shared/types/domain-types';
export const useReplacePlaylist = (args: MutationHookArgs) => {
const { options } = args || {};
const queryClient = useQueryClient();
const serverId = useCurrentServerId();
const { addRecentPlaylist } = useRecentPlaylists(serverId);
return useMutation<ReplacePlaylistResponse, AxiosError, ReplacePlaylistArgs, null>({
mutationFn: (args) => {
return api.controller.replacePlaylist({
...args,
apiClientProps: { serverId: args.apiClientProps.serverId },
});
},
onSuccess: (_data, variables, context) => {
const { apiClientProps } = variables;
const serverId = apiClientProps.serverId;
if (!serverId) return;
queryClient.invalidateQueries({
exact: false,
queryKey: queryKeys.playlists.list(serverId),
});
queryClient.invalidateQueries({
queryKey: queryKeys.playlists.detail(serverId, variables.query.id),
});
queryClient.invalidateQueries({
queryKey: queryKeys.playlists.songList(serverId, variables.query.id),
});
addRecentPlaylist(variables.query.id);
options?.onSuccess?.(_data, variables, context);
},
...options,
});
};
@@ -31,6 +31,9 @@ export const useUpdatePlaylist = (args: MutationHookArgs) => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: queryKeys.playlists.detail(serverId, query.id), queryKey: queryKeys.playlists.detail(serverId, query.id),
}); });
queryClient.invalidateQueries({
queryKey: queryKeys.playlists.songList(serverId, query.id),
});
} }
}, },
...options, ...options,
@@ -13,8 +13,8 @@ import { PlaylistQueryBuilderRef } from '/@/renderer/features/playlists/componen
import { PlaylistQueryEditor } from '/@/renderer/features/playlists/components/playlist-query-editor'; import { PlaylistQueryEditor } from '/@/renderer/features/playlists/components/playlist-query-editor';
import { SaveAsPlaylistForm } from '/@/renderer/features/playlists/components/save-as-playlist-form'; import { SaveAsPlaylistForm } from '/@/renderer/features/playlists/components/save-as-playlist-form';
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters'; import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation'; import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
import { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation';
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page'; import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container'; import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary'; import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
@@ -80,8 +80,8 @@ const PlaylistDetailSongListRoute = () => {
...playlistsQueries.detail({ query: { id: playlistId }, serverId: server?.id }), ...playlistsQueries.detail({ query: { id: playlistId }, serverId: server?.id }),
placeholderData: location.state?.item, placeholderData: location.state?.item,
}); });
const createPlaylistMutation = useCreatePlaylist({});
const deletePlaylistMutation = useDeletePlaylist({}); const deletePlaylistMutation = useDeletePlaylist({});
const updatePlaylistMutation = useUpdatePlaylist({});
const handleSave = ( const handleSave = (
filter: Record<string, any>, filter: Record<string, any>,
@@ -89,8 +89,6 @@ const PlaylistDetailSongListRoute = () => {
) => { ) => {
if (!detailQuery?.data) return; if (!detailQuery?.data) return;
// New syntax: sortBy is now a single string with comma-separated fields and +/- prefix
// e.g., "+album,-year" means sort by album ascending, then year descending
const sortValue = const sortValue =
extraFilters.sortBy && extraFilters.sortBy.length > 0 extraFilters.sortBy && extraFilters.sortBy.length > 0
? extraFilters.sortBy[0] ? extraFilters.sortBy[0]
@@ -99,11 +97,10 @@ const PlaylistDetailSongListRoute = () => {
const rules = { const rules = {
...filter, ...filter,
limit: extraFilters.limit || undefined, limit: extraFilters.limit || undefined,
// order field is now optional - sort direction is embedded in sort field
sort: sortValue, sort: sortValue,
}; };
createPlaylistMutation.mutate( updatePlaylistMutation.mutate(
{ {
apiClientProps: { serverId: detailQuery?.data?._serverId }, apiClientProps: { serverId: detailQuery?.data?._serverId },
body: { body: {
@@ -114,22 +111,11 @@ const PlaylistDetailSongListRoute = () => {
queryBuilderRules: rules, queryBuilderRules: rules,
sync: detailQuery?.data?.sync || false, sync: detailQuery?.data?.sync || false,
}, },
query: { id: playlistId },
}, },
{ {
onSuccess: (data) => { onSuccess: () => {
toast.success({ message: 'Playlist has been saved' }); toast.success({ message: 'Playlist has been saved' });
navigate(
generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, {
playlistId: data?.id || '',
}),
{
replace: true,
},
);
deletePlaylistMutation.mutate({
apiClientProps: { serverId: detailQuery?.data?._serverId },
query: { id: playlistId },
});
}, },
}, },
); );
@@ -297,7 +283,6 @@ const PlaylistDetailSongListRoute = () => {
</ListWithSidebarContainer> </ListWithSidebarContainer>
{(isSmartPlaylist || showQueryBuilder) && ( {(isSmartPlaylist || showQueryBuilder) && (
<PlaylistQueryEditor <PlaylistQueryEditor
createPlaylistMutation={createPlaylistMutation}
detailQuery={detailQuery} detailQuery={detailQuery}
handleSave={handleSave} handleSave={handleSave}
handleSaveAs={handleSaveAs} handleSaveAs={handleSaveAs}
@@ -305,6 +290,7 @@ const PlaylistDetailSongListRoute = () => {
onToggleExpand={handleToggleExpand} onToggleExpand={handleToggleExpand}
playlistId={playlistId} playlistId={playlistId}
queryBuilderRef={queryBuilderRef} queryBuilderRef={queryBuilderRef}
updatePlaylistMutation={updatePlaylistMutation}
/> />
)} )}
</ListContext.Provider> </ListContext.Provider>