feat: add color property to ObjectMetadata for object icon customization (#18672)
## Summary - Adds a `color` column to `ObjectMetadataEntity` with full GraphQL support so object icon colors are persisted at the metadata level - Adds a `type` column to `NavigationMenuItemEntity` (enum: `OBJECT`, `VIEW`, `FOLDER`, `LINK`, `RECORD`) replacing field-based type inference - Updates frontend to read object colors from `objectMetadata.color` (falling back to standard defaults) in the sidebar nav, record index header, and record show breadcrumb - Simplifies `NavigationMenuItemIcon` color resolution via `getEffectiveNavigationMenuItemColor` util ## Color rules | Item type | Color source | Editable in sidebar? | |-----------|-------------|---------------------| | **Object** | `objectMetadata.color` | Yes — persisted to `objectMetadata.color` on Save | | **Folder** | `navigationMenuItem.color` | Yes | | **Link** | Fixed default (`DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK`) | No | | **View** | `objectMetadata.color` (from the parent object) | No | | **Record** | None | No | - **Object** items represent the whole object (e.g. "Companies") and point to the INDEX view. Changing their color updates `objectMetadata.color` via `useSaveObjectMetadataColorsFromDraft`. - **View** items represent specific non-INDEX views. Their color comes from the parent object's metadata (read-only). - Only **folders** store their color on `navigationMenuItem.color` — enforced by `hasNavigationMenuItemOwnColor` util. - `getEffectiveNavigationMenuItemColor` returns `objectColor` for both OBJECT and VIEW items, folder's own color for folders, and the fixed default for links. ## NavigationMenuItemType enum - Shared enum created in `twenty-shared` with values: `OBJECT`, `VIEW`, `FOLDER`, `LINK`, `RECORD` - Registered as a GraphQL enum on the backend - Replaces string literals across entity, DTOs, input, converters, and frontend hooks - Migration backfills existing rows: INDEX views → `OBJECT`, non-INDEX views → `VIEW`, based on join with the view table ## Design decisions - **OBJECT vs VIEW distinction**: Items pointing to INDEX views are typed as `OBJECT` (represent the whole object, color editable). Items pointing to non-INDEX views are typed as `VIEW` (specific view, color read-only from parent object). - **Dual color storage**: `navigationMenuItem.color` is preserved for folders only. Objects use `objectMetadata.color` as their source of truth. - **Type discriminator**: The `type` column replaces field-based inference (checking `viewId`, `link`, `targetRecordId` presence) with an explicit enum, simplifying `isNavigationMenuItemLink` / `isNavigationMenuItemFolder` to simple `item.type ===` checks. - **No settings page color picker**: Object color editing is done from the sidebar edit panel, not the data model settings page. ## Test plan - [ ] Verify objects display their default standard colors in the sidebar - [ ] Verify object color editing works in the sidebar edit panel (persists to objectMetadata.color) - [ ] Verify folder color editing works in the sidebar edit panel - [ ] Verify views, links, and records do NOT show a color picker in the sidebar edit panel - [ ] Run `npx nx typecheck twenty-front` and `npx nx typecheck twenty-server` - [ ] Verify the database migrations add `color` to `objectMetadata` and `type` to `navigationMenuItem` Made with [Cursor](https://cursor.com)
This commit is contained in:
+4
-4
@@ -7,7 +7,6 @@ import { NavigationMenuItemType } from '@/navigation-menu-item/constants/Navigat
|
||||
import { useSelectedNavigationMenuItemEditItem } from '@/navigation-menu-item/hooks/useSelectedNavigationMenuItemEditItem';
|
||||
import { useSelectedNavigationMenuItemEditItemLabel } from '@/navigation-menu-item/hooks/useSelectedNavigationMenuItemEditItemLabel';
|
||||
import { useSelectedNavigationMenuItemEditItemObjectMetadata } from '@/navigation-menu-item/hooks/useSelectedNavigationMenuItemEditItemObjectMetadata';
|
||||
import { ViewKey } from '@/views/types/ViewKey';
|
||||
|
||||
export const SidePanelObjectViewRecordInfo = () => {
|
||||
const { t } = useLingui();
|
||||
@@ -25,19 +24,20 @@ export const SidePanelObjectViewRecordInfo = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isViewOrRecord = [
|
||||
const isObjectViewOrRecord = [
|
||||
NavigationMenuItemType.OBJECT,
|
||||
NavigationMenuItemType.VIEW,
|
||||
NavigationMenuItemType.RECORD,
|
||||
].includes(processedItem.itemType);
|
||||
|
||||
if (!isViewOrRecord) {
|
||||
if (!isObjectViewOrRecord) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label =
|
||||
processedItem.itemType === NavigationMenuItemType.RECORD
|
||||
? selectedItemObjectMetadata?.labelSingular
|
||||
: processedItem.viewKey === ViewKey.INDEX
|
||||
: processedItem.itemType === NavigationMenuItemType.OBJECT
|
||||
? t`Object`
|
||||
: t`View`;
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ export const SidePanelPageInfo = ({ pageChip }: SidePanelPageInfoProps) => {
|
||||
}
|
||||
|
||||
if (
|
||||
itemType === NavigationMenuItemType.OBJECT ||
|
||||
itemType === NavigationMenuItemType.VIEW ||
|
||||
itemType === NavigationMenuItemType.RECORD
|
||||
) {
|
||||
|
||||
+30
-5
@@ -1,3 +1,8 @@
|
||||
import { useObjectNavItemColor } from '@/navigation-menu-item/hooks/useObjectNavItemColor';
|
||||
import { navigationMenuItemsSelector } from '@/navigation-menu-item/states/navigationMenuItemsSelector';
|
||||
import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/types/processed-navigation-menu-item';
|
||||
import { getEffectiveNavigationMenuItemColor } from '@/navigation-menu-item/utils/getEffectiveNavigationMenuItemColor';
|
||||
import { parseThemeColor } from '@/navigation-menu-item/utils/parseThemeColor';
|
||||
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
|
||||
import { SidePanelList } from '@/side-panel/components/SidePanelList';
|
||||
import { SidePanelEditColorOption } from '@/side-panel/pages/navigation-menu-item/components/SidePanelEditColorOption';
|
||||
@@ -6,13 +11,15 @@ import {
|
||||
SidePanelEditOrganizeActions,
|
||||
} from '@/side-panel/pages/navigation-menu-item/components/SidePanelEditOrganizeActions';
|
||||
import { getOrganizeActionsSelectableItemIds } from '@/side-panel/pages/navigation-menu-item/utils/getOrganizeActionsSelectableItemIds';
|
||||
import { useSelectedNavigationMenuItemEditItem } from '@/navigation-menu-item/hooks/useSelectedNavigationMenuItemEditItem';
|
||||
import { parseThemeColor } from '@/navigation-menu-item/utils/parseThemeColor';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type SidePanelEditObjectViewBaseProps = OrganizeActionsProps & {
|
||||
onOpenFolderPicker: () => void;
|
||||
showColorOption?: boolean;
|
||||
selectedItem?: ProcessedNavigationMenuItem | null;
|
||||
};
|
||||
|
||||
export const SidePanelEditObjectViewBase = ({
|
||||
@@ -25,18 +32,36 @@ export const SidePanelEditObjectViewBase = ({
|
||||
onAddBefore,
|
||||
onAddAfter,
|
||||
showColorOption = false,
|
||||
selectedItem,
|
||||
}: SidePanelEditObjectViewBaseProps) => {
|
||||
const { t } = useLingui();
|
||||
const { selectedItem } = useSelectedNavigationMenuItemEditItem();
|
||||
const selectableItemIds = getOrganizeActionsSelectableItemIds(true);
|
||||
const objectColor = useObjectNavItemColor(
|
||||
selectedItem?.objectNameSingular ?? '',
|
||||
);
|
||||
|
||||
const navigationMenuItems = useAtomStateValue(navigationMenuItemsSelector);
|
||||
const persistedNavItem = navigationMenuItems.find(
|
||||
(item) => item.id === selectedItem?.id,
|
||||
);
|
||||
const hasUserChangedColor =
|
||||
isNonEmptyString(selectedItem?.color) &&
|
||||
selectedItem.color !== (persistedNavItem?.color ?? undefined);
|
||||
|
||||
const effectiveColor = isDefined(selectedItem)
|
||||
? getEffectiveNavigationMenuItemColor(selectedItem, objectColor)
|
||||
: undefined;
|
||||
const displayColor = hasUserChangedColor
|
||||
? selectedItem.color
|
||||
: effectiveColor;
|
||||
|
||||
return (
|
||||
<SidePanelList commandGroups={[]} selectableItemIds={selectableItemIds}>
|
||||
{showColorOption && selectedItem && (
|
||||
{showColorOption && isDefined(selectedItem) && (
|
||||
<SidePanelGroup heading={t`Customize`}>
|
||||
<SidePanelEditColorOption
|
||||
navigationMenuItemId={selectedItem.id}
|
||||
color={parseThemeColor(selectedItem.color)}
|
||||
color={parseThemeColor(displayColor)}
|
||||
/>
|
||||
</SidePanelGroup>
|
||||
)}
|
||||
|
||||
+17
-19
@@ -4,9 +4,9 @@ import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/hooks/u
|
||||
import { useOpenAddItemToFolderPage } from '@/navigation-menu-item/hooks/useOpenAddItemToFolderPage';
|
||||
import { useSelectedNavigationMenuItemEditItem } from '@/navigation-menu-item/hooks/useSelectedNavigationMenuItemEditItem';
|
||||
import { useSelectedNavigationMenuItemEditItemLabel } from '@/navigation-menu-item/hooks/useSelectedNavigationMenuItemEditItemLabel';
|
||||
import { useSelectedNavigationMenuItemEditItemObjectMetadata } from '@/navigation-menu-item/hooks/useSelectedNavigationMenuItemEditItemObjectMetadata';
|
||||
import { useUpdateLinkInDraft } from '@/navigation-menu-item/hooks/useUpdateLinkInDraft';
|
||||
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState';
|
||||
import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/types/processed-navigation-menu-item';
|
||||
import { parseThemeColor } from '@/navigation-menu-item/utils/parseThemeColor';
|
||||
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
|
||||
import { SidePanelList } from '@/side-panel/components/SidePanelList';
|
||||
@@ -21,7 +21,6 @@ import { getOrganizeActionsSelectableItemIds } from '@/side-panel/pages/navigati
|
||||
import { SidePanelSubPages } from '@/side-panel/types/SidePanelSubPages';
|
||||
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { ViewKey } from '@/views/types/ViewKey';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -47,8 +46,6 @@ export const SidePanelNavigationMenuItemEditPage = () => {
|
||||
);
|
||||
const { selectedItemLabel } = useSelectedNavigationMenuItemEditItemLabel();
|
||||
const { selectedItem } = useSelectedNavigationMenuItemEditItem();
|
||||
const { selectedItemObjectMetadata } =
|
||||
useSelectedNavigationMenuItemEditItemObjectMetadata();
|
||||
const selectedItemType = selectedItem?.itemType ?? null;
|
||||
|
||||
const { navigateToSidePanelSubPage } = useSidePanelSubPageHistory();
|
||||
@@ -97,8 +94,22 @@ export const SidePanelNavigationMenuItemEditPage = () => {
|
||||
}
|
||||
|
||||
switch (selectedItemType) {
|
||||
case NavigationMenuItemType.VIEW:
|
||||
if (!selectedItemObjectMetadata) return null;
|
||||
case NavigationMenuItemType.OBJECT:
|
||||
return (
|
||||
<SidePanelEditObjectViewBase
|
||||
onOpenFolderPicker={openFolderPicker}
|
||||
canMoveUp={canMoveUp}
|
||||
canMoveDown={canMoveDown}
|
||||
onMoveUp={onMoveUp}
|
||||
onMoveDown={onMoveDown}
|
||||
onRemove={onRemove}
|
||||
onAddBefore={onAddBefore}
|
||||
onAddAfter={onAddAfter}
|
||||
showColorOption={isDefined(selectedItem)}
|
||||
selectedItem={selectedItem as ProcessedNavigationMenuItem | undefined}
|
||||
/>
|
||||
);
|
||||
case NavigationMenuItemType.VIEW:
|
||||
return (
|
||||
<SidePanelEditObjectViewBase
|
||||
onOpenFolderPicker={openFolderPicker}
|
||||
@@ -109,11 +120,6 @@ export const SidePanelNavigationMenuItemEditPage = () => {
|
||||
onRemove={onRemove}
|
||||
onAddBefore={onAddBefore}
|
||||
onAddAfter={onAddAfter}
|
||||
showColorOption={
|
||||
selectedItem &&
|
||||
'viewKey' in selectedItem &&
|
||||
selectedItem.viewKey === ViewKey.INDEX
|
||||
}
|
||||
/>
|
||||
);
|
||||
case NavigationMenuItemType.LINK:
|
||||
@@ -187,14 +193,6 @@ export const SidePanelNavigationMenuItemEditPage = () => {
|
||||
commandGroups={[]}
|
||||
selectableItemIds={getOrganizeActionsSelectableItemIds(true)}
|
||||
>
|
||||
{selectedItem && (
|
||||
<SidePanelGroup heading={t`Customize`}>
|
||||
<SidePanelEditColorOption
|
||||
navigationMenuItemId={selectedItem.id}
|
||||
color={parseThemeColor(selectedItem.color)}
|
||||
/>
|
||||
</SidePanelGroup>
|
||||
)}
|
||||
<SidePanelEditOrganizeActions
|
||||
canMoveUp={canMoveUp}
|
||||
canMoveDown={canMoveDown}
|
||||
|
||||
+1
-5
@@ -56,16 +56,12 @@ export const SidePanelNewSidebarItemObjectSubPage = () => {
|
||||
objectMetadataIdsWithDisplayableViews,
|
||||
});
|
||||
|
||||
const handleSelectObject = (
|
||||
objectMetadataItem: ObjectMetadataItem,
|
||||
defaultViewId: string,
|
||||
) => {
|
||||
const handleSelectObject = (objectMetadataItem: ObjectMetadataItem) => {
|
||||
if (objectMetadataIdsInWorkspace.has(objectMetadataItem.id)) {
|
||||
return;
|
||||
}
|
||||
const itemId = addObjectToDraft(
|
||||
objectMetadataItem.id,
|
||||
defaultViewId,
|
||||
currentDraft,
|
||||
addMenuItemInsertionContext?.targetFolderId,
|
||||
addMenuItemInsertionContext?.targetIndex,
|
||||
|
||||
+1
-5
@@ -55,16 +55,12 @@ export const SidePanelNewSidebarItemObjectSystemPickerSubPage = () => {
|
||||
objectMetadataIdsWithDisplayableViews,
|
||||
});
|
||||
|
||||
const handleSelectObject = (
|
||||
objectMetadataItem: ObjectMetadataItem,
|
||||
defaultViewId: string,
|
||||
) => {
|
||||
const handleSelectObject = (objectMetadataItem: ObjectMetadataItem) => {
|
||||
if (objectMetadataIdsInWorkspace.has(objectMetadataItem.id)) {
|
||||
return;
|
||||
}
|
||||
const itemId = addObjectToDraft(
|
||||
objectMetadataItem.id,
|
||||
defaultViewId,
|
||||
currentDraft,
|
||||
addMenuItemInsertionContext?.targetFolderId,
|
||||
addMenuItemInsertionContext?.targetIndex,
|
||||
|
||||
+2
-6
@@ -16,10 +16,7 @@ import { indexViewIdFromObjectMetadataItemFamilySelector } from '@/views/states/
|
||||
|
||||
type SidePanelObjectMenuItemProps = {
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
onSelect: (
|
||||
objectMetadataItem: ObjectMetadataItem,
|
||||
defaultViewId: string,
|
||||
) => void;
|
||||
onSelect: (objectMetadataItem: ObjectMetadataItem) => void;
|
||||
variant: 'add' | 'edit';
|
||||
dragIndex?: number;
|
||||
disableDrag?: boolean;
|
||||
@@ -52,7 +49,7 @@ export const SidePanelObjectMenuItem = ({
|
||||
if (isDisabled || !defaultViewId) {
|
||||
return;
|
||||
}
|
||||
onSelect(objectMetadataItem, defaultViewId);
|
||||
onSelect(objectMetadataItem);
|
||||
};
|
||||
|
||||
const styledIcon = () => (
|
||||
@@ -72,7 +69,6 @@ export const SidePanelObjectMenuItem = ({
|
||||
payload={{
|
||||
type: NavigationMenuItemType.OBJECT,
|
||||
objectMetadataId: objectMetadataItem.id,
|
||||
defaultViewId: defaultViewId ?? '',
|
||||
label: objectMetadataItem.labelPlural,
|
||||
iconColor,
|
||||
}}
|
||||
|
||||
+1
-4
@@ -12,10 +12,7 @@ type SidePanelObjectPickerItemProps = {
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
isViewItem: boolean;
|
||||
onSelectObjectForViewEdit?: (objectMetadataItem: ObjectMetadataItem) => void;
|
||||
onChangeObject: (
|
||||
objectMetadataItem: ObjectMetadataItem,
|
||||
defaultViewId: string,
|
||||
) => void;
|
||||
onChangeObject: (objectMetadataItem: ObjectMetadataItem) => void;
|
||||
objectMenuItemVariant?: 'add' | 'edit';
|
||||
dragIndex?: number;
|
||||
disableDrag?: boolean;
|
||||
|
||||
+1
-4
@@ -19,10 +19,7 @@ type SidePanelObjectPickerSubViewProps = {
|
||||
onOpenSystemPicker: () => void;
|
||||
isViewItem: boolean;
|
||||
onSelectObjectForViewEdit?: (objectMetadataItem: ObjectMetadataItem) => void;
|
||||
onChangeObject: (
|
||||
objectMetadataItem: ObjectMetadataItem,
|
||||
defaultViewId: string,
|
||||
) => void;
|
||||
onChangeObject: (objectMetadataItem: ObjectMetadataItem) => void;
|
||||
objectMenuItemVariant?: 'add' | 'edit';
|
||||
emptyNoResultsText?: string;
|
||||
disableDrag?: boolean;
|
||||
|
||||
+1
-4
@@ -14,10 +14,7 @@ type SidePanelSystemObjectPickerSubViewProps = {
|
||||
onSearchChange: (value: string) => void;
|
||||
isViewItem: boolean;
|
||||
onSelectObjectForViewEdit?: (objectMetadataItem: ObjectMetadataItem) => void;
|
||||
onChangeObject: (
|
||||
objectMetadataItem: ObjectMetadataItem,
|
||||
defaultViewId: string,
|
||||
) => void;
|
||||
onChangeObject: (objectMetadataItem: ObjectMetadataItem) => void;
|
||||
objectMenuItemVariant?: 'add' | 'edit';
|
||||
emptyNoResultsText?: string;
|
||||
disableDrag?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user