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
542 changed files with 27968 additions and 35018 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
@@ -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;
@@ -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 '';
};
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`generateDepthRecordGqlFieldsFromObject should generate depth one record gql fields from object 1`] = `
{
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`generateDepthRecordGqlFieldsFromRecord should generate depth one record gql fields from empty record 1`] = `
{
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`useDeleteOneRecord A. Starting from empty cache 1. Should successfully delete record and update record cache entry 1`] = `
{
@@ -1,7 +1,6 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { useLoadRecordIndexStates } from '@/object-record/record-index/hooks/useLoadRecordIndexStates';
import { lastLoadedRecordTableWidgetViewIdComponentState } from '@/object-record/record-table-widget/states/lastLoadedRecordTableWidgetViewIdComponentState';
import { computeRecordTableWidgetViewLoadContentSignature } from '@/object-record/record-table-widget/utils/computeRecordTableWidgetViewLoadContentSignature';
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
import { recordTableWidgetViewDraftByWidgetIdComponentFamilySelector } from '@/page-layout/states/selectors/recordTableWidgetViewDraftByWidgetIdComponentFamilySelector';
import { constructViewFromRecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/utils/constructViewFromRecordTableWidgetViewSnapshot';
@@ -61,17 +60,11 @@ export const RecordTableWidgetViewLoadEffect = ({
return;
}
const contentSignature =
computeRecordTableWidgetViewLoadContentSignature(currentView);
const lastLoadedMatches =
if (
viewId === lastLoadedRecordTableWidgetViewId?.viewId &&
objectMetadataItem.updatedAt ===
lastLoadedRecordTableWidgetViewId?.objectMetadataItemUpdatedAt &&
contentSignature ===
lastLoadedRecordTableWidgetViewId?.loadedViewContentSignature;
if (lastLoadedMatches) {
lastLoadedRecordTableWidgetViewId?.objectMetadataItemUpdatedAt
) {
return;
}
@@ -80,7 +73,6 @@ export const RecordTableWidgetViewLoadEffect = ({
setLastLoadedRecordTableWidgetViewId({
viewId,
objectMetadataItemUpdatedAt: objectMetadataItem.updatedAt,
loadedViewContentSignature: contentSignature,
});
}, [
viewId,
@@ -5,7 +5,6 @@ export const lastLoadedRecordTableWidgetViewIdComponentState =
createAtomComponentState<{
viewId: string;
objectMetadataItemUpdatedAt: string;
loadedViewContentSignature: string;
} | null>({
key: 'lastLoadedRecordTableWidgetViewIdComponentState',
defaultValue: null,
@@ -1,34 +0,0 @@
import { type View } from '@/views/types/View';
export const computeRecordTableWidgetViewLoadContentSignature = (
view: View,
): string =>
JSON.stringify({
fields: [...view.viewFields]
.sort((fieldA, fieldB) => fieldA.position - fieldB.position)
.map((field) => ({
id: field.id,
fieldMetadataId: field.fieldMetadataId,
isVisible: field.isVisible,
position: field.position,
})),
filters: [...view.viewFilters]
.sort((filterA, filterB) => filterA.id.localeCompare(filterB.id))
.map((filter) => ({
id: filter.id,
fieldMetadataId: filter.fieldMetadataId,
operand: filter.operand,
value: filter.value,
viewFilterGroupId: filter.viewFilterGroupId,
})),
filterGroups: (view.viewFilterGroups ?? []).map((group) => ({
id: group.id,
logicalOperator: group.logicalOperator,
parentViewFilterGroupId: group.parentViewFilterGroupId,
})),
sorts: view.viewSorts.map((sort) => ({
id: sort.id,
fieldMetadataId: sort.fieldMetadataId,
direction: sort.direction,
})),
});
@@ -1,11 +0,0 @@
import { VIEW_FRAGMENT } from '@/views/graphql/fragments/viewFragment';
import { gql } from '@apollo/client';
export const UPSERT_VIEW_WIDGET = gql`
${VIEW_FRAGMENT}
mutation UpsertViewWidget($input: UpsertViewWidgetInput!) {
upsertViewWidget(input: $input) {
...ViewFragment
}
}
`;
@@ -1,8 +1,10 @@
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState';
import { getWidgetConfigurationViewId } from '@/page-layout/utils/getWidgetConfigurationViewId';
import { usePerformViewAPIPersist } from '@/views/hooks/internal/usePerformViewAPIPersist';
import { usePerformViewFieldAPIPersist } from '@/views/hooks/internal/usePerformViewFieldAPIPersist';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
@@ -11,6 +13,7 @@ import { WidgetType } from '~/generated-metadata/graphql';
export const useCreatePendingRecordTableWidgetViews = () => {
const { performViewAPICreate, performViewAPIDestroy } =
usePerformViewAPIPersist();
const { performViewFieldAPICreate } = usePerformViewFieldAPIPersist();
const store = useStore();
const createPendingRecordTableWidgetViews = useCallback(
@@ -98,6 +101,19 @@ export const useCreatePendingRecordTableWidgetViews = () => {
`Failed to create view for RECORD_TABLE widget ${widget.id}`,
);
}
const viewFieldInputs = widgetViewDraft.viewFields.map((field) => ({
id: field.id,
viewId: field.viewId,
fieldMetadataId: field.fieldMetadataId,
position: field.position,
size: field.size,
isVisible: field.isVisible,
}));
if (viewFieldInputs.length > 0) {
await performViewFieldAPICreate({ inputs: viewFieldInputs });
}
}
for (const [widgetId, viewId] of persistedRecordTableWidgets) {
@@ -105,8 +121,20 @@ export const useCreatePendingRecordTableWidgetViews = () => {
await performViewAPIDestroy({ id: viewId });
}
}
store.set(
recordTableWidgetViewPersistedComponentState.atomFamily({
instanceId: pageLayoutId,
}),
recordTableWidgetViewDraft,
);
},
[performViewAPICreate, performViewAPIDestroy, store],
[
performViewAPICreate,
performViewAPIDestroy,
performViewFieldAPICreate,
store,
],
);
return { createPendingRecordTableWidgetViews };
@@ -1,32 +0,0 @@
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const useHasRecordTableWidgetViewChanges = () => {
const store = useStore();
const hasRecordTableWidgetViewChanges = useCallback(
(pageLayoutId: string): boolean => {
const recordTableWidgetViewDraft = store.get(
recordTableWidgetViewDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
const recordTableWidgetViewPersisted = store.get(
recordTableWidgetViewPersistedComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
return !isDeeplyEqual(
recordTableWidgetViewDraft,
recordTableWidgetViewPersisted,
);
},
[store],
);
return { hasRecordTableWidgetViewChanges };
};
@@ -0,0 +1,29 @@
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { WidgetConfigurationType } from '~/generated-metadata/graphql';
export const useHasRecordTableWidgets = () => {
const store = useStore();
const hasRecordTableWidgets = useCallback(
(pageLayoutId: string): boolean => {
const pageLayoutDraft = store.get(
pageLayoutDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
return pageLayoutDraft.tabs.some((tab) =>
tab.widgets.some(
(widget) =>
widget.configuration.configurationType ===
WidgetConfigurationType.RECORD_TABLE,
),
);
},
[store],
);
return { hasRecordTableWidgets };
};
@@ -9,8 +9,6 @@ import { fieldsWidgetUngroupedFieldsPersistedComponentState } from '@/page-layou
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState';
import { convertPageLayoutToTabLayouts } from '@/page-layout/utils/convertPageLayoutToTabLayouts';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
@@ -85,17 +83,6 @@ export const useResetDraftPageLayoutToPersistedPageLayout = ({
componentInstanceId,
);
const recordTableWidgetViewDraftState = useAtomComponentStateCallbackState(
recordTableWidgetViewDraftComponentState,
componentInstanceId,
);
const recordTableWidgetViewPersistedState =
useAtomComponentStateCallbackState(
recordTableWidgetViewPersistedComponentState,
componentInstanceId,
);
const resetDraftPageLayoutToPersistedPageLayout = useCallback(() => {
const pageLayoutPersisted = store.get(pageLayoutPersistedState);
@@ -145,14 +132,6 @@ export const useResetDraftPageLayoutToPersistedPageLayout = ({
fieldsWidgetEditorModeDraftState,
fieldsWidgetEditorModePersisted,
);
const recordTableWidgetViewPersisted = store.get(
recordTableWidgetViewPersistedState,
);
store.set(
recordTableWidgetViewDraftState,
recordTableWidgetViewPersisted,
);
}
}, [
pageLayoutDraftState,
@@ -164,8 +143,6 @@ export const useResetDraftPageLayoutToPersistedPageLayout = ({
fieldsWidgetUngroupedFieldsPersistedState,
fieldsWidgetEditorModeDraftState,
fieldsWidgetEditorModePersistedState,
recordTableWidgetViewDraftState,
recordTableWidgetViewPersistedState,
activeTabId,
store,
]);
@@ -1,18 +1,18 @@
import { useHasFieldsWidgetChanges } from '@/page-layout/hooks/useHasFieldsWidgetChanges';
import { useHasRecordTableWidgetViewChanges } from '@/page-layout/hooks/useHasRecordTableWidgetViewChanges';
import { useHasRecordTableWidgets } from '@/page-layout/hooks/useHasRecordTableWidgets';
import { useSaveFieldsWidgetGroups } from '@/page-layout/hooks/useSaveFieldsWidgetGroups';
import { useSaveRecordTableWidgetViews } from '@/page-layout/hooks/useSaveRecordTableWidgetViews';
import { useSaveRecordTableWidgetsViewDataOnDashboardSave } from '@/page-layout/widgets/record-table/hooks/useSaveRecordTableWidgetsViewDataOnDashboardSave';
import { useCallback } from 'react';
export const useSavePageLayoutWidgetsData = () => {
const { hasFieldsWidgetChanges } = useHasFieldsWidgetChanges();
const { hasRecordTableWidgetViewChanges } =
useHasRecordTableWidgetViewChanges();
const { hasRecordTableWidgets } = useHasRecordTableWidgets();
const { saveFieldsWidgetGroups } = useSaveFieldsWidgetGroups();
const { saveRecordTableWidgetViews } = useSaveRecordTableWidgetViews();
const { saveRecordTableWidgetsViewDataOnDashboardSave } =
useSaveRecordTableWidgetsViewDataOnDashboardSave();
const savePageLayoutWidgetsData = useCallback(
async (pageLayoutId: string) => {
@@ -20,15 +20,15 @@ export const useSavePageLayoutWidgetsData = () => {
await saveFieldsWidgetGroups(pageLayoutId);
}
if (hasRecordTableWidgetViewChanges(pageLayoutId)) {
await saveRecordTableWidgetViews(pageLayoutId);
if (hasRecordTableWidgets(pageLayoutId)) {
await saveRecordTableWidgetsViewDataOnDashboardSave(pageLayoutId);
}
},
[
hasFieldsWidgetChanges,
hasRecordTableWidgetViewChanges,
saveFieldsWidgetGroups,
saveRecordTableWidgetViews,
saveRecordTableWidgetsViewDataOnDashboardSave,
hasRecordTableWidgets,
],
);
@@ -1,115 +0,0 @@
import { UPSERT_VIEW_WIDGET } from '@/page-layout/graphql/mutations/upsertViewWidget';
import { useHasRecordTableWidgetViewChanges } from '@/page-layout/hooks/useHasRecordTableWidgetViewChanges';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState';
import { getWidgetConfigurationViewId } from '@/page-layout/utils/getWidgetConfigurationViewId';
import { useMutation } from '@apollo/client/react';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
type UpsertViewWidgetInput,
type ViewFragmentFragment,
WidgetType,
} from '~/generated-metadata/graphql';
export const useSaveRecordTableWidgetViews = () => {
const [upsertViewWidgetMutation] = useMutation<
{ upsertViewWidget: ViewFragmentFragment },
{ input: UpsertViewWidgetInput }
>(UPSERT_VIEW_WIDGET);
const { hasRecordTableWidgetViewChanges } =
useHasRecordTableWidgetViewChanges();
const store = useStore();
const saveRecordTableWidgetViews = useCallback(
async (pageLayoutId: string) => {
if (!hasRecordTableWidgetViewChanges(pageLayoutId)) {
return;
}
const draft = store.get(
pageLayoutDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
const recordTableWidgetViewDraft = store.get(
recordTableWidgetViewDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
const draftRecordTableWidgets = draft.tabs
.flatMap((tab) => tab.widgets)
.filter((widget) => widget.type === WidgetType.RECORD_TABLE);
for (const widget of draftRecordTableWidgets) {
const viewId = getWidgetConfigurationViewId(widget.configuration);
if (!isDefined(viewId)) {
continue;
}
const widgetViewDraft = recordTableWidgetViewDraft[widget.id];
if (!isDefined(widgetViewDraft)) {
continue;
}
await upsertViewWidgetMutation({
variables: {
input: {
widgetId: widget.id,
viewFields: widgetViewDraft.viewFields.map((field) => ({
...(isDefined(field.id) ? { viewFieldId: field.id } : {}),
fieldMetadataId: field.fieldMetadataId,
isVisible: field.isVisible,
position: field.position,
size: field.size,
})),
viewFilters: widgetViewDraft.viewFilters.map((filter) => ({
id: filter.id,
fieldMetadataId: filter.fieldMetadataId,
operand: filter.operand,
value: filter.value,
viewFilterGroupId: filter.viewFilterGroupId ?? undefined,
positionInViewFilterGroup:
filter.positionInViewFilterGroup ?? undefined,
subFieldName: filter.subFieldName ?? undefined,
})),
viewFilterGroups: widgetViewDraft.viewFilterGroups.map(
(group) => ({
id: group.id,
parentViewFilterGroupId:
group.parentViewFilterGroupId ?? undefined,
logicalOperator: group.logicalOperator,
positionInViewFilterGroup:
group.positionInViewFilterGroup ?? undefined,
}),
),
viewSorts: widgetViewDraft.viewSorts.map((sort) => ({
id: sort.id,
fieldMetadataId: sort.fieldMetadataId,
direction: sort.direction,
})),
},
},
});
}
store.set(
recordTableWidgetViewPersistedComponentState.atomFamily({
instanceId: pageLayoutId,
}),
recordTableWidgetViewDraft,
);
},
[hasRecordTableWidgetViewChanges, store, upsertViewWidgetMutation],
);
return { saveRecordTableWidgetViews };
};
@@ -70,13 +70,7 @@ export const useAddDraftViewForRecordTableWidget = (pageLayoutId: string) => {
store.set(recordTableWidgetViewDraftState, (prev) => ({
...prev,
[widgetId]: {
view: flatView,
viewFields: flatViewFields,
viewFilters: [],
viewFilterGroups: [],
viewSorts: [],
},
[widgetId]: { view: flatView, viewFields: flatViewFields },
}));
requestAnimationFrame(() => {
@@ -1,7 +1,4 @@
import { type FlatViewField } from '@/metadata-store/types/FlatViewField';
import { type FlatViewFilter } from '@/metadata-store/types/FlatViewFilter';
import { type FlatViewFilterGroup } from '@/metadata-store/types/FlatViewFilterGroup';
import { type FlatViewSort } from '@/metadata-store/types/FlatViewSort';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState';
import { type RecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot';
@@ -41,43 +38,16 @@ export const useInitializeRecordTableWidgetViewDraft = ({
return;
}
const {
viewFields,
viewFilters,
viewFilterGroups,
viewSorts,
viewGroups: _viewGroups,
...viewProps
} = view;
const { viewFields, ...viewProps } = view;
const flatViewFields: FlatViewField[] = viewFields.map((field) => ({
...field,
viewId: view.id,
}));
const flatViewFilters: FlatViewFilter[] = viewFilters.map((filter) => ({
...filter,
viewId: view.id,
}));
const flatViewFilterGroups: FlatViewFilterGroup[] = (
viewFilterGroups ?? []
).map((group) => ({
...group,
viewId: view.id,
}));
const flatViewSorts: FlatViewSort[] = viewSorts.map((sort) => ({
...sort,
viewId: view.id,
}));
const snapshot: RecordTableWidgetViewSnapshot = {
view: viewProps,
viewFields: flatViewFields,
viewFilters: flatViewFilters,
viewFilterGroups: flatViewFilterGroups,
viewSorts: flatViewSorts,
};
store.set(recordTableWidgetViewDraftState, (prev) => ({
@@ -0,0 +1,36 @@
import { type RecordField } from '@/object-record/record-field/types/RecordField';
import { viewFieldAggregateOperationState } from '@/object-record/record-table/record-table-footer/states/viewFieldAggregateOperationState';
import { convertExtendedAggregateOperationToAggregateOperation } from '@/object-record/utils/convertExtendedAggregateOperationToAggregateOperation';
import { type ViewField } from '@/views/types/ViewField';
import { mapRecordFieldToViewField } from '@/views/utils/mapRecordFieldToViewField';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const useMapRecordFieldToViewFieldWithCurrentAggregateOperation = () => {
const store = useStore();
const mapRecordFieldToViewFieldWithCurrentAggregateOperation = useCallback(
(recordField: RecordField): Omit<ViewField, 'definition'> => {
const viewField = mapRecordFieldToViewField(recordField);
const extendedAggregateOperation = store.get(
viewFieldAggregateOperationState.atomFamily({
viewFieldId: recordField.id,
}),
);
return {
...viewField,
aggregateOperation: isDefined(extendedAggregateOperation)
? convertExtendedAggregateOperationToAggregateOperation(
extendedAggregateOperation,
)
: viewField.aggregateOperation,
};
},
[store],
);
return { mapRecordFieldToViewFieldWithCurrentAggregateOperation };
};
@@ -1,81 +0,0 @@
import { type RecordField } from '@/object-record/record-field/types/RecordField';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
type UseRecordTableWidgetFieldCallbacksParams = {
pageLayoutId: string;
widgetId: string;
viewId: string;
};
export const useRecordTableWidgetFieldCallbacks = ({
pageLayoutId,
widgetId,
viewId,
}: UseRecordTableWidgetFieldCallbacksParams) => {
const recordTableWidgetViewDraftState = useAtomComponentStateCallbackState(
recordTableWidgetViewDraftComponentState,
pageLayoutId,
);
const store = useStore();
const handleFieldUpdated = (
viewFieldId: string,
update: Partial<{ position: number; isVisible: boolean }>,
) => {
store.set(recordTableWidgetViewDraftState, (prev) => {
const widgetViewDraft = prev[widgetId];
if (!isDefined(widgetViewDraft)) {
return prev;
}
return {
...prev,
[widgetId]: {
...widgetViewDraft,
viewFields: widgetViewDraft.viewFields.map((field) =>
field.id === viewFieldId ||
field.clientRecordFieldId === viewFieldId
? { ...field, ...update }
: field,
),
},
};
});
};
const handleFieldCreated = (recordField: RecordField) => {
store.set(recordTableWidgetViewDraftState, (prev) => {
const widgetViewDraft = prev[widgetId];
if (!isDefined(widgetViewDraft)) {
return prev;
}
return {
...prev,
[widgetId]: {
...widgetViewDraft,
viewFields: [
...widgetViewDraft.viewFields,
{
fieldMetadataId: recordField.fieldMetadataItemId,
position: recordField.position,
isVisible: recordField.isVisible,
size: recordField.size,
isActive: true,
viewId,
clientRecordFieldId: recordField.id,
},
],
},
};
});
};
return { handleFieldUpdated, handleFieldCreated };
};
@@ -1,77 +0,0 @@
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { mapRecordFilterGroupToViewFilterGroup } from '@/views/utils/mapRecordFilterGroupToViewFilterGroup';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
type UseRecordTableWidgetFilterCallbacksParams = {
pageLayoutId: string;
widgetId: string;
viewId: string;
recordIndexId: string;
};
export const useRecordTableWidgetFilterCallbacks = ({
pageLayoutId,
widgetId,
viewId,
recordIndexId,
}: UseRecordTableWidgetFilterCallbacksParams) => {
const recordTableWidgetViewDraftState = useAtomComponentStateCallbackState(
recordTableWidgetViewDraftComponentState,
pageLayoutId,
);
const currentRecordFiltersState = useAtomComponentStateCallbackState(
currentRecordFiltersComponentState,
recordIndexId,
);
const currentRecordFilterGroupsState = useAtomComponentStateCallbackState(
currentRecordFilterGroupsComponentState,
recordIndexId,
);
const store = useStore();
const handleFilterUpdate = () => {
const currentRecordFilters = store.get(currentRecordFiltersState);
const currentRecordFilterGroups = store.get(currentRecordFilterGroupsState);
store.set(recordTableWidgetViewDraftState, (prev) => {
const widgetViewDraft = prev[widgetId];
if (!isDefined(widgetViewDraft)) {
return prev;
}
return {
...prev,
[widgetId]: {
...widgetViewDraft,
viewFilters: currentRecordFilters.map((recordFilter) => ({
id: recordFilter.id,
fieldMetadataId: recordFilter.fieldMetadataId,
operand: recordFilter.operand,
value: recordFilter.value,
viewId,
viewFilterGroupId: recordFilter.recordFilterGroupId ?? null,
positionInViewFilterGroup:
recordFilter.positionInRecordFilterGroup ?? null,
subFieldName: recordFilter.subFieldName ?? null,
})),
viewFilterGroups: currentRecordFilterGroups.map((recordFilterGroup) =>
mapRecordFilterGroupToViewFilterGroup({
recordFilterGroup,
view: { id: viewId },
}),
),
},
};
});
};
return { handleFilterUpdate };
};
@@ -1,58 +0,0 @@
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
type UseRecordTableWidgetSortCallbacksParams = {
pageLayoutId: string;
widgetId: string;
viewId: string;
recordIndexId: string;
};
export const useRecordTableWidgetSortCallbacks = ({
pageLayoutId,
widgetId,
viewId,
recordIndexId,
}: UseRecordTableWidgetSortCallbacksParams) => {
const recordTableWidgetViewDraftState = useAtomComponentStateCallbackState(
recordTableWidgetViewDraftComponentState,
pageLayoutId,
);
const currentRecordSortsState = useAtomComponentStateCallbackState(
currentRecordSortsComponentState,
recordIndexId,
);
const store = useStore();
const handleSortUpdate = () => {
const currentRecordSorts = store.get(currentRecordSortsState);
store.set(recordTableWidgetViewDraftState, (prev) => {
const widgetViewDraft = prev[widgetId];
if (!isDefined(widgetViewDraft)) {
return prev;
}
return {
...prev,
[widgetId]: {
...widgetViewDraft,
viewSorts: currentRecordSorts.map((recordSort) => ({
id: recordSort.id,
fieldMetadataId: recordSort.fieldMetadataId,
direction: recordSort.direction,
viewId,
})),
},
};
});
};
return { handleSortUpdate };
};
@@ -1,6 +1,5 @@
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { type RecordTableWidgetViewFieldItem } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewFieldItem';
import { getRecordTableWidgetDraftViewFieldClientId } from '@/page-layout/widgets/record-table/utils/getRecordTableWidgetDraftViewFieldClientId';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useStore } from 'jotai';
import { useCallback } from 'react';
@@ -55,10 +54,7 @@ export const useReorderRecordTableWidgetFields = ({
[widgetId]: {
...widgetViewDraft,
viewFields: widgetViewDraft.viewFields.map((field) => {
const newPosition = updatedPositions.get(
getRecordTableWidgetDraftViewFieldClientId(field),
);
const newPosition = updatedPositions.get(field.id);
return newPosition !== undefined
? { ...field, position: newPosition }
: field;
@@ -0,0 +1,162 @@
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { usePerformViewFilterAPIPersist } from '@/views/hooks/internal/usePerformViewFilterAPIPersist';
import { usePerformViewFilterGroupAPIPersist } from '@/views/hooks/internal/usePerformViewFilterGroupAPIPersist';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import { getViewFilterGroupsToCreate } from '@/views/utils/getViewFilterGroupsToCreate';
import { getViewFilterGroupsToDelete } from '@/views/utils/getViewFilterGroupsToDelete';
import { getViewFilterGroupsToUpdate } from '@/views/utils/getViewFilterGroupsToUpdate';
import { getViewFiltersToCreate } from '@/views/utils/getViewFiltersToCreate';
import { getViewFiltersToDelete } from '@/views/utils/getViewFiltersToDelete';
import { getViewFiltersToUpdate } from '@/views/utils/getViewFiltersToUpdate';
import { mapRecordFilterGroupToViewFilterGroup } from '@/views/utils/mapRecordFilterGroupToViewFilterGroup';
import { mapRecordFilterToViewFilter } from '@/views/utils/mapRecordFilterToViewFilter';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const useSaveRecordTableWidgetFiltersToView = (
viewId: string,
instanceId: string,
) => {
const {
performViewFilterAPICreate,
performViewFilterAPIUpdate,
performViewFilterAPIDelete,
} = usePerformViewFilterAPIPersist();
const {
performViewFilterGroupAPICreate,
performViewFilterGroupAPIUpdate,
performViewFilterGroupAPIDestroy,
} = usePerformViewFilterGroupAPIPersist();
const currentRecordFiltersCallbackState = useAtomComponentStateCallbackState(
currentRecordFiltersComponentState,
instanceId,
);
const currentRecordFilterGroupsCallbackState =
useAtomComponentStateCallbackState(
currentRecordFilterGroupsComponentState,
instanceId,
);
const store = useStore();
const saveRecordTableWidgetFiltersToView = useCallback(async () => {
const views = store.get(viewsSelector.atom);
const currentView = views.find((view) => view.id === viewId);
if (!isDefined(currentView)) {
return;
}
const currentRecordFilters = store.get(currentRecordFiltersCallbackState);
const currentRecordFilterGroups = store.get(
currentRecordFilterGroupsCallbackState,
);
const newViewFilterGroups = currentRecordFilterGroups.map(
(recordFilterGroup) =>
mapRecordFilterGroupToViewFilterGroup({
recordFilterGroup,
view: currentView,
}),
);
const currentViewFilterGroups = currentView.viewFilterGroups ?? [];
const viewFilterGroupsToCreate = getViewFilterGroupsToCreate(
currentViewFilterGroups,
newViewFilterGroups,
);
const viewFilterGroupsToUpdate = getViewFilterGroupsToUpdate(
currentViewFilterGroups,
newViewFilterGroups,
);
const viewFilterGroupsToDelete = getViewFilterGroupsToDelete(
currentViewFilterGroups,
newViewFilterGroups,
);
await performViewFilterGroupAPICreate(
viewFilterGroupsToCreate,
currentView,
);
await performViewFilterGroupAPIUpdate(viewFilterGroupsToUpdate);
const newViewFilters = currentRecordFilters.map(
mapRecordFilterToViewFilter,
);
const currentViewFilters = currentView.viewFilters ?? [];
const viewFiltersToCreate = getViewFiltersToCreate(
currentViewFilters,
newViewFilters,
);
const viewFiltersToUpdate = getViewFiltersToUpdate(
currentViewFilters,
newViewFilters,
);
const viewFiltersToDelete = getViewFiltersToDelete(
currentViewFilters,
newViewFilters,
);
await performViewFilterAPIDelete(
viewFiltersToDelete.map((viewFilter) => ({
input: { id: viewFilter.id },
})),
);
await performViewFilterAPIUpdate(
viewFiltersToUpdate.map((viewFilter) => ({
input: {
id: viewFilter.id,
update: {
value: viewFilter.value,
operand: viewFilter.operand,
positionInViewFilterGroup: viewFilter.positionInViewFilterGroup,
viewFilterGroupId: viewFilter.viewFilterGroupId,
subFieldName: viewFilter.subFieldName ?? null,
},
},
})),
);
await performViewFilterAPICreate(
viewFiltersToCreate.map((viewFilter) => ({
input: {
id: viewFilter.id,
fieldMetadataId: viewFilter.fieldMetadataId,
viewId: currentView.id,
value: viewFilter.value,
operand: viewFilter.operand,
viewFilterGroupId: viewFilter.viewFilterGroupId,
positionInViewFilterGroup: viewFilter.positionInViewFilterGroup,
subFieldName: viewFilter.subFieldName ?? null,
},
})),
);
await performViewFilterGroupAPIDestroy(
viewFilterGroupsToDelete.map((viewFilterGroup) => viewFilterGroup.id),
);
}, [
store,
viewId,
currentRecordFiltersCallbackState,
currentRecordFilterGroupsCallbackState,
performViewFilterAPICreate,
performViewFilterAPIUpdate,
performViewFilterAPIDelete,
performViewFilterGroupAPICreate,
performViewFilterGroupAPIUpdate,
performViewFilterGroupAPIDestroy,
]);
return { saveRecordTableWidgetFiltersToView };
};
@@ -0,0 +1,86 @@
import { type RecordSort } from '@/object-record/record-sort/types/RecordSort';
import { usePerformViewSortAPIPersist } from '@/views/hooks/internal/usePerformViewSortAPIPersist';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import { getViewSortsToCreate } from '@/views/utils/getViewSortsToCreate';
import { getViewSortsToDelete } from '@/views/utils/getViewSortsToDelete';
import { getViewSortsToUpdate } from '@/views/utils/getViewSortsToUpdate';
import { mapRecordSortToViewSort } from '@/views/utils/mapRecordSortToViewSort';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const useSaveRecordTableWidgetSortsToView = (viewId: string) => {
const {
performViewSortAPICreate,
performViewSortAPIUpdate,
performViewSortAPIDelete,
} = usePerformViewSortAPIPersist();
const store = useStore();
const saveRecordTableWidgetSortsToView = useCallback(
async (recordSorts: RecordSort[]) => {
const views = store.get(viewsSelector.atom);
const currentView = views.find((view) => view.id === viewId);
if (!isDefined(currentView)) {
return;
}
const currentViewSorts = currentView.viewSorts ?? [];
const newViewSorts = recordSorts.map(mapRecordSortToViewSort);
const viewSortsToCreate = getViewSortsToCreate(
currentViewSorts,
newViewSorts,
);
const viewSortsToUpdate = getViewSortsToUpdate(
currentViewSorts,
newViewSorts,
);
const viewSortsToDelete = getViewSortsToDelete(
currentViewSorts,
newViewSorts,
);
await performViewSortAPICreate(
viewSortsToCreate.map((viewSort) => ({
input: {
id: viewSort.id,
fieldMetadataId: viewSort.fieldMetadataId,
viewId: currentView.id,
direction: viewSort.direction,
},
})),
);
await performViewSortAPIUpdate(
viewSortsToUpdate.map((viewSort) => ({
input: {
id: viewSort.id,
update: {
direction: viewSort.direction,
},
},
})),
);
await performViewSortAPIDelete(
viewSortsToDelete.map((viewSort) => ({
input: {
id: viewSort.id,
},
})),
);
},
[
store,
viewId,
performViewSortAPICreate,
performViewSortAPIUpdate,
performViewSortAPIDelete,
],
);
return { saveRecordTableWidgetSortsToView };
};

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