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 { useDisclosure } from '/@/shared/hooks/use-disclosure';
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';
interface PlaylistDetailSongListHeaderFiltersProps {
@@ -193,7 +199,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
<MoreButton onClick={handleMore} />
</Group>
<Group gap="sm" wrap="nowrap">
{isViewEditMode && <SaveAndReplaceButton mode={mode} />}
{isViewEditMode && <SaveAndReplaceButton mode={mode} playlist={detailQuery.data} />}
{isViewEditMode && (
<Button
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({
innerProps: { listData, playlistId },
innerProps: { playlistId, updateBody },
modalKey: 'saveAndReplace',
size: 'sm',
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 { playlistId } = useParams() as { playlistId: string };
const { listData } = useListContext();
const handleOpenModal = useCallback(() => {
if (!playlistId || !listData) return;
openSaveAndReplaceModal(playlistId, listData);
}, [playlistId, listData]);
if (!playlistId || !playlist) return;
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') {
return null;
@@ -7,7 +7,7 @@ import {
PlaylistQueryBuilder,
PlaylistQueryBuilderRef,
} 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 { JsonPreview } from '/@/renderer/features/shared/components/json-preview';
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';
export interface PlaylistQueryEditorProps {
createPlaylistMutation: ReturnType<typeof useCreatePlaylist>;
detailQuery: ReturnType<typeof useQuery<any>>;
handleSave: (
filter: Record<string, any>,
@@ -39,6 +38,7 @@ export interface PlaylistQueryEditorProps {
onToggleExpand: () => void;
playlistId: string;
queryBuilderRef: React.RefObject<null | PlaylistQueryBuilderRef>;
updatePlaylistMutation: ReturnType<typeof useUpdatePlaylist>;
}
type AppliedJsonState = {
@@ -77,7 +77,6 @@ const parseRulesJsonToSaveArgs = (
};
export const PlaylistQueryEditor = ({
createPlaylistMutation,
detailQuery,
handleSave,
handleSaveAs,
@@ -85,6 +84,7 @@ export const PlaylistQueryEditor = ({
onToggleExpand,
playlistId,
queryBuilderRef,
updatePlaylistMutation,
}: PlaylistQueryEditorProps) => {
const { t } = useTranslation();
@@ -322,7 +322,7 @@ export const PlaylistQueryEditor = ({
<Button
disabled={!isQueryBuilderExpanded}
leftSection={<Icon icon="save" />}
loading={createPlaylistMutation?.isPending}
loading={updatePlaylistMutation?.isPending}
onClick={() => {
if (!isQueryBuilderExpanded) return;
const payload = getFiltersForSave();
@@ -1,40 +1,22 @@
import { closeAllModals, ContextModalProps } from '@mantine/modals';
import { useCallback, useMemo } from 'react';
import { useCallback } from 'react';
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 { ConfirmModal } from '/@/shared/components/modal/modal';
import { Text } from '/@/shared/components/text/text';
import { toast } from '/@/shared/components/toast/toast';
import { Song } from '/@/shared/types/domain-types';
import { UpdatePlaylistBody } from '/@/shared/types/domain-types';
export const SaveAndReplaceContextModal = ({
innerProps,
}: ContextModalProps<{ listData: unknown[]; playlistId: string }>) => {
}: ContextModalProps<{ playlistId: string; updateBody: UpdatePlaylistBody }>) => {
const { t } = useTranslation();
const { listData, playlistId } = innerProps;
const { playlistId, updateBody } = innerProps;
const serverId = useCurrentServerId();
const replacePlaylistMutation = useReplacePlaylist({});
// 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 updatePlaylistMutation = useUpdatePlaylist({});
const handleConfirm = useCallback(() => {
if (!serverId || !playlistId) {
@@ -42,24 +24,11 @@ export const SaveAndReplaceContextModal = ({
return;
}
if (currentSongIds.length === 0) {
console.error('currentSongIds is empty');
toast.error({
message: t('error.genericError', { postProcess: 'sentenceCase' }),
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
return;
}
replacePlaylistMutation.mutate(
updatePlaylistMutation.mutate(
{
apiClientProps: { serverId },
body: {
songId: currentSongIds,
},
query: {
id: playlistId,
},
body: updateBody,
query: { id: playlistId },
},
{
onError: (err) => {
@@ -81,10 +50,10 @@ export const SaveAndReplaceContextModal = ({
},
},
);
}, [t, currentSongIds, serverId, playlistId, replacePlaylistMutation]);
}, [t, serverId, playlistId, updateBody, updatePlaylistMutation]);
return (
<ConfirmModal loading={replacePlaylistMutation.isPending} onConfirm={handleConfirm}>
<ConfirmModal loading={updatePlaylistMutation.isPending} onConfirm={handleConfirm}>
<Text>{t('form.editPlaylist.editNote', { postProcess: 'sentenceCase' })}</Text>
</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({
queryKey: queryKeys.playlists.detail(serverId, query.id),
});
queryClient.invalidateQueries({
queryKey: queryKeys.playlists.songList(serverId, query.id),
});
}
},
...options,
@@ -13,8 +13,8 @@ import { PlaylistQueryBuilderRef } from '/@/renderer/features/playlists/componen
import { PlaylistQueryEditor } from '/@/renderer/features/playlists/components/playlist-query-editor';
import { SaveAsPlaylistForm } from '/@/renderer/features/playlists/components/save-as-playlist-form';
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 { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation';
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
@@ -80,8 +80,8 @@ const PlaylistDetailSongListRoute = () => {
...playlistsQueries.detail({ query: { id: playlistId }, serverId: server?.id }),
placeholderData: location.state?.item,
});
const createPlaylistMutation = useCreatePlaylist({});
const deletePlaylistMutation = useDeletePlaylist({});
const updatePlaylistMutation = useUpdatePlaylist({});
const handleSave = (
filter: Record<string, any>,
@@ -89,8 +89,6 @@ const PlaylistDetailSongListRoute = () => {
) => {
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 =
extraFilters.sortBy && extraFilters.sortBy.length > 0
? extraFilters.sortBy[0]
@@ -99,11 +97,10 @@ const PlaylistDetailSongListRoute = () => {
const rules = {
...filter,
limit: extraFilters.limit || undefined,
// order field is now optional - sort direction is embedded in sort field
sort: sortValue,
};
createPlaylistMutation.mutate(
updatePlaylistMutation.mutate(
{
apiClientProps: { serverId: detailQuery?.data?._serverId },
body: {
@@ -114,22 +111,11 @@ const PlaylistDetailSongListRoute = () => {
queryBuilderRules: rules,
sync: detailQuery?.data?.sync || false,
},
query: { id: playlistId },
},
{
onSuccess: (data) => {
onSuccess: () => {
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>
{(isSmartPlaylist || showQueryBuilder) && (
<PlaylistQueryEditor
createPlaylistMutation={createPlaylistMutation}
detailQuery={detailQuery}
handleSave={handleSave}
handleSaveAs={handleSaveAs}
@@ -305,6 +290,7 @@ const PlaylistDetailSongListRoute = () => {
onToggleExpand={handleToggleExpand}
playlistId={playlistId}
queryBuilderRef={queryBuilderRef}
updatePlaylistMutation={updatePlaylistMutation}
/>
)}
</ListContext.Provider>