251 create top bar chips inside the command menu (#9809)
Closes #https://github.com/twentyhq/core-team-issues/issues/251 https://github.com/user-attachments/assets/065c97fe-1daf-4b48-9d57-6bbb96d24ede
This commit is contained in:
@ -0,0 +1,63 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const StyledChip = styled.div`
|
||||
align-items: center;
|
||||
background: ${({ theme }) => theme.background.transparent.light};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
height: ${({ theme }) => theme.spacing(8)};
|
||||
padding: 0 ${({ theme }) => theme.spacing(2)};
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
line-height: ${({ theme }) => theme.text.lineHeight.lg};
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
`;
|
||||
|
||||
const StyledIconsContainer = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const StyledIconWrapper = styled.div<{ withIconBackground?: boolean }>`
|
||||
background: ${({ theme, withIconBackground }) =>
|
||||
withIconBackground ? theme.background.primary : 'unset'};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
padding: ${({ theme }) => theme.spacing(0.5)};
|
||||
border: 1px solid
|
||||
${({ theme, withIconBackground }) =>
|
||||
withIconBackground ? theme.border.color.medium : 'transparent'};
|
||||
&:not(:first-of-type) {
|
||||
margin-left: -${({ theme }) => theme.spacing(1)};
|
||||
}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
export const CommandMenuContextChip = ({
|
||||
Icons,
|
||||
text,
|
||||
withIconBackground,
|
||||
}: {
|
||||
Icons: React.ReactNode[];
|
||||
text?: string;
|
||||
withIconBackground?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<StyledChip>
|
||||
<StyledIconsContainer>
|
||||
{Icons.map((Icon, index) => (
|
||||
<StyledIconWrapper
|
||||
key={index}
|
||||
withIconBackground={withIconBackground}
|
||||
>
|
||||
{Icon}
|
||||
</StyledIconWrapper>
|
||||
))}
|
||||
</StyledIconsContainer>
|
||||
<span>{text}</span>
|
||||
</StyledChip>
|
||||
);
|
||||
};
|
||||
@ -1,30 +1,10 @@
|
||||
import { CommandMenuContextChip } from '@/command-menu/components/CommandMenuContextChip';
|
||||
import { CommandMenuContextRecordChipAvatars } from '@/command-menu/components/CommandMenuContextRecordChipAvatars';
|
||||
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
|
||||
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
|
||||
import { getObjectRecordIdentifier } from '@/object-metadata/utils/getObjectRecordIdentifier';
|
||||
import styled from '@emotion/styled';
|
||||
import { capitalize } from 'twenty-shared';
|
||||
|
||||
const StyledChip = styled.div`
|
||||
align-items: center;
|
||||
background: ${({ theme }) => theme.background.transparent.light};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
height: ${({ theme }) => theme.spacing(8)};
|
||||
padding: 0 ${({ theme }) => theme.spacing(2)};
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
line-height: ${({ theme }) => theme.text.lineHeight.lg};
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
`;
|
||||
|
||||
const StyledAvatarContainer = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
export const CommandMenuContextRecordChip = ({
|
||||
objectMetadataItemId,
|
||||
}: {
|
||||
@ -43,21 +23,25 @@ export const CommandMenuContextRecordChip = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const Avatars = records.map((record) => (
|
||||
<CommandMenuContextRecordChipAvatars
|
||||
objectMetadataItem={objectMetadataItem}
|
||||
key={record.id}
|
||||
record={record}
|
||||
/>
|
||||
));
|
||||
|
||||
const text =
|
||||
totalCount === 1
|
||||
? getObjectRecordIdentifier({ objectMetadataItem, record: records[0] })
|
||||
.name
|
||||
: `${totalCount} ${capitalize(objectMetadataItem.namePlural)}`;
|
||||
|
||||
return (
|
||||
<StyledChip>
|
||||
<StyledAvatarContainer>
|
||||
{records.map((record) => (
|
||||
<CommandMenuContextRecordChipAvatars
|
||||
objectMetadataItem={objectMetadataItem}
|
||||
key={record.id}
|
||||
record={record}
|
||||
/>
|
||||
))}
|
||||
</StyledAvatarContainer>
|
||||
{totalCount === 1
|
||||
? getObjectRecordIdentifier({ objectMetadataItem, record: records[0] })
|
||||
.name
|
||||
: `${totalCount} ${capitalize(objectMetadataItem.namePlural)}`}
|
||||
</StyledChip>
|
||||
<CommandMenuContextChip
|
||||
text={text}
|
||||
Icons={Avatars}
|
||||
withIconBackground={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -3,22 +3,8 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { useRecordChipData } from '@/object-record/hooks/useRecordChipData';
|
||||
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { Avatar } from 'twenty-ui';
|
||||
|
||||
const StyledAvatarWrapper = styled.div`
|
||||
background-color: ${({ theme }) => theme.background.primary};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
padding: ${({ theme }) => theme.spacing(0.5)};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
&:not(:first-of-type) {
|
||||
margin-left: -${({ theme }) => theme.spacing(1)};
|
||||
}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
export const CommandMenuContextRecordChipAvatars = ({
|
||||
objectMetadataItem,
|
||||
record,
|
||||
@ -38,7 +24,7 @@ export const CommandMenuContextRecordChipAvatars = ({
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<StyledAvatarWrapper>
|
||||
<>
|
||||
{Icon ? (
|
||||
<Icon color={IconColor} size={theme.icon.size.sm} />
|
||||
) : (
|
||||
@ -50,6 +36,6 @@ export const CommandMenuContextRecordChipAvatars = ({
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</StyledAvatarWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
import { CommandMenuContextChip } from '@/command-menu/components/CommandMenuContextChip';
|
||||
import { CommandMenuContextRecordChip } from '@/command-menu/components/CommandMenuContextRecordChip';
|
||||
import { CommandMenuPages } from '@/command-menu/components/CommandMenuPages';
|
||||
import { COMMAND_MENU_SEARCH_BAR_HEIGHT } from '@/command-menu/constants/CommandMenuSearchBarHeight';
|
||||
import { COMMAND_MENU_SEARCH_BAR_PADDING } from '@/command-menu/constants/CommandMenuSearchBarPadding';
|
||||
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
|
||||
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
|
||||
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageTitle';
|
||||
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
|
||||
import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState';
|
||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { IconX, LightIconButton, isDefined, useIsMobile } from 'twenty-ui';
|
||||
@ -82,6 +85,10 @@ export const CommandMenuTopBar = () => {
|
||||
|
||||
const commandMenuPage = useRecoilValue(commandMenuPageState);
|
||||
|
||||
const { title, Icon } = useRecoilValue(commandMenuPageInfoState);
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<StyledInputContainer>
|
||||
<StyledContentContainer>
|
||||
@ -90,6 +97,13 @@ export const CommandMenuTopBar = () => {
|
||||
objectMetadataItemId={contextStoreCurrentObjectMetadataId}
|
||||
/>
|
||||
)}
|
||||
{isDefined(Icon) && (
|
||||
<CommandMenuContextChip
|
||||
Icons={[<Icon size={theme.icon.size.sm} />]}
|
||||
text={title}
|
||||
/>
|
||||
)}
|
||||
|
||||
{commandMenuPage === CommandMenuPages.Root && (
|
||||
<StyledInput
|
||||
autoFocus
|
||||
|
||||
@ -0,0 +1,53 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import {
|
||||
ComponentDecorator,
|
||||
IconBuildingSkyscraper,
|
||||
IconUser,
|
||||
} from 'twenty-ui';
|
||||
import { CommandMenuContextChip } from '../CommandMenuContextChip';
|
||||
|
||||
const meta: Meta<typeof CommandMenuContextChip> = {
|
||||
title: 'Modules/CommandMenu/CommandMenuContextChip',
|
||||
component: CommandMenuContextChip,
|
||||
decorators: [ComponentDecorator],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CommandMenuContextChip>;
|
||||
|
||||
export const SingleIcon: Story = {
|
||||
args: {
|
||||
Icons: [<IconUser size={16} />],
|
||||
text: 'Person',
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleIcons: Story = {
|
||||
args: {
|
||||
Icons: [<IconUser size={16} />, <IconBuildingSkyscraper size={16} />],
|
||||
text: 'Person & Company',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIconBackground: Story = {
|
||||
args: {
|
||||
Icons: [<IconUser size={16} />],
|
||||
text: 'Person',
|
||||
withIconBackground: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleIconsWithIconBackground: Story = {
|
||||
args: {
|
||||
Icons: [<IconUser size={16} />, <IconBuildingSkyscraper size={16} />],
|
||||
text: 'Person & Company',
|
||||
withIconBackground: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const IconsOnly: Story = {
|
||||
args: {
|
||||
Icons: [<IconUser size={16} />, <IconBuildingSkyscraper size={16} />],
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,261 @@
|
||||
import { gql } from '@apollo/client';
|
||||
import { Decorator, Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { CommandMenuContextRecordChip } from '@/command-menu/components/CommandMenuContextRecordChip';
|
||||
import { PreComputedChipGeneratorsContext } from '@/object-metadata/contexts/PreComputedChipGeneratorsContext';
|
||||
import { RecordChipData } from '@/object-record/record-field/types/RecordChipData';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { ComponentDecorator } from 'twenty-ui';
|
||||
import { getJestMetadataAndApolloMocksAndActionMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndContextStoreWrapper';
|
||||
import { getCompaniesMock } from '~/testing/mock-data/companies';
|
||||
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
|
||||
|
||||
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
|
||||
}
|
||||
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 = generatedMockObjectMetadataItems.find(
|
||||
(item) => item.nameSingular === 'company',
|
||||
);
|
||||
|
||||
const companiesMock = getCompaniesMock();
|
||||
|
||||
const companyMock = companiesMock[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 companiesMock;
|
||||
componentInstanceId: string;
|
||||
}) => {
|
||||
return getJestMetadataAndApolloMocksAndActionMenuWrapper({
|
||||
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,
|
||||
onInitializeRecoilSnapshot: (snapshot) => {
|
||||
for (const company of companies) {
|
||||
snapshot.set(recordStoreFamilyState(company.id), company);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const ContextStoreDecorator: Decorator = (Story) => {
|
||||
const ContextStoreWrapper = createContextStoreWrapper({
|
||||
companies: [companyMock],
|
||||
componentInstanceId: '1',
|
||||
});
|
||||
|
||||
return (
|
||||
<ContextStoreWrapper>
|
||||
<Story />
|
||||
</ContextStoreWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const meta: Meta<typeof CommandMenuContextRecordChip> = {
|
||||
title: 'Modules/CommandMenu/CommandMenuContextRecordChip',
|
||||
component: CommandMenuContextRecordChip,
|
||||
decorators: [
|
||||
ContextStoreDecorator,
|
||||
ChipGeneratorsDecorator,
|
||||
ComponentDecorator,
|
||||
],
|
||||
args: {
|
||||
objectMetadataItemId: companyMockObjectMetadataItem?.id,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CommandMenuContextRecordChip>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const WithTwoCompanies: Story = {
|
||||
decorators: [
|
||||
(Story) => {
|
||||
const twoCompaniesMock = companiesMock.slice(0, 2);
|
||||
const TwoCompaniesWrapper = createContextStoreWrapper({
|
||||
companies: twoCompaniesMock,
|
||||
componentInstanceId: '2',
|
||||
});
|
||||
|
||||
return (
|
||||
<TwoCompaniesWrapper>
|
||||
<Story />
|
||||
</TwoCompaniesWrapper>
|
||||
);
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const WithTenCompanies: Story = {
|
||||
decorators: [
|
||||
(Story) => {
|
||||
const tenCompaniesMock = companiesMock.slice(0, 10);
|
||||
const TenCompaniesWrapper = createContextStoreWrapper({
|
||||
companies: tenCompaniesMock,
|
||||
componentInstanceId: '3',
|
||||
});
|
||||
|
||||
return (
|
||||
<TenCompaniesWrapper>
|
||||
<Story />
|
||||
</TenCompaniesWrapper>
|
||||
);
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -9,6 +9,7 @@ import { isDefined } from '~/utils/isDefined';
|
||||
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
|
||||
import { CommandMenuPages } from '@/command-menu/components/CommandMenuPages';
|
||||
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
|
||||
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageTitle';
|
||||
import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState';
|
||||
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
|
||||
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
|
||||
@ -213,6 +214,10 @@ export const useCommandMenu = () => {
|
||||
|
||||
set(viewableRecordIdState, null);
|
||||
set(commandMenuPageState, CommandMenuPages.Root);
|
||||
set(commandMenuPageInfoState, {
|
||||
title: undefined,
|
||||
Icon: undefined,
|
||||
});
|
||||
set(isCommandMenuOpenedState, false);
|
||||
resetSelectedItem();
|
||||
goBackToPreviousHotkeyScope();
|
||||
@ -278,6 +283,11 @@ export const useCommandMenu = () => {
|
||||
}),
|
||||
null,
|
||||
);
|
||||
|
||||
set(commandMenuPageInfoState, {
|
||||
title: undefined,
|
||||
Icon: undefined,
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
import { createState } from '@ui/utilities/state/utils/createState';
|
||||
import { IconComponent } from 'twenty-ui';
|
||||
|
||||
export const commandMenuPageInfoState = createState<{
|
||||
title: string | undefined;
|
||||
Icon: IconComponent | undefined;
|
||||
}>({
|
||||
key: 'command-menu/commandMenuPageInfoState',
|
||||
defaultValue: { title: undefined, Icon: undefined },
|
||||
});
|
||||
Reference in New Issue
Block a user