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:
@@ -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 }}
|
||||
|
||||
-52
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
-269
@@ -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>
|
||||
);
|
||||
},
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user