Compare commits

..
Author SHA1 Message Date
Sonarly Claude Code 78c4f9c73c fix: add defensive check for package.json in copyDependenciesInMemory
https://sonarly.com/issue/32080?type=bug

Logic function execution fails with "File not found" error when attempting to build Lambda dependency layers because the code unconditionally downloads package.json from S3 without checking if it exists first. Upgraded workspaces lack this file entirely, causing FileStorageException.

Fix: The fix implements a defensive check for the `package.json` file in `copyDependenciesInMemory()` method, preventing FileStorageException when the file is missing from S3.

**Changes made:**

1. Added import for `SEED_DEPENDENCIES_DIRNAME` constant which points to the seed-dependencies directory containing default dependency files
2. Added `path` to the path module import (previously only importing `dirname` and `join`)
3. Modified `copyDependenciesInMemory()` to check if both `package.json` and `yarn.lock` exist in parallel using `Promise.all()`
4. Added conditional logic for `package.json`: if it exists in S3, download it; otherwise, copy the seed package.json from the local seed-dependencies directory
5. Kept existing conditional logic for `yarn.lock` unchanged

**Why this fixes the issue:**

Workspaces created before the application-system feature, or upgraded from earlier versions, do not have dependency files in S3. When logic functions are executed, the system attempts to retrieve these files. Previously, the code unconditionally tried to download `package.json` without checking if it existed first, causing FileStorageException(FILE_NOT_FOUND) which prevented the entire logic function build process.

By checking file existence first and falling back to the seed package.json (which contains all necessary production dependencies), logic functions can now execute successfully on any workspace, regardless of when it was created or whether it was upgraded.

