Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1879110d78 | ||
|
|
11628d19a3 | ||
|
|
46ba5fd16c | ||
|
|
4649736d49 | ||
|
|
51384bc085 | ||
|
|
abfa6200dd | ||
|
|
480e5796ec | ||
|
|
d8e2de48e6 | ||
|
|
b49e58dfc6 | ||
|
|
a6118b7dc3 | ||
|
|
19ee9444ed | ||
|
|
b92617d46e | ||
|
|
c476c6c80b | ||
|
|
d2dda67596 | ||
|
|
6aec449a56 | ||
|
|
7ea1dfdd49 | ||
|
|
3290bf3ab1 | ||
|
|
fbaea0639a | ||
|
|
2a3b8adcfb | ||
|
|
8f362186ce | ||
|
|
8998009805 |
@@ -3193,6 +3193,7 @@ 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!
|
||||
@@ -3510,6 +3511,59 @@ 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,6 +2675,7 @@ export interface Mutation {
|
||||
updateView: View
|
||||
deleteView: Scalars['Boolean']
|
||||
destroyView: Scalars['Boolean']
|
||||
upsertViewWidget: View
|
||||
createViewSort: ViewSort
|
||||
updateViewSort: ViewSort
|
||||
deleteViewSort: Scalars['Boolean']
|
||||
@@ -5725,6 +5726,7 @@ 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} }
|
||||
@@ -5944,6 +5946,30 @@ 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,10 +11,12 @@ 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
|
||||
|
||||
|
||||
@@ -61,8 +61,8 @@ const jestConfig = {
|
||||
extensionsToTreatAsEsm: ['.ts', '.tsx'],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
statements: 47.9,
|
||||
lines: 46,
|
||||
statements: 47.3,
|
||||
lines: 45.9,
|
||||
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
+1
-1
@@ -86,7 +86,7 @@ const StyledFields = styled.div`
|
||||
`;
|
||||
|
||||
const StyledPropertyBoxContainer = styled.div`
|
||||
height: ${themeCssVariables.spacing[6]};
|
||||
min-height: ${themeCssVariables.spacing[6]};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
|
||||
@@ -58,16 +58,15 @@ export const useCustomResolver = <
|
||||
pageSize,
|
||||
};
|
||||
|
||||
const {
|
||||
data,
|
||||
loading: firstQueryLoading,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useQuery<CustomResolverQueryResult<T>>(query, {
|
||||
const { data, loading, fetchMore, error } = useQuery<
|
||||
CustomResolverQueryResult<T>
|
||||
>(query, {
|
||||
client: apolloCoreClient,
|
||||
variables: queryVariables,
|
||||
});
|
||||
|
||||
const firstQueryLoading = loading && !data;
|
||||
|
||||
useSnackBarOnQueryError(error);
|
||||
|
||||
const fetchMoreRecords = async () => {
|
||||
|
||||
@@ -121,6 +121,24 @@ 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,6 +180,28 @@ 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'
|
||||
@@ -752,6 +774,18 @@ 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 />}
|
||||
|
||||
+7
@@ -39,6 +39,13 @@ export const APPLICATION_FRAGMENT = gql`
|
||||
name
|
||||
description
|
||||
applicationId
|
||||
componentName
|
||||
builtComponentChecksum
|
||||
universalIdentifier
|
||||
isHeadless
|
||||
usesSdkClient
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
objects {
|
||||
...ObjectMetadataFields
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
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';
|
||||
@@ -17,10 +11,8 @@ 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();
|
||||
|
||||
@@ -147,55 +139,15 @@ describe('useAuth', () => {
|
||||
});
|
||||
|
||||
it('should handle sign-out', async () => {
|
||||
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,
|
||||
},
|
||||
);
|
||||
sessionStorage.setItem('lingering-key', 'should-be-cleared');
|
||||
|
||||
const { signOut, client } = result.current;
|
||||
const { result } = renderHooks();
|
||||
|
||||
await act(async () => {
|
||||
await signOut();
|
||||
result.current.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,8 +1,4 @@
|
||||
import {
|
||||
useApolloClient,
|
||||
useLazyQuery,
|
||||
useMutation,
|
||||
} from '@apollo/client/react';
|
||||
import { useLazyQuery, useMutation } from '@apollo/client/react';
|
||||
import { useCallback } from 'react';
|
||||
import { AppPath } from 'twenty-shared/types';
|
||||
|
||||
@@ -28,16 +24,7 @@ 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,
|
||||
@@ -49,18 +36,13 @@ 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';
|
||||
@@ -78,8 +60,6 @@ export const useAuth = () => {
|
||||
);
|
||||
|
||||
const { origin } = useOrigin();
|
||||
const { requestFreshCaptchaToken } = useRequestFreshCaptchaToken();
|
||||
const isCaptchaScriptLoaded = useAtomStateValue(isCaptchaScriptLoadedState);
|
||||
const isMultiWorkspaceEnabled = useAtomStateValue(
|
||||
isMultiWorkspaceEnabledState,
|
||||
);
|
||||
@@ -87,9 +67,7 @@ export const useAuth = () => {
|
||||
isEmailVerificationRequiredState,
|
||||
);
|
||||
const { loadCurrentUser } = useLoadCurrentUser();
|
||||
const { clearSseClient } = useClearSseClient();
|
||||
|
||||
const { applyMockedMetadata } = useLoadMockedMetadata();
|
||||
const { createWorkspace } = useSignUpInNewWorkspace();
|
||||
|
||||
const setSignInUpStep = useSetAtomState(signInUpStepState);
|
||||
@@ -121,64 +99,17 @@ export const useAuth = () => {
|
||||
CheckUserExistsDocument,
|
||||
);
|
||||
|
||||
const client = useApolloClient();
|
||||
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
const clearSession = useCallback(() => {
|
||||
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);
|
||||
navigate(AppPath.SignInUp);
|
||||
store.set(isAppEffectRedirectEnabledState.atom, true);
|
||||
}, [
|
||||
clearSseClient,
|
||||
client,
|
||||
setLastAuthenticateWorkspaceDomain,
|
||||
applyMockedMetadata,
|
||||
navigate,
|
||||
store,
|
||||
]);
|
||||
window.location.assign(AppPath.SignInUp);
|
||||
}, [store, setLastAuthenticateWorkspaceDomain]);
|
||||
|
||||
const handleSetAuthTokens = useCallback(
|
||||
(tokens: AuthTokenPair) => {
|
||||
@@ -475,11 +406,10 @@ export const useAuth = () => {
|
||||
[handleGetLoginTokenFromCredentials, handleGetAuthTokensFromLoginToken],
|
||||
);
|
||||
|
||||
const handleSignOut = useCallback(async () => {
|
||||
const handleSignOut = useCallback(() => {
|
||||
broadcastSignOutToOtherTabs();
|
||||
await clearSession();
|
||||
if (isCaptchaScriptLoaded) await requestFreshCaptchaToken();
|
||||
}, [clearSession, isCaptchaScriptLoaded, requestFreshCaptchaToken]);
|
||||
clearSession();
|
||||
}, [clearSession]);
|
||||
|
||||
const handleCredentialsSignUpInWorkspace = useCallback(
|
||||
async ({
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
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';
|
||||
@@ -17,22 +12,27 @@ 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: returnPath ?? window.location.pathname,
|
||||
returnPath: targetPath,
|
||||
};
|
||||
sessionStorage.setItem(
|
||||
IMPERSONATION_SESSION_KEY,
|
||||
@@ -40,30 +40,25 @@ export const useImpersonationSession = () => {
|
||||
);
|
||||
}
|
||||
|
||||
clearSseClient();
|
||||
await client.clearStore();
|
||||
|
||||
store.set(isAppEffectRedirectEnabledState.atom, false);
|
||||
await getAuthTokensFromLoginToken(loginToken);
|
||||
store.set(isAppEffectRedirectEnabledState.atom, true);
|
||||
reloadWithSession(targetPath);
|
||||
},
|
||||
[store, client, clearSseClient, getAuthTokensFromLoginToken],
|
||||
[store, getAuthTokensFromLoginToken],
|
||||
);
|
||||
|
||||
const stopImpersonating = useCallback(async () => {
|
||||
const raw = sessionStorage.getItem(IMPERSONATION_SESSION_KEY);
|
||||
|
||||
if (!raw) {
|
||||
// No stored session — likely a cross-workspace tab opened via redirect.
|
||||
// Try closing the tab (works when opened via window.open or target=_blank).
|
||||
// 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().
|
||||
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 {
|
||||
@@ -73,19 +68,9 @@ export const useImpersonationSession = () => {
|
||||
}
|
||||
|
||||
sessionStorage.removeItem(IMPERSONATION_SESSION_KEY);
|
||||
|
||||
clearSseClient();
|
||||
await client.clearStore();
|
||||
|
||||
store.set(isAppEffectRedirectEnabledState.atom, false);
|
||||
store.set(tokenPairState.atom, session.tokenPair);
|
||||
|
||||
await loadCurrentUser();
|
||||
|
||||
store.set(isAppEffectRedirectEnabledState.atom, true);
|
||||
|
||||
navigate(session.returnPath);
|
||||
}, [store, client, clearSseClient, loadCurrentUser, signOut, navigate]);
|
||||
reloadWithSession(session.returnPath);
|
||||
}, [store, signOut]);
|
||||
|
||||
const hasStoredSession = useCallback(() => {
|
||||
return sessionStorage.getItem(IMPERSONATION_SESSION_KEY) !== null;
|
||||
|
||||
+9
-15
@@ -1,8 +1,7 @@
|
||||
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';
|
||||
@@ -21,23 +20,18 @@ export const RecordIndexCommandMenu = () => {
|
||||
isLayoutCustomizationModeEnabledState,
|
||||
);
|
||||
|
||||
const showEditModePinnedButtons = isLayoutCustomizationModeEnabled;
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextStoreCurrentObjectMetadataItemId && (
|
||||
<>
|
||||
{!isMobile && showEditModePinnedButtons ? (
|
||||
<PinnedCommandMenuItemButtonsEditMode />
|
||||
) : (
|
||||
<CommandMenuContextProvider
|
||||
isInSidePanel={false}
|
||||
displayType="button"
|
||||
containerType="index-page-header"
|
||||
>
|
||||
{!isMobile && <PinnedCommandMenuItemButtons />}
|
||||
</CommandMenuContextProvider>
|
||||
)}
|
||||
<CommandMenuContextProvider
|
||||
isInSidePanel={false}
|
||||
displayType="button"
|
||||
containerType="index-page-header"
|
||||
isInPreviewMode={isLayoutCustomizationModeEnabled}
|
||||
>
|
||||
{!isMobile && <PinnedCommandMenuItemButtons />}
|
||||
</CommandMenuContextProvider>
|
||||
<CommandMenuContextProvider
|
||||
isInSidePanel={false}
|
||||
displayType="dropdownItem"
|
||||
|
||||
+6
@@ -4,7 +4,9 @@ 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 = () => {
|
||||
@@ -23,6 +25,9 @@ export const RecordShowCommandMenu = () => {
|
||||
contextStoreTargetedRecordsRule.selectedRecordIds.length === 1;
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const isLayoutCustomizationModeEnabled = useAtomStateValue(
|
||||
isLayoutCustomizationModeEnabledState,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -32,6 +37,7 @@ export const RecordShowCommandMenu = () => {
|
||||
isInSidePanel={false}
|
||||
displayType="button"
|
||||
containerType="show-page-header"
|
||||
isInPreviewMode={isLayoutCustomizationModeEnabled}
|
||||
>
|
||||
{!isMobile && <PinnedCommandMenuItemButtons />}
|
||||
</CommandMenuContextProvider>
|
||||
|
||||
+1
@@ -125,6 +125,7 @@ export const StandalonePageCommandMenu = () => {
|
||||
containerType: 'standalone-page-header',
|
||||
commandMenuItems: filteredCommandMenuItems,
|
||||
commandMenuContextApi,
|
||||
isInPreviewMode: false,
|
||||
}}
|
||||
>
|
||||
{!isMobile && <PinnedCommandMenuItemButtons />}
|
||||
|
||||
+1
@@ -45,6 +45,7 @@ const meta: Meta<typeof RecordIndexCommandMenuDropdown> = {
|
||||
containerType: 'index-page-dropdown',
|
||||
commandMenuItems: createMockCommandMenuItems(),
|
||||
commandMenuContextApi: EMPTY_COMMAND_MENU_CONTEXT_API,
|
||||
isInPreviewMode: false,
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
|
||||
+2
-1
@@ -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,6 +44,7 @@ const meta: Meta<typeof RecordPageSidePanelCommandMenuDropdown> = {
|
||||
...EMPTY_COMMAND_MENU_CONTEXT_API,
|
||||
isInSidePanel: true,
|
||||
},
|
||||
isInPreviewMode: false,
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
|
||||
@@ -9,6 +9,7 @@ export type CommandMenuContextType = {
|
||||
containerType: CommandMenuItemContainerType;
|
||||
commandMenuItems: CommandMenuItemFieldsFragment[];
|
||||
commandMenuContextApi: CommandMenuContextApi;
|
||||
isInPreviewMode: boolean;
|
||||
};
|
||||
|
||||
export const CommandMenuContext = createContext<CommandMenuContextType>({
|
||||
@@ -16,4 +17,5 @@ export const CommandMenuContext = createContext<CommandMenuContextType>({
|
||||
displayType: 'button',
|
||||
commandMenuItems: [],
|
||||
commandMenuContextApi: EMPTY_COMMAND_MENU_CONTEXT_API,
|
||||
isInPreviewMode: false,
|
||||
});
|
||||
|
||||
+6
-2
@@ -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 { useCommandMenuContextApi } from '@/command-menu-item/hooks/useCommandMenuContextApi';
|
||||
import { useCurrentCommandMenuContextApi } from '@/command-menu-item/hooks/useCurrentCommandMenuContextApi';
|
||||
|
||||
import { CommandMenuContextProviderContent } from './CommandMenuContextProviderContent';
|
||||
import { CommandMenuContextProviderWithWorkflowEnrichment } from './CommandMenuContextProviderWithWorkflowEnrichment';
|
||||
@@ -12,6 +12,7 @@ type CommandMenuContextProviderProps = {
|
||||
displayType: CommandMenuContextType['displayType'];
|
||||
containerType: CommandMenuContextType['containerType'];
|
||||
children: React.ReactNode;
|
||||
isInPreviewMode?: boolean;
|
||||
};
|
||||
|
||||
export const CommandMenuContextProvider = ({
|
||||
@@ -19,8 +20,9 @@ export const CommandMenuContextProvider = ({
|
||||
displayType,
|
||||
containerType,
|
||||
children,
|
||||
isInPreviewMode = false,
|
||||
}: CommandMenuContextProviderProps) => {
|
||||
const commandMenuContextApiFromHook = useCommandMenuContextApi();
|
||||
const commandMenuContextApiFromHook = useCurrentCommandMenuContextApi();
|
||||
|
||||
const commandMenuContextApi = isInSidePanel
|
||||
? { ...commandMenuContextApiFromHook, isInSidePanel: true }
|
||||
@@ -45,6 +47,7 @@ export const CommandMenuContextProvider = ({
|
||||
containerType={containerType}
|
||||
commandMenuContextApi={commandMenuContextApi}
|
||||
selectedWorkflowRecordIds={selectedWorkflowRecordIds}
|
||||
isInPreviewMode={isInPreviewMode}
|
||||
>
|
||||
{children}
|
||||
</CommandMenuContextProviderWithWorkflowEnrichment>
|
||||
@@ -56,6 +59,7 @@ export const CommandMenuContextProvider = ({
|
||||
displayType={displayType}
|
||||
containerType={containerType}
|
||||
commandMenuContextApi={commandMenuContextApi}
|
||||
isInPreviewMode={isInPreviewMode}
|
||||
>
|
||||
{children}
|
||||
</CommandMenuContextProviderContent>
|
||||
|
||||
+20
-2
@@ -2,10 +2,12 @@ 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';
|
||||
@@ -17,6 +19,7 @@ type CommandMenuContextProviderContentProps = {
|
||||
containerType: CommandMenuContextType['containerType'];
|
||||
children: React.ReactNode;
|
||||
commandMenuContextApi: CommandMenuContextApi;
|
||||
isInPreviewMode: boolean;
|
||||
};
|
||||
|
||||
export const CommandMenuContextProviderContent = ({
|
||||
@@ -24,19 +27,27 @@ 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 commandMenuItems
|
||||
return commandMenuItemsToDisplay
|
||||
.filter(
|
||||
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
|
||||
)
|
||||
.filter(doesCommandMenuItemMatchPageType(commandMenuContextApi.pageType))
|
||||
.filter(doesCommandMenuItemMatchSelectionState(hasSelectedRecords))
|
||||
.filter(doesCommandMenuItemMatchPageLayoutId(currentPageLayoutId))
|
||||
.filter((item) =>
|
||||
evaluateConditionalAvailabilityExpression(
|
||||
@@ -47,7 +58,13 @@ export const CommandMenuContextProviderContent = ({
|
||||
.sort(
|
||||
(firstItem, secondItem) => firstItem.position - secondItem.position,
|
||||
);
|
||||
}, [commandMenuItems, commandMenuContextApi, currentPageLayoutId]);
|
||||
}, [
|
||||
commandMenuContextApi,
|
||||
commandMenuItems,
|
||||
commandMenuItemsDraft,
|
||||
currentPageLayoutId,
|
||||
isInPreviewMode,
|
||||
]);
|
||||
|
||||
return (
|
||||
<CommandMenuContext.Provider
|
||||
@@ -56,6 +73,7 @@ export const CommandMenuContextProviderContent = ({
|
||||
containerType,
|
||||
commandMenuItems: filteredCommandMenuItems,
|
||||
commandMenuContextApi,
|
||||
isInPreviewMode,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
+3
@@ -12,6 +12,7 @@ type CommandMenuContextProviderWithWorkflowEnrichmentProps = {
|
||||
children: React.ReactNode;
|
||||
commandMenuContextApi: CommandMenuContextApi;
|
||||
selectedWorkflowRecordIds: string[];
|
||||
isInPreviewMode: boolean;
|
||||
};
|
||||
|
||||
export const CommandMenuContextProviderWithWorkflowEnrichment = ({
|
||||
@@ -20,6 +21,7 @@ export const CommandMenuContextProviderWithWorkflowEnrichment = ({
|
||||
children,
|
||||
commandMenuContextApi,
|
||||
selectedWorkflowRecordIds,
|
||||
isInPreviewMode,
|
||||
}: CommandMenuContextProviderWithWorkflowEnrichmentProps) => {
|
||||
const workflowsWithCurrentVersions = useWorkflowsWithCurrentVersions(
|
||||
selectedWorkflowRecordIds,
|
||||
@@ -54,6 +56,7 @@ export const CommandMenuContextProviderWithWorkflowEnrichment = ({
|
||||
displayType={displayType}
|
||||
containerType={containerType}
|
||||
commandMenuContextApi={enrichedCommandMenuContextApi}
|
||||
isInPreviewMode={isInPreviewMode}
|
||||
>
|
||||
{children}
|
||||
</CommandMenuContextProviderContent>
|
||||
|
||||
+22
-7
@@ -11,6 +11,7 @@ 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';
|
||||
@@ -18,6 +19,14 @@ 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;
|
||||
};
|
||||
@@ -27,7 +36,8 @@ type CommandMenuItemButtonRendererProps = CommandMenuItemRendererProps;
|
||||
const CommandMenuItemButtonRenderer = ({
|
||||
item,
|
||||
}: CommandMenuItemButtonRendererProps) => {
|
||||
const { commandMenuContextApi } = useContext(CommandMenuContext);
|
||||
const { commandMenuContextApi, isInPreviewMode } =
|
||||
useContext(CommandMenuContext);
|
||||
const { getIcon } = useIcons();
|
||||
|
||||
const { iconKey, label, shortLabel } = interpolateCommandMenuItemFields(
|
||||
@@ -43,14 +53,19 @@ const CommandMenuItemButtonRenderer = ({
|
||||
label,
|
||||
});
|
||||
|
||||
const command = { key: item.id, label, shortLabel, Icon };
|
||||
|
||||
if (isInPreviewMode) {
|
||||
return (
|
||||
<StyledPreviewWrapper>
|
||||
<CommandMenuButton command={command} />
|
||||
</StyledPreviewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandMenuButton
|
||||
command={{
|
||||
key: item.id,
|
||||
label,
|
||||
shortLabel,
|
||||
Icon,
|
||||
}}
|
||||
command={command}
|
||||
onClick={disabled ? undefined : handleClick}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
+30
-10
@@ -2,6 +2,7 @@ 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';
|
||||
|
||||
@@ -25,18 +26,37 @@ export const usePinnedCommandMenuItemsInlineLayout = ({
|
||||
[pinnedCommandMenuItems],
|
||||
);
|
||||
|
||||
const hasKnownPinnedInlineLayout = useMemo(
|
||||
() =>
|
||||
commandMenuPinnedInlineLayout.containerWidth > 0 &&
|
||||
pinnedCommandMenuItemKeysInDisplayOrder.every((commandMenuItemKey) =>
|
||||
isNumber(
|
||||
commandMenuPinnedInlineLayout.commandMenuItemWidthsByKey[
|
||||
commandMenuItemKey
|
||||
],
|
||||
),
|
||||
),
|
||||
[commandMenuPinnedInlineLayout, pinnedCommandMenuItemKeysInDisplayOrder],
|
||||
);
|
||||
|
||||
const visiblePinnedCommandMenuItemCount = useMemo(
|
||||
() =>
|
||||
getVisibleCommandMenuItemCountForContainerWidth({
|
||||
commandMenuItemKeysInDisplayOrder:
|
||||
pinnedCommandMenuItemKeysInDisplayOrder,
|
||||
commandMenuItemWidthsByKey:
|
||||
commandMenuPinnedInlineLayout.commandMenuItemWidthsByKey,
|
||||
commandMenuItemsContainerWidth:
|
||||
commandMenuPinnedInlineLayout.containerWidth,
|
||||
commandMenuItemsGapWidth: PINNED_COMMAND_MENU_ITEMS_GAP,
|
||||
}),
|
||||
[commandMenuPinnedInlineLayout, pinnedCommandMenuItemKeysInDisplayOrder],
|
||||
hasKnownPinnedInlineLayout
|
||||
? getVisibleCommandMenuItemCountForContainerWidth({
|
||||
commandMenuItemKeysInDisplayOrder:
|
||||
pinnedCommandMenuItemKeysInDisplayOrder,
|
||||
commandMenuItemWidthsByKey:
|
||||
commandMenuPinnedInlineLayout.commandMenuItemWidthsByKey,
|
||||
commandMenuItemsContainerWidth:
|
||||
commandMenuPinnedInlineLayout.containerWidth,
|
||||
commandMenuItemsGapWidth: PINNED_COMMAND_MENU_ITEMS_GAP,
|
||||
})
|
||||
: 0,
|
||||
[
|
||||
commandMenuPinnedInlineLayout,
|
||||
hasKnownPinnedInlineLayout,
|
||||
pinnedCommandMenuItemKeysInDisplayOrder,
|
||||
],
|
||||
);
|
||||
|
||||
const pinnedInlineCommandMenuItems = useMemo(
|
||||
|
||||
+3
-2
@@ -83,7 +83,7 @@ export const CommandMenuItemEditRecordSelectionDropdown = ({
|
||||
const TriggerIcon = isNoneSelected ? IconSquareX : IconSquareCheck;
|
||||
const triggerLabel = isNoneSelected
|
||||
? t`No record selected`
|
||||
: t`Records selected`;
|
||||
: t`Record(s) selected`;
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
@@ -108,6 +108,7 @@ export const CommandMenuItemEditRecordSelectionDropdown = ({
|
||||
</StyledClickableArea>
|
||||
}
|
||||
dropdownPlacement="bottom-start"
|
||||
dropdownOffset={{ y: 4 }}
|
||||
dropdownComponents={
|
||||
<DropdownContent widthInPixels={GenericDropdownContentWidth.Medium}>
|
||||
<StyledDropdownMenuContainer
|
||||
@@ -122,7 +123,7 @@ export const CommandMenuItemEditRecordSelectionDropdown = ({
|
||||
/>
|
||||
<MenuItemSelect
|
||||
LeftIcon={IconSquareCheck}
|
||||
text={t`Records selected`}
|
||||
text={t`Record(s) selected`}
|
||||
selected={!isNoneSelected}
|
||||
onClick={() => handleSelectMode('selection')}
|
||||
/>
|
||||
|
||||
-140
@@ -1,140 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+7
-36
@@ -1,15 +1,13 @@
|
||||
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 { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState';
|
||||
import { useCommandMenuContextApi } from '@/command-menu-item/hooks/useCommandMenuContextApi';
|
||||
import { useCurrentCommandMenuContextApi } from '@/command-menu-item/hooks/useCurrentCommandMenuContextApi';
|
||||
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';
|
||||
@@ -36,10 +34,7 @@ import {
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { MenuItem, MenuItemDraggable } from 'twenty-ui/navigation';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import {
|
||||
CommandMenuItemAvailabilityType,
|
||||
type CommandMenuItemFieldsFragment,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
|
||||
import { normalizeSearchText } from '~/utils/normalizeSearchText';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
@@ -66,7 +61,7 @@ const StyledContent = styled.div`
|
||||
export const SidePanelCommandMenuItemEditPage = () => {
|
||||
const { t } = useLingui();
|
||||
const { getIcon } = useIcons();
|
||||
const commandMenuContextApi = useCommandMenuContextApi();
|
||||
const commandMenuContextApi = useCurrentCommandMenuContextApi();
|
||||
|
||||
const currentObjectMetadataItemId =
|
||||
commandMenuContextApi.objectMetadataItem.id;
|
||||
@@ -76,44 +71,20 @@ 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 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 editableCommandMenuItems = useEditableCommandMenuItems();
|
||||
|
||||
const filteredCommandMenuItemIds = new Set(
|
||||
filteredCommandMenuItems.map((item) => item.id),
|
||||
editableCommandMenuItems.map((item) => item.id),
|
||||
);
|
||||
|
||||
const getDisplayLabel = (item: CommandMenuItemFieldsFragment) =>
|
||||
@@ -123,7 +94,7 @@ export const SidePanelCommandMenuItemEditPage = () => {
|
||||
}) ?? item.label;
|
||||
|
||||
const { pinned: allPinnedItems, other: allOtherItems } =
|
||||
groupCommandMenuItems(filteredCommandMenuItems);
|
||||
groupCommandMenuItems(editableCommandMenuItems);
|
||||
|
||||
const normalizedSearch =
|
||||
sidePanelSearch.length > 0
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
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]);
|
||||
};
|
||||
+1
@@ -70,6 +70,7 @@ const getWrapper =
|
||||
objectMetadataItem: {},
|
||||
objectMetadataLabel: '',
|
||||
},
|
||||
isInPreviewMode: false,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ import {
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined, resolveObjectMetadataLabel } from 'twenty-shared/utils';
|
||||
|
||||
export const useCommandMenuContextApi = (): CommandMenuContextApi => {
|
||||
export const useCurrentCommandMenuContextApi = (): CommandMenuContextApi => {
|
||||
const store = useStore();
|
||||
|
||||
const contextStoreInstanceId = useAvailableComponentInstanceIdOrThrow(
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import {
|
||||
CommandMenuItemAvailabilityType,
|
||||
type CommandMenuItemFieldsFragment,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
export const doesCommandMenuItemMatchSelectionState =
|
||||
(hasSelectedRecords: boolean) => (item: CommandMenuItemFieldsFragment) =>
|
||||
item.availabilityType !==
|
||||
CommandMenuItemAvailabilityType.RECORD_SELECTION || hasSelectedRecords;
|
||||
+1
@@ -15,6 +15,7 @@ export const LOGIC_FUNCTION_FRAGMENT = gql`
|
||||
databaseEventTriggerSettings
|
||||
httpRouteTriggerSettings
|
||||
applicationId
|
||||
universalIdentifier
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
|
||||
+3
@@ -46,6 +46,9 @@ describe('useLogicFunctionUpdateFormState', () => {
|
||||
properties: {},
|
||||
type: 'object',
|
||||
},
|
||||
cronTriggerSettings: null,
|
||||
databaseEventTriggerSettings: null,
|
||||
httpRouteTriggerSettings: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+16
@@ -1,6 +1,11 @@
|
||||
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';
|
||||
@@ -12,6 +17,9 @@ export type LogicFunctionFormValues = {
|
||||
timeoutSeconds: number;
|
||||
sourceHandlerCode: string;
|
||||
toolInputSchema?: object;
|
||||
cronTriggerSettings: CronTriggerSettings | null;
|
||||
databaseEventTriggerSettings: DatabaseEventTriggerSettings | null;
|
||||
httpRouteTriggerSettings: HttpRouteTriggerSettings | null;
|
||||
};
|
||||
|
||||
type SetLogicFunctionFormValues = Dispatch<
|
||||
@@ -35,6 +43,9 @@ export const useLogicFunctionUpdateFormState = ({
|
||||
sourceHandlerCode: '',
|
||||
timeoutSeconds: 300,
|
||||
toolInputSchema: DEFAULT_TOOL_INPUT_SCHEMA,
|
||||
cronTriggerSettings: null,
|
||||
databaseEventTriggerSettings: null,
|
||||
httpRouteTriggerSettings: null,
|
||||
});
|
||||
|
||||
const { sourceHandlerCode, loading: logicFunctionSourceCodeLoading } =
|
||||
@@ -57,6 +68,11 @@ 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]);
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
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
Reference in New Issue
Block a user