Sync command menu with main context store (#19650)

## PR description

- The command menu in the side panel now reads directly from
`MAIN_CONTEXT_STORE_INSTANCE_ID` instead of snapshotting the main
context store into a separate side-panel instance when opening. This
keeps the command menu always in sync with the current page state
(selection, filters, view, etc.).
- Removed the broadening/reset-to-selection feature (Backspace to clear
context, "Reset to" button) since the command menu no longer maintains
its own copy of the context.

## Video QA


https://github.com/user-attachments/assets/5d5bc664-b6d4-431d-a271-6ce23d8a4ae0
This commit is contained in:
Raphaël Bosi
2026-04-13 18:08:15 +02:00
committed by GitHub
parent 123d6241d7
commit fb772c7695
19 changed files with 17 additions and 1144 deletions
@@ -1,5 +1,6 @@
import { styled } from '@linaria/react';
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { SIDE_PANEL_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelComponentInstanceId';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
@@ -30,7 +31,7 @@ type SidePanelContainerProps = {
export const SidePanelContainer = ({ children }: SidePanelContainerProps) => {
const contextStoreCurrentObjectMetadataItemId = useAtomComponentStateValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
SIDE_PANEL_COMPONENT_INSTANCE_ID,
MAIN_CONTEXT_STORE_INSTANCE_ID,
);
const isMobile = useIsMobile();
@@ -43,7 +44,7 @@ export const SidePanelContainer = ({ children }: SidePanelContainerProps) => {
const contextStoreCurrentViewId = useAtomComponentStateValue(
contextStoreCurrentViewIdComponentState,
SIDE_PANEL_COMPONENT_INSTANCE_ID,
MAIN_CONTEXT_STORE_INSTANCE_ID,
);
const recordIndexId = getRecordIndexIdFromObjectNamePluralAndViewId(
@@ -54,7 +55,7 @@ export const SidePanelContainer = ({ children }: SidePanelContainerProps) => {
return (
<RecordComponentInstanceContextsWrapper componentInstanceId={recordIndexId}>
<ContextStoreComponentInstanceContext.Provider
value={{ instanceId: SIDE_PANEL_COMPONENT_INSTANCE_ID }}
value={{ instanceId: MAIN_CONTEXT_STORE_INSTANCE_ID }}
>
<CommandMenuComponentInstanceContext.Provider
value={{ instanceId: SIDE_PANEL_COMPONENT_INSTANCE_ID }}
@@ -1,52 +0,0 @@
import { SidePanelContextChip } from '@/side-panel/components/SidePanelContextChip';
import { SidePanelContextRecordChipAvatars } from '@/side-panel/components/SidePanelContextRecordChipAvatars';
import { getSelectedRecordsContextText } from '@/side-panel/utils/getSelectedRecordsContextText';
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const SidePanelContextRecordsChip = ({
objectMetadataItemId,
instanceId,
}: {
objectMetadataItemId: string;
instanceId?: string;
}) => {
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataItemId,
});
const allowRequestsToTwentyIcons = useAtomStateValue(
allowRequestsToTwentyIconsState,
);
const { records, loading, totalCount } =
useFindManyRecordsSelectedInContextStore({
limit: 3,
instanceId,
});
if (loading || !totalCount || records.length === 0) {
return null;
}
const Avatars = records.map((record) => (
<SidePanelContextRecordChipAvatars
objectMetadataItem={objectMetadataItem}
key={record.id}
record={record}
/>
));
return (
<SidePanelContextChip
text={getSelectedRecordsContextText(
objectMetadataItem,
records,
totalCount,
allowRequestsToTwentyIcons,
)}
Icons={Avatars}
/>
);
};
@@ -1,269 +0,0 @@
import { gql } from '@apollo/client';
import {
type Decorator,
type Meta,
type StoryObj,
} from '@storybook/react-vite';
import { SidePanelContextRecordsChip } from '@/side-panel/components/SidePanelContextRecordsChip';
import { PreComputedChipGeneratorsContext } from '@/object-metadata/contexts/PreComputedChipGeneratorsContext';
import { type RecordChipData } from '@/object-record/record-field/ui/types/RecordChipData';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ComponentDecorator } from 'twenty-ui/testing';
import { getJestMetadataAndApolloMocksAndCommandMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndCommandMenuWrapper';
import { mockedCompanyRecords } from '~/testing/mock-data/generated/data/companies/mock-companies-data';
import { getTestEnrichedObjectMetadataItemsMock } from '~/testing/utils/getTestEnrichedObjectMetadataItemsMock';
const FIND_MANY_COMPANIES = gql`
query FindManyCompanies(
$filter: CompanyFilterInput
$orderBy: [CompanyOrderByInput]
$lastCursor: String
$limit: Int
) {
companies(
filter: $filter
orderBy: $orderBy
first: $limit
after: $lastCursor
) {
edges {
node {
__typename
accountOwnerId
address {
addressStreet1
addressStreet2
addressCity
addressState
addressCountry
addressPostcode
addressLat
addressLng
}
annualRecurringRevenue {
amountMicros
currencyCode
}
createdAt
createdBy {
source
workspaceMemberId
name
context
}
deletedAt
domainName {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
employees
id
idealCustomerProfile
introVideo {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
linkedinLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
name
position
tagline
updatedAt
visaSponsorship
workPolicy
xLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
totalCount
}
}
`;
const companyMockObjectMetadataItem =
getTestEnrichedObjectMetadataItemsMock().find(
(item) => item.nameSingular === 'company',
);
const companyMock = mockedCompanyRecords[0];
const chipGeneratorPerObjectPerField: Record<
string,
Record<string, (record: ObjectRecord) => RecordChipData>
> = {
company: {
name: (record: ObjectRecord): RecordChipData => ({
recordId: record.id,
name: record.name as string,
avatarUrl: '',
avatarType: 'rounded',
isLabelIdentifier: true,
objectNameSingular: 'company',
}),
},
};
const identifierChipGeneratorPerObject: Record<
string,
(record: ObjectRecord) => RecordChipData
> = {
company: chipGeneratorPerObjectPerField.company.name,
};
const ChipGeneratorsDecorator: Decorator = (Story) => (
<PreComputedChipGeneratorsContext.Provider
value={{
chipGeneratorPerObjectPerField,
identifierChipGeneratorPerObject,
}}
>
<Story />
</PreComputedChipGeneratorsContext.Provider>
);
const createContextStoreWrapper = ({
companies,
componentInstanceId,
}: {
companies: typeof mockedCompanyRecords;
componentInstanceId: string;
}) => {
return getJestMetadataAndApolloMocksAndCommandMenuWrapper({
apolloMocks: [
{
request: {
query: FIND_MANY_COMPANIES,
variables: {
filter: {
id: { in: companies.map((company) => company.id) },
deletedAt: { is: 'NOT_NULL' },
},
orderBy: [{ position: 'AscNullsFirst' }],
limit: 3,
},
},
result: {
data: {
companies: {
edges: companies.slice(0, 3).map((company, index) => ({
node: company,
cursor: `cursor-${index + 1}`,
})),
pageInfo: {
hasNextPage: companies.length > 3,
hasPreviousPage: false,
startCursor: 'cursor-1',
endCursor:
companies.length > 0
? `cursor-${Math.min(companies.length, 3)}`
: null,
},
totalCount: companies.length,
},
},
},
},
],
componentInstanceId,
contextStoreCurrentObjectMetadataNameSingular:
companyMockObjectMetadataItem?.nameSingular,
contextStoreTargetedRecordsRule: {
mode: 'selection',
selectedRecordIds: companies.map((company) => company.id),
},
contextStoreNumberOfSelectedRecords: companies.length,
onInitializeJotaiStore: () => {
for (const company of companies) {
jotaiStore.set(
recordStoreFamilyState.atomFamily(company.id),
company as ObjectRecord,
);
}
},
});
};
const ContextStoreDecorator: Decorator = (Story) => {
const ContextStoreWrapper = createContextStoreWrapper({
companies: [companyMock],
componentInstanceId: '1',
});
return (
<ContextStoreWrapper>
<Story />
</ContextStoreWrapper>
);
};
const meta: Meta<typeof SidePanelContextRecordsChip> = {
title: 'Modules/SidePanel/SidePanelContextRecordChip',
component: SidePanelContextRecordsChip,
decorators: [
ContextStoreDecorator,
ChipGeneratorsDecorator,
ComponentDecorator,
],
args: {
objectMetadataItemId: companyMockObjectMetadataItem?.id,
},
};
export default meta;
type Story = StoryObj<typeof SidePanelContextRecordsChip>;
export const Default: Story = {};
export const WithTwoCompanies: Story = {
decorators: [
(Story) => {
const twoCompaniesMock = mockedCompanyRecords.slice(0, 2);
const TwoCompaniesWrapper = createContextStoreWrapper({
companies: twoCompaniesMock,
componentInstanceId: '2',
});
return (
<TwoCompaniesWrapper>
<Story />
</TwoCompaniesWrapper>
);
},
],
};
export const WithTenCompanies: Story = {
decorators: [
(Story) => {
const tenCompaniesMock = mockedCompanyRecords.slice(0, 10);
const TenCompaniesWrapper = createContextStoreWrapper({
companies: tenCompaniesMock,
componentInstanceId: '3',
});
return (
<TenCompaniesWrapper>
<Story />
</TenCompaniesWrapper>
);
},
],
};