Files
twenty/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx
Raphaël Bosi 051f0fc83f Use data attributes for click outside instead of classNames (#12228)
We previously used classnames to exclude elements from the click outside
listener.

With this PR we can now use `data-click-outside-id` instead of
`classNames` to target the elements we want to exclude from the click
outside listener.

We can also add `data-globally-prevent-click-outside` to a component to
globally prevent triggering click outside listeners for other
components. This attribute is especially useful for confirmation modals
and snackbar items.

Fixes #11785:


https://github.com/user-attachments/assets/318baa7e-0f82-4e3a-a447-bf981328462d
2025-05-22 18:10:51 +02:00

268 lines
9.6 KiB
TypeScript

import { useEffect, useState } from 'react';
import {
matchPath,
useLocation,
useNavigate,
useParams,
} from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import {
setSessionId,
useEventTracker,
} from '@/analytics/hooks/useEventTracker';
import { useExecuteTasksOnAnyLocationChange } from '@/app/hooks/useExecuteTasksOnAnyLocationChange';
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoadedState';
import { isCaptchaRequiredForPath } from '@/captcha/utils/isCaptchaRequiredForPath';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { useActiveRecordBoardCard } from '@/object-record/record-board/hooks/useActiveRecordBoardCard';
import { useFocusedRecordBoardCard } from '@/object-record/record-board/hooks/useFocusedRecordBoardCard';
import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection';
import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope';
import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection';
import { useActiveRecordTableRow } from '@/object-record/record-table/hooks/useActiveRecordTableRow';
import { useFocusedRecordTableRow } from '@/object-record/record-table/hooks/useFocusedRecordTableRow';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { AppBasePath } from '@/types/AppBasePath';
import { AppPath } from '@/types/AppPath';
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { SettingsPath } from '@/types/SettingsPath';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { isDefined } from 'twenty-shared/utils';
import { AnalyticsType } from '~/generated/graphql';
import { usePageChangeEffectNavigateLocation } from '~/hooks/usePageChangeEffectNavigateLocation';
import { useInitializeQueryParamState } from '~/modules/app/hooks/useInitializeQueryParamState';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
import { getPageTitleFromPath } from '~/utils/title-utils';
// TODO: break down into smaller functions and / or hooks
// - moved usePageChangeEffectNavigateLocation into dedicated hook
export const PageChangeEffect = () => {
const navigate = useNavigate();
const [previousLocation, setPreviousLocation] = useState('');
const setHotkeyScope = useSetHotkeyScope();
const location = useLocation();
const pageChangeEffectNavigateLocation =
usePageChangeEffectNavigateLocation();
const eventTracker = useEventTracker();
const { initializeQueryParamState } = useInitializeQueryParamState();
//TODO: refactor useResetTableRowSelection hook to not throw when the argument `recordTableId` is an empty string
// - replace CoreObjectNamePlural.Person
const objectNamePlural =
useParams().objectNamePlural ?? CoreObjectNamePlural.Person;
const contextStoreCurrentViewId = useRecoilComponentValueV2(
contextStoreCurrentViewIdComponentState,
MAIN_CONTEXT_STORE_INSTANCE_ID,
);
const contextStoreCurrentViewType = useRecoilComponentValueV2(
contextStoreCurrentViewTypeComponentState,
MAIN_CONTEXT_STORE_INSTANCE_ID,
);
const recordIndexId = getRecordIndexIdFromObjectNamePluralAndViewId(
objectNamePlural,
contextStoreCurrentViewId || '',
);
const resetTableSelections = useResetTableRowSelection(recordIndexId);
const { unfocusRecordTableRow } = useFocusedRecordTableRow(recordIndexId);
const { deactivateRecordTableRow } = useActiveRecordTableRow(recordIndexId);
const { resetRecordSelection } = useRecordBoardSelection(recordIndexId);
const { deactivateBoardCard } = useActiveRecordBoardCard(recordIndexId);
const { unfocusBoardCard } = useFocusedRecordBoardCard(recordIndexId);
const { executeTasksOnAnyLocationChange } =
useExecuteTasksOnAnyLocationChange();
const { closeCommandMenu } = useCommandMenu();
useEffect(() => {
closeCommandMenu();
}, [location.pathname, closeCommandMenu]);
useEffect(() => {
if (!previousLocation || previousLocation !== location.pathname) {
setPreviousLocation(location.pathname);
executeTasksOnAnyLocationChange();
} else {
return;
}
}, [location, previousLocation, executeTasksOnAnyLocationChange]);
useEffect(() => {
initializeQueryParamState();
if (isDefined(pageChangeEffectNavigateLocation)) {
navigate(pageChangeEffectNavigateLocation);
}
}, [navigate, pageChangeEffectNavigateLocation, initializeQueryParamState]);
useEffect(() => {
const isLeavingRecordIndexPage = !!matchPath(
AppPath.RecordIndexPage,
previousLocation,
);
if (isLeavingRecordIndexPage) {
if (contextStoreCurrentViewType === ContextStoreViewType.Table) {
resetTableSelections();
unfocusRecordTableRow();
deactivateRecordTableRow();
}
if (contextStoreCurrentViewType === ContextStoreViewType.Kanban) {
resetRecordSelection();
deactivateBoardCard();
unfocusBoardCard();
}
}
}, [
previousLocation,
resetTableSelections,
unfocusRecordTableRow,
deactivateRecordTableRow,
contextStoreCurrentViewType,
resetRecordSelection,
deactivateBoardCard,
unfocusBoardCard,
]);
useEffect(() => {
switch (true) {
case isMatchingLocation(location, AppPath.RecordIndexPage): {
setHotkeyScope(RecordIndexHotkeyScope.RecordIndex, {
goto: true,
keyboardShortcutMenu: true,
});
break;
}
case isMatchingLocation(location, AppPath.RecordShowPage): {
setHotkeyScope(PageHotkeyScope.CompanyShowPage, {
goto: true,
keyboardShortcutMenu: true,
});
break;
}
case isMatchingLocation(location, AppPath.OpportunitiesPage): {
setHotkeyScope(PageHotkeyScope.OpportunitiesPage, {
goto: true,
keyboardShortcutMenu: true,
});
break;
}
case isMatchingLocation(location, AppPath.TasksPage): {
setHotkeyScope(PageHotkeyScope.TaskPage, {
goto: true,
keyboardShortcutMenu: true,
});
break;
}
case isMatchingLocation(location, AppPath.SignInUp): {
setHotkeyScope(PageHotkeyScope.SignInUp);
break;
}
case isMatchingLocation(location, AppPath.Invite): {
setHotkeyScope(PageHotkeyScope.SignInUp);
break;
}
case isMatchingLocation(location, AppPath.CreateProfile): {
setHotkeyScope(PageHotkeyScope.CreateProfile);
break;
}
case isMatchingLocation(location, AppPath.CreateWorkspace): {
setHotkeyScope(PageHotkeyScope.CreateWorkspace);
break;
}
case isMatchingLocation(location, AppPath.SyncEmails): {
setHotkeyScope(PageHotkeyScope.SyncEmail);
break;
}
case isMatchingLocation(location, AppPath.InviteTeam): {
setHotkeyScope(PageHotkeyScope.InviteTeam);
break;
}
case isMatchingLocation(location, AppPath.PlanRequired): {
setHotkeyScope(PageHotkeyScope.PlanRequired);
break;
}
case isMatchingLocation(
location,
SettingsPath.ProfilePage,
AppBasePath.Settings,
): {
setHotkeyScope(PageHotkeyScope.ProfilePage, {
goto: true,
keyboardShortcutMenu: true,
});
break;
}
case isMatchingLocation(
location,
SettingsPath.Domain,
AppBasePath.Settings,
): {
setHotkeyScope(PageHotkeyScope.Settings, {
goto: false,
keyboardShortcutMenu: true,
});
break;
}
case isMatchingLocation(
location,
SettingsPath.WorkspaceMembersPage,
AppBasePath.Settings,
): {
setHotkeyScope(PageHotkeyScope.WorkspaceMemberPage, {
goto: true,
keyboardShortcutMenu: true,
});
break;
}
}
}, [location, setHotkeyScope]);
useEffect(() => {
setTimeout(() => {
setSessionId();
eventTracker(AnalyticsType['PAGEVIEW'], {
name: getPageTitleFromPath(location.pathname),
properties: {
pathname: location.pathname,
locale: navigator.language,
userAgent: window.navigator.userAgent,
href: window.location.href,
referrer: document.referrer,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
});
}, 500);
}, [eventTracker, location.pathname]);
const { requestFreshCaptchaToken } = useRequestFreshCaptchaToken();
const isCaptchaScriptLoaded = useRecoilValue(isCaptchaScriptLoadedState);
useEffect(() => {
if (isCaptchaScriptLoaded && isCaptchaRequiredForPath(location.pathname)) {
requestFreshCaptchaToken();
}
}, [isCaptchaScriptLoaded, location.pathname, requestFreshCaptchaToken]);
return <></>;
};