-
Notifications
You must be signed in to change notification settings - Fork 5.7k
feat: add command menu item to layout customization #18764
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 40 commits
984bf6e
a7bb9b0
9a9cc0c
047e2ae
e08c292
e722f5c
539f182
320c84c
74ca958
2e5bdab
b3a532e
f692427
c0e5923
f372306
3588f7c
239cb7f
bd8c9dc
2d30f24
6c46478
eb937a9
f95f1a9
3b77d8e
3c205de
393bf2b
7c90b2c
442c28d
b1ea211
550b51e
bf5b034
42cb345
9d88056
f09ef69
6e36176
c54e981
2c121b9
7a9ab4f
705b637
45d5c43
5b91ec9
b0ec972
fa915a7
6ef11ff
abb3c0b
063c83a
546cd0c
815a451
a81acfe
252f3be
8741f43
6dac377
a1cd5a7
b963176
d380438
adc19c8
010b198
fb555d0
612a797
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import { styled } from '@linaria/react'; | ||
| import { motion } from 'framer-motion'; | ||
| import { useContext } from 'react'; | ||
| import { type IconComponent } from 'twenty-ui/display'; | ||
| import { ThemeContext } from 'twenty-ui/theme-constants'; | ||
|
|
||
| const StyledContainer = styled.div<{ size: number }>` | ||
| height: ${({ size }) => size}px; | ||
| overflow: hidden; | ||
| position: relative; | ||
| width: ${({ size }) => size}px; | ||
| `; | ||
|
|
||
| const StyledLayer = styled(motion.div)` | ||
| align-items: center; | ||
| display: flex; | ||
| inset: 0; | ||
| justify-content: center; | ||
| position: absolute; | ||
| `; | ||
|
|
||
| type AnimatedIconCrossfadeProps = { | ||
| isActive: boolean; | ||
| ActiveIcon: IconComponent; | ||
| InactiveIcon: IconComponent; | ||
| size?: number; | ||
| }; | ||
|
|
||
| export const AnimatedIconCrossfade = ({ | ||
| isActive, | ||
| ActiveIcon, | ||
| InactiveIcon, | ||
| size, | ||
| }: AnimatedIconCrossfadeProps) => { | ||
| const { theme } = useContext(ThemeContext); | ||
|
|
||
| const iconSize = size ?? theme.icon.size.sm; | ||
|
|
||
| return ( | ||
| <StyledContainer size={iconSize}> | ||
| <StyledLayer | ||
| initial={false} | ||
| animate={{ | ||
| opacity: isActive ? 0 : 1, | ||
| scale: isActive ? 0.85 : 1, | ||
| }} | ||
| transition={{ | ||
| duration: theme.animation.duration.fast, | ||
| ease: 'easeInOut', | ||
| }} | ||
| > | ||
| <InactiveIcon size={iconSize} /> | ||
| </StyledLayer> | ||
| <StyledLayer | ||
| initial={false} | ||
| animate={{ | ||
| opacity: isActive ? 1 : 0, | ||
| scale: isActive ? 1 : 0.85, | ||
| }} | ||
| transition={{ | ||
| duration: theme.animation.duration.fast, | ||
| ease: 'easeInOut', | ||
| }} | ||
| > | ||
| <ActiveIcon size={iconSize} /> | ||
| </StyledLayer> | ||
| </StyledContainer> | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import { COMMAND_MENU_ITEM_FRAGMENT } from '@/command-menu-item/graphql/fragments/commandMenuItemFragment'; | ||
| import { gql } from '@apollo/client'; | ||
|
|
||
| export const UPDATE_COMMAND_MENU_ITEM = gql` | ||
| ${COMMAND_MENU_ITEM_FRAGMENT} | ||
| mutation UpdateCommandMenuItem($input: UpdateCommandMenuItemInput!) { | ||
| updateCommandMenuItem(input: $input) { | ||
| ...CommandMenuItemFields | ||
| } | ||
| } | ||
| `; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; | ||
| import { objectPermissionsFamilySelector } from '@/auth/states/objectPermissionsFamilySelector'; | ||
| import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext'; | ||
| import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId'; | ||
| import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState'; | ||
| import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState'; | ||
| import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState'; | ||
|
|
@@ -13,24 +13,28 @@ import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPe | |
| import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView'; | ||
| import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore'; | ||
| import { recordStoreRecordsSelector } from '@/object-record/record-store/states/selectors/recordStoreRecordsSelector'; | ||
| import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState'; | ||
| import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; | ||
| import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; | ||
| import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue'; | ||
| import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; | ||
| import { isNonEmptyArray } from '@sniptt/guards'; | ||
| import { useStore } from 'jotai'; | ||
| import { useContext } from 'react'; | ||
| import { useAtomValue, useStore } from 'jotai'; | ||
| import { | ||
| CommandMenuContextApiPageType, | ||
| SidePanelPages, | ||
| type CommandMenuContextApi, | ||
| } from 'twenty-shared/types'; | ||
| import { isDefined } from 'twenty-shared/utils'; | ||
|
|
||
| export const useCommandMenuContextApi = (): CommandMenuContextApi => { | ||
| // Display-only hook. For the edit page, use useCommandMenuContextApiForEdition. | ||
| export const useCommandMenuContextApi = ({ | ||
| isInSidePanel, | ||
| }: { | ||
| isInSidePanel: boolean; | ||
| }): CommandMenuContextApi => { | ||
| const store = useStore(); | ||
|
|
||
| const { isInSidePanel } = useContext(CommandMenuContext); | ||
|
|
||
| const contextStoreCurrentObjectMetadataItemId = useAtomComponentStateValue( | ||
| contextStoreCurrentObjectMetadataItemIdComponentState, | ||
| ); | ||
|
|
@@ -39,32 +43,70 @@ export const useCommandMenuContextApi = (): CommandMenuContextApi => { | |
| contextStoreTargetedRecordsRuleComponentState, | ||
| ); | ||
|
|
||
| const contextStoreNumberOfSelectedRecords = useAtomComponentStateValue( | ||
| contextStoreNumberOfSelectedRecordsComponentState, | ||
| ); | ||
|
|
||
| const mainContextStoreTargetedRecordsRule = useAtomValue( | ||
| contextStoreTargetedRecordsRuleComponentState.atomFamily({ | ||
| instanceId: MAIN_CONTEXT_STORE_INSTANCE_ID, | ||
|
||
| }), | ||
| ); | ||
|
|
||
| const mainContextStoreNumberOfSelectedRecords = useAtomValue( | ||
| contextStoreNumberOfSelectedRecordsComponentState.atomFamily({ | ||
| instanceId: MAIN_CONTEXT_STORE_INSTANCE_ID, | ||
| }), | ||
| ); | ||
|
|
||
| const mainContextStoreCurrentObjectMetadataItemId = useAtomValue( | ||
| contextStoreCurrentObjectMetadataItemIdComponentState.atomFamily({ | ||
| instanceId: MAIN_CONTEXT_STORE_INSTANCE_ID, | ||
| }), | ||
| ); | ||
|
|
||
| const { objectMetadataItems } = useObjectMetadataItems(); | ||
|
|
||
| const sidePanelPage = useAtomStateValue(sidePanelPageState); | ||
|
|
||
| const shouldUseMainContext = | ||
| isInSidePanel && | ||
| (sidePanelPage === SidePanelPages.CommandMenuDisplay || | ||
| sidePanelPage === SidePanelPages.CommandMenuEdit); | ||
|
|
||
| const effectiveObjectMetadataItemId = shouldUseMainContext | ||
| ? mainContextStoreCurrentObjectMetadataItemId | ||
| : contextStoreCurrentObjectMetadataItemId; | ||
|
|
||
| const effectiveTargetedRecordsRule = shouldUseMainContext | ||
| ? mainContextStoreTargetedRecordsRule | ||
| : contextStoreTargetedRecordsRule; | ||
|
|
||
| const effectiveNumberOfSelectedRecords = shouldUseMainContext | ||
| ? mainContextStoreNumberOfSelectedRecords | ||
| : contextStoreNumberOfSelectedRecords; | ||
|
|
||
| const objectMetadataItem = objectMetadataItems.find( | ||
| (item) => item.id === contextStoreCurrentObjectMetadataItemId, | ||
| (item) => item.id === effectiveObjectMetadataItemId, | ||
| ); | ||
|
|
||
| const { navigationMenuItems } = useNavigationMenuItemsData(); | ||
|
|
||
| const recordIds = | ||
| contextStoreTargetedRecordsRule.mode === 'selection' | ||
| ? contextStoreTargetedRecordsRule.selectedRecordIds | ||
| effectiveTargetedRecordsRule.mode === 'selection' | ||
| ? effectiveTargetedRecordsRule.selectedRecordIds | ||
| : undefined; | ||
|
|
||
| const favoriteRecordIds = (() => { | ||
| if (!isNonEmptyArray(recordIds) || !isDefined(objectMetadataItem)) { | ||
| return []; | ||
| } | ||
|
|
||
| return recordIds.filter((recordId) => | ||
| navigationMenuItems?.some( | ||
| (item) => | ||
| item.targetRecordId === recordId && | ||
| item.targetObjectMetadataId === objectMetadataItem.id, | ||
| ), | ||
| ); | ||
| })(); | ||
| const favoriteRecordIds = | ||
| !isNonEmptyArray(recordIds) || !isDefined(objectMetadataItem) | ||
| ? [] | ||
| : recordIds.filter((recordId) => | ||
| navigationMenuItems?.some( | ||
| (item) => | ||
| item.targetRecordId === recordId && | ||
| item.targetObjectMetadataId === objectMetadataItem.id, | ||
| ), | ||
| ); | ||
|
|
||
| const selectedRecords = useAtomFamilySelectorValue( | ||
| recordStoreRecordsSelector, | ||
|
|
@@ -107,11 +149,7 @@ export const useCommandMenuContextApi = (): CommandMenuContextApi => { | |
| ? CommandMenuContextApiPageType.RECORD_PAGE | ||
| : CommandMenuContextApiPageType.INDEX_PAGE; | ||
|
|
||
| const contextStoreNumberOfSelectedRecords = useAtomComponentStateValue( | ||
| contextStoreNumberOfSelectedRecordsComponentState, | ||
| ); | ||
|
|
||
| const isSelectAll = contextStoreTargetedRecordsRule.mode === 'exclusion'; | ||
| const isSelectAll = effectiveTargetedRecordsRule.mode === 'exclusion'; | ||
|
|
||
| const currentWorkspace = useAtomStateValue(currentWorkspaceState); | ||
|
|
||
|
|
@@ -143,7 +181,7 @@ export const useCommandMenuContextApi = (): CommandMenuContextApi => { | |
| favoriteRecordIds, | ||
| isSelectAll, | ||
| hasAnySoftDeleteFilterOnView, | ||
| numberOfSelectedRecords: contextStoreNumberOfSelectedRecords, | ||
| numberOfSelectedRecords: effectiveNumberOfSelectedRecords, | ||
| objectPermissions, | ||
| selectedRecords, | ||
| featureFlags, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: The code unconditionally accesses
.nameSingularonobjectMetadataItem, which can beundefined, causing a crash if no matching object metadata item is found.Severity: CRITICAL
Suggested Fix
Add a null check before accessing
commandMenuContextApi.objectMetadataItem.nameSingular. You can use optional chaining (?.) to safely access the property, preventing the crash whenobjectMetadataItemisundefined.Prompt for AI Agent