mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-11 17:47:00 -05:00
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:
committed by
GitHub
parent
b6452f930b
commit
ee0e7ed3e0
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
53
packages/desktop-client/src/hooks/useThemeCatalog.ts
Normal file
53
packages/desktop-client/src/hooks/useThemeCatalog.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
6
upcoming-release-notes/6681.md
Normal file
6
upcoming-release-notes/6681.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Themes: async fetch the theme catalog
|
||||
Reference in New Issue
Block a user