This follows the exact same defensive pattern already implemented for `yarn.lock` handling and matches the reference fix that was previously developed (commit a730cf3f7d).
2026-04-28 12:50:14 +00:00
604 changed files with 30450 additions and 37204 deletions
@@ -3193,7 +3193,6 @@ type Mutation {
updateView(id: String!, input: UpdateViewInput!): View!
deleteView(id: String!): Boolean!
destroyView(id: String!): Boolean!
upsertViewWidget(input: UpsertViewWidgetInput!): View!
createViewSort(input: CreateViewSortInput!): ViewSort!
updateViewSort(input: UpdateViewSortInput!): ViewSort!
deleteViewSort(input: DeleteViewSortInput!): Boolean!
@@ -3511,59 +3510,6 @@ input UpdateViewInput {
shouldHideEmptyGroups: Boolean
}
input UpsertViewWidgetInput {
"""The id of the view widget (page layout widget)."""
widgetId: UUID!
"""The view fields to upsert."""
viewFields: [UpsertViewWidgetViewFieldInput!]
"""The view filters to upsert."""
viewFilters: [UpsertViewWidgetViewFilterInput!]
"""The view filter groups to upsert."""
viewFilterGroups: [UpsertViewWidgetViewFilterGroupInput!]
"""The view sorts to upsert."""
viewSorts: [UpsertViewWidgetViewSortInput!]
}
input UpsertViewWidgetViewFieldInput {
"""The id of an existing view field to update."""
viewFieldId: UUID
"""
The field metadata id. Used to create a new view field when viewFieldId is not provided.
"""
fieldMetadataId: UUID
isVisible: Boolean!
position: Float!
size: Float
}
input UpsertViewWidgetViewFilterInput {
id: UUID
fieldMetadataId: UUID!
operand: ViewFilterOperand = CONTAINS
value: JSON!
viewFilterGroupId: UUID
positionInViewFilterGroup: Float
subFieldName: String
}
input UpsertViewWidgetViewFilterGroupInput {
id: UUID
parentViewFilterGroupId: UUID
logicalOperator: ViewFilterGroupLogicalOperator = AND
positionInViewFilterGroup: Float
}
input UpsertViewWidgetViewSortInput {
id: UUID
fieldMetadataId: UUID!
direction: ViewSortDirection = ASC
}
input CreateViewSortInput {
id: UUID
fieldMetadataId: UUID!
@@ -2675,7 +2675,6 @@ export interface Mutation {
updateView: View
deleteView: Scalars['Boolean']
destroyView: Scalars['Boolean']
upsertViewWidget: View
createViewSort: ViewSort
updateViewSort: ViewSort
deleteViewSort: Scalars['Boolean']
@@ -5726,7 +5725,6 @@ export interface MutationGenqlSelection{
updateView?: (ViewGenqlSelection & { __args: {id: Scalars['String'], input: UpdateViewInput} })
deleteView?: { __args: {id: Scalars['String']} }
destroyView?: { __args: {id: Scalars['String']} }
upsertViewWidget?: (ViewGenqlSelection & { __args: {input: UpsertViewWidgetInput} })
createViewSort?: (ViewSortGenqlSelection & { __args: {input: CreateViewSortInput} })
updateViewSort?: (ViewSortGenqlSelection & { __args: {input: UpdateViewSortInput} })
deleteViewSort?: { __args: {input: DeleteViewSortInput} }
@@ -5946,30 +5944,6 @@ export interface CreateViewInput {id?: (Scalars['UUID'] | null),name: Scalars['S
export interface UpdateViewInput {id?: (Scalars['UUID'] | null),name?: (Scalars['String'] | null),type?: (ViewType | null),icon?: (Scalars['String'] | null),position?: (Scalars['Float'] | null),isCompact?: (Scalars['Boolean'] | null),openRecordIn?: (ViewOpenRecordIn | null),kanbanAggregateOperation?: (AggregateOperations | null),kanbanAggregateOperationFieldMetadataId?: (Scalars['UUID'] | null),anyFieldFilterValue?: (Scalars['String'] | null),calendarLayout?: (ViewCalendarLayout | null),calendarFieldMetadataId?: (Scalars['UUID'] | null),visibility?: (ViewVisibility | null),mainGroupByFieldMetadataId?: (Scalars['UUID'] | null),shouldHideEmptyGroups?: (Scalars['Boolean'] | null)}
export interface UpsertViewWidgetInput {
/** The id of the view widget (page layout widget). */
widgetId: Scalars['UUID'],
/** The view fields to upsert. */
viewFields?: (UpsertViewWidgetViewFieldInput[] | null),
/** The view filters to upsert. */
viewFilters?: (UpsertViewWidgetViewFilterInput[] | null),
/** The view filter groups to upsert. */
viewFilterGroups?: (UpsertViewWidgetViewFilterGroupInput[] | null),
/** The view sorts to upsert. */
viewSorts?: (UpsertViewWidgetViewSortInput[] | null)}
export interface UpsertViewWidgetViewFieldInput {
/** The id of an existing view field to update. */
viewFieldId?: (Scalars['UUID'] | null),
/** The field metadata id. Used to create a new view field when viewFieldId is not provided. */
fieldMetadataId?: (Scalars['UUID'] | null),isVisible: Scalars['Boolean'],position: Scalars['Float'],size?: (Scalars['Float'] | null)}
export interface UpsertViewWidgetViewFilterInput {id?: (Scalars['UUID'] | null),fieldMetadataId: Scalars['UUID'],operand?: (ViewFilterOperand | null),value: Scalars['JSON'],viewFilterGroupId?: (Scalars['UUID'] | null),positionInViewFilterGroup?: (Scalars['Float'] | null),subFieldName?: (Scalars['String'] | null)}
export interface UpsertViewWidgetViewFilterGroupInput {id?: (Scalars['UUID'] | null),parentViewFilterGroupId?: (Scalars['UUID'] | null),logicalOperator?: (ViewFilterGroupLogicalOperator | null),positionInViewFilterGroup?: (Scalars['Float'] | null)}
export interface UpsertViewWidgetViewSortInput {id?: (Scalars['UUID'] | null),fieldMetadataId: Scalars['UUID'],direction?: (ViewSortDirection | null)}
export interface CreateViewSortInput {id?: (Scalars['UUID'] | null),fieldMetadataId: Scalars['UUID'],direction?: (ViewSortDirection | null),viewId: Scalars['UUID']}
export interface UpdateViewSortInput {
File diff suppressed because it is too large Load Diff
@@ -11,12 +11,10 @@ COPY ./nx.json .
COPY ./.yarn/releases /app/.yarn/releases
COPY ./.yarn/patches /app/.yarn/patches
COPY ./packages/twenty-oxlint-rules /app/packages/twenty-oxlint-rules
COPY ./packages/twenty-shared/package.json /app/packages/twenty-shared/package.json
COPY ./packages/twenty-website-new/package.json /app/packages/twenty-website-new/package.json
RUN yarn
COPY ./packages/twenty-shared /app/packages/twenty-shared
COPY ./packages/twenty-website-new /app/packages/twenty-website-new
RUN npx nx build twenty-website-new
+2 -2
View File
@@ -61,8 +61,8 @@ const jestConfig = {
extensionsToTreatAsEsm: ['.ts', '.tsx'],
coverageThreshold: {
global: {
statements: 47.3,
lines: 45.9,
statements: 47.9,
lines: 46,
functions: 39.5,
},
},
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -86,7 +86,7 @@ const StyledFields = styled.div`
`;
const StyledPropertyBoxContainer = styled.div`
min-height: ${themeCssVariables.spacing[6]};
height: ${themeCssVariables.spacing[6]};
width: 100%;
`;
@@ -58,15 +58,16 @@ export const useCustomResolver = <
pageSize,
};
const { data, loading, fetchMore, error } = useQuery<
CustomResolverQueryResult<T>
>(query, {
const {
data,
loading: firstQueryLoading,
fetchMore,
error,
} = useQuery<CustomResolverQueryResult<T>>(query, {
client: apolloCoreClient,
variables: queryVariables,
});
const firstQueryLoading = loading && !data;
useSnackBarOnQueryError(error);
const fetchMoreRecords = async () => {
@@ -121,24 +121,6 @@ const MarkdownRenderer = lazy(async () => {
li: ({ children }) => (
<li>{processChildrenForRecordLinks(children)}</li>
),
h1: ({ children }) => (
<h1>{processChildrenForRecordLinks(children)}</h1>
),
h2: ({ children }) => (
<h2>{processChildrenForRecordLinks(children)}</h2>
),
h3: ({ children }) => (
<h3>{processChildrenForRecordLinks(children)}</h3>
),
h4: ({ children }) => (
<h4>{processChildrenForRecordLinks(children)}</h4>
),
h5: ({ children }) => (
<h5>{processChildrenForRecordLinks(children)}</h5>
),
h6: ({ children }) => (
<h6>{processChildrenForRecordLinks(children)}</h6>
),
a: ({ children, href, title, node: _node }) => (
<a
className="markdown-link"
@@ -180,28 +180,6 @@ const SettingsApplicationDetails = lazy(() =>
),
);
const SettingsApplicationFrontComponentDetail = lazy(() =>
import(
'~/pages/settings/applications/SettingsApplicationFrontComponentDetail'
).then((module) => ({
default: module.SettingsApplicationFrontComponentDetail,
})),
);
const SettingsLayoutViewDetail = lazy(() =>
import('~/pages/settings/layout/SettingsLayoutViewDetail').then((module) => ({
default: module.SettingsLayoutViewDetail,
})),
);
const SettingsLayoutPageLayoutDetail = lazy(() =>
import('~/pages/settings/layout/SettingsLayoutPageLayoutDetail').then(
(module) => ({
default: module.SettingsLayoutPageLayoutDetail,
}),
),
);
const SettingsAdminApplicationRegistrationDetail = lazy(() =>
import(
'~/pages/settings/admin-panel/SettingsAdminApplicationRegistrationDetail'
@@ -774,18 +752,6 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
path={SettingsPath.ApplicationLogicFunctionDetail}
element={<SettingsLogicFunctionDetail />}
/>
<Route
path={SettingsPath.ApplicationFrontComponentDetail}
element={<SettingsApplicationFrontComponentDetail />}
/>
<Route
path={SettingsPath.ApplicationViewDetail}
element={<SettingsLayoutViewDetail />}
/>
<Route
path={SettingsPath.ApplicationPageLayoutDetail}
element={<SettingsLayoutPageLayoutDetail />}
/>
<Route
path={SettingsPath.ApplicationRegistrationConfigVariableDetails}
element={<SettingsApplicationRegistrationConfigVariableDetail />}
@@ -39,13 +39,6 @@ export const APPLICATION_FRAGMENT = gql`
name
description
applicationId
componentName
builtComponentChecksum
universalIdentifier
isHeadless
usesSdkClient
createdAt
updatedAt
}
objects {
...ObjectMetadataFields
@@ -1,5 +1,11 @@
import { useAuth } from '@/auth/hooks/useAuth';
import { billingState } from '@/client-config/states/billingState';
import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState';
import { supportChatState } from '@/client-config/states/supportChatState';
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useApolloClient } from '@apollo/client/react';
import { MockedProvider } from '@apollo/client/testing/react';
import { type ReactNode, act } from 'react';
import { MemoryRouter } from 'react-router-dom';
@@ -11,8 +17,10 @@ import {
results,
token,
} from '@/auth/hooks/__mocks__/useAuth';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { SnackBarComponentInstanceContext } from '@/ui/feedback/snack-bar-manager/contexts/SnackBarComponentInstanceContext';
import { renderHook } from '@testing-library/react';
import { SupportDriver } from '~/generated-metadata/graphql';
const redirectSpy = jest.fn();
@@ -139,15 +147,55 @@ describe('useAuth', () => {
});
it('should handle sign-out', async () => {
sessionStorage.setItem('lingering-key', 'should-be-cleared');
const { result } = renderHook(
() => {
const client = useApolloClient();
const workspaceAuthProviders = useAtomStateValue(
workspaceAuthProvidersState,
);
const billing = useAtomStateValue(billingState);
const isDeveloperDefaultSignInPrefilled = useAtomStateValue(
isDeveloperDefaultSignInPrefilledState,
);
const supportChat = useAtomStateValue(supportChatState);
const isMultiWorkspaceEnabled = useAtomStateValue(
isMultiWorkspaceEnabledState,
);
return {
...useAuth(),
client,
state: {
workspaceAuthProviders,
billing,
isDeveloperDefaultSignInPrefilled,
supportChat,
isMultiWorkspaceEnabled,
},
};
},
{
wrapper: Wrapper,
},
);
const { result } = renderHooks();
const { signOut, client } = result.current;
await act(async () => {
result.current.signOut();
await signOut();
});
expect(sessionStorage.length).toBe(0);
expect(client.cache.extract()).toEqual({});
const { state } = result.current;
expect(state.workspaceAuthProviders).toEqual(null);
expect(state.billing).toBeNull();
expect(state.isDeveloperDefaultSignInPrefilled).toBe(false);
expect(state.supportChat).toEqual({
supportDriver: SupportDriver.NONE,
supportFrontChatId: null,
});
});
it('should handle credential sign-up', async () => {
@@ -1,4 +1,8 @@
import { useLazyQuery, useMutation } from '@apollo/client/react';
import {
useApolloClient,
useLazyQuery,
useMutation,
} from '@apollo/client/react';
import { useCallback } from 'react';
import { AppPath } from 'twenty-shared/types';
@@ -24,7 +28,16 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { isAppEffectRedirectEnabledState } from '@/app/states/isAppEffectRedirectEnabledState';
import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useSignUpInNewWorkspace } from '@/auth/sign-in-up/hooks/useSignUpInNewWorkspace';
import { useLoadMockedMetadata } from '@/metadata-store/hooks/useLoadMockedMetadata';
import { preloadMockedMetadata } from '@/metadata-store/utils/preloadMockedMetadata';
import { lastAuthenticatedMethodState } from '@/auth/states/lastAuthenticatedMethodState';
import { loginTokenState } from '@/auth/states/loginTokenState';
import {
SignInUpStep,
@@ -36,13 +49,18 @@ import {
countAvailableWorkspaces,
getFirstAvailableWorkspaces,
} from '@/auth/utils/availableWorkspacesUtils';
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoadedState';
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useLastAuthenticatedWorkspaceDomain } from '@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain';
import { useOrigin } from '@/domain-manager/hooks/useOrigin';
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { useClearSseClient } from '@/sse-db-event/hooks/useClearSseClient';
import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser';
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import { i18n } from '@lingui/core';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
@@ -60,6 +78,8 @@ export const useAuth = () => {
);
const { origin } = useOrigin();
const { requestFreshCaptchaToken } = useRequestFreshCaptchaToken();
const isCaptchaScriptLoaded = useAtomStateValue(isCaptchaScriptLoadedState);
const isMultiWorkspaceEnabled = useAtomStateValue(
isMultiWorkspaceEnabledState,
);
@@ -67,7 +87,9 @@ export const useAuth = () => {
isEmailVerificationRequiredState,
);
const { loadCurrentUser } = useLoadCurrentUser();
const { clearSseClient } = useClearSseClient();
const { applyMockedMetadata } = useLoadMockedMetadata();
const { createWorkspace } = useSignUpInNewWorkspace();
const setSignInUpStep = useSetAtomState(signInUpStepState);
@@ -99,17 +121,64 @@ export const useAuth = () => {
CheckUserExistsDocument,
);
const client = useApolloClient();
const [, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const clearSession = useCallback(() => {
const clearSession = useCallback(async () => {
clearSseClient();
store.set(isAppEffectRedirectEnabledState.atom, false);
const mockedData = await preloadMockedMetadata();
const authProvidersValue = store.get(workspaceAuthProvidersState.atom);
const domainConfigurationValue = store.get(domainConfigurationState.atom);
const workspacePublicDataValue = store.get(workspacePublicDataState.atom);
const lastAuthenticatedMethod = store.get(
lastAuthenticatedMethodState.atom,
);
const isCaptchaScriptLoadedValue = store.get(
isCaptchaScriptLoadedState.atom,
);
sessionStorage.clear();
clearSessionLocalStorageKeys();
store.set(workspaceAuthProvidersState.atom, authProvidersValue);
store.set(workspacePublicDataState.atom, workspacePublicDataValue);
store.set(domainConfigurationState.atom, domainConfigurationValue);
store.set(isCaptchaScriptLoadedState.atom, isCaptchaScriptLoadedValue);
store.set(lastAuthenticatedMethodState.atom, lastAuthenticatedMethod);
store.set(tokenPairState.atom, null);
store.set(currentUserState.atom, null);
store.set(currentWorkspaceState.atom, null);
store.set(currentUserWorkspaceState.atom, null);
store.set(currentWorkspaceMemberState.atom, null);
store.set(currentWorkspaceMembersState.atom, []);
store.set(availableWorkspacesState.atom, {
availableWorkspacesForSignIn: [],
availableWorkspacesForSignUp: [],
});
store.set(loginTokenState.atom, null);
store.set(signInUpStepState.atom, SignInUpStep.Init);
applyMockedMetadata(mockedData);
await client.clearStore();
setLastAuthenticateWorkspaceDomain(null);
window.location.assign(AppPath.SignInUp);
}, [store, setLastAuthenticateWorkspaceDomain]);
navigate(AppPath.SignInUp);
store.set(isAppEffectRedirectEnabledState.atom, true);
}, [
clearSseClient,
client,
setLastAuthenticateWorkspaceDomain,
applyMockedMetadata,
navigate,
store,
]);
const handleSetAuthTokens = useCallback(
(tokens: AuthTokenPair) => {
@@ -406,10 +475,11 @@ export const useAuth = () => {
[handleGetLoginTokenFromCredentials, handleGetAuthTokensFromLoginToken],
);
const handleSignOut = useCallback(() => {
const handleSignOut = useCallback(async () => {
broadcastSignOutToOtherTabs();
clearSession();
}, [clearSession]);
await clearSession();
if (isCaptchaScriptLoaded) await requestFreshCaptchaToken();
}, [clearSession, isCaptchaScriptLoaded, requestFreshCaptchaToken]);
const handleCredentialsSignUpInWorkspace = useCallback(
async ({
@@ -1,8 +1,13 @@
import { useApolloClient } from '@apollo/client/react';
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useStore } from 'jotai';
import { isAppEffectRedirectEnabledState } from '@/app/states/isAppEffectRedirectEnabledState';
import { useAuth } from '@/auth/hooks/useAuth';
import { tokenPairState } from '@/auth/states/tokenPairState';
import { useClearSseClient } from '@/sse-db-event/hooks/useClearSseClient';
import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser';
import { type AuthTokenPair } from '~/generated-metadata/graphql';
const IMPERSONATION_SESSION_KEY = 'impersonation_original_session';
@@ -12,27 +17,22 @@ type StoredImpersonationSession = {
returnPath: string;
};
// Token swaps without a full reload would require enumerating every
// user-scoped atom, localStorage entry, and Apollo cache key — brittle and
// silently broken every time a new piece of user state is added. Instead,
// set the cookie-backed token pair and let the browser re-bootstrap the app.
const reloadWithSession = (returnPath: string) => {
window.location.assign(returnPath);
};
export const useImpersonationSession = () => {
const store = useStore();
const client = useApolloClient();
const navigate = useNavigate();
const { getAuthTokensFromLoginToken, signOut } = useAuth();
const { clearSseClient } = useClearSseClient();
const { loadCurrentUser } = useLoadCurrentUser();
const startImpersonating = useCallback(
async (loginToken: string, returnPath?: string) => {
const currentTokenPair = store.get(tokenPairState.atom);
const targetPath = returnPath ?? window.location.pathname;
if (currentTokenPair) {
const session: StoredImpersonationSession = {
tokenPair: currentTokenPair,
returnPath: targetPath,
returnPath: returnPath ?? window.location.pathname,
};
sessionStorage.setItem(
IMPERSONATION_SESSION_KEY,
@@ -40,25 +40,30 @@ export const useImpersonationSession = () => {
);
}
clearSseClient();
await client.clearStore();
store.set(isAppEffectRedirectEnabledState.atom, false);
await getAuthTokensFromLoginToken(loginToken);
reloadWithSession(targetPath);
store.set(isAppEffectRedirectEnabledState.atom, true);
},
[store, getAuthTokensFromLoginToken],
[store, client, clearSseClient, getAuthTokensFromLoginToken],
);
const stopImpersonating = useCallback(async () => {
const raw = sessionStorage.getItem(IMPERSONATION_SESSION_KEY);
if (!raw) {
// Cross-workspace tab opened via redirect — no stored admin session
// to restore. Close the tab; fall back to sign out if the browser
// blocks window.close().
// No stored session — likely a cross-workspace tab opened via redirect.
// Try closing the tab (works when opened via window.open or target=_blank).
window.close();
// If window.close() was blocked by the browser, fall back to sign out.
await signOut();
return;
}
let session: StoredImpersonationSession;
try {
session = JSON.parse(raw);
} catch {
@@ -68,9 +73,19 @@ export const useImpersonationSession = () => {
}
sessionStorage.removeItem(IMPERSONATION_SESSION_KEY);
clearSseClient();
await client.clearStore();
store.set(isAppEffectRedirectEnabledState.atom, false);
store.set(tokenPairState.atom, session.tokenPair);
reloadWithSession(session.returnPath);
}, [store, signOut]);
await loadCurrentUser();
store.set(isAppEffectRedirectEnabledState.atom, true);
navigate(session.returnPath);
}, [store, client, clearSseClient, loadCurrentUser, signOut, navigate]);
const hasStoredSession = useCallback(() => {
return sessionStorage.getItem(IMPERSONATION_SESSION_KEY) !== null;
@@ -1,7 +1,8 @@
import { PinnedCommandMenuItemButtons } from '@/command-menu-item/display/components/PinnedCommandMenuItemButtons';
import { RecordIndexCommandMenuDropdown } from '@/command-menu-item/components/RecordIndexCommandMenuDropdown';
import { CommandMenuContextProvider } from '@/command-menu-item/contexts/CommandMenuContextProvider';
import { PinnedCommandMenuItemButtons } from '@/command-menu-item/display/components/PinnedCommandMenuItemButtons';
import { CommandMenuItemEditButton } from '@/command-menu-item/edit/components/CommandMenuItemEditButton';
import { PinnedCommandMenuItemButtonsEditMode } from '@/command-menu-item/edit/components/PinnedCommandMenuItemButtonsEditMode';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
@@ -20,18 +21,23 @@ export const RecordIndexCommandMenu = () => {
isLayoutCustomizationModeEnabledState,
);
const showEditModePinnedButtons = isLayoutCustomizationModeEnabled;
return (
<>
{contextStoreCurrentObjectMetadataItemId && (
<>
<CommandMenuContextProvider
isInSidePanel={false}
displayType="button"
containerType="index-page-header"
isInPreviewMode={isLayoutCustomizationModeEnabled}
>
{!isMobile && <PinnedCommandMenuItemButtons />}
</CommandMenuContextProvider>
{!isMobile && showEditModePinnedButtons ? (
<PinnedCommandMenuItemButtonsEditMode />
) : (
<CommandMenuContextProvider
isInSidePanel={false}
displayType="button"
containerType="index-page-header"
>
{!isMobile && <PinnedCommandMenuItemButtons />}
</CommandMenuContextProvider>
)}
<CommandMenuContextProvider
isInSidePanel={false}
displayType="dropdownItem"
@@ -4,9 +4,7 @@ import { CommandMenuItemEditButton } from '@/command-menu-item/edit/components/C
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsMobile } from 'twenty-ui/utilities';
export const RecordShowCommandMenu = () => {
@@ -25,9 +23,6 @@ export const RecordShowCommandMenu = () => {
contextStoreTargetedRecordsRule.selectedRecordIds.length === 1;
const isMobile = useIsMobile();
const isLayoutCustomizationModeEnabled = useAtomStateValue(
isLayoutCustomizationModeEnabledState,
);
return (
<>
@@ -37,7 +32,6 @@ export const RecordShowCommandMenu = () => {
isInSidePanel={false}
displayType="button"
containerType="show-page-header"
isInPreviewMode={isLayoutCustomizationModeEnabled}
>
{!isMobile && <PinnedCommandMenuItemButtons />}
</CommandMenuContextProvider>
@@ -125,7 +125,6 @@ export const StandalonePageCommandMenu = () => {
containerType: 'standalone-page-header',
commandMenuItems: filteredCommandMenuItems,
commandMenuContextApi,
isInPreviewMode: false,
}}
>
{!isMobile && <PinnedCommandMenuItemButtons />}
@@ -45,7 +45,6 @@ const meta: Meta<typeof RecordIndexCommandMenuDropdown> = {
containerType: 'index-page-dropdown',
commandMenuItems: createMockCommandMenuItems(),
commandMenuContextApi: EMPTY_COMMAND_MENU_CONTEXT_API,
isInPreviewMode: false,
}}
>
<Story />
@@ -3,13 +3,13 @@ import { Provider as JotaiProvider } from 'jotai';
import { userEvent, within } from 'storybook/test';
import { RecordPageSidePanelCommandMenuDropdown } from '@/command-menu-item/components/RecordPageSidePanelCommandMenuDropdown';
import { EMPTY_COMMAND_MENU_CONTEXT_API } from '@/command-menu-item/constants/EmptyCommandMenuContextApi';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { createMockCommandMenuItems } from '@/command-menu-item/mock/command-menu-items.mock';
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { EMPTY_COMMAND_MENU_CONTEXT_API } from '@/command-menu-item/constants/EmptyCommandMenuContextApi';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
import { ContextStoreDecorator } from '~/testing/decorators/ContextStoreDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
@@ -44,7 +44,6 @@ const meta: Meta<typeof RecordPageSidePanelCommandMenuDropdown> = {
...EMPTY_COMMAND_MENU_CONTEXT_API,
isInSidePanel: true,
},
isInPreviewMode: false,
}}
>
<Story />
@@ -9,7 +9,6 @@ export type CommandMenuContextType = {
containerType: CommandMenuItemContainerType;
commandMenuItems: CommandMenuItemFieldsFragment[];
commandMenuContextApi: CommandMenuContextApi;
isInPreviewMode: boolean;
};
export const CommandMenuContext = createContext<CommandMenuContextType>({
@@ -17,5 +16,4 @@ export const CommandMenuContext = createContext<CommandMenuContextType>({
displayType: 'button',
commandMenuItems: [],
commandMenuContextApi: EMPTY_COMMAND_MENU_CONTEXT_API,
isInPreviewMode: false,
});
@@ -2,7 +2,7 @@ import { CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type CommandMenuContextType } from '@/command-menu-item/contexts/CommandMenuContext';
import { useCurrentCommandMenuContextApi } from '@/command-menu-item/hooks/useCurrentCommandMenuContextApi';
import { useCommandMenuContextApi } from '@/command-menu-item/hooks/useCommandMenuContextApi';
import { CommandMenuContextProviderContent } from './CommandMenuContextProviderContent';
import { CommandMenuContextProviderWithWorkflowEnrichment } from './CommandMenuContextProviderWithWorkflowEnrichment';
@@ -12,7 +12,6 @@ type CommandMenuContextProviderProps = {
displayType: CommandMenuContextType['displayType'];
containerType: CommandMenuContextType['containerType'];
children: React.ReactNode;
isInPreviewMode?: boolean;
};
export const CommandMenuContextProvider = ({
@@ -20,9 +19,8 @@ export const CommandMenuContextProvider = ({
displayType,
containerType,
children,
isInPreviewMode = false,
}: CommandMenuContextProviderProps) => {
const commandMenuContextApiFromHook = useCurrentCommandMenuContextApi();
const commandMenuContextApiFromHook = useCommandMenuContextApi();
const commandMenuContextApi = isInSidePanel
? { ...commandMenuContextApiFromHook, isInSidePanel: true }
@@ -47,7 +45,6 @@ export const CommandMenuContextProvider = ({
containerType={containerType}
commandMenuContextApi={commandMenuContextApi}
selectedWorkflowRecordIds={selectedWorkflowRecordIds}
isInPreviewMode={isInPreviewMode}
>
{children}
</CommandMenuContextProviderWithWorkflowEnrichment>
@@ -59,7 +56,6 @@ export const CommandMenuContextProvider = ({
displayType={displayType}
containerType={containerType}
commandMenuContextApi={commandMenuContextApi}
isInPreviewMode={isInPreviewMode}
>
{children}
</CommandMenuContextProviderContent>
@@ -2,12 +2,10 @@ import {
CommandMenuContext,
type CommandMenuContextType,
} from '@/command-menu-item/contexts/CommandMenuContext';
import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState';
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/utils/doesCommandMenuItemMatchObjectMetadataId';
import { doesCommandMenuItemMatchPageLayoutId } from '@/command-menu-item/utils/doesCommandMenuItemMatchPageLayoutId';
import { doesCommandMenuItemMatchPageType } from '@/command-menu-item/utils/doesCommandMenuItemMatchPageType';
import { doesCommandMenuItemMatchSelectionState } from '@/command-menu-item/utils/doesCommandMenuItemMatchSelectionState';
import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useMemo } from 'react';
@@ -19,7 +17,6 @@ type CommandMenuContextProviderContentProps = {
containerType: CommandMenuContextType['containerType'];
children: React.ReactNode;
commandMenuContextApi: CommandMenuContextApi;
isInPreviewMode: boolean;
};
export const CommandMenuContextProviderContent = ({
@@ -27,27 +24,19 @@ export const CommandMenuContextProviderContent = ({
containerType,
children,
commandMenuContextApi,
isInPreviewMode,
}: CommandMenuContextProviderContentProps) => {
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
const commandMenuItemsDraft = useAtomStateValue(commandMenuItemsDraftState);
const currentPageLayoutId = useAtomStateValue(currentPageLayoutIdState);
const filteredCommandMenuItems = useMemo(() => {
const currentObjectMetadataItemId =
commandMenuContextApi.objectMetadataItem.id;
const hasSelectedRecords =
commandMenuContextApi.numberOfSelectedRecords > 0;
const commandMenuItemsToDisplay = isInPreviewMode
? (commandMenuItemsDraft ?? commandMenuItems)
: commandMenuItems;
return commandMenuItemsToDisplay
return commandMenuItems
.filter(
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
)
.filter(doesCommandMenuItemMatchPageType(commandMenuContextApi.pageType))
.filter(doesCommandMenuItemMatchSelectionState(hasSelectedRecords))
.filter(doesCommandMenuItemMatchPageLayoutId(currentPageLayoutId))
.filter((item) =>
evaluateConditionalAvailabilityExpression(
@@ -58,13 +47,7 @@ export const CommandMenuContextProviderContent = ({
.sort(
(firstItem, secondItem) => firstItem.position - secondItem.position,
);
}, [
commandMenuContextApi,
commandMenuItems,
commandMenuItemsDraft,
currentPageLayoutId,
isInPreviewMode,
]);
}, [commandMenuItems, commandMenuContextApi, currentPageLayoutId]);
return (
<CommandMenuContext.Provider
@@ -73,7 +56,6 @@ export const CommandMenuContextProviderContent = ({
containerType,
commandMenuItems: filteredCommandMenuItems,
commandMenuContextApi,
isInPreviewMode,
}}
>
{children}
@@ -12,7 +12,6 @@ type CommandMenuContextProviderWithWorkflowEnrichmentProps = {
children: React.ReactNode;
commandMenuContextApi: CommandMenuContextApi;
selectedWorkflowRecordIds: string[];
isInPreviewMode: boolean;
};
export const CommandMenuContextProviderWithWorkflowEnrichment = ({
@@ -21,7 +20,6 @@ export const CommandMenuContextProviderWithWorkflowEnrichment = ({
children,
commandMenuContextApi,
selectedWorkflowRecordIds,
isInPreviewMode,
}: CommandMenuContextProviderWithWorkflowEnrichmentProps) => {
const workflowsWithCurrentVersions = useWorkflowsWithCurrentVersions(
selectedWorkflowRecordIds,
@@ -56,7 +54,6 @@ export const CommandMenuContextProviderWithWorkflowEnrichment = ({
displayType={displayType}
containerType={containerType}
commandMenuContextApi={enrichedCommandMenuContextApi}
isInPreviewMode={isInPreviewMode}
>
{children}
</CommandMenuContextProviderContent>
@@ -11,7 +11,6 @@ import { isSelectedItemIdComponentFamilyState } from '@/ui/layout/selectable-lis
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
import { COMMAND_MENU_DEFAULT_ICON } from '@/workflow/workflow-trigger/constants/CommandMenuDefaultIcon';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
@@ -19,14 +18,6 @@ import { Loader } from 'twenty-ui/feedback';
import { MenuItem } from 'twenty-ui/navigation';
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
const StyledPreviewWrapper = styled.div`
cursor: not-allowed;
& * {
pointer-events: none;
}
`;
type CommandMenuItemRendererProps = {
item: CommandMenuItemFieldsFragment;
};
@@ -36,8 +27,7 @@ type CommandMenuItemButtonRendererProps = CommandMenuItemRendererProps;
const CommandMenuItemButtonRenderer = ({
item,
}: CommandMenuItemButtonRendererProps) => {
const { commandMenuContextApi, isInPreviewMode } =
useContext(CommandMenuContext);
const { commandMenuContextApi } = useContext(CommandMenuContext);
const { getIcon } = useIcons();
const { iconKey, label, shortLabel } = interpolateCommandMenuItemFields(
@@ -53,19 +43,14 @@ const CommandMenuItemButtonRenderer = ({
label,
});
const command = { key: item.id, label, shortLabel, Icon };
if (isInPreviewMode) {
return (
<StyledPreviewWrapper>
<CommandMenuButton command={command} />
</StyledPreviewWrapper>
);
}
return (
<CommandMenuButton
command={command}
command={{
key: item.id,
label,
shortLabel,
Icon,
}}
onClick={disabled ? undefined : handleClick}
disabled={disabled}
/>
@@ -2,7 +2,6 @@ import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/display/const
import { commandMenuPinnedInlineLayoutState } from '@/command-menu-item/display/states/commandMenuPinnedInlineLayoutState';
import { getVisibleCommandMenuItemCountForContainerWidth } from '@/command-menu-item/display/utils/getVisibleCommandMenuItemCountForContainerWidth';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { isNumber } from '@sniptt/guards';
import { useCallback, useMemo } from 'react';
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
@@ -26,37 +25,18 @@ export const usePinnedCommandMenuItemsInlineLayout = ({
[pinnedCommandMenuItems],
);
const hasKnownPinnedInlineLayout = useMemo(
() =>
commandMenuPinnedInlineLayout.containerWidth > 0 &&
pinnedCommandMenuItemKeysInDisplayOrder.every((commandMenuItemKey) =>
isNumber(
commandMenuPinnedInlineLayout.commandMenuItemWidthsByKey[
commandMenuItemKey
],
),
),
[commandMenuPinnedInlineLayout, pinnedCommandMenuItemKeysInDisplayOrder],
);
const visiblePinnedCommandMenuItemCount = useMemo(
() =>
hasKnownPinnedInlineLayout
? getVisibleCommandMenuItemCountForContainerWidth({
commandMenuItemKeysInDisplayOrder:
pinnedCommandMenuItemKeysInDisplayOrder,
commandMenuItemWidthsByKey:
commandMenuPinnedInlineLayout.commandMenuItemWidthsByKey,
commandMenuItemsContainerWidth:
commandMenuPinnedInlineLayout.containerWidth,
commandMenuItemsGapWidth: PINNED_COMMAND_MENU_ITEMS_GAP,
})
: 0,
[
commandMenuPinnedInlineLayout,
hasKnownPinnedInlineLayout,
pinnedCommandMenuItemKeysInDisplayOrder,
],
getVisibleCommandMenuItemCountForContainerWidth({
commandMenuItemKeysInDisplayOrder:
pinnedCommandMenuItemKeysInDisplayOrder,
commandMenuItemWidthsByKey:
commandMenuPinnedInlineLayout.commandMenuItemWidthsByKey,
commandMenuItemsContainerWidth:
commandMenuPinnedInlineLayout.containerWidth,
commandMenuItemsGapWidth: PINNED_COMMAND_MENU_ITEMS_GAP,
}),
[commandMenuPinnedInlineLayout, pinnedCommandMenuItemKeysInDisplayOrder],
);
const pinnedInlineCommandMenuItems = useMemo(
@@ -83,7 +83,7 @@ export const CommandMenuItemEditRecordSelectionDropdown = ({
const TriggerIcon = isNoneSelected ? IconSquareX : IconSquareCheck;
const triggerLabel = isNoneSelected
? t`No record selected`
: t`Record(s) selected`;
: t`Records selected`;
return (
<Dropdown
@@ -108,7 +108,6 @@ export const CommandMenuItemEditRecordSelectionDropdown = ({
</StyledClickableArea>
}
dropdownPlacement="bottom-start"
dropdownOffset={{ y: 4 }}
dropdownComponents={
<DropdownContent widthInPixels={GenericDropdownContentWidth.Medium}>
<StyledDropdownMenuContainer
@@ -123,7 +122,7 @@ export const CommandMenuItemEditRecordSelectionDropdown = ({
/>
<MenuItemSelect
LeftIcon={IconSquareCheck}
text={t`Record(s) selected`}
text={t`Records selected`}
selected={!isNoneSelected}
onClick={() => handleSelectMode('selection')}
/>
@@ -0,0 +1,140 @@
import { PinnedCommandMenuItemsInlineMeasurements } from '@/command-menu-item/display/components/PinnedCommandMenuItemsInlineMeasurements';
import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/display/constants/PinnedCommandMenuItemsGap';
import { usePinnedCommandMenuItemsInlineLayout } from '@/command-menu-item/display/hooks/usePinnedCommandMenuItemsInlineLayout';
import { interpolateCommandMenuItemFields } from '@/command-menu-item/display/utils/interpolateCommandMenuItemFields';
import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState';
import { useCommandMenuContextApi } from '@/command-menu-item/hooks/useCommandMenuContextApi';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/utils/doesCommandMenuItemMatchObjectMetadataId';
import { CommandMenuButton } from '@/command-menu/components/CommandMenuButton';
import { mainContextStoreHasSelectedRecordsSelector } from '@/context-store/states/selectors/mainContextStoreHasSelectedRecordsSelector';
import { NodeDimension } from '@/ui/utilities/dimensions/components/NodeDimension';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { COMMAND_MENU_DEFAULT_ICON } from '@/workflow/workflow-trigger/constants/CommandMenuDefaultIcon';
import { styled } from '@linaria/react';
import { motion } from 'framer-motion';
import { useContext, useMemo } from 'react';
import { useIcons } from 'twenty-ui/display';
import { ThemeContext } from 'twenty-ui/theme-constants';
import { CommandMenuItemAvailabilityType } from '~/generated-metadata/graphql';
const StyledCommandMenuItemContainer = styled(motion.div)`
align-items: center;
display: flex;
justify-content: center;
`;
const StyledWrapper = styled.div`
flex: 1 1 0;
min-width: 0;
overflow: hidden;
`;
const StyledContainer = styled.div`
display: flex;
justify-content: flex-end;
min-width: 0;
width: 100%;
`;
const StyledItemsContainer = styled.div`
display: flex;
gap: ${PINNED_COMMAND_MENU_ITEMS_GAP}px;
max-width: 100%;
overflow: hidden;
`;
export const PinnedCommandMenuItemButtonsEditMode = () => {
const { theme } = useContext(ThemeContext);
const { getIcon } = useIcons();
const commandMenuContextApi = useCommandMenuContextApi();
const currentObjectMetadataItemId =
commandMenuContextApi.objectMetadataItem.id;
const commandMenuItemsDraft =
useAtomStateValue(commandMenuItemsDraftState) ?? [];
const mainContextStoreHasSelectedRecords = useAtomStateValue(
mainContextStoreHasSelectedRecordsSelector,
);
const allowedAvailabilityTypes = useMemo(
() =>
new Set<CommandMenuItemAvailabilityType>([
CommandMenuItemAvailabilityType.GLOBAL,
CommandMenuItemAvailabilityType.GLOBAL_OBJECT_CONTEXT,
mainContextStoreHasSelectedRecords
? CommandMenuItemAvailabilityType.RECORD_SELECTION
: CommandMenuItemAvailabilityType.FALLBACK,
]),
[mainContextStoreHasSelectedRecords],
);
const pinnedCommandMenuItems = commandMenuItemsDraft
.filter(
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
)
.filter((item) => allowedAvailabilityTypes.has(item.availabilityType))
.filter((item) => item.isPinned);
const {
pinnedInlineCommandMenuItems,
pinnedOverflowCommandMenuItems,
onContainerDimensionChange,
onCommandMenuItemDimensionChange,
} = usePinnedCommandMenuItemsInlineLayout({
pinnedCommandMenuItems,
});
return (
<>
<PinnedCommandMenuItemsInlineMeasurements
pinnedCommandMenuItems={[
...pinnedInlineCommandMenuItems,
...pinnedOverflowCommandMenuItems,
]}
onPinnedCommandMenuItemDimensionChange={
onCommandMenuItemDimensionChange
}
/>
<StyledWrapper>
<NodeDimension onDimensionChange={onContainerDimensionChange}>
<StyledContainer>
<StyledItemsContainer>
{pinnedInlineCommandMenuItems.map((item) => {
const { iconKey, label, shortLabel } =
interpolateCommandMenuItemFields(item, commandMenuContextApi);
const Icon = getIcon(iconKey, COMMAND_MENU_DEFAULT_ICON);
return (
<StyledCommandMenuItemContainer
key={item.id}
layout
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'unset', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{
duration: theme.animation.duration.instant,
ease: 'easeInOut',
}}
>
<CommandMenuButton
command={{
key: item.id,
label,
shortLabel,
Icon,
}}
disabled
/>
</StyledCommandMenuItemContainer>
);
})}
</StyledItemsContainer>
</StyledContainer>
</NodeDimension>
</StyledWrapper>
</>
);
};
@@ -1,13 +1,15 @@
import { CommandMenuItemEditRecordSelectionDropdown } from '@/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown';
import { CommandMenuItemOptionsDropdown } from '@/command-menu-item/edit/components/CommandMenuItemOptionsDropdown';
import { useEditableCommandMenuItems } from '@/command-menu-item/edit/hooks/useEditableCommandMenuItems';
import { useReorderCommandMenuItemsInDraft } from '@/command-menu-item/edit/hooks/useReorderCommandMenuItemsInDraft';
import { useResetCommandMenuItemsDraft } from '@/command-menu-item/edit/hooks/useResetCommandMenuItemsDraft';
import { useUpdateCommandMenuItemInDraft } from '@/command-menu-item/edit/hooks/useUpdateCommandMenuItemInDraft';
import { useCurrentCommandMenuContextApi } from '@/command-menu-item/hooks/useCurrentCommandMenuContextApi';
import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState';
import { useCommandMenuContextApi } from '@/command-menu-item/hooks/useCommandMenuContextApi';
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/utils/doesCommandMenuItemMatchObjectMetadataId';
import { groupCommandMenuItems } from '@/command-menu-item/utils/groupCommandMenuItems';
import { COMMAND_MENU_CLICK_OUTSIDE_ID } from '@/command-menu/constants/CommandMenuClickOutsideId';
import { mainContextStoreHasSelectedRecordsSelector } from '@/context-store/states/selectors/mainContextStoreHasSelectedRecordsSelector';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
@@ -34,7 +36,10 @@ import {
import { Button } from 'twenty-ui/input';
import { MenuItem, MenuItemDraggable } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
import {
CommandMenuItemAvailabilityType,
type CommandMenuItemFieldsFragment,
} from '~/generated-metadata/graphql';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
const StyledContainer = styled.div`
@@ -61,7 +66,7 @@ const StyledContent = styled.div`
export const SidePanelCommandMenuItemEditPage = () => {
const { t } = useLingui();
const { getIcon } = useIcons();
const commandMenuContextApi = useCurrentCommandMenuContextApi();
const commandMenuContextApi = useCommandMenuContextApi();
const currentObjectMetadataItemId =
commandMenuContextApi.objectMetadataItem.id;
@@ -71,20 +76,44 @@ export const SidePanelCommandMenuItemEditPage = () => {
const isRecordPage =
commandMenuContextApi.pageType === ContextStorePageType.Record;
const isIndexPage =
commandMenuContextApi.pageType === ContextStorePageType.Index;
const mainContextStoreHasSelectedRecords = useAtomStateValue(
mainContextStoreHasSelectedRecordsSelector,
);
const sidePanelSearch = useAtomStateValue(sidePanelSearchState);
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
const serverItemsById = new Map(
commandMenuItems.map((item) => [item.id, item]),
);
const commandMenuItemsDraft =
useAtomStateValue(commandMenuItemsDraftState) ?? [];
const { updateCommandMenuItemInDraft } = useUpdateCommandMenuItemInDraft();
const { reorderCommandMenuItemInDraft } = useReorderCommandMenuItemsInDraft();
const { resetCommandMenuItemsDraft } = useResetCommandMenuItemsDraft();
const editableCommandMenuItems = useEditableCommandMenuItems();
const allowedAvailabilityTypes = new Set<CommandMenuItemAvailabilityType>([
CommandMenuItemAvailabilityType.GLOBAL,
...(isIndexPage || isRecordPage
? [CommandMenuItemAvailabilityType.GLOBAL_OBJECT_CONTEXT]
: []),
...(mainContextStoreHasSelectedRecords
? [CommandMenuItemAvailabilityType.RECORD_SELECTION]
: []),
]);
const filteredCommandMenuItems = commandMenuItemsDraft
.filter(
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
)
.filter((item) => allowedAvailabilityTypes.has(item.availabilityType))
.sort((firstItem, secondItem) => firstItem.position - secondItem.position);
const filteredCommandMenuItemIds = new Set(
editableCommandMenuItems.map((item) => item.id),
filteredCommandMenuItems.map((item) => item.id),
);
const getDisplayLabel = (item: CommandMenuItemFieldsFragment) =>
@@ -94,7 +123,7 @@ export const SidePanelCommandMenuItemEditPage = () => {
}) ?? item.label;
const { pinned: allPinnedItems, other: allOtherItems } =
groupCommandMenuItems(editableCommandMenuItems);
groupCommandMenuItems(filteredCommandMenuItems);
const normalizedSearch =
sidePanelSearch.length > 0
@@ -1,38 +0,0 @@
import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState';
import { useCurrentCommandMenuContextApi } from '@/command-menu-item/hooks/useCurrentCommandMenuContextApi';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/utils/doesCommandMenuItemMatchObjectMetadataId';
import { doesCommandMenuItemMatchPageLayoutId } from '@/command-menu-item/utils/doesCommandMenuItemMatchPageLayoutId';
import { doesCommandMenuItemMatchPageType } from '@/command-menu-item/utils/doesCommandMenuItemMatchPageType';
import { doesCommandMenuItemMatchSelectionState } from '@/command-menu-item/utils/doesCommandMenuItemMatchSelectionState';
import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useMemo } from 'react';
import { CommandMenuItemAvailabilityType } from '~/generated-metadata/graphql';
export const useEditableCommandMenuItems = () => {
const commandMenuContextApi = useCurrentCommandMenuContextApi();
const commandMenuItemsDraft = useAtomStateValue(commandMenuItemsDraftState);
const currentPageLayoutId = useAtomStateValue(currentPageLayoutIdState);
return useMemo(() => {
const currentObjectMetadataItemId =
commandMenuContextApi.objectMetadataItem.id;
const hasSelectedRecords =
commandMenuContextApi.numberOfSelectedRecords > 0;
return (commandMenuItemsDraft ?? [])
.filter(
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
)
.filter(doesCommandMenuItemMatchPageType(commandMenuContextApi.pageType))
.filter(doesCommandMenuItemMatchSelectionState(hasSelectedRecords))
.filter(
(item) =>
item.availabilityType !== CommandMenuItemAvailabilityType.FALLBACK,
)
.filter(doesCommandMenuItemMatchPageLayoutId(currentPageLayoutId))
.sort(
(firstItem, secondItem) => firstItem.position - secondItem.position,
);
}, [commandMenuItemsDraft, commandMenuContextApi, currentPageLayoutId]);
};
@@ -70,7 +70,6 @@ const getWrapper =
objectMetadataItem: {},
objectMetadataLabel: '',
},
isInPreviewMode: false,
}}
>
{children}
@@ -30,7 +30,7 @@ import {
} from 'twenty-shared/types';
import { isDefined, resolveObjectMetadataLabel } from 'twenty-shared/utils';
export const useCurrentCommandMenuContextApi = (): CommandMenuContextApi => {
export const useCommandMenuContextApi = (): CommandMenuContextApi => {
const store = useStore();
const contextStoreInstanceId = useAvailableComponentInstanceIdOrThrow(
@@ -1,36 +0,0 @@
import { doesCommandMenuItemMatchSelectionState } from '@/command-menu-item/utils/doesCommandMenuItemMatchSelectionState';
import {
CommandMenuItemAvailabilityType,
type CommandMenuItemFieldsFragment,
} from '~/generated-metadata/graphql';
const buildCommandMenuItem = (
availabilityType: CommandMenuItemAvailabilityType,
) =>
({
availabilityType,
}) as CommandMenuItemFieldsFragment;
describe('doesCommandMenuItemMatchSelectionState', () => {
it('should keep a non-record-selection item when no records are selected', () => {
const item = buildCommandMenuItem(CommandMenuItemAvailabilityType.GLOBAL);
expect(doesCommandMenuItemMatchSelectionState(false)(item)).toBe(true);
});
it('should hide a record-selection item when no records are selected', () => {
const item = buildCommandMenuItem(
CommandMenuItemAvailabilityType.RECORD_SELECTION,
);
expect(doesCommandMenuItemMatchSelectionState(false)(item)).toBe(false);
});
it('should keep a record-selection item when records are selected', () => {
const item = buildCommandMenuItem(
CommandMenuItemAvailabilityType.RECORD_SELECTION,
);
expect(doesCommandMenuItemMatchSelectionState(true)(item)).toBe(true);
});
});
@@ -1,9 +0,0 @@
import {
CommandMenuItemAvailabilityType,
type CommandMenuItemFieldsFragment,
} from '~/generated-metadata/graphql';
export const doesCommandMenuItemMatchSelectionState =
(hasSelectedRecords: boolean) => (item: CommandMenuItemFieldsFragment) =>
item.availabilityType !==
CommandMenuItemAvailabilityType.RECORD_SELECTION || hasSelectedRecords;
@@ -15,7 +15,6 @@ export const LOGIC_FUNCTION_FRAGMENT = gql`
databaseEventTriggerSettings
httpRouteTriggerSettings
applicationId
universalIdentifier
createdAt
updatedAt
}
@@ -46,9 +46,6 @@ describe('useLogicFunctionUpdateFormState', () => {
properties: {},
type: 'object',
},
cronTriggerSettings: null,
databaseEventTriggerSettings: null,
httpRouteTriggerSettings: null,
});
});
});
@@ -1,11 +1,6 @@
import { useGetOneLogicFunction } from '@/logic-functions/hooks/useGetOneLogicFunction';
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
type CronTriggerSettings,
type DatabaseEventTriggerSettings,
type HttpRouteTriggerSettings,
} from 'twenty-shared/application';
import { type LogicFunction } from '~/generated-metadata/graphql';
import { useGetLogicFunctionSourceCode } from '@/logic-functions/hooks/useGetLogicFunctionSourceCode';
import { DEFAULT_TOOL_INPUT_SCHEMA } from 'twenty-shared/logic-function';
@@ -17,9 +12,6 @@ export type LogicFunctionFormValues = {
timeoutSeconds: number;
sourceHandlerCode: string;
toolInputSchema?: object;
cronTriggerSettings: CronTriggerSettings | null;
databaseEventTriggerSettings: DatabaseEventTriggerSettings | null;
httpRouteTriggerSettings: HttpRouteTriggerSettings | null;
};
type SetLogicFunctionFormValues = Dispatch<
@@ -43,9 +35,6 @@ export const useLogicFunctionUpdateFormState = ({
sourceHandlerCode: '',
timeoutSeconds: 300,
toolInputSchema: DEFAULT_TOOL_INPUT_SCHEMA,
cronTriggerSettings: null,
databaseEventTriggerSettings: null,
httpRouteTriggerSettings: null,
});
const { sourceHandlerCode, loading: logicFunctionSourceCodeLoading } =
@@ -68,11 +57,6 @@ export const useLogicFunctionUpdateFormState = ({
timeoutSeconds: logicFunction.timeoutSeconds ?? 300,
toolInputSchema:
logicFunction.toolInputSchema || DEFAULT_TOOL_INPUT_SCHEMA,
cronTriggerSettings: logicFunction.cronTriggerSettings ?? null,
databaseEventTriggerSettings:
logicFunction.databaseEventTriggerSettings ?? null,
httpRouteTriggerSettings:
logicFunction.httpRouteTriggerSettings ?? null,
}));
}
}, [logicFunction]);
@@ -1,55 +0,0 @@
import { getLogicFunctionTriggerLabel } from '@/logic-functions/utils/getLogicFunctionTriggerLabel';
describe('getLogicFunctionTriggerLabel', () => {
it('returns Post-install when the function matches the post-install identifier', () => {
expect(
getLogicFunctionTriggerLabel(
{ universalIdentifier: 'uid-post' },
{ postInstallUniversalIdentifier: 'uid-post' },
),
).toBe('Post-install');
});
it('returns Pre-install when the function matches the pre-install identifier', () => {
expect(
getLogicFunctionTriggerLabel(
{ universalIdentifier: 'uid-pre' },
{ preInstallUniversalIdentifier: 'uid-pre' },
),
).toBe('Pre-install');
});
it('does not match when both identifiers are undefined', () => {
expect(getLogicFunctionTriggerLabel({}, {})).toBe('');
});
it('returns AI tool when isTool is set', () => {
expect(getLogicFunctionTriggerLabel({ isTool: true })).toBe('AI tool');
});
it('returns Cron when cron settings are present', () => {
expect(getLogicFunctionTriggerLabel({ cronTriggerSettings: {} })).toBe(
'Cron',
);
});
it('returns HTTP when http settings are present', () => {
expect(getLogicFunctionTriggerLabel({ httpRouteTriggerSettings: {} })).toBe(
'HTTP',
);
});
it('returns the database event name when it exists', () => {
expect(
getLogicFunctionTriggerLabel({
databaseEventTriggerSettings: { eventName: 'person.created' },
}),
).toBe('person.created');
});
it('falls back to a generic label when the database event name is missing', () => {
expect(
getLogicFunctionTriggerLabel({ databaseEventTriggerSettings: {} }),
).toBe('Database event');
});
});
@@ -1,38 +0,0 @@
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
type LogicFunctionLike = {
universalIdentifier?: string | null;
isTool?: boolean;
cronTriggerSettings?: unknown;
httpRouteTriggerSettings?: unknown;
databaseEventTriggerSettings?: { eventName?: string } | null;
};
export const getLogicFunctionTriggerLabel = (
lf: LogicFunctionLike,
options: {
postInstallUniversalIdentifier?: string;
preInstallUniversalIdentifier?: string;
} = {},
): string => {
if (
isDefined(lf.universalIdentifier) &&
lf.universalIdentifier === options.postInstallUniversalIdentifier
) {
return t`Post-install`;
}
if (
isDefined(lf.universalIdentifier) &&
lf.universalIdentifier === options.preInstallUniversalIdentifier
) {
return t`Pre-install`;
}
if (lf.isTool) return t`AI tool`;
if (lf.cronTriggerSettings) return t`Cron`;
if (lf.httpRouteTriggerSettings) return t`HTTP`;
if (lf.databaseEventTriggerSettings) {
return lf.databaseEventTriggerSettings.eventName ?? t`Database event`;
}
return '';
};

Some files were not shown because too many files have changed in this diff Show More