Compare commits

...

5 Commits

Author SHA1 Message Date
lelemm
148d3759bf Delete upcoming-release-notes/5174.md 2025-06-19 17:27:34 -03:00
Leandro Menezes
d480065924 trying to create a generic solution for this 2025-06-19 00:46:48 -03:00
autofix-ci[bot]
8bf59fe1e5 [autofix.ci] apply automated fixes 2025-06-15 23:40:34 +00:00
Leandro Menezes
24fd50ab3b md 2025-06-15 20:39:16 -03:00
Leandro Menezes
dabad3258e Restore navigation scroll 2025-06-15 20:36:47 -03:00
10 changed files with 254 additions and 51 deletions

View File

@@ -31,6 +31,7 @@ import { LoadingIndicator } from './reports/LoadingIndicator';
import { NarrowAlternate, WideComponent } from './responsive';
import { UserDirectoryPage } from './responsive/wide';
import { ScrollProvider } from './ScrollProvider';
import { ScrollRestoreProvider } from './ScrollRestore';
import { useMultiuserEnabled } from './ServerContext';
import { Settings } from './settings';
import { FloatableSidebar } from './sidebar';
@@ -202,10 +203,11 @@ export function FinancesApp() {
width: '100%',
}}
>
<ScrollProvider
isDisabled={!isNarrowWidth}
scrollableRef={scrollableRef}
>
<ScrollRestoreProvider>
<ScrollProvider
isDisabled={!isNarrowWidth}
scrollableRef={scrollableRef}
>
<View
ref={scrollableRef}
style={{
@@ -338,6 +340,7 @@ export function FinancesApp() {
<Route path="*" element={null} />
</Routes>
</ScrollProvider>
</ScrollRestoreProvider>
</View>
</View>
</View>

View File

@@ -0,0 +1,183 @@
import React, {
type ReactNode,
type RefObject,
createContext,
useContext,
useEffect,
useCallback,
useRef,
useMemo,
} from 'react';
import { useLocation } from 'react-router-dom';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import debounce from 'debounce';
type ScrollPosition = {
x: number;
y: number;
};
type ScrollRestoreContextValue = {
registerScrollElement: (key: string, element: HTMLElement) => () => void;
navigate: ReturnType<typeof useNavigate>;
};
const ScrollRestoreContext = createContext<ScrollRestoreContextValue | undefined>(undefined);
type ScrollRestoreProviderProps = {
children: ReactNode;
};
export function ScrollRestoreProvider({ children }: ScrollRestoreProviderProps) {
const location = useLocation();
const navigate = useNavigate();
const scrollElements = useRef<Map<string, HTMLElement>>(new Map());
const originalNavigate = useRef(navigate);
// Update navigate ref when it changes
useEffect(() => {
originalNavigate.current = navigate;
}, [navigate]);
// Create a custom navigate function that saves scroll positions before navigating
const enhancedNavigate = useCallback((to: any, options: any = {}) => {
// Get current scroll positions
const scrollPositions: Record<string, ScrollPosition> = {};
scrollElements.current.forEach((element, key) => {
scrollPositions[key] = {
x: element.scrollLeft,
y: element.scrollTop,
};
});
// If we have scroll positions, update the current location state
if (Object.keys(scrollPositions).length > 0) {
// First, update current location with scroll positions
originalNavigate.current(location.pathname + location.search, {
replace: true,
state: {
...location.state,
scrollPositions,
},
});
}
// Then navigate to the new location
originalNavigate.current(to, options);
}, [location, originalNavigate]);
// Restore scroll positions when coming to a page
useEffect(() => {
const savedPositions = location.state?.scrollPositions;
if (!savedPositions || typeof savedPositions !== 'object') {
return;
}
let attemptCount = 0;
const maxAttempts = 20; // Allow more attempts for loading states
const restoreScrollPositions = () => {
attemptCount++;
// Check if we have any registered elements that match the saved positions
const savedKeys = Object.keys(savedPositions as Record<string, ScrollPosition>);
const hasRegisteredElements = savedKeys.some(key => scrollElements.current.has(key));
if (!hasRegisteredElements) {
if (attemptCount < maxAttempts) {
// Elements not registered yet, try again with increasing delay for loading states
const delay = attemptCount < 5 ? 50 : attemptCount < 10 ? 150 : 300;
setTimeout(restoreScrollPositions, delay);
}
return;
}
// Wait for next frame to ensure elements are fully rendered
requestAnimationFrame(() => {
Object.entries(savedPositions as Record<string, ScrollPosition>).forEach(([key, position]) => {
const element = scrollElements.current.get(key);
if (element && typeof position === 'object' && 'x' in position && 'y' in position) {
element.scrollTo(position.x, position.y);
}
});
});
};
// Start restoration after a small delay to allow initial mounting
const timeoutId = setTimeout(restoreScrollPositions, 10);
return () => clearTimeout(timeoutId);
}, [location.state?.scrollPositions]);
const registerScrollElement = useCallback((key: string, element: HTMLElement) => {
scrollElements.current.set(key, element);
return () => {
scrollElements.current.delete(key);
};
}, []);
const contextValue = useMemo(
() => ({
registerScrollElement,
navigate: enhancedNavigate,
}),
[registerScrollElement, enhancedNavigate],
);
return (
<ScrollRestoreContext.Provider value={contextValue}>
{children}
</ScrollRestoreContext.Provider>
);
}
type ScrollRestoreProps = {
scrollKey?: string;
children: ReactNode;
};
export function ScrollRestore({ scrollKey = 'default', children }: ScrollRestoreProps) {
const context = useContext(ScrollRestoreContext);
const scrollElementRef = useRef<HTMLElement>(null);
if (!context) {
throw new Error('ScrollRestore must be used within a ScrollRestoreProvider');
}
const { registerScrollElement } = context;
useEffect(() => {
const element = scrollElementRef.current;
if (element) {
return registerScrollElement(scrollKey, element);
}
}, [registerScrollElement, scrollKey]);
// Clone the child element and add ref
const childElement = React.Children.only(children) as React.ReactElement<any>;
return React.cloneElement(childElement, {
innerRef: (ref: HTMLElement) => {
scrollElementRef.current = ref;
// Call original innerRef if it exists
const originalRef = childElement.props.innerRef;
if (typeof originalRef === 'function') {
originalRef(ref);
} else if (originalRef && typeof originalRef === 'object') {
(originalRef as RefObject<HTMLElement>).current = ref;
}
},
});
}
export function useScrollRestore() {
const context = useContext(ScrollRestoreContext);
if (!context) {
throw new Error('useScrollRestore must be used within a ScrollRestoreProvider');
}
return context;
}

View File

@@ -9,6 +9,8 @@ import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { ScrollRestore } from '../ScrollRestore';
import { q } from 'loot-core/shared/query';
import {
type CategoryEntity,
@@ -91,7 +93,7 @@ export function BudgetTable(props: BudgetTableProps) {
);
const [categoryExpandedStatePref] = useGlobalPref('categoryExpandedState');
const categoryExpandedState = categoryExpandedStatePref ?? 0;
const [editing, setEditing] = useState<{ id: string; cell: string } | null>(
const [editing, setEditing] = useState<{ id: string; cell: string } | null>(
null,
);
@@ -229,6 +231,8 @@ export function BudgetTable(props: BudgetTableProps) {
onCollapse(categoryGroups.map(g => g.id));
};
const schedulesQuery = useMemo(() => q('schedules').select('*'), []);
return (
@@ -280,15 +284,16 @@ export function BudgetTable(props: BudgetTableProps) {
expandAllCategories={expandAllCategories}
collapseAllCategories={collapseAllCategories}
/>
<View
style={{
overflowY: 'scroll',
overflowAnchor: 'none',
flex: 1,
paddingLeft: 5,
paddingRight: 5,
}}
>
<ScrollRestore scrollKey="budget-table">
<View
style={{
overflowY: 'scroll',
overflowAnchor: 'none',
flex: 1,
paddingLeft: 5,
paddingRight: 5,
}}
>
<View
style={{
flexShrink: 0,
@@ -315,7 +320,8 @@ export function BudgetTable(props: BudgetTableProps) {
/>
</SchedulesProvider>
</View>
</View>
</View>
</ScrollRestore>
</MonthsProvider>
</View>
);

View File

@@ -51,9 +51,9 @@ import {
} from '@desktop-client/components/table';
import { useCategoryScheduleGoalTemplateIndicator } from '@desktop-client/hooks/useCategoryScheduleGoalTemplateIndicator';
import { useContextMenu } from '@desktop-client/hooks/useContextMenu';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { useUndo } from '@desktop-client/hooks/useUndo';
import { envelopeBudget } from '@desktop-client/queries/queries';
import { useScrollRestore } from '@desktop-client/components/ScrollRestore';
export function useEnvelopeSheetName<
FieldName extends SheetFields<'envelope-budget'>,
@@ -251,7 +251,7 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
const { showUndoNotification } = useUndo();
const navigate = useNavigate();
const { navigate } = useScrollRestore();
const { schedule, scheduleStatus, isScheduleRecurring, description } =
useCategoryScheduleGoalTemplateIndicator({
@@ -414,6 +414,7 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
}}
/>
</View>
<Field name="spent" width="flex" style={{ textAlign: 'right' }}>
<View
data-testid="category-month-spent"
@@ -439,7 +440,7 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
? theme.warningText
: theme.upcomingText,
}}
onPress={() =>
onPress={() =>
schedule._account
? navigate(`/accounts/${schedule._account}`)
: navigate('/accounts')

View File

@@ -37,6 +37,7 @@ import {
updateGroup,
} from '@desktop-client/queries/queriesSlice';
import { useDispatch } from '@desktop-client/redux';
import { useScrollRestore } from '../ScrollRestore';
type TrackingReportComponents = {
SummaryComponent: typeof trackingBudget.BudgetSummary;
@@ -69,7 +70,7 @@ function BudgetInner(props: BudgetInnerProps) {
const currentMonth = monthUtils.currentMonth();
const spreadsheet = useSpreadsheet();
const dispatch = useDispatch();
const navigate = useNavigate();
const { navigate } = useScrollRestore();
const [summaryCollapsed, setSummaryCollapsedPref] = useLocalPref(
'budget.summaryCollapsed',
);
@@ -280,6 +281,7 @@ function BudgetInner(props: BudgetInnerProps) {
type: 'date',
},
];
navigate('/accounts', {
state: {
goBack: true,

View File

@@ -49,7 +49,7 @@ import {
type SheetCellProps,
} from '@desktop-client/components/table';
import { useCategoryScheduleGoalTemplateIndicator } from '@desktop-client/hooks/useCategoryScheduleGoalTemplateIndicator';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { useScrollRestore } from '@desktop-client/components/ScrollRestore';
import { useUndo } from '@desktop-client/hooks/useUndo';
import { trackingBudget } from '@desktop-client/queries/queries';
@@ -242,7 +242,7 @@ export const CategoryMonth = memo(function CategoryMonth({
const { showUndoNotification } = useUndo();
const navigate = useNavigate();
const { navigate } = useScrollRestore();
const { schedule, scheduleStatus, isScheduleRecurring, description } =
useCategoryScheduleGoalTemplateIndicator({
@@ -417,7 +417,7 @@ export const CategoryMonth = memo(function CategoryMonth({
? theme.warningText
: theme.upcomingText,
}}
onPress={() =>
onPress={() =>
schedule._account
? navigate(`/accounts/${schedule._account}`)
: navigate('/accounts')

View File

@@ -33,6 +33,7 @@ import { IncomeGroup } from './IncomeGroup';
import { MOBILE_NAV_HEIGHT } from '@desktop-client/components/mobile/MobileNavTabs';
import { PullToRefresh } from '@desktop-client/components/mobile/PullToRefresh';
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
import { ScrollRestore, useScrollRestore } from '@desktop-client/components/ScrollRestore';
import { PrivacyFilter } from '@desktop-client/components/PrivacyFilter';
import { CellValue } from '@desktop-client/components/spreadsheet/CellValue';
import { useFormat } from '@desktop-client/components/spreadsheet/useFormat';
@@ -41,7 +42,6 @@ import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
import { useCategories } from '@desktop-client/hooks/useCategories';
import { useLocale } from '@desktop-client/hooks/useLocale';
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { useOverspentCategories } from '@desktop-client/hooks/useOverspentCategories';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { useUndo } from '@desktop-client/hooks/useUndo';
@@ -331,6 +331,8 @@ export function BudgetTable({
'mobile.showSpentColumn',
);
function toggleSpentColumn() {
setShowSpentColumnPref(!showSpentColumn);
}
@@ -343,6 +345,8 @@ export function BudgetTable({
const schedulesQuery = useMemo(() => q('schedules').select('*'), []);
return (
<Page
padding={0}
@@ -400,27 +404,30 @@ export function BudgetTable({
onShowBudgetSummary={onShowBudgetSummary}
/>
<PullToRefresh onRefresh={onRefresh}>
<View
data-testid="budget-table"
style={{
backgroundColor: theme.pageBackground,
paddingBottom: MOBILE_NAV_HEIGHT,
}}
>
<SchedulesProvider query={schedulesQuery}>
<BudgetGroups
type={budgetType}
categoryGroups={categoryGroups}
showBudgetedColumn={!showSpentColumn}
show3Columns={show3Columns}
showHiddenCategories={showHiddenCategories}
month={month}
onEditCategoryGroup={onEditCategoryGroup}
onEditCategory={onEditCategory}
onBudgetAction={onBudgetAction}
/>
</SchedulesProvider>
</View>
<ScrollRestore scrollKey="mobile-budget-table">
<View
data-testid="budget-table"
style={{
backgroundColor: theme.pageBackground,
paddingBottom: MOBILE_NAV_HEIGHT,
overflowY: 'auto',
}}
>
<SchedulesProvider query={schedulesQuery}>
<BudgetGroups
type={budgetType}
categoryGroups={categoryGroups}
showBudgetedColumn={!showSpentColumn}
show3Columns={show3Columns}
showHiddenCategories={showHiddenCategories}
month={month}
onEditCategoryGroup={onEditCategoryGroup}
onEditCategory={onEditCategory}
onBudgetAction={onBudgetAction}
/>
</SchedulesProvider>
</View>
</ScrollRestore>
</PullToRefresh>
</Page>
);
@@ -450,7 +457,7 @@ function Banner({ type = 'info', children }) {
function UncategorizedTransactionsBanner(props) {
const count = useSheetValue(uncategorizedCount());
const navigate = useNavigate();
const { navigate } = useScrollRestore();
if (count === null || count <= 0) {
return null;

View File

@@ -16,11 +16,11 @@ import { MobileBackButton } from '@desktop-client/components/mobile/MobileBackBu
import { AddTransactionButton } from '@desktop-client/components/mobile/transactions/AddTransactionButton';
import { TransactionListWithBalances } from '@desktop-client/components/mobile/transactions/TransactionListWithBalances';
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
import { useScrollRestore } from '@desktop-client/components/ScrollRestore';
import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
import { useCategoryPreviewTransactions } from '@desktop-client/hooks/useCategoryPreviewTransactions';
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useLocale } from '@desktop-client/hooks/useLocale';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { useTransactions } from '@desktop-client/hooks/useTransactions';
import { useTransactionsSearch } from '@desktop-client/hooks/useTransactionsSearch';
import * as queries from '@desktop-client/queries/queries';
@@ -74,7 +74,7 @@ function TransactionListWithPreviews({
month,
}: TransactionListWithPreviewsProps) {
const dispatch = useDispatch();
const navigate = useNavigate();
const { navigate } = useScrollRestore();
const baseTransactionsQuery = useCallback(
() =>

View File

@@ -21,7 +21,7 @@ import { SpentCell } from './SpentCell';
import { useSheetValue } from '@desktop-client/components/spreadsheet/useSheetValue';
import { useCategories } from '@desktop-client/hooks/useCategories';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { useScrollRestore } from '@desktop-client/components/ScrollRestore';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { useUndo } from '@desktop-client/hooks/useUndo';
import { collapseModals, pushModal } from '@desktop-client/modals/modalsSlice';
@@ -386,11 +386,12 @@ export function ExpenseCategoryListItem({
onCover,
]);
const navigate = useNavigate();
const { navigate } = useScrollRestore();
const onShowActivity = useCallback(() => {
if (!category) {
return;
}
navigate(`/categories/${category.id}?month=${month}`);
}, [category, month, navigate]);

View File

@@ -16,7 +16,7 @@ import { BalanceCell } from './BalanceCell';
import { BudgetCell } from './BudgetCell';
import { getColumnWidth, ROW_HEIGHT } from './BudgetTable';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { useScrollRestore } from '@desktop-client/components/ScrollRestore';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { collapseModals, pushModal } from '@desktop-client/modals/modalsSlice';
import {
@@ -185,7 +185,7 @@ export function IncomeCategoryListItem({
}: IncomeCategoryListItemProps) {
const { value: category } = props;
const dispatch = useDispatch();
const navigate = useNavigate();
const { navigate } = useScrollRestore();
const [budgetType = 'envelope'] = useSyncedPref('budgetType');
const balanceMenuModalName = `envelope-income-balance-menu`;