Refactor theme catalog fetching to use custom hook (#6681)

* Refactor theme catalog fetching to use custom hook

- Move catalog fetching logic from ThemeInstaller to useThemeCatalog hook
- Fetch catalog asynchronously from GitHub instead of direct import
- Update tests to mock useThemeCatalog hook for faster test execution
- Remove catalog validation and translation dependencies from hook

* Remove redundant visibility checks for 'Demo Theme' button in ThemeInstaller tests and add assertion to verify presence of images.

* Refactor ThemeInstaller component to improve error handling and loading state display

- Change conditional rendering for catalog error to use ternary operator for clarity
- Simplify loading state display logic within the theme catalog view
- Ensure consistent styling and structure for theme items in the catalog

* Initialize loading state in useThemeCatalog hook to true

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Matiss Janis Aboltins
2026-01-17 11:06:16 +01:00
committed by GitHub
parent b6452f930b
commit ee0e7ed3e0
4 changed files with 274 additions and 164 deletions

View File

@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ThemeInstaller } from './ThemeInstaller';
import { useThemeCatalog } from '@desktop-client/hooks/useThemeCatalog';
import {
fetchThemeCss,
validateThemeCss,
@@ -24,8 +25,21 @@ vi.mock('@desktop-client/style/customThemes', async () => {
),
};
});
vi.mock('@desktop-client/data/customThemeCatalog.json', () => ({
default: [
vi.mock('@desktop-client/hooks/useThemeCatalog', () => ({
useThemeCatalog: vi.fn(),
}));
describe('ThemeInstaller', () => {
const mockOnInstall = vi.fn();
const mockOnClose = vi.fn();
const mockValidCss = `:root {
--color-primary: #007bff;
--color-secondary: #6c757d;
}`;
const mockCatalog = [
{
name: 'Demo Theme',
repo: 'actualbudget/demo-theme',
@@ -38,17 +52,7 @@ vi.mock('@desktop-client/data/customThemeCatalog.json', () => ({
name: 'Forest Green',
repo: 'actualbudget/forest-theme',
},
],
}));
describe('ThemeInstaller', () => {
const mockOnInstall = vi.fn();
const mockOnClose = vi.fn();
const mockValidCss = `:root {
--color-primary: #007bff;
--color-secondary: #6c757d;
}`;
];
beforeEach(() => {
vi.clearAllMocks();
@@ -56,6 +60,13 @@ describe('ThemeInstaller', () => {
mockOnClose.mockClear();
vi.mocked(fetchThemeCss).mockResolvedValue(mockValidCss);
vi.mocked(validateThemeCss).mockImplementation(css => css.trim());
// Mock useThemeCatalog to return catalog data immediately
vi.mocked(useThemeCatalog).mockReturnValue({
data: mockCatalog,
isLoading: false,
error: null,
});
});
describe('rendering', () => {

View File

@@ -12,7 +12,7 @@ import { View } from '@actual-app/components/view';
import { Link } from '@desktop-client/components/common/Link';
import { FixedSizeList } from '@desktop-client/components/FixedSizeList';
import customThemeCatalog from '@desktop-client/data/customThemeCatalog.json';
import { useThemeCatalog } from '@desktop-client/hooks/useThemeCatalog';
import {
extractRepoOwner,
fetchThemeCss,
@@ -48,6 +48,13 @@ export function ThemeInstaller({
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch catalog from GitHub using custom hook
const {
data: catalog,
isLoading: catalogLoading,
error: catalogError,
} = useThemeCatalog();
// Initialize pastedCss with installed custom theme CSS if it exists
useEffect(() => {
// If there's an installed theme with empty repo (custom pasted CSS), restore it
@@ -56,9 +63,6 @@ export function ThemeInstaller({
}
}, [installedTheme]);
// TODO: inlined for now, but eventually we will fetch this from github directly
const catalog = customThemeCatalog as CatalogTheme[];
// Calculate items per row based on container width
const getItemsPerRow = useCallback((containerWidth: number) => {
const padding = 8; // 4px on each side
@@ -167,155 +171,191 @@ export function ThemeInstaller({
<Text style={{ marginBottom: 8, color: themeStyle.pageTextSubdued }}>
<Trans>Choose from catalog:</Trans>
</Text>
<View
style={{
height: CATALOG_MAX_HEIGHT,
marginBottom: 16,
}}
>
<AutoSizer>
{({ width, height }) => {
if (width === 0 || height === 0) {
return null;
}
const itemsPerRow = getItemsPerRow(width);
const rows: CatalogTheme[][] = [];
for (let i = 0; i < catalog.length; i += itemsPerRow) {
rows.push(catalog.slice(i, i + itemsPerRow));
}
return (
<FixedSizeList
width={width}
height={height}
itemCount={rows.length}
itemSize={THEME_ITEM_HEIGHT + THEME_ITEM_GAP}
itemKey={index => `row-${index}`}
renderRow={({ index, style }) => {
const rowThemes = rows[index];
return (
<div
style={{
...style,
display: 'flex',
gap: THEME_ITEM_GAP,
padding: '0 4px',
}}
>
{rowThemes.map((theme, themeIndex) => {
const isSelected =
selectedCatalogTheme?.name === theme.name &&
selectedCatalogTheme?.repo === theme.repo;
const isLoadingSelected = isLoading && isSelected;
return (
<Button
key={`${theme.name}-${index}-${themeIndex}`}
variant="bare"
aria-label={theme.name}
onPress={() => handleCatalogThemeClick(theme)}
style={{
width: THEME_ITEM_WIDTH,
height: THEME_ITEM_HEIGHT,
padding: 8,
borderRadius: 6,
border: `2px solid ${
isSelected
? themeStyle.buttonPrimaryBackground
: themeStyle.tableBorder
}`,
backgroundColor: isSelected
? themeStyle.tableRowBackgroundHover
: 'transparent',
flexDirection: 'column',
alignItems: 'center',
gap: 8,
flexShrink: 0,
position: 'relative',
}}
>
<View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: 6,
backgroundColor: themeStyle.overlayBackground,
alignItems: 'center',
justifyContent: 'center',
zIndex: 1,
opacity: isLoadingSelected ? 1 : 0,
pointerEvents: isLoadingSelected
? 'auto'
: 'none',
transition: 'opacity 0.2s ease-in-out',
}}
>
<AnimatedLoading
style={{
width: 24,
height: 24,
color: themeStyle.pageText,
}}
/>
</View>
<img
src={getThemeScreenshotUrl(theme.repo)}
alt={theme.name}
style={{
width: '100%',
height: 60,
objectFit: 'cover',
borderRadius: 4,
}}
/>
<Text
style={{
fontSize: 12,
fontWeight: 500,
textAlign: 'center',
}}
>
{theme.name}
</Text>
<SpaceBetween
direction="horizontal"
align="center"
gap={4}
style={{ fontSize: 10 }}
>
<Text
style={{ color: themeStyle.pageTextSubdued }}
>
{t('by')}{' '}
<Text style={{ fontWeight: 'bold' }}>
{extractRepoOwner(theme.repo)}
</Text>
</Text>
<Link
variant="external"
to={normalizeGitHubRepo(theme.repo)}
onClick={e => e.stopPropagation()}
>
<Trans>Source</Trans>
</Link>
</SpaceBetween>
</Button>
);
})}
</div>
);
{catalogError ? (
<Text
style={{
color: themeStyle.errorText,
marginBottom: 12,
fontSize: 12,
}}
>
<Trans>
Failed to load theme catalog. You can still paste custom CSS below.
</Trans>
</Text>
) : (
<View
style={{
height: CATALOG_MAX_HEIGHT,
marginBottom: 16,
}}
>
{catalogLoading ? (
<View
style={{
height: '100%',
alignItems: 'center',
justifyContent: 'center',
}}
>
<AnimatedLoading
style={{
width: 24,
height: 24,
color: themeStyle.pageText,
}}
/>
);
}}
</AutoSizer>
</View>
</View>
) : (
<AutoSizer>
{({ width, height }) => {
if (width === 0 || height === 0) {
return null;
}
const catalogItems = catalog ?? [];
const itemsPerRow = getItemsPerRow(width);
const rows: CatalogTheme[][] = [];
for (let i = 0; i < catalogItems.length; i += itemsPerRow) {
rows.push(catalogItems.slice(i, i + itemsPerRow));
}
return (
<FixedSizeList
width={width}
height={height}
itemCount={rows.length}
itemSize={THEME_ITEM_HEIGHT + THEME_ITEM_GAP}
itemKey={index => `row-${index}`}
renderRow={({ index, style }) => {
const rowThemes = rows[index];
return (
<div
style={{
...style,
display: 'flex',
gap: THEME_ITEM_GAP,
padding: '0 4px',
}}
>
{rowThemes.map((theme, themeIndex) => {
const isSelected =
selectedCatalogTheme?.name === theme.name &&
selectedCatalogTheme?.repo === theme.repo;
const isLoadingSelected = isLoading && isSelected;
return (
<Button
key={`${theme.name}-${index}-${themeIndex}`}
variant="bare"
aria-label={theme.name}
onPress={() => handleCatalogThemeClick(theme)}
style={{
width: THEME_ITEM_WIDTH,
height: THEME_ITEM_HEIGHT,
padding: 8,
borderRadius: 6,
border: `2px solid ${
isSelected
? themeStyle.buttonPrimaryBackground
: themeStyle.tableBorder
}`,
backgroundColor: isSelected
? themeStyle.tableRowBackgroundHover
: 'transparent',
flexDirection: 'column',
alignItems: 'center',
gap: 8,
flexShrink: 0,
position: 'relative',
}}
>
<View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: 6,
backgroundColor:
themeStyle.overlayBackground,
alignItems: 'center',
justifyContent: 'center',
zIndex: 1,
opacity: isLoadingSelected ? 1 : 0,
pointerEvents: isLoadingSelected
? 'auto'
: 'none',
transition: 'opacity 0.2s ease-in-out',
}}
>
<AnimatedLoading
style={{
width: 24,
height: 24,
color: themeStyle.pageText,
}}
/>
</View>
<img
src={getThemeScreenshotUrl(theme.repo)}
alt={theme.name}
style={{
width: '100%',
height: 60,
objectFit: 'cover',
borderRadius: 4,
}}
/>
<Text
style={{
fontSize: 12,
fontWeight: 500,
textAlign: 'center',
}}
>
{theme.name}
</Text>
<SpaceBetween
direction="horizontal"
align="center"
gap={4}
style={{ fontSize: 10 }}
>
<Text
style={{
color: themeStyle.pageTextSubdued,
}}
>
{t('by')}{' '}
<Text style={{ fontWeight: 'bold' }}>
{extractRepoOwner(theme.repo)}
</Text>
</Text>
<Link
variant="external"
to={normalizeGitHubRepo(theme.repo)}
onClick={e => e.stopPropagation()}
>
<Trans>Source</Trans>
</Link>
</SpaceBetween>
</Button>
);
})}
</div>
);
}}
/>
);
}}
</AutoSizer>
)}
</View>
)}
{/* Paste CSS Input */}
<View

View File

@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';
import { type CatalogTheme } from '@desktop-client/style/customThemes';
const CATALOG_URL =
'https://raw.githubusercontent.com/actualbudget/actual/master/packages/desktop-client/src/data/customThemeCatalog.json';
/**
* Custom hook to fetch and manage the theme catalog from GitHub.
*/
export function useThemeCatalog() {
const [data, setData] = useState<CatalogTheme[] | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchCatalog = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(CATALOG_URL);
if (!response.ok) {
throw new Error(`Failed to fetch catalog: ${response.statusText}`);
}
const data = await response.json();
// Validate that data is an array
if (!Array.isArray(data)) {
throw new Error('Invalid catalog format: expected an array');
}
setData(data);
} catch (err) {
setError(
err instanceof Error ? err.message : 'Failed to load theme catalog',
);
} finally {
setIsLoading(false);
}
};
fetchCatalog();
}, []);
return {
data,
isLoading,
error,
};
}

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [MatissJanis]
---
Themes: async fetch the theme catalog