Skip to content

Commit

Permalink
Implement <ScrollRestoration /> (#5086)
Browse files Browse the repository at this point in the history
### Description

Implement &lt;ScrollRestoration /&gt;

### Refs


[#4357

### Demo


https://github.com/twentyhq/twenty/assets/140154534/321242e1-4751-4204-8c86-e9b921c1733e

Fixes #4357

---------

Co-authored-by: gitstart-twenty <gitstart-twenty@users.noreply.github.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
Co-authored-by: v1b3m <vibenjamin6@gmail.com>
Co-authored-by: RubensRafael <rubensrafael2@live.com>
  • Loading branch information
5 people committed May 17, 2024
1 parent 992602b commit 0e525ca
Show file tree
Hide file tree
Showing 8 changed files with 161 additions and 71 deletions.
99 changes: 83 additions & 16 deletions packages/twenty-front/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,39 @@
import { Route, Routes, useLocation } from 'react-router-dom';
import { StrictMode } from 'react';
import {
createBrowserRouter,
createRoutesFromElements,
Outlet,
redirect,
Route,
RouterProvider,
Routes,
useLocation,
} from 'react-router-dom';
import { useRecoilValue } from 'recoil';

import { ApolloProvider } from '@/apollo/components/ApolloProvider';
import { VerifyEffect } from '@/auth/components/VerifyEffect';
import { ClientConfigProvider } from '@/client-config/components/ClientConfigProvider';
import { ClientConfigProviderEffect } from '@/client-config/components/ClientConfigProviderEffect';
import { billingState } from '@/client-config/states/billingState';
import { PromiseRejectionEffect } from '@/error-handler/components/PromiseRejectionEffect';
import { ApolloMetadataClientProvider } from '@/object-metadata/components/ApolloMetadataClientProvider';
import { ObjectMetadataItemsProvider } from '@/object-metadata/components/ObjectMetadataItemsProvider';
import { PrefetchDataProvider } from '@/prefetch/components/PrefetchDataProvider';
import { AppPath } from '@/types/AppPath';
import { SettingsPath } from '@/types/SettingsPath';
import { DialogManager } from '@/ui/feedback/dialog-manager/components/DialogManager';
import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope';
import { SnackBarProvider } from '@/ui/feedback/snack-bar-manager/components/SnackBarProvider';
import { BlankLayout } from '@/ui/layout/page/BlankLayout';
import { DefaultLayout } from '@/ui/layout/page/DefaultLayout';
import { AppThemeProvider } from '@/ui/theme/components/AppThemeProvider';
import { PageTitle } from '@/ui/utilities/page-title/PageTitle';
import { UserProvider } from '@/users/components/UserProvider';
import { UserProviderEffect } from '@/users/components/UserProviderEffect';
import { CommandMenuEffect } from '~/effect-components/CommandMenuEffect';
import { GotoHotkeysEffect } from '~/effect-components/GotoHotkeysEffect';
import { PageChangeEffect } from '~/effect-components/PageChangeEffect';
import { Authorize } from '~/pages/auth/Authorize';
import { ChooseYourPlan } from '~/pages/auth/ChooseYourPlan';
import { CreateProfile } from '~/pages/auth/CreateProfile';
Expand Down Expand Up @@ -54,17 +78,53 @@ import { SettingsWorkspaceMembers } from '~/pages/settings/SettingsWorkspaceMemb
import { Tasks } from '~/pages/tasks/Tasks';
import { getPageTitleFromPath } from '~/utils/title-utils';

export const App = () => {
const billing = useRecoilValue(billingState);
const ProvidersThatNeedRouterContext = () => {
const { pathname } = useLocation();
const pageTitle = getPageTitleFromPath(pathname);

return (
<>
<PageTitle title={pageTitle} />
<GotoHotkeysEffect />
<CommandMenuEffect />
<Routes>
<ApolloProvider>
<ClientConfigProviderEffect />
<ClientConfigProvider>
<UserProviderEffect />
<UserProvider>
<ApolloMetadataClientProvider>
<ObjectMetadataItemsProvider>
<PrefetchDataProvider>
<AppThemeProvider>
<SnackBarProvider>
<DialogManagerScope dialogManagerScopeId="dialog-manager">
<DialogManager>
<StrictMode>
<PromiseRejectionEffect />
<CommandMenuEffect />
<GotoHotkeysEffect />
<PageTitle title={pageTitle} />
<Outlet />
</StrictMode>
</DialogManager>
</DialogManagerScope>
</SnackBarProvider>
</AppThemeProvider>
</PrefetchDataProvider>
<PageChangeEffect />
</ObjectMetadataItemsProvider>
</ApolloMetadataClientProvider>
</UserProvider>
</ClientConfigProvider>
</ApolloProvider>
);
};

const createRouter = (isBillingEnabled?: boolean) =>
createBrowserRouter(
createRoutesFromElements(
<Route
element={<ProvidersThatNeedRouterContext />}
// To switch state to `loading` temporarily to enable us
// to set scroll position before the page is rendered
loader={async () => Promise.resolve(null)}
>
<Route element={<DefaultLayout />}>
<Route path={AppPath.Verify} element={<VerifyEffect />} />
<Route path={AppPath.SignInUp} element={<SignInUp />} />
Expand Down Expand Up @@ -119,12 +179,14 @@ export const App = () => {
path={SettingsPath.AccountsEmailsInboxSettings}
element={<SettingsAccountsEmailsInboxSettings />}
/>
{billing?.isBillingEnabled && (
<Route
path={SettingsPath.Billing}
element={<SettingsBilling />}
/>
)}
<Route
path={SettingsPath.Billing}
element={<SettingsBilling />}
loader={() => {
if (!isBillingEnabled) return redirect(AppPath.Index);
return null;
}}
/>
<Route
path={SettingsPath.WorkspaceMembersPage}
element={<SettingsWorkspaceMembers />}
Expand Down Expand Up @@ -217,7 +279,12 @@ export const App = () => {
<Route element={<BlankLayout />}>
<Route path={AppPath.Authorize} element={<Authorize />} />
</Route>
</Routes>
</>
</Route>,
),
);

export const App = () => {
const billing = useRecoilValue(billingState);

return <RouterProvider router={createRouter(billing?.isBillingEnabled)} />;
};
34 changes: 34 additions & 0 deletions packages/twenty-front/src/hooks/useScrollRestoration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useEffect } from 'react';
import { useLocation, useNavigation } from 'react-router-dom';
import { useRecoilState, useRecoilValue } from 'recoil';

import { overlayScrollbarsState } from '@/ui/utilities/scroll/states/overlayScrollbarsState';
import { scrollPositionState } from '@/ui/utilities/scroll/states/scrollPositionState';
import { isDefined } from '~/utils/isDefined';

/**
* Note that `location.key` is used in the cache key, not `location.pathname`,
* so the same path navigated to at different points in the history stack will
* not share the same scroll position.
*/
export const useScrollRestoration = (viewportHeight?: number) => {
const key = `scroll-position-${useLocation().key}`;
const { state } = useNavigation();

const [scrollPosition, setScrollPosition] = useRecoilState(
scrollPositionState(key),
);

const overlayScrollbars = useRecoilValue(overlayScrollbarsState);

const scrollWrapper = overlayScrollbars?.elements().viewport;
const skip = isDefined(viewportHeight) && scrollPosition > viewportHeight;

useEffect(() => {
if (state === 'loading') {
setScrollPosition(scrollWrapper?.scrollTop ?? 0);
} else if (state === 'idle' && isDefined(scrollWrapper) && !skip) {
scrollWrapper.scrollTo({ top: scrollPosition });
}
}, [key, state, scrollWrapper, skip, scrollPosition, setScrollPosition]);
};
62 changes: 9 additions & 53 deletions packages/twenty-front/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,14 @@
import { StrictMode } from 'react';
import ReactDOM from 'react-dom/client';
import { HelmetProvider } from 'react-helmet-async';
import { BrowserRouter } from 'react-router-dom';
import { RecoilRoot } from 'recoil';
import { IconsProvider } from 'twenty-ui';

import { ApolloProvider } from '@/apollo/components/ApolloProvider';
import { CaptchaProvider } from '@/captcha/components/CaptchaProvider';
import { ClientConfigProvider } from '@/client-config/components/ClientConfigProvider';
import { ClientConfigProviderEffect } from '@/client-config/components/ClientConfigProviderEffect';
import { ApolloDevLogEffect } from '@/debug/components/ApolloDevLogEffect';
import { RecoilDebugObserverEffect } from '@/debug/components/RecoilDebugObserver';
import { AppErrorBoundary } from '@/error-handler/components/AppErrorBoundary';
import { ExceptionHandlerProvider } from '@/error-handler/components/ExceptionHandlerProvider';
import { PromiseRejectionEffect } from '@/error-handler/components/PromiseRejectionEffect';
import { ApolloMetadataClientProvider } from '@/object-metadata/components/ApolloMetadataClientProvider';
import { ObjectMetadataItemsProvider } from '@/object-metadata/components/ObjectMetadataItemsProvider';
import { PrefetchDataProvider } from '@/prefetch/components/PrefetchDataProvider';
import { DialogManager } from '@/ui/feedback/dialog-manager/components/DialogManager';
import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope';
import { SnackBarProvider } from '@/ui/feedback/snack-bar-manager/components/SnackBarProvider';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { AppThemeProvider } from '@/ui/theme/components/AppThemeProvider';
import { UserProvider } from '@/users/components/UserProvider';
import { UserProviderEffect } from '@/users/components/UserProviderEffect';
import { PageChangeEffect } from '~/effect-components/PageChangeEffect';

import '@emotion/react';

Expand All @@ -43,43 +27,15 @@ root.render(
<CaptchaProvider>
<RecoilDebugObserverEffect />
<ApolloDevLogEffect />
<BrowserRouter>
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
<IconsProvider>
<ExceptionHandlerProvider>
<ApolloProvider>
<HelmetProvider>
<ClientConfigProviderEffect />
<ClientConfigProvider>
<UserProviderEffect />
<UserProvider>
<ApolloMetadataClientProvider>
<ObjectMetadataItemsProvider>
<PrefetchDataProvider>
<AppThemeProvider>
<SnackBarProvider>
<DialogManagerScope dialogManagerScopeId="dialog-manager">
<DialogManager>
<StrictMode>
<PromiseRejectionEffect />
<App />
</StrictMode>
</DialogManager>
</DialogManagerScope>
</SnackBarProvider>
</AppThemeProvider>
</PrefetchDataProvider>
<PageChangeEffect />
</ObjectMetadataItemsProvider>
</ApolloMetadataClientProvider>
</UserProvider>
</ClientConfigProvider>
</HelmetProvider>
</ApolloProvider>
</ExceptionHandlerProvider>
</IconsProvider>
</SnackBarProviderScope>
</BrowserRouter>
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
<IconsProvider>
<ExceptionHandlerProvider>
<HelmetProvider>
<App />
</HelmetProvider>
</ExceptionHandlerProvider>
</IconsProvider>
</SnackBarProviderScope>
</CaptchaProvider>
</AppErrorBoundary>
</RecoilRoot>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutsideByClassName } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useScrollRestoration } from '~/hooks/useScrollRestoration';

export type RecordBoardProps = {
recordBoardId: string;
Expand All @@ -42,6 +43,11 @@ const StyledBoardHeader = styled.div`
z-index: 1;
`;

const RecordBoardScrollRestoreEffect = () => {
useScrollRestoration();
return null;
};

export const RecordBoard = ({ recordBoardId }: RecordBoardProps) => {
const { updateOneRecord, selectFieldMetadataItem } =
useContext(RecordBoardContext);
Expand Down Expand Up @@ -152,6 +158,7 @@ export const RecordBoard = ({ recordBoardId }: RecordBoardProps) => {
))}
</DragDropContext>
</StyledContainer>
<RecordBoardScrollRestoreEffect />
</ScrollWrapper>
<DragSelect
dragSelectable={boardRef}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useRecoilState, useRecoilValue } from 'recoil';
import { useLoadRecordIndexTable } from '@/object-record/record-index/hooks/useLoadRecordIndexTable';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { isFetchingMoreRecordsFamilyState } from '@/object-record/states/isFetchingMoreRecordsFamilyState';
import { useScrollRestoration } from '~/hooks/useScrollRestoration';

type RecordTableBodyEffectProps = {
objectNameSingular: string;
Expand Down Expand Up @@ -31,6 +32,11 @@ export const RecordTableBodyEffect = ({
isFetchingMoreRecordsFamilyState(queryStateIdentifier),
);

const rowHeight = 32;
const viewportHeight = records.length * rowHeight;

useScrollRestoration(viewportHeight);

useEffect(() => {
if (!loading) {
setRecordTableData(records, totalCount);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { createContext, RefObject, useEffect, useRef } from 'react';
import styled from '@emotion/styled';
import { OverlayScrollbars } from 'overlayscrollbars';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import { useRecoilCallback } from 'recoil';
import { useRecoilCallback, useSetRecoilState } from 'recoil';

import { overlayScrollbarsState } from '@/ui/utilities/scroll/states/overlayScrollbarsState';
import { scrollLeftState } from '@/ui/utilities/scroll/states/scrollLeftState';
import { scrollTopState } from '@/ui/utilities/scroll/states/scrollTopState';

Expand Down Expand Up @@ -48,7 +49,9 @@ export const ScrollWrapper = ({
[],
);

const [initialize] = useOverlayScrollbars({
const setOverlayScrollbars = useSetRecoilState(overlayScrollbarsState);

const [initialize, instance] = useOverlayScrollbars({
options: {
scrollbars: { autoHide: 'scroll' },
overflow: {
Expand All @@ -67,6 +70,10 @@ export const ScrollWrapper = ({
}
}, [initialize, scrollableRef]);

useEffect(() => {
setOverlayScrollbars(instance());
}, [instance, setOverlayScrollbars]);

return (
<ScrollWrapperContext.Provider value={scrollableRef}>
<StyledScrollWrapper ref={scrollableRef} className={className}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { OverlayScrollbars } from 'overlayscrollbars';
import { createState } from 'twenty-ui';

export const overlayScrollbarsState = createState<OverlayScrollbars | null>({
key: 'scroll/overlayScrollbarsState',
defaultValue: null,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState';

export const scrollPositionState = createFamilyState({
key: 'scroll/scrollPositionState',
defaultValue: 0,
});

0 comments on commit 0e525ca

Please sign in to comment.