add JSON editor for playlist query builder (#1711)

This commit is contained in:
jeffvli
2026-02-13 21:05:34 -08:00
parent e497734c07
commit 1163c4ad5e
3 changed files with 213 additions and 43 deletions
+1
View File
@@ -211,6 +211,7 @@
"credentialsRequired": "credentials required", "credentialsRequired": "credentials required",
"endpointNotImplementedError": "endpoint {{endpoint}} is not implemented for {{serverType}}", "endpointNotImplementedError": "endpoint {{endpoint}} is not implemented for {{serverType}}",
"genericError": "an error occurred", "genericError": "an error occurred",
"invalidJson": "invalid JSON",
"invalidServer": "invalid server", "invalidServer": "invalid server",
"localFontAccessDenied": "access denied to local fonts", "localFontAccessDenied": "access denied to local fonts",
"loginRateError": "too many login attempts, please try again in a few seconds", "loginRateError": "too many login attempts, please try again in a few seconds",
@@ -30,11 +30,15 @@ import {
usePlaylistTarget, usePlaylistTarget,
} from '/@/renderer/store'; } 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 { Flex } from '/@/shared/components/flex/flex';
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 { JsonInput } from '/@/shared/components/json-input/json-input';
import { ConfirmModal } from '/@/shared/components/modal/modal'; import { ConfirmModal } from '/@/shared/components/modal/modal';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area'; import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Spinner } from '/@/shared/components/spinner/spinner'; import { Spinner } from '/@/shared/components/spinner/spinner';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
@@ -42,6 +46,14 @@ import { toast } from '/@/shared/components/toast/toast';
import { LibraryItem, ServerType, SongListSort } from '/@/shared/types/domain-types'; import { LibraryItem, ServerType, SongListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
type AppliedJsonState = {
limit?: number;
query: Record<string, any>;
sort?: string;
};
type EditorMode = 'builder' | 'json';
interface PlaylistQueryEditorProps { interface PlaylistQueryEditorProps {
createPlaylistMutation: ReturnType<typeof useCreatePlaylist>; createPlaylistMutation: ReturnType<typeof useCreatePlaylist>;
detailQuery: ReturnType<typeof useQuery<any>>; detailQuery: ReturnType<typeof useQuery<any>>;
@@ -59,6 +71,33 @@ interface PlaylistQueryEditorProps {
queryBuilderRef: React.RefObject<null | PlaylistQueryBuilderRef>; queryBuilderRef: React.RefObject<null | PlaylistQueryBuilderRef>;
} }
const serializeFiltersToRulesJson = (filters: {
extraFilters: { limit?: number; sortBy?: string[] };
filters: any;
}): Record<string, any> => {
const queryValue = convertQueryGroupToNDQuery(filters.filters);
const sortString = filters.extraFilters.sortBy?.[0];
return {
...queryValue,
...(filters.extraFilters.limit != null && { limit: filters.extraFilters.limit }),
...(sortString && { sort: sortString }),
};
};
const parseRulesJsonToSaveArgs = (
parsed: Record<string, any>,
): { extraFilters: { limit?: number; sortBy?: string[] }; filter: Record<string, any> } => {
const rootKey = parsed.all ? 'all' : 'any';
const filter = rootKey in parsed ? { [rootKey]: parsed[rootKey] } : { all: [] };
return {
extraFilters: {
...(parsed.limit != null && { limit: parsed.limit }),
...(parsed.sort != null && { sortBy: [parsed.sort] }),
},
filter,
};
};
const PlaylistQueryEditor = ({ const PlaylistQueryEditor = ({
createPlaylistMutation, createPlaylistMutation,
detailQuery, detailQuery,
@@ -71,57 +110,74 @@ const PlaylistQueryEditor = ({
}: PlaylistQueryEditorProps) => { }: PlaylistQueryEditorProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const openPreviewModal = useCallback(() => { const [editorMode, setEditorMode] = useState<EditorMode>('builder');
const filters = queryBuilderRef.current?.getFilters(); const [jsonText, setJsonText] = useState('');
const [appliedJsonState, setAppliedJsonState] = useState<AppliedJsonState | null>(null);
if (!filters) { const getFiltersForSave = useCallback((): null | {
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string };
filter: Record<string, any>;
} => {
if (editorMode === 'json') {
try {
const parsed = JSON.parse(jsonText) as Record<string, any>;
const { extraFilters, filter } = parseRulesJsonToSaveArgs(parsed);
return { extraFilters, filter };
} catch {
return null;
}
}
const filters = queryBuilderRef.current?.getFilters();
if (!filters) return null;
return {
extraFilters: filters.extraFilters,
filter: convertQueryGroupToNDQuery(filters.filters),
};
}, [editorMode, jsonText, queryBuilderRef]);
const openPreviewModal = useCallback(() => {
const payload = getFiltersForSave();
if (!payload) {
if (editorMode === 'json') {
toast.error({ message: t('error.invalidJson', { postProcess: 'sentenceCase' }) });
}
return; return;
} }
const queryValue = convertQueryGroupToNDQuery(filters.filters);
const sortString = filters.extraFilters.sortBy?.[0];
const previewValue = { const previewValue = {
...queryValue, ...payload.filter,
...(filters.extraFilters.limit && { limit: filters.extraFilters.limit }), ...(payload.extraFilters.limit != null && { limit: payload.extraFilters.limit }),
...(sortString && { sort: sortString }), ...(payload.extraFilters.sortBy?.[0] && { sort: payload.extraFilters.sortBy[0] }),
}; };
openModal({ openModal({
children: <JsonPreview value={previewValue} />, children: <JsonPreview value={previewValue} />,
size: 'xl', size: 'xl',
title: t('common.preview', { postProcess: 'titleCase' }), title: t('common.preview', { postProcess: 'titleCase' }),
}); });
}, [queryBuilderRef, t]); }, [editorMode, getFiltersForSave, t]);
const openSaveAndReplaceModal = useCallback(() => { const openSaveAndReplaceModal = useCallback(() => {
if (!isQueryBuilderExpanded) { if (!isQueryBuilderExpanded) return;
const payload = getFiltersForSave();
if (!payload) {
if (editorMode === 'json') {
toast.error({ message: t('error.invalidJson', { postProcess: 'sentenceCase' }) });
}
return; return;
} }
const filters = queryBuilderRef.current?.getFilters();
if (!filters) {
return;
}
openModal({ openModal({
children: ( children: (
<ConfirmModal <ConfirmModal
onConfirm={() => { onConfirm={() => {
handleSave( handleSave(payload.filter, payload.extraFilters);
convertQueryGroupToNDQuery(filters.filters),
filters.extraFilters,
);
closeAllModals(); closeAllModals();
}} }}
> >
<Text>{t('common.areYouSure', { postProcess: 'sentenceCase' })}</Text> <Text>{t('common.areYouSure', { postProcess: 'sentenceCase' })}</Text>
</ConfirmModal> </ConfirmModal>
), ),
title: t('common.saveAndReplace', { postProcess: 'sentenceCase' }), title: t('common.saveAndReplace', { postProcess: 'titleCase' }),
}); });
}, [isQueryBuilderExpanded, queryBuilderRef, handleSave, t]); }, [editorMode, getFiltersForSave, handleSave, isQueryBuilderExpanded, t]);
const parseSortBy = useCallback((): string[] => { const parseSortBy = useCallback((): string[] => {
const sort = detailQuery?.data?.rules?.sort; const sort = detailQuery?.data?.rules?.sort;
@@ -163,6 +219,75 @@ const PlaylistQueryEditor = ({
return detailQuery?.data?.rules?.order || 'asc'; return detailQuery?.data?.rules?.order || 'asc';
}, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]); }, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]);
const effectiveQuery = useMemo(
() =>
appliedJsonState?.query ??
(detailQuery?.data?.rules?.all
? { all: detailQuery.data.rules.all }
: detailQuery?.data?.rules?.any
? { any: detailQuery.data.rules.any }
: detailQuery?.data?.rules),
[appliedJsonState?.query, detailQuery?.data?.rules],
);
const effectiveLimit = appliedJsonState?.limit ?? detailQuery?.data?.rules?.limit;
const effectiveSortBy = useMemo(
() =>
(appliedJsonState?.sort ? [appliedJsonState.sort] : parseSortBy()) as
| SongListSort
| SongListSort[],
[appliedJsonState?.sort, parseSortBy],
);
const effectiveSortOrder = appliedJsonState?.sort
? appliedJsonState.sort.startsWith('-')
? 'desc'
: 'asc'
: parseSortOrder();
const handleEditorModeChange = useCallback(
(value: string) => {
const nextMode = value as EditorMode;
if (nextMode === 'json') {
const filters = queryBuilderRef.current?.getFilters();
if (filters) {
setJsonText(JSON.stringify(serializeFiltersToRulesJson(filters), null, 2));
} else {
const fallback: Record<string, any> = effectiveQuery
? { ...effectiveQuery }
: { all: [] };
if (effectiveLimit != null) fallback.limit = effectiveLimit;
if (effectiveSortBy?.[0]) fallback.sort = effectiveSortBy[0];
if (!fallback.sort) fallback.sort = '+dateAdded';
setJsonText(JSON.stringify(fallback, null, 2));
}
setEditorMode('json');
} else {
if (editorMode === 'json') {
try {
const parsed = JSON.parse(jsonText) as Record<string, any>;
const rootKey = parsed.all ? 'all' : 'any';
if (!parsed[rootKey] || !Array.isArray(parsed[rootKey])) {
throw new Error('Invalid rules structure');
}
setAppliedJsonState({
limit: parsed.limit,
query: { [rootKey]: parsed[rootKey] },
sort: parsed.sort,
});
} catch {
toast.error({
message: t('error.invalidJson', {
postProcess: 'sentenceCase',
}),
});
return;
}
}
setEditorMode('builder');
}
},
[editorMode, effectiveLimit, effectiveQuery, effectiveSortBy, jsonText, queryBuilderRef, t],
);
return ( return (
<div <div
className="query-editor-container" className="query-editor-container"
@@ -186,6 +311,31 @@ const PlaylistQueryEditor = ({
postProcess: 'titleCase', postProcess: 'titleCase',
})} })}
</Button> </Button>
{isQueryBuilderExpanded && (
<SegmentedControl
data={[
{
label: (
<Flex>
<Icon icon="queryBuilder" />
</Flex>
),
value: 'builder',
},
{
label: (
<Flex>
<Icon icon="json" />
</Flex>
),
value: 'json',
},
]}
onChange={handleEditorModeChange}
size="xs"
value={editorMode}
/>
)}
</Group> </Group>
<Group gap="xs"> <Group gap="xs">
<Button onClick={openPreviewModal} size="sm" variant="subtle"> <Button onClick={openPreviewModal} size="sm" variant="subtle">
@@ -197,12 +347,15 @@ const PlaylistQueryEditor = ({
loading={createPlaylistMutation?.isPending} loading={createPlaylistMutation?.isPending}
onClick={() => { onClick={() => {
if (!isQueryBuilderExpanded) return; if (!isQueryBuilderExpanded) return;
const filters = queryBuilderRef.current?.getFilters(); const payload = getFiltersForSave();
if (filters) { if (payload) {
handleSaveAs( handleSaveAs(payload.filter, payload.extraFilters);
convertQueryGroupToNDQuery(filters.filters), } else if (editorMode === 'json') {
filters.extraFilters, toast.error({
); message: t('error.invalidJson', {
postProcess: 'sentenceCase',
}),
});
} }
}} }}
size="sm" size="sm"
@@ -223,7 +376,8 @@ const PlaylistQueryEditor = ({
</Button> </Button>
</Group> </Group>
</Group> </Group>
<div <Box
py="md"
style={{ style={{
display: isQueryBuilderExpanded ? 'flex' : 'none', display: isQueryBuilderExpanded ? 'flex' : 'none',
flex: 1, flex: 1,
@@ -231,16 +385,27 @@ const PlaylistQueryEditor = ({
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
{editorMode === 'builder' ? (
<PlaylistQueryBuilder <PlaylistQueryBuilder
key={JSON.stringify(detailQuery?.data?.rules)} key={JSON.stringify(appliedJsonState ?? detailQuery?.data?.rules)}
limit={detailQuery?.data?.rules?.limit} limit={effectiveLimit}
playlistId={playlistId} playlistId={playlistId}
query={detailQuery?.data?.rules} query={effectiveQuery}
ref={queryBuilderRef} ref={queryBuilderRef}
sortBy={parseSortBy() as SongListSort | SongListSort[]} sortBy={effectiveSortBy}
sortOrder={parseSortOrder()} sortOrder={effectiveSortOrder}
/> />
</div> ) : (
<JsonInput
autosize
minRows={8}
onChange={(value) => setJsonText(value)}
placeholder='{ "all": [], "limit": 100, "sort": "+dateAdded" }'
style={{ flex: 1, minHeight: 0 }}
value={jsonText}
/>
)}
</Box>
</Stack> </Stack>
</div> </div>
); );
+4
View File
@@ -21,6 +21,7 @@ import {
LuArrowUpNarrowWide, LuArrowUpNarrowWide,
LuArrowUpToLine, LuArrowUpToLine,
LuBookOpen, LuBookOpen,
LuBraces,
LuCheck, LuCheck,
LuChevronDown, LuChevronDown,
LuChevronLast, LuChevronLast,
@@ -117,6 +118,7 @@ import {
LuVolumeX, LuVolumeX,
LuWifi, LuWifi,
LuWifiOff, LuWifiOff,
LuWrench,
LuX, LuX,
} from 'react-icons/lu'; } from 'react-icons/lu';
import { MdOutlineVisibility, MdOutlineVisibilityOff } from 'react-icons/md'; import { MdOutlineVisibility, MdOutlineVisibilityOff } from 'react-icons/md';
@@ -187,6 +189,7 @@ export const AppIcon = {
info: LuInfo, info: LuInfo,
itemAlbum: LuDisc3, itemAlbum: LuDisc3,
itemSong: LuMusic, itemSong: LuMusic,
json: LuBraces,
keyboard: LuKeyboard, keyboard: LuKeyboard,
lastPlayed: LuHeadphones, lastPlayed: LuHeadphones,
layoutDetail: LuLayoutList, layoutDetail: LuLayoutList,
@@ -227,6 +230,7 @@ export const AppIcon = {
playlistAdd: LuListPlus, playlistAdd: LuListPlus,
playlistDelete: LuListMinus, playlistDelete: LuListMinus,
plus: LuPlus, plus: LuPlus,
queryBuilder: LuWrench,
queue: LuList, queue: LuList,
radio: LuRadio, radio: LuRadio,
refresh: LuRotateCw, refresh: LuRotateCw,