Compare commits

...

1 Commits

Author SHA1 Message Date
Devessier 2696cbd1f3 test: wip 2026-04-16 18:51:58 +02:00
10 changed files with 444 additions and 23 deletions
@@ -1,10 +1,10 @@
import { type ObjectPermissions } from 'twenty-shared/types';
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
import { type ObjectPermissions } from 'twenty-shared/types';
import { type UserWorkspace } from '~/generated-metadata/graphql';
export type CurrentUserWorkspace = Pick<
UserWorkspace,
'permissionFlags' | 'twoFactorAuthenticationMethodSummary'
'id' | 'permissionFlags' | 'twoFactorAuthenticationMethodSummary'
> & {
objectsPermissions: Array<ObjectPermissions & { objectMetadataId: string }>;
};
@@ -8,7 +8,7 @@ export const getLabelIdentifierFieldValue = (
labelIdentifierFieldMetadataItem: FieldMetadataItem | undefined,
): string => {
if (!isDefined(labelIdentifierFieldMetadataItem)) {
return record.id;
return record.id ?? '';
}
const recordIdentifierValue = record[labelIdentifierFieldMetadataItem.name];
@@ -27,7 +27,6 @@ import { isFieldSelect } from '@/object-record/record-field/ui/types/guards/isFi
import { isFieldSelectValue } from '@/object-record/record-field/ui/types/guards/isFieldSelectValue';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { getRecordFromRecordNode } from '@/object-record/cache/utils/getRecordFromRecordNode';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
@@ -63,10 +62,6 @@ export const usePersistField = ({
}: {
objectMetadataItemId: string;
}) => {
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataItemId,
});
const { objectMetadataItems } = useObjectMetadataItems();
const { updateOneRecord } = useUpdateOneRecord();
@@ -159,6 +154,16 @@ export const usePersistField = ({
return;
}
const objectMetadataItem = objectMetadataItems.find(
(item) => item.id === objectMetadataItemId,
);
if (!isDefined(objectMetadataItem)) {
throw new Error(
`Object metadata item not found for id ${objectMetadataItemId}`,
);
}
const isValuePersistable =
fieldIsMorphRelationManyToOne ||
fieldIsRelationManyToOne ||
@@ -290,7 +295,7 @@ export const usePersistField = ({
}
},
[
objectMetadataItem?.nameSingular,
objectMetadataItemId,
objectMetadataItems,
store,
updateOneRecord,
@@ -387,7 +387,10 @@ export const PageLayoutTabList = ({
onDragEnd={handleDragEnd}
onDragUpdate={handleDragUpdate}
>
<StyledContainer className={className}>
<StyledContainer
data-testid="page-layout-tab-list"
className={className}
>
<PageLayoutTabListVisibleTabs
visibleTabs={tabsWithIcons}
visibleTabCount={visibleTabCount}
@@ -452,7 +455,10 @@ export const PageLayoutTabList = ({
</StyledContainer>
</DragDropContext>
) : (
<StyledContainer className={className}>
<StyledContainer
data-testid="page-layout-tab-list"
className={className}
>
<PageLayoutTabListVisibleTabs
visibleTabs={tabsWithIcons}
visibleTabCount={visibleTabCount}
@@ -0,0 +1,348 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { graphql, HttpResponse } from 'msw';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { RecordShowPage } from '~/pages/object-record/RecordShowPage';
import { ContextStoreDecorator } from '~/testing/decorators/ContextStoreDecorator';
import {
PageDecorator,
type PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { RecordStoreDecorator } from '~/testing/decorators/RecordStoreDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { mockedCompanyRecords } from '~/testing/mock-data/generated/data/companies/mock-companies-data';
import { mockedUserData } from '~/testing/mock-data/users';
import { getOperationName } from '~/utils/getOperationName';
const companyRecord = mockedCompanyRecords[0];
const mockedUserDataWithFeatureFlags = {
...mockedUserData,
currentWorkspace: {
...mockedUserData.currentWorkspace,
featureFlags: [
{
__typename: 'FeatureFlag' as const,
key: FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED,
value: true,
},
{
__typename: 'FeatureFlag' as const,
key: FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED,
value: true,
},
],
},
};
const enterEditMode = async (canvasElement: HTMLElement) => {
const body = canvasElement.ownerDocument.body;
const sidePanelButton = await within(body).findByTestId(
'page-header-side-panel-button',
);
await userEvent.click(sidePanelButton);
const searchInput = await within(body).findByTestId('side-panel-focus');
await userEvent.type(searchInput, 'Edit Layout');
const editLayoutItem = await within(body).findByText('Edit Layout', {
exact: true,
});
await userEvent.click(editLayoutItem);
await waitFor(
() => {
expect(within(body).getByText('Layout customization')).toBeVisible();
},
{ timeout: 5000 },
);
};
const meta: Meta<PageDecoratorArgs> = {
title: 'Modules/PageLayout/RecordPageLayoutEditing',
component: RecordShowPage,
decorators: [PageDecorator, ContextStoreDecorator, RecordStoreDecorator],
args: {
routePath: '/object/:objectNameSingular/:objectRecordId',
routeParams: {
':objectNameSingular': 'company',
':objectRecordId': companyRecord.id,
},
},
parameters: {
records: [companyRecord],
layout: 'fullscreen',
msw: {
handlers: [
graphql.query(getOperationName(GET_CURRENT_USER) ?? '', () => {
return HttpResponse.json({
data: {
currentUser: mockedUserDataWithFeatureFlags,
},
});
}),
graphql.query('FindOneCompany', () => {
return HttpResponse.json({
data: {
company: companyRecord,
},
});
}),
...graphqlMocks.handlers,
],
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const DefaultLayout: Story = {
play: async ({ canvasElement }) => {
const body = canvasElement.ownerDocument.body;
await expect(await within(body).findByText('Fields')).toBeVisible();
await expect(
await within(
await within(body).findByTestId('page-layout-tab-list'),
).findByText('Timeline'),
).toBeVisible();
},
};
export const CreateTab: Story = {
play: async ({ canvasElement }) => {
const body = canvasElement.ownerDocument.body;
await enterEditMode(canvasElement);
const newTabButton = await within(body).findByRole('button', {
name: 'New Tab',
});
await userEvent.click(newTabButton);
const emptyTabInput = await within(body).findByPlaceholderText('Tab');
await expect(emptyTabInput).toHaveValue('Untitled');
await userEvent.keyboard('{Escape}');
const moreButtons = await within(
within(body).getByTestId('page-layout-tab-list'),
).findAllByRole('button', {
name: /\+\d{1} More/,
});
await expect(moreButtons[1]).toBeVisible();
await userEvent.click(moreButtons[1]);
await waitFor(() => {
expect(
within(within(body).getAllByRole('listbox')[0]).getByText('Untitled', {
exact: true,
}),
).toBeVisible();
});
},
};
export const RenameTab: Story = {
play: async ({ canvasElement }) => {
const body = canvasElement.ownerDocument.body;
await enterEditMode(canvasElement);
const timelineTab = await within(
within(body).getByTestId('page-layout-tab-list'),
).findByText('Timeline', {
exact: true,
});
await userEvent.click(timelineTab);
const sidePanel = await waitFor(() => {
return canvasElement.querySelector('[data-side-panel]') as HTMLElement;
});
await userEvent.click(await within(sidePanel!).findByText('Timeline'));
const titleInput =
await within(body).findByPlaceholderText('Full tab widget');
await userEvent.clear(titleInput);
await userEvent.type(titleInput, 'My Custom Timeline{Enter}');
await waitFor(() => {
expect(
within(within(body).getByTestId('page-layout-tab-list')).getByText(
'My Custom Timeline',
{ exact: true },
),
).toBeVisible();
});
},
};
// TODO
export const DeleteTab: Story = {
play: async ({ canvasElement }) => {
const body = canvasElement.ownerDocument.body;
await enterEditMode(canvasElement);
const defaultMoreTabsCount = 5;
await waitFor(
() => {
expect(
within(body).getByText(`+${defaultMoreTabsCount} More`, {
exact: true,
}),
).toBeVisible();
},
{ timeout: 5000 },
);
const newTabButton = await within(body).findByRole('button', {
name: 'New Tab',
});
await userEvent.click(newTabButton);
const emptyTabInput = await within(body).findByRole('textbox', {
name: 'Tab',
});
await expect(emptyTabInput).toHaveValue('Untitled');
await userEvent.keyboard('{Escape}');
await waitFor(() => {
expect(
within(body).getByText(`+${defaultMoreTabsCount + 1} More`, {
exact: true,
}),
).toBeVisible();
});
const deleteButton = await within(body).findByText('Delete', {
exact: true,
});
await userEvent.click(deleteButton);
await waitFor(() => {
expect(
within(body).getByText(`+${defaultMoreTabsCount} More`, {
exact: true,
}),
).toBeVisible();
});
},
};
// TODO
export const DuplicateTab: Story = {
play: async ({ canvasElement }) => {
const body = canvasElement.ownerDocument.body;
await enterEditMode(canvasElement);
const timelineTab = await within(body).findByText('Timeline', {
exact: true,
});
await userEvent.click(timelineTab);
await userEvent.click(timelineTab);
const duplicateButton = await within(body).findByText('Duplicate', {
exact: true,
});
await userEvent.click(duplicateButton);
await waitFor(() => {
expect(
within(body).getByText('Timeline - Copy', { exact: true }),
).toBeVisible();
});
},
};
// TODO
export const ReorderTabMoveLeft: Story = {
play: async ({ canvasElement }) => {
const body = canvasElement.ownerDocument.body;
await enterEditMode(canvasElement);
const tasksTab = await within(body).findByText('Tasks', { exact: true });
await userEvent.click(tasksTab);
await userEvent.click(tasksTab);
const moveLeftButton = await within(body).findByText('Move left', {
exact: true,
});
await userEvent.click(moveLeftButton);
await waitFor(() => {
expect(within(body).getByText('Tasks', { exact: true })).toBeVisible();
expect(within(body).getByText('Timeline', { exact: true })).toBeVisible();
});
},
};
// TODO
export const TabSwitching: Story = {
play: async ({ canvasElement }) => {
const body = canvasElement.ownerDocument.body;
const emailsTab = await within(body).findByText('Emails', {
exact: true,
});
await userEvent.click(emailsTab);
const tasksTab = await within(body).findByText('Tasks', { exact: true });
await userEvent.click(tasksTab);
const timelineTab = await within(body).findByText('Timeline', {
exact: true,
});
await userEvent.click(timelineTab);
},
};
// TODO
export const CancelDiscardsChanges: Story = {
play: async ({ canvasElement }) => {
const body = canvasElement.ownerDocument.body;
await enterEditMode(canvasElement);
const newTabButton = await within(body).findByRole('button', {
name: 'New Tab',
});
await userEvent.click(newTabButton);
const emptyTabInput = await within(body).findByRole('textbox', {
name: 'Tab',
});
await expect(emptyTabInput).toHaveValue('Untitled');
await userEvent.keyboard('{Escape}');
await waitFor(() => {
expect(within(body).getByText('Untitled', { exact: true })).toBeVisible();
});
const cancelButton = await within(body).findByRole('button', {
name: 'Cancel',
});
await userEvent.click(cancelButton);
await waitFor(
() => {
expect(
within(body).queryByText('Untitled', { exact: true }),
).not.toBeInTheDocument();
},
{ timeout: 5000 },
);
},
};
@@ -30,7 +30,7 @@ export const RecordShowPageTitle = ({
)
: '';
const pageTitle = pageName.trim()
const pageTitle = (pageName ?? '').trim()
? `${pageName} - ${objectMetadataItem.labelSingular}`
: objectMetadataItem.labelSingular;
@@ -1,5 +1,7 @@
import { ApolloProvider } from '@apollo/client/react';
import { ClientConfigProviderEffect } from '@/client-config/components/ClientConfigProviderEffect';
import { ApolloCoreClientMockedProvider } from '@/object-metadata/hooks/__mocks__/ApolloCoreClientMockedProvider';
import { loadDevMessages } from '@apollo/client/dev';
import { ApolloProvider } from '@apollo/client/react';
import { type Decorator } from '@storybook/react-vite';
import { Provider as JotaiProvider } from 'jotai';
import { HelmetProvider } from 'react-helmet-async';
@@ -10,19 +12,18 @@ import {
Route,
RouterProvider,
} from 'react-router-dom';
import { ClientConfigProviderEffect } from '@/client-config/components/ClientConfigProviderEffect';
import { ApolloCoreClientMockedProvider } from '@/object-metadata/hooks/__mocks__/ApolloCoreClientMockedProvider';
import { DefaultLayout } from '@/ui/layout/page/components/DefaultLayout';
import { MinimalMetadataGater } from '@/metadata-store/components/MinimalMetadataGater';
import { UserMetadataProviderInitialEffect } from '@/metadata-store/effect-components/UserMetadataProviderInitialEffect';
import { IsMinimalMetadataReadyEffect } from '@/metadata-store/effect-components/IsMinimalMetadataReadyEffect';
import { MinimalMetadataLoadEffect } from '@/metadata-store/effect-components/MinimalMetadataLoadEffect';
import { UserMetadataProviderInitialEffect } from '@/metadata-store/effect-components/UserMetadataProviderInitialEffect';
import { DefaultLayout } from '@/ui/layout/page/components/DefaultLayout';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { useState } from 'react';
import { ClientConfigProvider } from '~/modules/client-config/components/ClientConfigProvider';
import { mockedApolloClient } from '~/testing/mockedApolloClient';
import { CommandRunner } from '@/command-menu-item/engine-command/components/CommandRunner';
import { MainContextStoreProvider } from '@/context-store/components/MainContextStoreProvider';
import { PreComputedChipGeneratorsProvider } from '@/object-metadata/components/PreComputedChipGeneratorsProvider';
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
@@ -104,6 +105,7 @@ const Providers = () => {
</FullHeightStorybookLayout>
</PreComputedChipGeneratorsProvider>
<MainContextStoreProvider />
<CommandRunner />
</ApolloCoreClientMockedProvider>
</MinimalMetadataGater>
</ClientConfigProvider>
@@ -18,21 +18,21 @@ import { GET_PUBLIC_WORKSPACE_DATA_BY_DOMAIN } from '@/auth/graphql/queries/getP
import { LIST_PLANS } from '@/settings/billing/graphql/queries/listPlans';
import { GET_ROLES } from '@/settings/roles/graphql/queries/getRolesQuery';
import { mockBillingPlans } from '~/testing/mock-data/billing-plans';
import { mockedBackendCommandMenuItems } from '~/testing/mock-data/command-menu-items';
import { mockedCompanyRecords } from '~/testing/mock-data/generated/data/companies/mock-companies-data';
import { mockedTaskRecords } from '~/testing/mock-data/generated/data/tasks/mock-tasks-data';
import { mockedStandardObjectMetadataQueryResult } from '~/testing/mock-data/generated/metadata/objects/mock-objects-metadata';
import { mockedRoles } from '~/testing/mock-data/generated/metadata/roles/mock-roles-data';
import { mockedBackendCommandMenuItems } from '~/testing/mock-data/command-menu-items';
import { type Task } from '@/activities/types/Task';
import { FIND_MINIMAL_METADATA } from '@/metadata-store/graphql/queries/findMinimalMetadata';
import { getEmptyPageInfo } from '@/object-record/cache/utils/getEmptyPageInfo';
import { getRecordFromRecordNode } from '@/object-record/cache/utils/getRecordFromRecordNode';
import {
getConnectionTypename,
getEdgeTypename,
isDefined,
} from 'twenty-shared/utils';
import { getEmptyPageInfo } from '@/object-record/cache/utils/getEmptyPageInfo';
import { getRecordFromRecordNode } from '@/object-record/cache/utils/getRecordFromRecordNode';
import { mockedApiKeys } from '~/testing/mock-data/generated/metadata/api-keys/mock-api-keys-data';
import { mockedMinimalMetadata } from '~/testing/mock-data/generated/metadata/minimal/mock-minimal-metadata';
import { mockedNavigationMenuItems } from '~/testing/mock-data/generated/metadata/navigation-menu-items/mock-navigation-menu-items-data';
@@ -648,6 +648,13 @@ export const graphqlMocks = {
},
});
}),
metadataGraphql.query('FindManyFrontComponents', () => {
return HttpResponse.json({
data: {
frontComponents: [],
},
});
}),
metadataGraphql.query('GetWebhook', ({ variables }) => {
const webhookId = variables.input?.id;
@@ -21,6 +21,7 @@ export const mockedBackendCommandMenuItems: CommandMenuItemFieldsFragment[] = [
conditionalAvailabilityExpression: 'targetObjectReadPermissions.person',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
availabilityObjectMetadataId: null,
payload: null,
},
{
__typename: 'CommandMenuItem',
@@ -39,6 +40,7 @@ export const mockedBackendCommandMenuItems: CommandMenuItemFieldsFragment[] = [
'targetObjectReadPermissions.opportunity',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
availabilityObjectMetadataId: null,
payload: null,
},
{
__typename: 'CommandMenuItem',
@@ -56,6 +58,7 @@ export const mockedBackendCommandMenuItems: CommandMenuItemFieldsFragment[] = [
conditionalAvailabilityExpression: null,
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
availabilityObjectMetadataId: null,
payload: null,
},
{
__typename: 'CommandMenuItem',
@@ -73,6 +76,7 @@ export const mockedBackendCommandMenuItems: CommandMenuItemFieldsFragment[] = [
conditionalAvailabilityExpression: 'targetObjectReadPermissions.task',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
availabilityObjectMetadataId: null,
payload: null,
},
{
__typename: 'CommandMenuItem',
@@ -90,6 +94,26 @@ export const mockedBackendCommandMenuItems: CommandMenuItemFieldsFragment[] = [
conditionalAvailabilityExpression: 'targetObjectReadPermissions.note',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
availabilityObjectMetadataId: null,
payload: null,
},
{
__typename: 'CommandMenuItem',
id: 'mock-edit-record-page-layout',
workflowVersionId: null,
frontComponentId: null,
frontComponent: null,
engineComponentKey: EngineComponentKey.EDIT_RECORD_PAGE_LAYOUT,
label: 'Edit Layout',
icon: 'IconPencil',
shortLabel: 'Edit Layout',
position: 18,
isPinned: false,
hotKeys: null,
conditionalAvailabilityExpression:
'pageType == "RECORD_PAGE" and featureFlags.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED and noneDefined(selectedRecords, "deletedAt") and objectPermissions.canUpdateObjectRecords and objectMetadataItem.nameSingular != "dashboard"',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
availabilityObjectMetadataId: null,
payload: null,
},
{
__typename: 'CommandMenuItem',
@@ -107,6 +131,7 @@ export const mockedBackendCommandMenuItems: CommandMenuItemFieldsFragment[] = [
conditionalAvailabilityExpression: null,
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
availabilityObjectMetadataId: null,
payload: null,
},
{
__typename: 'CommandMenuItem',
@@ -124,5 +149,6 @@ export const mockedBackendCommandMenuItems: CommandMenuItemFieldsFragment[] = [
conditionalAvailabilityExpression: null,
availabilityType: CommandMenuItemAvailabilityType.FALLBACK,
availabilityObjectMetadataId: null,
payload: null,
},
];
@@ -1,10 +1,10 @@
import { type CurrentUserWorkspace } from '@/auth/states/currentUserWorkspaceState';
import { CUSTOM_WORKSPACE_APPLICATION_MOCK } from '@/object-metadata/hooks/__tests__/constants/CustomWorkspaceApplicationMock.test.constant';
import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import {
AUTO_SELECT_FAST_MODEL_ID,
AUTO_SELECT_SMART_MODEL_ID,
} from 'twenty-shared/constants';
import { type CurrentUserWorkspace } from '@/auth/states/currentUserWorkspaceState';
import { CUSTOM_WORKSPACE_APPLICATION_MOCK } from '@/object-metadata/hooks/__tests__/constants/CustomWorkspaceApplicationMock.test.constant';
import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import {
OnboardingStatus,
PermissionFlagType,
@@ -14,6 +14,7 @@ import {
type Workspace,
WorkspaceActivationStatus,
WorkspaceMemberDateFormatEnum,
WorkspaceMemberNumberFormatEnum,
WorkspaceMemberTimeFormatEnum,
} from '~/generated-metadata/graphql';
import { mockBillingPlans } from '~/testing/mock-data/billing-plans';
@@ -33,6 +34,7 @@ type MockedUser = Pick<
| 'userVars'
| 'availableWorkspaces'
| 'hasPassword'
| 'deletedWorkspaceMembers'
> & {
workspaceMember: WorkspaceMember | null;
locale: string;
@@ -161,6 +163,26 @@ export const mockCurrentWorkspace = {
databaseSchema: null,
isTwoFactorAuthenticationEnforced: false,
eventLogRetentionDays: 90,
customDomain: null,
defaultRole: {
__typename: 'Role',
id: '5722c41f-6ed1-42e7-a61c-525a377d4a83',
label: 'Admin',
description: 'Admin role',
icon: 'IconUserCog',
canUpdateAllSettings: true,
canAccessAllTools: true,
isEditable: false,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: true,
canBeAssignedToUsers: true,
canBeAssignedToAgents: false,
canBeAssignedToApiKeys: true,
},
aiAdditionalInstructions: null,
editableProfileFields: [],
__typename: 'Workspace',
} as const satisfies Workspace;
@@ -181,6 +203,9 @@ export const mockedWorkspaceMemberData: WorkspaceMember = {
dateFormat: WorkspaceMemberDateFormatEnum.DAY_FIRST,
timeFormat: WorkspaceMemberTimeFormatEnum.HOUR_24,
timeZone: 'America/New_York',
userWorkspaceId: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6e',
calendarStartDay: 0,
numberFormat: WorkspaceMemberNumberFormatEnum.SYSTEM,
};
export const mockedUserData: MockedUser = {
@@ -196,7 +221,9 @@ export const mockedUserData: MockedUser = {
'a95afad9ff6f0b364e2a3fd3e246a1a852c22b6e55a3ca33745a86c201f9c10d',
workspaceMember: mockedWorkspaceMemberData,
currentWorkspace: mockCurrentWorkspace,
deletedWorkspaceMembers: [],
currentUserWorkspace: {
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6e',
permissionFlags: [
PermissionFlagType.WORKSPACE_MEMBERS,
PermissionFlagType.CONNECTED_ACCOUNTS,