refactor(webui): finish stats standalone handling in react
- render the standalone notice directly in the React stats header - keep the legacy standalone sweep from hiding the stats control incorrectly - update the stats route test and header layout to match the new behavior
This commit is contained in:
parent
d0245e3e16
commit
824b118759
7 changed files with 129 additions and 196 deletions
6
webui/src/platform/shell/globals.d.ts
vendored
6
webui/src/platform/shell/globals.d.ts
vendored
|
|
@ -45,12 +45,6 @@ declare global {
|
||||||
) => void;
|
) => void;
|
||||||
cancelSimilarArtistsLoad: () => void;
|
cancelSimilarArtistsLoad: () => void;
|
||||||
showReactHost: (pageId: ShellPageId) => void;
|
showReactHost: (pageId: ShellPageId) => void;
|
||||||
navigateToArtistDetail: (
|
|
||||||
artistId: string | number,
|
|
||||||
artistName: string,
|
|
||||||
sourceOverride?: string | null,
|
|
||||||
options?: Record<string, unknown>,
|
|
||||||
) => void;
|
|
||||||
playLibraryTrack: (
|
playLibraryTrack: (
|
||||||
track: {
|
track: {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
|
|
|
||||||
|
|
@ -2,35 +2,10 @@ import { createMemoryHistory } from '@tanstack/react-router';
|
||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import type { ShellBridge, ShellPageId } from '@/platform/shell/bridge';
|
|
||||||
|
|
||||||
import { createAppQueryClient } from '@/app/query-client';
|
import { createAppQueryClient } from '@/app/query-client';
|
||||||
import { AppRouterProvider, createAppRouter } from '@/app/router';
|
import { AppRouterProvider, createAppRouter } from '@/app/router';
|
||||||
|
import { HttpResponse, http, server } from '@/test/msw';
|
||||||
function createResponse(body: unknown, ok = true, status = 200) {
|
import { createShellBridge } from '@/test/shell-bridge';
|
||||||
return new Response(JSON.stringify(body), {
|
|
||||||
status,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function createShellBridge(overrides: Partial<ShellBridge> = {}): ShellBridge {
|
|
||||||
return {
|
|
||||||
getCurrentProfileContext: vi.fn(() => ({ profileId: 2, isAdmin: true })),
|
|
||||||
isPageAllowed: vi.fn(() => true),
|
|
||||||
getProfileHomePage: vi.fn<() => ShellPageId>(() => 'discover'),
|
|
||||||
resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'search'),
|
|
||||||
setActivePageChrome: vi.fn(),
|
|
||||||
activateLegacyPath: vi.fn(),
|
|
||||||
showReactHost: vi.fn(),
|
|
||||||
navigateToArtistDetail: vi.fn(),
|
|
||||||
playLibraryTrack: vi.fn(),
|
|
||||||
startStream: vi.fn(),
|
|
||||||
showLoadingOverlay: vi.fn(),
|
|
||||||
hideLoadingOverlay: vi.fn(),
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStatsRoute(initialEntries = ['/stats']) {
|
function renderStatsRoute(initialEntries = ['/stats']) {
|
||||||
const queryClient = createAppQueryClient();
|
const queryClient = createAppQueryClient();
|
||||||
|
|
@ -47,52 +22,50 @@ describe('stats route', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
window.SoulSyncWebShellBridge = createShellBridge();
|
window.SoulSyncWebShellBridge = createShellBridge();
|
||||||
window.showToast = vi.fn();
|
window.showToast = vi.fn();
|
||||||
vi.stubGlobal(
|
server.use(
|
||||||
'fetch',
|
http.get('/api/stats/cached', () =>
|
||||||
vi.fn(async (input: RequestInfo | URL) => {
|
HttpResponse.json({
|
||||||
const url = input instanceof Request ? input.url : String(input);
|
success: true,
|
||||||
if (url.includes('/api/stats/cached')) {
|
overview: {
|
||||||
return createResponse({
|
total_plays: 24,
|
||||||
success: true,
|
total_time_ms: 6_600_000,
|
||||||
overview: {
|
unique_artists: 3,
|
||||||
total_plays: 24,
|
unique_albums: 4,
|
||||||
total_time_ms: 6_600_000,
|
unique_tracks: 12,
|
||||||
unique_artists: 3,
|
},
|
||||||
unique_albums: 4,
|
top_artists: [{ id: 7, name: 'Artist A', play_count: 10 }],
|
||||||
unique_tracks: 12,
|
top_albums: [],
|
||||||
},
|
top_tracks: [],
|
||||||
top_artists: [{ id: 7, name: 'Artist A', play_count: 10 }],
|
timeline: [{ date: 'May 10', plays: 4 }],
|
||||||
top_albums: [],
|
genres: [{ genre: 'House', play_count: 10, percentage: 80 }],
|
||||||
top_tracks: [],
|
recent: [{ title: 'Track A', artist: 'Artist A', played_at: '2026-05-14T08:00:00Z' }],
|
||||||
timeline: [{ date: 'May 10', plays: 4 }],
|
health: { total_tracks: 12, format_breakdown: { FLAC: 12 } },
|
||||||
genres: [{ genre: 'House', play_count: 10, percentage: 80 }],
|
}),
|
||||||
recent: [{ title: 'Track A', artist: 'Artist A', played_at: '2026-05-14T08:00:00Z' }],
|
),
|
||||||
health: { total_tracks: 12, format_breakdown: { FLAC: 12 } },
|
http.get('/api/listening-stats/status', () =>
|
||||||
});
|
HttpResponse.json({ stats: { last_poll: '2026-05-14 10:00:00' } }),
|
||||||
}
|
),
|
||||||
if (url.includes('/api/listening-stats/status')) {
|
http.get('/status', () =>
|
||||||
return createResponse({ stats: { last_poll: '2026-05-14 10:00:00' } });
|
HttpResponse.json({ media_server: { type: 'plex', connected: true } }),
|
||||||
}
|
),
|
||||||
if (url.includes('/api/stats/db-storage')) {
|
http.get('/api/stats/db-storage', () =>
|
||||||
return createResponse({
|
HttpResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
tables: [{ name: 'tracks', size: 2048 }],
|
tables: [{ name: 'tracks', size: 2048 }],
|
||||||
total_file_size: 4096,
|
total_file_size: 4096,
|
||||||
method: 'dbstat',
|
method: 'dbstat',
|
||||||
});
|
}),
|
||||||
}
|
),
|
||||||
if (url.includes('/api/stats/library-disk-usage')) {
|
http.get('/api/stats/library-disk-usage', () =>
|
||||||
return createResponse({
|
HttpResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
has_data: true,
|
has_data: true,
|
||||||
total_bytes: 2048,
|
total_bytes: 2048,
|
||||||
tracks_with_size: 12,
|
tracks_with_size: 12,
|
||||||
tracks_without_size: 0,
|
tracks_without_size: 0,
|
||||||
by_format: { flac: 2048 },
|
by_format: { flac: 2048 },
|
||||||
});
|
}),
|
||||||
}
|
),
|
||||||
return createResponse({ success: true });
|
|
||||||
}) as unknown as typeof fetch,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -107,52 +80,10 @@ describe('stats route', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('still renders when listening stats status prefetch fails', async () => {
|
it('still renders when listening stats status prefetch fails', async () => {
|
||||||
vi.stubGlobal(
|
server.use(
|
||||||
'fetch',
|
http.get('/api/listening-stats/status', () =>
|
||||||
vi.fn(async (input: RequestInfo | URL) => {
|
HttpResponse.json({ error: 'status unavailable' }, { status: 500 }),
|
||||||
const url = input instanceof Request ? input.url : String(input);
|
),
|
||||||
if (url.includes('/api/stats/cached')) {
|
|
||||||
return createResponse({
|
|
||||||
success: true,
|
|
||||||
overview: {
|
|
||||||
total_plays: 24,
|
|
||||||
total_time_ms: 6_600_000,
|
|
||||||
unique_artists: 3,
|
|
||||||
unique_albums: 4,
|
|
||||||
unique_tracks: 12,
|
|
||||||
},
|
|
||||||
top_artists: [{ id: 7, name: 'Artist A', play_count: 10 }],
|
|
||||||
top_albums: [],
|
|
||||||
top_tracks: [],
|
|
||||||
timeline: [{ date: 'May 10', plays: 4 }],
|
|
||||||
genres: [{ genre: 'House', play_count: 10, percentage: 80 }],
|
|
||||||
recent: [{ title: 'Track A', artist: 'Artist A', played_at: '2026-05-14T08:00:00Z' }],
|
|
||||||
health: { total_tracks: 12, format_breakdown: { FLAC: 12 } },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (url.includes('/api/listening-stats/status')) {
|
|
||||||
return createResponse({ error: 'status unavailable' }, false, 500);
|
|
||||||
}
|
|
||||||
if (url.includes('/api/stats/db-storage')) {
|
|
||||||
return createResponse({
|
|
||||||
success: true,
|
|
||||||
tables: [{ name: 'tracks', size: 2048 }],
|
|
||||||
total_file_size: 4096,
|
|
||||||
method: 'dbstat',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (url.includes('/api/stats/library-disk-usage')) {
|
|
||||||
return createResponse({
|
|
||||||
success: true,
|
|
||||||
has_data: true,
|
|
||||||
total_bytes: 2048,
|
|
||||||
tracks_with_size: 12,
|
|
||||||
tracks_without_size: 0,
|
|
||||||
by_format: { flac: 2048 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return createResponse({ success: true });
|
|
||||||
}) as unknown as typeof fetch,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
renderStatsRoute();
|
renderStatsRoute();
|
||||||
|
|
@ -160,6 +91,22 @@ describe('stats route', () => {
|
||||||
await waitFor(() => expect(screen.getByTestId('stats-page')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByTestId('stats-page')).toBeInTheDocument());
|
||||||
expect(await screen.findByText('Listening Stats')).toBeInTheDocument();
|
expect(await screen.findByText('Listening Stats')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Not synced yet')).toBeInTheDocument();
|
expect(screen.getByText('Not synced yet')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Sync listening stats' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an explicit standalone notice instead of the sync button', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/status', () =>
|
||||||
|
HttpResponse.json({ media_server: { type: 'soulsync', connected: true } }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderStatsRoute();
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('stats-page')).toBeInTheDocument());
|
||||||
|
expect(await screen.findByText('Listening Stats')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Standalone mode: manual sync unavailable')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Sync listening stats' })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stores the time range in route search state', async () => {
|
it('stores the time range in route search state', async () => {
|
||||||
|
|
@ -186,61 +133,16 @@ describe('stats route', () => {
|
||||||
startStream: vi.fn(),
|
startStream: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.stubGlobal(
|
server.use(
|
||||||
'fetch',
|
http.post('/api/stats/resolve-track', () =>
|
||||||
vi.fn(async (input: RequestInfo | URL) => {
|
HttpResponse.json({ error: 'resolve unavailable' }, { status: 500 }),
|
||||||
const url = input instanceof Request ? input.url : String(input);
|
),
|
||||||
if (url.includes('/api/stats/cached')) {
|
http.post('/api/enhanced-search/stream-track', () =>
|
||||||
return createResponse({
|
HttpResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
overview: {
|
result: { stream_url: '/api/stream/1' },
|
||||||
total_plays: 24,
|
}),
|
||||||
total_time_ms: 6_600_000,
|
),
|
||||||
unique_artists: 3,
|
|
||||||
unique_albums: 4,
|
|
||||||
unique_tracks: 12,
|
|
||||||
},
|
|
||||||
top_artists: [{ id: 7, name: 'Artist A', play_count: 10 }],
|
|
||||||
top_albums: [],
|
|
||||||
top_tracks: [{ name: 'Track A', artist: 'Artist A', album: 'Album A', play_count: 3 }],
|
|
||||||
timeline: [{ date: 'May 10', plays: 4 }],
|
|
||||||
genres: [{ genre: 'House', play_count: 10, percentage: 80 }],
|
|
||||||
recent: [{ title: 'Track A', artist: 'Artist A', played_at: '2026-05-14T08:00:00Z' }],
|
|
||||||
health: { total_tracks: 12, format_breakdown: { FLAC: 12 } },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (url.includes('/api/listening-stats/status')) {
|
|
||||||
return createResponse({ stats: { last_poll: '2026-05-14 10:00:00' } });
|
|
||||||
}
|
|
||||||
if (url.includes('/api/stats/resolve-track')) {
|
|
||||||
return createResponse({ error: 'resolve unavailable' }, false, 500);
|
|
||||||
}
|
|
||||||
if (url.includes('/api/enhanced-search/stream-track')) {
|
|
||||||
return createResponse({
|
|
||||||
success: true,
|
|
||||||
result: { stream_url: '/api/stream/1' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (url.includes('/api/stats/db-storage')) {
|
|
||||||
return createResponse({
|
|
||||||
success: true,
|
|
||||||
tables: [{ name: 'tracks', size: 2048 }],
|
|
||||||
total_file_size: 4096,
|
|
||||||
method: 'dbstat',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (url.includes('/api/stats/library-disk-usage')) {
|
|
||||||
return createResponse({
|
|
||||||
success: true,
|
|
||||||
has_data: true,
|
|
||||||
total_bytes: 2048,
|
|
||||||
tracks_with_size: 12,
|
|
||||||
tracks_without_size: 0,
|
|
||||||
by_format: { flac: 2048 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return createResponse({ success: true });
|
|
||||||
}) as unknown as typeof fetch,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
renderStatsRoute();
|
renderStatsRoute();
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,9 @@
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statsTimeRange {
|
.statsTimeRange {
|
||||||
|
|
@ -133,11 +136,25 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statsLastSynced {
|
.statsLastSynced {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: rgba(255, 255, 255, 0.3);
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsStandaloneNotice {
|
||||||
|
max-width: 260px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: rgba(255, 255, 255, 0.38);
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statsSyncButton {
|
.statsSyncButton {
|
||||||
|
|
@ -863,6 +880,11 @@
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.statsStandaloneNotice {
|
||||||
|
max-width: none;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.headerIcon {
|
.headerIcon {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
height: 100px;
|
height: 100px;
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import {
|
||||||
|
|
||||||
import type { ShellBridge } from '@/platform/shell/bridge';
|
import type { ShellBridge } from '@/platform/shell/bridge';
|
||||||
|
|
||||||
import { useReactPageShell } from '@/platform/shell/route-controllers';
|
import { useReactPageShell, useShellStatus } from '@/platform/shell/route-controllers';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
StatsAlbumRow,
|
StatsAlbumRow,
|
||||||
|
|
@ -125,6 +125,8 @@ export function StatsPage() {
|
||||||
const overview = cachedStats?.overview ?? EMPTY_STATS_OVERVIEW;
|
const overview = cachedStats?.overview ?? EMPTY_STATS_OVERVIEW;
|
||||||
const hasData = hasStatsData(overview);
|
const hasData = hasStatsData(overview);
|
||||||
const lastSynced = listeningStatusQuery.data?.stats?.last_poll ?? null;
|
const lastSynced = listeningStatusQuery.data?.stats?.last_poll ?? null;
|
||||||
|
const shellStatus = useShellStatus();
|
||||||
|
const isStandalone = shellStatus?.media_server?.type === 'soulsync';
|
||||||
|
|
||||||
const onRangeChange = (nextRange: StatsRange) => {
|
const onRangeChange = (nextRange: StatsRange) => {
|
||||||
void navigate({
|
void navigate({
|
||||||
|
|
@ -166,20 +168,32 @@ export function StatsPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.statsSyncControls}>
|
<div className={styles.statsSyncControls}>
|
||||||
<span className={styles.statsLastSynced}>
|
{isStandalone ? (
|
||||||
{lastSynced ? `Last synced: ${lastSynced}` : 'Not synced yet'}
|
<span
|
||||||
</span>
|
className={styles.statsStandaloneNotice}
|
||||||
<button
|
role="note"
|
||||||
id="stats-sync-btn"
|
title="SoulSync standalone does not use an external media server, so manual listening stats sync is unavailable."
|
||||||
type="button"
|
>
|
||||||
className={`${styles.statsSyncButton} ${syncing ? styles.statsSyncButtonSyncing : ''}`}
|
Standalone mode: manual sync unavailable
|
||||||
onClick={() => syncMutation.mutate()}
|
</span>
|
||||||
disabled={syncing}
|
) : (
|
||||||
aria-label="Sync listening stats"
|
<>
|
||||||
title="Sync now"
|
<span className={styles.statsLastSynced}>
|
||||||
>
|
{lastSynced ? `Last synced: ${lastSynced}` : 'Not synced yet'}
|
||||||
<span aria-hidden="true">↻</span>
|
</span>
|
||||||
</button>
|
<button
|
||||||
|
id="stats-sync-btn"
|
||||||
|
type="button"
|
||||||
|
className={`${styles.statsSyncButton} ${syncing ? styles.statsSyncButtonSyncing : ''}`}
|
||||||
|
onClick={() => syncMutation.mutate()}
|
||||||
|
disabled={syncing}
|
||||||
|
aria-label="Sync listening stats"
|
||||||
|
title="Sync now"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">↻</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -515,6 +515,7 @@ function handleServiceStatusUpdate(data) {
|
||||||
const isSoulsyncStandalone = data.media_server?.type === 'soulsync';
|
const isSoulsyncStandalone = data.media_server?.type === 'soulsync';
|
||||||
_isSoulsyncStandalone = isSoulsyncStandalone;
|
_isSoulsyncStandalone = isSoulsyncStandalone;
|
||||||
document.querySelectorAll('.sync-to-server-btn, [id$="-sync-btn"], [onclick*="startPlaylistSync"], [onclick*="syncPlaylistToServer"], [onclick*="startDecadeSync"]').forEach(btn => {
|
document.querySelectorAll('.sync-to-server-btn, [id$="-sync-btn"], [onclick*="startPlaylistSync"], [onclick*="syncPlaylistToServer"], [onclick*="startDecadeSync"]').forEach(btn => {
|
||||||
|
if (btn.id === 'stats-sync-btn') return; // React stats page owns this control now.
|
||||||
if (isSoulsyncStandalone) {
|
if (isSoulsyncStandalone) {
|
||||||
btn.dataset.hiddenByStandalone = '1';
|
btn.dataset.hiddenByStandalone = '1';
|
||||||
btn.style.display = 'none';
|
btn.style.display = 'none';
|
||||||
|
|
|
||||||
|
|
@ -3199,6 +3199,7 @@ async function fetchAndUpdateServiceStatus() {
|
||||||
const isSoulsyncStandalone2 = data.media_server?.type === 'soulsync';
|
const isSoulsyncStandalone2 = data.media_server?.type === 'soulsync';
|
||||||
_isSoulsyncStandalone = isSoulsyncStandalone2;
|
_isSoulsyncStandalone = isSoulsyncStandalone2;
|
||||||
document.querySelectorAll('.sync-to-server-btn, [id$="-sync-btn"], [onclick*="startPlaylistSync"], [onclick*="syncPlaylistToServer"], [onclick*="startDecadeSync"]').forEach(btn => {
|
document.querySelectorAll('.sync-to-server-btn, [id$="-sync-btn"], [onclick*="startPlaylistSync"], [onclick*="syncPlaylistToServer"], [onclick*="startDecadeSync"]').forEach(btn => {
|
||||||
|
if (btn.id === 'stats-sync-btn') return; // React stats page owns this control now.
|
||||||
if (isSoulsyncStandalone2) {
|
if (isSoulsyncStandalone2) {
|
||||||
btn.dataset.hiddenByStandalone = '1';
|
btn.dataset.hiddenByStandalone = '1';
|
||||||
btn.style.display = 'none';
|
btn.style.display = 'none';
|
||||||
|
|
|
||||||
|
|
@ -168,7 +168,6 @@ window.SoulSyncWebShellBridge = {
|
||||||
activateLegacyPath(pathname) {
|
activateLegacyPath(pathname) {
|
||||||
activateLegacyPath(pathname);
|
activateLegacyPath(pathname);
|
||||||
},
|
},
|
||||||
navigateToArtistDetail,
|
|
||||||
cancelSimilarArtistsLoad() {
|
cancelSimilarArtistsLoad() {
|
||||||
if (typeof cancelSimilarArtistsLoad === 'function') {
|
if (typeof cancelSimilarArtistsLoad === 'function') {
|
||||||
cancelSimilarArtistsLoad();
|
cancelSimilarArtistsLoad();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue