Admin panel refactor (#10119)

addressing > 
There are two patterns to avoid:
Creating functions that return JSX like renderThing() -> this was taken
already addressed in https://github.com/twentyhq/twenty/pull/10011
Making a hook that "stores" all the logic of a component - > this PR is
addressing this particular pattern
In essence, handlers should remain in the component and be connected to
their events.
And everything in a handler can be abstracted into its dedicated hook.
For example:
const { myReactiveState } =
useRecoilValue(myReactiveStateComponentState);
const { removeThingFromOtherThing } = useRemoveThingFromOtherThing();

const handleClick = () => {
  if (isDefined(myReactiveState)) {
    removeThingFromOtherThing();
  }
}

Broadly speaking, this is how you can split large components into
several sub-hooks.

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
nitin
2025-02-11 22:40:28 +05:30
committed by GitHub
parent 83bf2d1739
commit 252922b522
11 changed files with 266 additions and 296 deletions

View File

@ -36,6 +36,8 @@ const StyledShowMoreButton = styled(Button)<{ isSelected?: boolean }>`
`}
`;
const StyledEnvVariablesDescription = styled.div``;
export const SettingsAdminEnvVariables = () => {
const { data: environmentVariables } = useGetEnvironmentVariablesGroupedQuery(
{
@ -65,59 +67,68 @@ export const SettingsAdminEnvVariables = () => {
);
return (
<Section>
{visibleGroups.map((group) => (
<StyledGroupContainer key={group.name}>
<H1Title title={group.name} fontColor={H1TitleFontColor.Primary} />
{group.description !== '' && (
<StyledGroupDescription>{group.description}</StyledGroupDescription>
)}
{group.variables.length > 0 && (
<StyledGroupVariablesContainer>
<SettingsAdminEnvVariablesTable variables={group.variables} />
</StyledGroupVariablesContainer>
)}
</StyledGroupContainer>
))}
<>
<StyledEnvVariablesDescription>
These are only the server values. Ensure your worker environment has the
same variables and values, this is required for asynchronous tasks like
email sync.
</StyledEnvVariablesDescription>
<Section>
{visibleGroups.map((group) => (
<StyledGroupContainer key={group.name}>
<H1Title title={group.name} fontColor={H1TitleFontColor.Primary} />
{group.description !== '' && (
<StyledGroupDescription>
{group.description}
</StyledGroupDescription>
)}
{group.variables.length > 0 && (
<StyledGroupVariablesContainer>
<SettingsAdminEnvVariablesTable variables={group.variables} />
</StyledGroupVariablesContainer>
)}
</StyledGroupContainer>
))}
{hiddenGroups.length > 0 && (
<>
<StyledButtonsRow>
{hiddenGroups.map((group) => (
<StyledShowMoreButton
key={group.name}
onClick={() => toggleGroupVisibility(group.name)}
title={group.name}
variant="secondary"
isSelected={selectedGroup === group.name}
>
{group.name} variables
</StyledShowMoreButton>
))}
</StyledButtonsRow>
{hiddenGroups.length > 0 && (
<>
<StyledButtonsRow>
{hiddenGroups.map((group) => (
<StyledShowMoreButton
key={group.name}
onClick={() => toggleGroupVisibility(group.name)}
title={group.name}
variant="secondary"
isSelected={selectedGroup === group.name}
>
{group.name} variables
</StyledShowMoreButton>
))}
</StyledButtonsRow>
{selectedGroupData && (
<StyledGroupContainer>
<H1Title
title={selectedGroupData.name}
fontColor={H1TitleFontColor.Primary}
/>
{selectedGroupData.description !== '' && (
<StyledGroupDescription>
{selectedGroupData.description}
</StyledGroupDescription>
)}
{selectedGroupData.variables.length > 0 && (
<StyledGroupVariablesContainer>
<SettingsAdminEnvVariablesTable
variables={selectedGroupData.variables}
/>
</StyledGroupVariablesContainer>
)}
</StyledGroupContainer>
)}
</>
)}
</Section>
{selectedGroupData && (
<StyledGroupContainer>
<H1Title
title={selectedGroupData.name}
fontColor={H1TitleFontColor.Primary}
/>
{selectedGroupData.description !== '' && (
<StyledGroupDescription>
{selectedGroupData.description}
</StyledGroupDescription>
)}
{selectedGroupData.variables.length > 0 && (
<StyledGroupVariablesContainer>
<SettingsAdminEnvVariablesTable
variables={selectedGroupData.variables}
/>
</StyledGroupVariablesContainer>
)}
</StyledGroupContainer>
)}
</>
)}
</Section>
</>
);
};

View File

@ -1,10 +1,9 @@
import { canManageFeatureFlagsState } from '@/client-config/states/canManageFeatureFlagsState';
import { SettingsAdminWorkspaceContent } from '@/settings/admin-panel/components/SettingsAdminWorkspaceContent';
import { SETTINGS_ADMIN_USER_LOOKUP_WORKSPACE_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminUserLookupWorkspaceTabsId';
import { useImpersonate } from '@/settings/admin-panel/hooks/useImpersonate';
import { useUserLookup } from '@/settings/admin-panel/hooks/useUserLookup';
import { adminPanelErrorState } from '@/settings/admin-panel/states/adminPanelErrorState';
import { userLookupResultState } from '@/settings/admin-panel/states/userLookupResultState';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { TextInput } from '@/ui/input/components/TextInput';
import { TabList } from '@/ui/layout/tab/components/TabList';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
@ -12,7 +11,7 @@ import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/consta
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { useRecoilState, useRecoilValue } from 'recoil';
import { getImageAbsoluteURI, isDefined } from 'twenty-shared';
import {
Button,
@ -23,6 +22,7 @@ import {
Section,
} from 'twenty-ui';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useUserLookupAdminPanelMutation } from '~/generated/graphql';
const StyledLinkContainer = styled.div`
margin-right: ${({ theme }) => theme.spacing(2)};
@ -35,11 +35,6 @@ const StyledContainer = styled.div`
flex-direction: row;
`;
const StyledErrorSection = styled.div`
color: ${({ theme }) => theme.font.color.danger};
margin-top: ${({ theme }) => theme.spacing(2)};
`;
const StyledUserInfo = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(5)};
`;
@ -60,40 +55,48 @@ const StyledContentContainer = styled.div`
export const SettingsAdminGeneral = () => {
const [userIdentifier, setUserIdentifier] = useState('');
const [userId, setUserId] = useState('');
const { error: impersonateError } = useImpersonate();
const { enqueueSnackBar } = useSnackBar();
const { activeTabId, setActiveTabId } = useTabList(
SETTINGS_ADMIN_USER_LOOKUP_WORKSPACE_TABS_ID,
);
const userLookupResult = useRecoilValue(userLookupResultState);
const adminPanelError = useRecoilValue(adminPanelErrorState);
const [userLookupResult, setUserLookupResult] = useRecoilState(
userLookupResultState,
);
const [isUserLookupLoading, setIsUserLookupLoading] = useState(false);
const { handleUserLookup, isLoading } = useUserLookup();
const [userLookup] = useUserLookupAdminPanelMutation();
const canManageFeatureFlags = useRecoilValue(canManageFeatureFlagsState);
const handleSearch = async () => {
setActiveTabId('');
setIsUserLookupLoading(true);
setUserLookupResult(null);
const result = await handleUserLookup(userIdentifier);
const response = await userLookup({
variables: { userIdentifier },
onCompleted: (data) => {
setIsUserLookupLoading(false);
if (isDefined(data?.userLookupAdminPanel)) {
setUserLookupResult(data.userLookupAdminPanel);
}
},
onError: (error) => {
setIsUserLookupLoading(false);
enqueueSnackBar(error.message, {
variant: SnackBarVariant.Error,
});
},
});
if (isDefined(result?.user?.id) && !adminPanelError) {
setUserId(result.user.id.trim());
}
const result = response.data?.userLookupAdminPanel;
if (
isDefined(result?.workspaces) &&
result.workspaces.length > 0 &&
!adminPanelError
) {
if (isDefined(result?.workspaces) && result.workspaces.length > 0) {
setActiveTabId(result.workspaces[0].id);
}
};
const shouldShowUserData = userLookupResult && !adminPanelError;
const activeWorkspace = userLookupResult?.workspaces.find(
(workspace) => workspace.id === activeTabId,
);
@ -139,7 +142,7 @@ export const SettingsAdminGeneral = () => {
onInputEnter={handleSearch}
placeholder="Enter user ID or email address"
fullWidth
disabled={isLoading}
disabled={isUserLookupLoading}
/>
</StyledLinkContainer>
<Button
@ -148,18 +151,12 @@ export const SettingsAdminGeneral = () => {
accent="blue"
title="Search"
onClick={handleSearch}
disabled={!userIdentifier.trim() || isLoading}
disabled={!userIdentifier.trim() || isUserLookupLoading}
/>
</StyledContainer>
{(adminPanelError || impersonateError) && (
<StyledErrorSection>
{adminPanelError ?? impersonateError}
</StyledErrorSection>
)}
</Section>
{shouldShowUserData && (
{isDefined(userLookupResult) && (
<Section>
<StyledUserInfo>
<H1Title title="User Info" fontColor={H1TitleFontColor.Primary} />
@ -180,10 +177,7 @@ export const SettingsAdminGeneral = () => {
/>
</StyledTabListContainer>
<StyledContentContainer>
<SettingsAdminWorkspaceContent
activeWorkspace={activeWorkspace}
userId={userId}
/>
<SettingsAdminWorkspaceContent activeWorkspace={activeWorkspace} />
</StyledContentContainer>
</Section>
)}

View File

@ -1,19 +1,30 @@
import { Button, H2Title, IconUser, Toggle } from 'twenty-ui';
import { currentUserState } from '@/auth/states/currentUserState';
import { canManageFeatureFlagsState } from '@/client-config/states/canManageFeatureFlagsState';
import { useFeatureFlag } from '@/settings/admin-panel/hooks/useFeatureFlag';
import { useImpersonate } from '@/settings/admin-panel/hooks/useImpersonate';
import { useFeatureFlagState } from '@/settings/admin-panel/hooks/useFeatureFlagState';
import { useImpersonationAuth } from '@/settings/admin-panel/hooks/useImpersonationAuth';
import { useImpersonationRedirect } from '@/settings/admin-panel/hooks/useImpersonationRedirect';
import { userLookupResultState } from '@/settings/admin-panel/states/userLookupResultState';
import { WorkspaceInfo } from '@/settings/admin-panel/types/WorkspaceInfo';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
import {
FeatureFlagKey,
useImpersonateMutation,
useUpdateWorkspaceFeatureFlagMutation,
} from '~/generated/graphql';
type SettingsAdminWorkspaceContentProps = {
activeWorkspace: WorkspaceInfo | undefined;
userId: string;
};
const StyledTable = styled(Table)`
@ -22,17 +33,85 @@ const StyledTable = styled(Table)`
export const SettingsAdminWorkspaceContent = ({
activeWorkspace,
userId,
}: SettingsAdminWorkspaceContentProps) => {
const canManageFeatureFlags = useRecoilValue(canManageFeatureFlagsState);
const [isImpersonateLoading, setIsImpersonationLoading] = useState(false);
const { enqueueSnackBar } = useSnackBar();
const [currentUser] = useRecoilState(currentUserState);
const {
handleImpersonate,
isLoading: isImpersonateLoading,
canImpersonate,
} = useImpersonate();
const { executeImpersonationAuth } = useImpersonationAuth();
const { executeImpersonationRedirect } = useImpersonationRedirect();
const [updateFeatureFlag] = useUpdateWorkspaceFeatureFlagMutation();
const [impersonate] = useImpersonateMutation();
const { handleFeatureFlagUpdate } = useFeatureFlag();
const { updateFeatureFlagState } = useFeatureFlagState();
const [userLookupResult, setUserLookupResult] = useRecoilState(
userLookupResultState,
);
const handleImpersonate = async (workspaceId: string) => {
if (!userLookupResult?.user.id) {
enqueueSnackBar('Please search for a user first', {
variant: SnackBarVariant.Error,
});
return;
}
setIsImpersonationLoading(true);
await impersonate({
variables: { userId: userLookupResult.user.id, workspaceId },
onCompleted: async (data) => {
const { loginToken, workspace } = data.impersonate;
const isCurrentWorkspace = workspace.id === activeWorkspace?.id;
setUserLookupResult(null);
if (isCurrentWorkspace) {
await executeImpersonationAuth(loginToken.token);
return;
}
return executeImpersonationRedirect(
workspace.workspaceUrls,
loginToken.token,
);
},
onError: (error) => {
enqueueSnackBar(`Failed to impersonate user. ${error.message}`, {
variant: SnackBarVariant.Error,
});
},
}).finally(() => {
setIsImpersonationLoading(false);
});
};
const handleFeatureFlagUpdate = async (
workspaceId: string,
featureFlag: FeatureFlagKey,
value: boolean,
) => {
const previousValue = userLookupResult?.workspaces
.find((workspace) => workspace.id === workspaceId)
?.featureFlags.find((flag) => flag.key === featureFlag)?.value;
updateFeatureFlagState(workspaceId, featureFlag, value);
await updateFeatureFlag({
variables: {
workspaceId,
featureFlag,
value,
},
onError: (error) => {
if (isDefined(previousValue)) {
updateFeatureFlagState(workspaceId, featureFlag, previousValue);
}
enqueueSnackBar(`Failed to update feature flag. ${error.message}`, {
variant: SnackBarVariant.Error,
});
},
});
};
if (!activeWorkspace) return null;
@ -45,13 +124,13 @@ export const SettingsAdminWorkspaceContent = ({
}`}
description={'Total Users'}
/>
{canImpersonate && (
{currentUser?.canImpersonate && (
<Button
Icon={IconUser}
variant="primary"
accent="blue"
title={'Impersonate'}
onClick={() => handleImpersonate(userId, activeWorkspace.id)}
onClick={() => handleImpersonate(activeWorkspace.id)}
disabled={
isImpersonateLoading || activeWorkspace.allowImpersonation === false
}

View File

@ -1,63 +0,0 @@
import { adminPanelErrorState } from '@/settings/admin-panel/states/adminPanelErrorState';
import { userLookupResultState } from '@/settings/admin-panel/states/userLookupResultState';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared';
import {
FeatureFlagKey,
useUpdateWorkspaceFeatureFlagMutation,
} from '~/generated/graphql';
export const useFeatureFlag = () => {
const [userLookupResult, setUserLookupResult] = useRecoilState(
userLookupResultState,
);
const setError = useSetRecoilState(adminPanelErrorState);
const [updateFeatureFlag] = useUpdateWorkspaceFeatureFlagMutation();
const handleFeatureFlagUpdate = async (
workspaceId: string,
featureFlag: FeatureFlagKey,
value: boolean,
) => {
setError(null);
const previousState = userLookupResult;
if (isDefined(userLookupResult)) {
setUserLookupResult({
...userLookupResult,
workspaces: userLookupResult.workspaces.map((workspace) =>
workspace.id === workspaceId
? {
...workspace,
featureFlags: workspace.featureFlags.map((flag) =>
flag.key === featureFlag ? { ...flag, value } : flag,
),
}
: workspace,
),
});
}
const response = await updateFeatureFlag({
variables: {
workspaceId,
featureFlag,
value,
},
onError: (error) => {
if (isDefined(previousState)) {
setUserLookupResult(previousState);
}
setError(error.message);
},
});
return !!response.data;
};
return {
handleFeatureFlagUpdate,
};
};

View File

@ -0,0 +1,36 @@
import { userLookupResultState } from '@/settings/admin-panel/states/userLookupResultState';
import { useRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared';
import { FeatureFlagKey } from '~/generated/graphql';
export const useFeatureFlagState = () => {
const [userLookupResult, setUserLookupResult] = useRecoilState(
userLookupResultState,
);
const updateFeatureFlagState = (
workspaceId: string,
featureFlag: FeatureFlagKey,
value: boolean,
) => {
if (!isDefined(userLookupResult)) return;
setUserLookupResult({
...userLookupResult,
workspaces: userLookupResult.workspaces.map((workspace) =>
workspace.id === workspaceId
? {
...workspace,
featureFlags: workspace.featureFlags.map((flag) =>
flag.key === featureFlag ? { ...flag, value } : flag,
),
}
: workspace,
),
});
};
return {
updateFeatureFlagState,
};
};

View File

@ -1,78 +0,0 @@
import { useAuth } from '@/auth/hooks/useAuth';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/states/isAppWaitingForFreshObjectMetadataState';
import { AppPath } from '@/types/AppPath';
import { useState } from 'react';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared';
import { useImpersonateMutation } from '~/generated/graphql';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
export const useImpersonate = () => {
const [currentUser] = useRecoilState(currentUserState);
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const setIsAppWaitingForFreshObjectMetadata = useSetRecoilState(
isAppWaitingForFreshObjectMetadataState,
);
const { getAuthTokensFromLoginToken } = useAuth();
const [impersonate] = useImpersonateMutation();
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleImpersonate = async (userId: string, workspaceId: string) => {
if (!userId.trim()) {
setError('Please enter a user ID');
return;
}
setIsLoading(true);
setError(null);
try {
const impersonateResult = await impersonate({
variables: { userId, workspaceId },
});
if (isDefined(impersonateResult.errors)) {
throw impersonateResult.errors;
}
if (!impersonateResult.data?.impersonate) {
throw new Error('No impersonate result');
}
const { loginToken, workspace } = impersonateResult.data.impersonate;
if (workspace.id === currentWorkspace?.id) {
setIsAppWaitingForFreshObjectMetadata(true);
await getAuthTokensFromLoginToken(loginToken.token);
setIsAppWaitingForFreshObjectMetadata(false);
return;
}
return redirectToWorkspaceDomain(
getWorkspaceUrl(workspace.workspaceUrls),
AppPath.Verify,
{
loginToken: loginToken.token,
},
);
} catch (error) {
setError('Failed to impersonate user. Please try again.');
setIsLoading(false);
}
};
return {
handleImpersonate,
isLoading,
error,
canImpersonate: currentUser?.canImpersonate,
};
};

View File

@ -0,0 +1,18 @@
import { useAuth } from '@/auth/hooks/useAuth';
import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/states/isAppWaitingForFreshObjectMetadataState';
import { useSetRecoilState } from 'recoil';
export const useImpersonationAuth = () => {
const { getAuthTokensFromLoginToken } = useAuth();
const setIsAppWaitingForFreshObjectMetadata = useSetRecoilState(
isAppWaitingForFreshObjectMetadataState,
);
const executeImpersonationAuth = async (loginToken: string) => {
setIsAppWaitingForFreshObjectMetadata(true);
await getAuthTokensFromLoginToken(loginToken);
setIsAppWaitingForFreshObjectMetadata(false);
};
return { executeImpersonationAuth };
};

View File

@ -0,0 +1,21 @@
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { AppPath } from '@/types/AppPath';
import { WorkspaceUrls } from '~/generated/graphql';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
export const useImpersonationRedirect = () => {
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const executeImpersonationRedirect = (
workspaceUrls: WorkspaceUrls,
loginToken: string,
) => {
return redirectToWorkspaceDomain(
getWorkspaceUrl(workspaceUrls),
AppPath.Verify,
{ loginToken },
);
};
return { executeImpersonationRedirect };
};

View File

@ -1,42 +0,0 @@
import { adminPanelErrorState } from '@/settings/admin-panel/states/adminPanelErrorState';
import { userLookupResultState } from '@/settings/admin-panel/states/userLookupResultState';
import { useState } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared';
import { useUserLookupAdminPanelMutation } from '~/generated/graphql';
export const useUserLookup = () => {
const setUserLookupResult = useSetRecoilState(userLookupResultState);
const setError = useSetRecoilState(adminPanelErrorState);
const [isLoading, setIsLoading] = useState(false);
const [userLookup] = useUserLookupAdminPanelMutation({
onCompleted: (data) => {
setIsLoading(false);
if (isDefined(data?.userLookupAdminPanel)) {
setUserLookupResult(data.userLookupAdminPanel);
}
},
onError: (error) => {
setIsLoading(false);
setError(error.message);
},
});
const handleUserLookup = async (userIdentifier: string) => {
setError(null);
setIsLoading(true);
setUserLookupResult(null);
const response = await userLookup({
variables: { userIdentifier },
});
return response.data?.userLookupAdminPanel;
};
return {
handleUserLookup,
isLoading,
};
};

View File

@ -1,6 +0,0 @@
import { atom } from 'recoil';
export const adminPanelErrorState = atom<string | null>({
key: 'adminPanelErrorState',
default: null,
});

View File

@ -19,7 +19,7 @@ export const ENVIRONMENT_VARIABLES_GROUP_METADATA: Record<
position: 200,
description:
'We use this to limit the number of requests to the server. This is useful to prevent abuse.',
isHiddenOnLoad: false,
isHiddenOnLoad: true,
},
[EnvironmentVariablesGroup.StorageConfig]: {
position: 300,
@ -46,13 +46,13 @@ export const ENVIRONMENT_VARIABLES_GROUP_METADATA: Record<
[EnvironmentVariablesGroup.Logging]: {
position: 700,
description: '',
isHiddenOnLoad: false,
isHiddenOnLoad: true,
},
[EnvironmentVariablesGroup.ExceptionHandler]: {
position: 800,
description:
'By default, exceptions are sent to the logs. This should be enough for most self-hosting use-cases. For our cloud app we use Sentry.',
isHiddenOnLoad: false,
isHiddenOnLoad: true,
},
[EnvironmentVariablesGroup.Other]: {
position: 900,