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:
Charles Bochet
2026-03-16 23:54:56 +01:00
committed by GitHub
parent 087ee19807
commit a121d00ddd
123 changed files with 1176 additions and 742 deletions
@@ -474,7 +474,7 @@ const createExampleNavigationMenuItem = async ({
const universalIdentifier = v4();
const content = `import { defineNavigationMenuItem } from 'twenty-sdk';
import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/example-view';
import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/example-view';
export default defineNavigationMenuItem({
universalIdentifier: '${universalIdentifier}',
@@ -482,6 +482,7 @@ export default defineNavigationMenuItem({
icon: 'IconList',
color: 'blue',
position: 0,
type: 'VIEW',
viewUniversalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER,
});
`;
@@ -1,8 +1,10 @@
import { defineNavigationMenuItem } from 'twenty-sdk';
import { ALL_POST_CARD_RECIPIENTS_VIEW_ID } from '../views/all-post-card-recipients.view';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/post-card-recipient.object';
export default defineNavigationMenuItem({
universalIdentifier: 'c1a2b3c4-0003-4a7b-8c9d-0e1f2a3b4c5d',
position: 2,
viewUniversalIdentifier: ALL_POST_CARD_RECIPIENTS_VIEW_ID,
type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
});
@@ -1,8 +1,10 @@
import { defineNavigationMenuItem } from 'twenty-sdk';
import { ALL_POST_CARDS_VIEW_ID } from '../views/all-post-cards.view';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
export default defineNavigationMenuItem({
universalIdentifier: 'c1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d',
position: 0,
viewUniversalIdentifier: ALL_POST_CARDS_VIEW_ID,
type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
});
@@ -1,8 +1,10 @@
import { defineNavigationMenuItem } from 'twenty-sdk';
import { ALL_RECIPIENTS_VIEW_ID } from '../views/all-recipients.view';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/recipient.object';
export default defineNavigationMenuItem({
universalIdentifier: 'c1a2b3c4-0002-4a7b-8c9d-0e1f2a3b4c5d',
position: 1,
viewUniversalIdentifier: ALL_RECIPIENTS_VIEW_ID,
type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier: RECIPIENT_UNIVERSAL_IDENTIFIER,
});
@@ -1,5 +1,6 @@
import { defineNavigationMenuItem } from 'twenty-sdk';
import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/example-view';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/example-view';
export default defineNavigationMenuItem({
universalIdentifier: '10f90627-e9c2-44b7-9742-bed77e3d1b17',
@@ -7,5 +8,6 @@ export default defineNavigationMenuItem({
icon: 'IconList',
color: 'blue',
position: 0,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,10 +1,12 @@
import { CALL_RECORDING_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/call-recording-view';
import { defineNavigationMenuItem } from 'twenty-sdk';
import { NavigationMenuItemType } from 'twenty-shared/types';
export default defineNavigationMenuItem({
universalIdentifier: '5248a62d-7d2e-43a7-ba45-6e8f61876a71',
name: 'Call recordings',
icon: 'IconPhone',
position: 0,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier: CALL_RECORDING_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,4 +1,5 @@
import { defineNavigationMenuItem } from 'twenty-sdk';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers.constant';
export default defineNavigationMenuItem({
@@ -6,6 +7,7 @@ export default defineNavigationMenuItem({
name: 'Self host user',
icon: 'IconList',
position: 1,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.views.selfHostingUserView.universalIdentifier,
});
File diff suppressed because one or more lines are too long
@@ -1,15 +1,13 @@
import { styled } from '@linaria/react';
import { isNonEmptyString } from '@sniptt/guards';
import { useContext, type ReactNode } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconGripVertical, type IconComponent } from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { NavigationMenuItemStyleIcon } from '@/navigation-menu-item/components/NavigationMenuItemStyleIcon';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER } from '@/navigation-menu-item/constants/NavigationMenuItemDefaultColorFolder';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK } from '@/navigation-menu-item/constants/NavigationMenuItemDefaultColorLink';
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
import type { AddToNavigationDragPayload } from '@/navigation-menu-item/types/add-to-navigation-drag-payload';
import { getEffectiveNavigationMenuItemColor } from '@/navigation-menu-item/utils/getEffectiveNavigationMenuItemColor';
const StyledIconSlot = styled.div<{
$hasFixedSize: boolean;
@@ -83,14 +81,14 @@ export const AddToNavigationDragHandle = ({
disableDrag = false,
}: AddToNavigationDragHandleProps) => {
const { theme } = useContext(ThemeContext);
const effectiveColor =
payload.type === 'object' && isNonEmptyString(payload.iconColor)
const objectColor =
payload.type === NavigationMenuItemType.OBJECT
? payload.iconColor
: payload.type === 'folder'
? DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER
: payload.type === 'link'
? DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK
: undefined;
: undefined;
const effectiveColor = getEffectiveNavigationMenuItemColor(
{ itemType: payload.type as NavigationMenuItemType },
objectColor,
);
const hasBackgroundColor =
payload.type !== NavigationMenuItemType.RECORD &&
isDefined(effectiveColor) &&
@@ -8,8 +8,8 @@ import { StyledNavigationMenuItemIconContainer } from '@/navigation-menu-item/co
import { ObjectIconWithViewOverlay } from '@/navigation-menu-item/components/ObjectIconWithViewOverlay';
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
import { useObjectNavItemColor } from '@/navigation-menu-item/hooks/useObjectNavItemColor';
import { getNavigationMenuItemIconStyleFromColor } from '@/navigation-menu-item/utils/getNavigationMenuItemIconStyleFromColor';
import { getEffectiveNavigationMenuItemColor } from '@/navigation-menu-item/utils/getEffectiveNavigationMenuItemColor';
import { getNavigationMenuItemIconStyleFromColor } from '@/navigation-menu-item/utils/getNavigationMenuItemIconStyleFromColor';
import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
import { useGetStandardObjectIcon } from '@/object-metadata/hooks/useGetStandardObjectIcon';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
@@ -73,8 +73,10 @@ export const NavigationMenuItemIcon = ({
const iconToUse =
StandardIcon ??
(navigationMenuItem.Icon ? getIcon(navigationMenuItem.Icon) : undefined);
const effectiveColor =
getEffectiveNavigationMenuItemColor(navigationMenuItem);
const effectiveColor = getEffectiveNavigationMenuItemColor(
navigationMenuItem,
objectNavItemColor,
);
const useStyledIcon = !isRecord && isNonEmptyString(effectiveColor);
const iconStyle = useStyledIcon
? getNavigationMenuItemIconStyleFromColor(effectiveColor)
@@ -39,6 +39,7 @@ export const WorkspaceNavigationMenuItemFolderSubItem = ({
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
const views = useAtomStateValue(viewsSelector);
const objectMetadataItem =
navigationMenuItem.itemType === NavigationMenuItemType.OBJECT ||
navigationMenuItem.itemType === NavigationMenuItemType.VIEW ||
navigationMenuItem.itemType === NavigationMenuItemType.RECORD
? getObjectMetadataForNavigationMenuItem(
@@ -28,7 +28,7 @@ import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/utils
import { preloadWorkspaceDndKit } from '@/navigation/preloadWorkspaceDndKit';
import { NavigationDrawerSectionForWorkspaceItems } from '@/object-metadata/components/NavigationDrawerSectionForWorkspaceItems';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { navigationMenuItemsState } from '@/navigation-menu-item/states/navigationMenuItemsState';
import { navigationMenuItemsSelector } from '@/navigation-menu-item/states/navigationMenuItemsSelector';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -47,7 +47,9 @@ export const WorkspaceNavigationMenuItems = () => {
const { workspaceNavigationMenuItemsSorted } = useSortedNavigationMenuItems();
const store = useStore();
const enterEditMode = () => {
const currentNavigationMenuItems = store.get(navigationMenuItemsState.atom);
const currentNavigationMenuItems = store.get(
navigationMenuItemsSelector.atom,
);
const workspaceNavigationMenuItems = filterWorkspaceNavigationMenuItems(
currentNavigationMenuItems,
);
@@ -106,7 +108,8 @@ export const WorkspaceNavigationMenuItems = () => {
if (objectMetadataItem) {
openNavigationMenuItemInSidePanel({
pageTitle:
item.itemType === NavigationMenuItemType.VIEW
item.itemType === NavigationMenuItemType.VIEW ||
item.itemType === NavigationMenuItemType.OBJECT
? item.labelIdentifier
: objectMetadataItem.labelSingular,
pageIcon: getIcon(objectMetadataItem.icon),
@@ -1,7 +1,7 @@
export enum NavigationMenuItemType {
FOLDER = 'folder',
LINK = 'link',
OBJECT = 'object',
RECORD = 'record',
VIEW = 'view',
FOLDER = 'FOLDER',
LINK = 'LINK',
OBJECT = 'OBJECT',
RECORD = 'RECORD',
VIEW = 'VIEW',
}
@@ -3,6 +3,7 @@ import { gql } from '@apollo/client';
export const NAVIGATION_MENU_ITEM_FRAGMENT = gql`
fragment NavigationMenuItemFields on NavigationMenuItem {
id
type
userWorkspaceId
targetRecordId
targetObjectMetadataId
@@ -1,3 +1,4 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
@@ -37,6 +38,7 @@ export const useAddFolderToNavigationMenuDraft = () => {
const newItem: NavigationMenuItem = {
__typename: 'NavigationMenuItem',
id: newItemId,
type: NavigationMenuItemType.FOLDER,
viewId: undefined,
targetObjectMetadataId: undefined,
targetRecordId: undefined,
@@ -1,3 +1,4 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
@@ -40,6 +41,7 @@ export const useAddLinkToNavigationMenuDraft = () => {
const newItem: NavigationMenuItem = {
__typename: 'NavigationMenuItem',
id: newItemId,
type: NavigationMenuItemType.LINK,
viewId: undefined,
targetObjectMetadataId: undefined,
targetRecordId: undefined,
@@ -1,3 +1,4 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
@@ -13,7 +14,6 @@ export const useAddObjectToNavigationMenuDraft = () => {
const addObjectToDraft = (
objectMetadataId: string,
defaultViewId: string,
currentDraft: NavigationMenuItem[],
targetFolderId?: string | null,
targetIndex?: number,
@@ -38,7 +38,8 @@ export const useAddObjectToNavigationMenuDraft = () => {
const newItem: NavigationMenuItem = {
__typename: 'NavigationMenuItem',
id: newItemId,
viewId: defaultViewId,
type: NavigationMenuItemType.OBJECT,
viewId: undefined,
targetObjectMetadataId: objectMetadataId,
position,
userWorkspaceId: undefined,
@@ -1,4 +1,5 @@
import { v4 } from 'uuid';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
@@ -60,6 +61,7 @@ export const useAddRecordToNavigationMenuDraft = () => {
const newItem: NavigationMenuItem = {
__typename: 'NavigationMenuItem',
id: newItemId,
type: NavigationMenuItemType.RECORD,
viewId: undefined,
targetObjectMetadataId: objectMetadataId,
targetRecordId: searchRecord.recordId,
@@ -1,3 +1,4 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
@@ -37,6 +38,7 @@ export const useAddViewToNavigationMenuDraft = () => {
const newItem: NavigationMenuItem = {
__typename: 'NavigationMenuItem',
id: newItemId,
type: NavigationMenuItemType.VIEW,
viewId,
targetObjectMetadataId: undefined,
position,
@@ -1,3 +1,4 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useMutation } from '@apollo/client/react';
import { CreateNavigationMenuItemDocument } from '~/generated-metadata/graphql';
@@ -42,6 +43,7 @@ export const useCreateNavigationMenuItem = () => {
await createNavigationMenuItemMutation({
variables: {
input: {
type: NavigationMenuItemType.VIEW,
viewId: targetRecord.id,
userWorkspaceId: currentWorkspaceMemberId,
folderId,
@@ -75,6 +77,7 @@ export const useCreateNavigationMenuItem = () => {
await createNavigationMenuItemMutation({
variables: {
input: {
type: NavigationMenuItemType.RECORD,
targetRecordId: targetRecord.id,
targetObjectMetadataId: objectMetadataItem.id,
userWorkspaceId: currentWorkspaceMemberId,
@@ -1,3 +1,4 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useMutation } from '@apollo/client/react';
import { CreateNavigationMenuItemDocument } from '~/generated-metadata/graphql';
@@ -40,6 +41,7 @@ export const useCreateNavigationMenuItemFolder = () => {
await createNavigationMenuItemMutation({
variables: {
input: {
type: NavigationMenuItemType.FOLDER,
name,
targetRecordId: null,
targetObjectMetadataId: null,
@@ -139,7 +139,6 @@ export const useHandleAddToNavigationDrop = () => {
);
const newItemId = addObjectToDraft(
payload.objectMetadataId,
payload.defaultViewId,
currentDraft,
folderId,
index,
@@ -2,7 +2,7 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState';
import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/utils/filterWorkspaceNavigationMenuItems';
import { navigationMenuItemsState } from '@/navigation-menu-item/states/navigationMenuItemsState';
import { navigationMenuItemsSelector } from '@/navigation-menu-item/states/navigationMenuItemsSelector';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDefined } from 'twenty-shared/utils';
@@ -17,7 +17,7 @@ type NavigationMenuItemsData = {
export const useNavigationMenuItemsData = (): NavigationMenuItemsData => {
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const currentWorkspaceMemberId = currentWorkspaceMember?.id;
const navigationMenuItems = useAtomStateValue(navigationMenuItemsState);
const navigationMenuItems = useAtomStateValue(navigationMenuItemsSelector);
const isNavigationMenuInEditMode = useAtomStateValue(
isNavigationMenuInEditModeState,
);
@@ -4,14 +4,14 @@ import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/utils/filterWorkspaceNavigationMenuItems';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState';
import { navigationMenuItemsState } from '@/navigation-menu-item/states/navigationMenuItemsState';
import { navigationMenuItemsSelector } from '@/navigation-menu-item/states/navigationMenuItemsSelector';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const useNavigationMenuItemsDraftState = () => {
const isNavigationMenuInEditMode = useAtomStateValue(
isNavigationMenuInEditModeState,
);
const navigationMenuItems = useAtomStateValue(navigationMenuItemsState);
const navigationMenuItems = useAtomStateValue(navigationMenuItemsSelector);
const navigationMenuItemsDraft = useAtomStateValue(
navigationMenuItemsDraftState,
);
@@ -1,17 +1,20 @@
import { useWorkspaceSectionItems } from '@/navigation-menu-item/hooks/useWorkspaceSectionItems';
import { isNonEmptyString } from '@sniptt/guards';
import { getStandardObjectIconColor } from '@/navigation-menu-item/utils/getStandardObjectIconColor';
import { ViewKey } from '@/views/types/ViewKey';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
export const useObjectNavItemColor = (objectNameSingular: string): string => {
const items = useWorkspaceSectionItems();
const objectNavItem = items.find(
(item) =>
'viewKey' in item &&
item.viewKey === ViewKey.INDEX &&
item.objectNameSingular === objectNameSingular,
);
return (
(objectNavItem && 'color' in objectNavItem ? objectNavItem.color : null) ??
getStandardObjectIconColor(objectNameSingular)
const { objectMetadataItems } = useObjectMetadataItems();
const objectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === objectNameSingular,
);
const storedColor = objectMetadataItem?.color;
const fallbackColor = getStandardObjectIconColor(objectNameSingular);
if (isNonEmptyString(storedColor)) {
return storedColor;
}
return fallbackColor;
};
@@ -1,4 +1,5 @@
import { useCallback } from 'react';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useMutation } from '@apollo/client/react';
@@ -12,7 +13,9 @@ import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/utils
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder';
import { isNavigationMenuItemLink } from '@/navigation-menu-item/utils/isNavigationMenuItemLink';
import { orderFoldersForCreation } from '@/navigation-menu-item/utils/orderFoldersForCreation';
import { navigationMenuItemsState } from '@/navigation-menu-item/states/navigationMenuItemsState';
import { navigationMenuItemsSelector } from '@/navigation-menu-item/states/navigationMenuItemsSelector';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useStore } from 'jotai';
export const useSaveNavigationMenuItemsDraft = () => {
@@ -24,15 +27,46 @@ export const useSaveNavigationMenuItemsDraft = () => {
refetchQueries: ['FindManyNavigationMenuItems'],
},
);
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
const store = useStore();
const saveDraft = useCallback(async () => {
const draft = store.get(navigationMenuItemsDraftState.atom);
const currentItems = store.get(navigationMenuItemsState.atom);
const currentItems = store.get(navigationMenuItemsSelector.atom);
if (!draft) return;
const objectMetadataItems = store.get(objectMetadataItemsState.atom);
for (const draftItem of draft) {
if (draftItem.type !== NavigationMenuItemType.OBJECT) {
continue;
}
if (!isDefined(draftItem.targetObjectMetadataId)) {
continue;
}
if (!isDefined(draftItem.color)) {
continue;
}
const objectMetadataItem = objectMetadataItems.find(
(item) => item.id === draftItem.targetObjectMetadataId,
);
if (!isDefined(objectMetadataItem)) {
continue;
}
if (objectMetadataItem.color === draftItem.color) {
continue;
}
await updateOneObjectMetadataItem({
idToUpdate: draftItem.targetObjectMetadataId,
updatePayload: { color: draftItem.color },
});
}
const workspaceItems = filterWorkspaceNavigationMenuItems(currentItems);
const topLevelWorkspace = workspaceItems.filter(
(item) => !isDefined(item.folderId),
@@ -131,13 +165,17 @@ export const useSaveNavigationMenuItemsDraft = () => {
const iconChanged =
isNavigationMenuItemFolder(draftItem) &&
(original.icon ?? null) !== (draftItem.icon ?? null);
const colorChanged =
isNavigationMenuItemFolder(draftItem) &&
(original.color ?? null) !== (draftItem.color ?? null);
if (
positionChanged ||
folderIdChanged ||
nameChanged ||
linkChanged ||
iconChanged
iconChanged ||
colorChanged
) {
const updateInput: {
id: string;
@@ -146,6 +184,7 @@ export const useSaveNavigationMenuItemsDraft = () => {
name?: string;
link?: string | null;
icon?: string | null;
color?: string | null;
} = { id: draftItem.id };
if (positionChanged) {
@@ -174,6 +213,9 @@ export const useSaveNavigationMenuItemsDraft = () => {
if (iconChanged && isNavigationMenuItemFolder(draftItem)) {
updateInput.icon = draftItem.icon ?? null;
}
if (colorChanged) {
updateInput.color = draftItem.color ?? null;
}
await updateNavigationMenuItem(updateInput);
}
@@ -182,6 +224,7 @@ export const useSaveNavigationMenuItemsDraft = () => {
updateNavigationMenuItem,
deleteNavigationMenuItem,
createNavigationMenuItemMutation,
updateOneObjectMetadataItem,
store,
]);
@@ -12,6 +12,7 @@ const getLabelForItemType = (
return item.name ?? 'Folder';
case NavigationMenuItemType.LINK:
return item.name ?? 'Link';
case NavigationMenuItemType.OBJECT:
case NavigationMenuItemType.VIEW:
return item.labelIdentifier ?? objectLabelSingular ?? '';
default:
@@ -3,6 +3,7 @@ import { isDefined } from 'twenty-shared/utils';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder';
import { isNavigationMenuItemLink } from '@/navigation-menu-item/utils/isNavigationMenuItemLink';
import { isNavigationMenuItemObject } from '@/navigation-menu-item/utils/isNavigationMenuItemObject';
import { recordIdentifierToObjectRecordIdentifier } from '@/navigation-menu-item/utils/recordIdentifierToObjectRecordIdentifier';
import { sortNavigationMenuItems } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
@@ -83,6 +84,9 @@ export const useSortedNavigationMenuItems = () => {
if (isNavigationMenuItemLink(item)) {
return true;
}
if (isNavigationMenuItemObject(item)) {
return isDefined(item.targetObjectMetadataId);
}
if (isDefined(item.viewId)) {
return views.some((view) => view.id === item.viewId);
}
@@ -2,10 +2,10 @@ import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
export const navigationMenuItemsState = createAtomSelector<
export const navigationMenuItemsSelector = createAtomSelector<
NavigationMenuItem[]
>({
key: 'navigationMenuItemsState',
key: 'navigationMenuItemsSelector',
get: ({ get }) => {
const entry = get(metadataStoreState, 'navigationMenuItems');
@@ -1,19 +1,18 @@
export type AddToNavigationDragPayloadObject = {
type: 'object';
type: 'OBJECT';
objectMetadataId: string;
defaultViewId: string;
label: string;
iconColor?: string;
};
export type AddToNavigationDragPayloadView = {
type: 'view';
type: 'VIEW';
viewId: string;
label: string;
};
export type AddToNavigationDragPayloadRecord = {
type: 'record';
type: 'RECORD';
recordId: string;
objectMetadataId: string;
objectNameSingular: string;
@@ -22,13 +21,13 @@ export type AddToNavigationDragPayloadRecord = {
};
export type AddToNavigationDragPayloadFolder = {
type: 'folder';
type: 'FOLDER';
folderId: string;
name: string;
};
export type AddToNavigationDragPayloadLink = {
type: 'link';
type: 'LINK';
linkId: string;
name: string;
link: string;
@@ -1,45 +1,26 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder';
describe('isNavigationMenuItemFolder', () => {
it('should return true only when item has name and no link/view/record metadata', () => {
it('should return true when type is folder', () => {
expect(
isNavigationMenuItemFolder({
name: 'My Folder',
link: null,
viewId: null,
targetRecordId: null,
targetObjectMetadataId: null,
}),
isNavigationMenuItemFolder({ type: NavigationMenuItemType.FOLDER }),
).toBe(true);
});
it('should return false when name is missing or when link/view/record is defined', () => {
it('should return false for other types', () => {
expect(
isNavigationMenuItemFolder({
name: undefined,
link: null,
viewId: null,
targetRecordId: null,
targetObjectMetadataId: null,
}),
isNavigationMenuItemFolder({ type: NavigationMenuItemType.LINK }),
).toBe(false);
expect(
isNavigationMenuItemFolder({
name: 'My Folder',
link: 'https://example.com',
viewId: null,
targetRecordId: null,
targetObjectMetadataId: null,
}),
isNavigationMenuItemFolder({ type: NavigationMenuItemType.VIEW }),
).toBe(false);
expect(
isNavigationMenuItemFolder({
name: 'My Folder',
link: null,
viewId: 'view-1',
targetRecordId: null,
targetObjectMetadataId: null,
}),
isNavigationMenuItemFolder({ type: NavigationMenuItemType.RECORD }),
).toBe(false);
expect(
isNavigationMenuItemFolder({ type: NavigationMenuItemType.OBJECT }),
).toBe(false);
});
});
@@ -1,60 +1,26 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isNavigationMenuItemLink } from '@/navigation-menu-item/utils/isNavigationMenuItemLink';
describe('isNavigationMenuItemLink', () => {
it('should return true only when item has non-empty link and no view/record metadata', () => {
it('should return true when type is link', () => {
expect(
isNavigationMenuItemLink({
link: 'https://example.com',
viewId: null,
targetRecordId: null,
targetObjectMetadataId: null,
}),
isNavigationMenuItemLink({ type: NavigationMenuItemType.LINK }),
).toBe(true);
});
it('should return false when link is missing, empty or only whitespace', () => {
it('should return false for other types', () => {
expect(
isNavigationMenuItemLink({
link: '',
viewId: null,
targetRecordId: null,
targetObjectMetadataId: null,
}),
isNavigationMenuItemLink({ type: NavigationMenuItemType.FOLDER }),
).toBe(false);
expect(
isNavigationMenuItemLink({
link: ' ',
viewId: null,
targetRecordId: null,
targetObjectMetadataId: null,
}),
isNavigationMenuItemLink({ type: NavigationMenuItemType.VIEW }),
).toBe(false);
expect(
isNavigationMenuItemLink({
link: undefined,
viewId: null,
targetRecordId: null,
targetObjectMetadataId: null,
}),
).toBe(false);
});
it('should return false when viewId, targetRecordId or targetObjectMetadataId is defined', () => {
expect(
isNavigationMenuItemLink({
link: 'https://example.com',
viewId: 'view-1',
targetRecordId: null,
targetObjectMetadataId: null,
}),
isNavigationMenuItemLink({ type: NavigationMenuItemType.RECORD }),
).toBe(false);
expect(
isNavigationMenuItemLink({
link: 'https://example.com',
viewId: null,
targetRecordId: 'record-1',
targetObjectMetadataId: null,
}),
isNavigationMenuItemLink({ type: NavigationMenuItemType.OBJECT }),
).toBe(false);
});
});
@@ -1,3 +1,5 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { sortNavigationMenuItems } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier';
@@ -324,6 +326,7 @@ describe('sortNavigationMenuItems', () => {
[
{
id: 'link-1',
type: NavigationMenuItemType.LINK,
link: 'https://example.com',
name: 'My Link',
position: 1,
@@ -341,6 +344,7 @@ describe('sortNavigationMenuItems', () => {
[
{
id: 'link-2',
type: NavigationMenuItemType.LINK,
link: 'example.com',
position: 2,
} as NavigationMenuItem,
@@ -7,12 +7,14 @@ import { isDefined } from 'twenty-shared/utils';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder';
import { isNavigationMenuItemLink } from '@/navigation-menu-item/utils/isNavigationMenuItemLink';
import { isNavigationMenuItemObject } from '@/navigation-menu-item/utils/isNavigationMenuItemObject';
export const buildCreateNavigationMenuItemInput = (
draftItem: NavigationMenuItem,
resolveFolderId: (draftFolderId: string) => string,
): CreateNavigationMenuItemInput => {
const input: CreateNavigationMenuItemInput = {
type: draftItem.type,
position: draftItem.position,
};
@@ -28,6 +30,9 @@ export const buildCreateNavigationMenuItemInput = (
: linkUrl
? `https://${linkUrl}`
: undefined;
} else if (isNavigationMenuItemObject(draftItem)) {
input.targetObjectMetadataId =
draftItem.targetObjectMetadataId ?? undefined;
} else if (isDefined(draftItem.viewId)) {
input.viewId = draftItem.viewId;
} else if (isDefined(draftItem.targetRecordId)) {
@@ -3,16 +3,24 @@ import { isNonEmptyString } from '@sniptt/guards';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER } from '@/navigation-menu-item/constants/NavigationMenuItemDefaultColorFolder';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK } from '@/navigation-menu-item/constants/NavigationMenuItemDefaultColorLink';
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/types/processed-navigation-menu-item';
export const getEffectiveNavigationMenuItemColor = (
navigationMenuItem: ProcessedNavigationMenuItem,
navigationMenuItem: {
itemType: NavigationMenuItemType;
color?: string | null;
},
objectColor?: string,
): string | undefined => {
if (isNonEmptyString(navigationMenuItem.color)) {
return navigationMenuItem.color;
}
if (navigationMenuItem.itemType === NavigationMenuItemType.FOLDER) {
return DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER;
return isNonEmptyString(navigationMenuItem.color)
? navigationMenuItem.color
: DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER;
}
if (navigationMenuItem.itemType === NavigationMenuItemType.OBJECT) {
return objectColor;
}
if (navigationMenuItem.itemType === NavigationMenuItemType.VIEW) {
return objectColor;
}
if (navigationMenuItem.itemType === NavigationMenuItemType.LINK) {
return DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK;
@@ -19,6 +19,16 @@ export const getObjectMetadataForNavigationMenuItem = (
return null;
}
if (
navigationMenuItem.itemType === NavigationMenuItemType.OBJECT &&
isDefined(navigationMenuItem.targetObjectMetadataId)
) {
const objectMetadataItem = objectMetadataItems.find(
(meta) => meta.id === navigationMenuItem.targetObjectMetadataId,
);
return objectMetadataItem ?? null;
}
if (
navigationMenuItem.itemType === NavigationMenuItemType.VIEW &&
isDefined(navigationMenuItem.viewId)
@@ -0,0 +1,4 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
export const hasNavigationMenuItemOwnColor = (item: { type?: string | null }) =>
item.type === NavigationMenuItemType.FOLDER;
@@ -6,9 +6,10 @@ export const isLocationMatchingNavigationMenuItem = (
currentViewPath: string,
navigationMenuItem: Pick<ProcessedNavigationMenuItem, 'itemType' | 'link'>,
) => {
const isViewItem =
navigationMenuItem.itemType === NavigationMenuItemType.VIEW;
return isViewItem
const isViewBasedItem =
navigationMenuItem.itemType === NavigationMenuItemType.VIEW ||
navigationMenuItem.itemType === NavigationMenuItemType.OBJECT;
return isViewBasedItem
? navigationMenuItem.link === currentViewPath
: navigationMenuItem.link === currentPath;
};
@@ -1,15 +1,6 @@
import { isDefined } from 'twenty-shared/utils';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
export const isNavigationMenuItemFolder = (item: {
name?: string | null;
link?: string | null;
folderId?: string | null;
viewId?: string | null;
targetRecordId?: string | null;
targetObjectMetadataId?: string | null;
}) =>
isDefined(item.name) &&
!isDefined(item.link) &&
!isDefined(item.targetRecordId) &&
!isDefined(item.targetObjectMetadataId) &&
!isDefined(item.viewId);
export const isNavigationMenuItemFolder = (
item: Pick<NavigationMenuItem, 'type'>,
) => item.type === NavigationMenuItemType.FOLDER;
@@ -1,13 +1,6 @@
import { isDefined } from 'twenty-shared/utils';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
export const isNavigationMenuItemLink = (item: {
link?: string | null;
viewId?: string | null;
targetRecordId?: string | null;
targetObjectMetadataId?: string | null;
}) =>
isDefined(item.link) &&
(item.link ?? '').trim() !== '' &&
!isDefined(item.viewId) &&
!isDefined(item.targetRecordId) &&
!isDefined(item.targetObjectMetadataId);
export const isNavigationMenuItemLink = (
item: Pick<NavigationMenuItem, 'type'>,
) => item.type === NavigationMenuItemType.LINK;
@@ -0,0 +1,6 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
export const isNavigationMenuItemObject = (
item: Pick<NavigationMenuItem, 'type'>,
) => item.type === NavigationMenuItemType.OBJECT;
@@ -26,6 +26,45 @@ export const sortNavigationMenuItems = (
): ProcessedNavigationMenuItem[] => {
return navigationMenuItems
.map((navigationMenuItem) => {
if (
navigationMenuItem.type === NavigationMenuItemType.OBJECT &&
isDefined(navigationMenuItem.targetObjectMetadataId)
) {
const objectMetadataItem = objectMetadataItems.find(
(meta) => meta.id === navigationMenuItem.targetObjectMetadataId,
);
if (!isDefined(objectMetadataItem)) {
return null;
}
const indexView = views.find(
(view) =>
view.objectMetadataId === objectMetadataItem.id &&
view.key === ViewKey.INDEX,
);
const displayFields: NavigationMenuItemDisplayFields = {
labelIdentifier: objectMetadataItem.labelPlural,
avatarUrl: '',
avatarType: 'icon',
link: getAppPath(
AppPath.RecordIndexPage,
{ objectNamePlural: objectMetadataItem.namePlural },
indexView ? { viewId: indexView.id } : {},
),
objectNameSingular: objectMetadataItem.nameSingular,
Icon: objectMetadataItem.icon ?? 'IconBox',
};
return {
...navigationMenuItem,
...displayFields,
viewKey: ViewKey.INDEX,
itemType: NavigationMenuItemType.OBJECT,
};
}
if (isDefined(navigationMenuItem.viewId)) {
const view = views.find(
(view) => view.id === navigationMenuItem.viewId,
@@ -94,7 +94,7 @@ export const FavoritesDragDropProviderContent = ({
destination.droppableId,
);
const isFolderOverFolder =
payload?.type === 'folder' && folderId !== null;
payload?.type === 'FOLDER' && folderId !== null;
setForbiddenDropTargetId(isFolderOverFolder ? dropTargetId : null);
} else {
setForbiddenDropTargetId(null);
@@ -229,7 +229,7 @@ export const useWorkspaceDndKit = (): {
resolved.destination.droppableId,
);
const folderDrag =
getPayload()?.type === 'folder' && isDefined(folderId);
getPayload()?.type === 'FOLDER' && isDefined(folderId);
setForbiddenDropTargetId(
folderDrag ? resolved.effectiveDropTargetId : null,
);
@@ -9,5 +9,5 @@ export const isFolderDrag = (
payload: AddToNavPayload,
sourceItem: NavigationMenuItem | undefined,
): boolean =>
payload?.type === 'folder' ||
payload?.type === 'FOLDER' ||
(isDefined(sourceItem) && isNavigationMenuItemFolder(sourceItem));
@@ -2,7 +2,6 @@ import { ObjectIconWithViewOverlay } from '@/navigation-menu-item/components/Obj
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
import { useObjectNavItemColor } from '@/navigation-menu-item/hooks/useObjectNavItemColor';
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { getStandardObjectIconColor } from '@/navigation-menu-item/utils/getStandardObjectIconColor';
import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
import { lastVisitedViewPerObjectMetadataItemState } from '@/navigation/states/lastVisitedViewPerObjectMetadataItemState';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
@@ -52,7 +51,9 @@ export const NavigationDrawerItemForObjectMetadataItem = ({
const isRecord =
navigationMenuItem?.itemType === NavigationMenuItemType.RECORD;
const isView = navigationMenuItem?.itemType === NavigationMenuItemType.VIEW;
const hasCustomLink = isRecord || isView;
const isObject =
navigationMenuItem?.itemType === NavigationMenuItemType.OBJECT;
const hasCustomLink = isRecord || isView || isObject;
const navigationPath = hasCustomLink
? navigationMenuItem!.link
@@ -63,7 +64,7 @@ export const NavigationDrawerItemForObjectMetadataItem = ({
);
const isActive = hasCustomLink
? (isView ? currentPathWithSearch : currentPath) ===
? (isView || isObject ? currentPathWithSearch : currentPath) ===
navigationMenuItem!.link
: currentPath ===
getAppPath(AppPath.RecordIndexPage, {
@@ -114,11 +115,7 @@ export const NavigationDrawerItemForObjectMetadataItem = ({
)
: getIcon(objectMetadataItem.icon);
const iconThemeColor = !isRecord
? isDefined(navigationMenuItem?.color)
? navigationMenuItem.color
: (getStandardObjectIconColor(objectMetadataItem.nameSingular) ?? 'gray')
: undefined;
const iconThemeColor = !isRecord ? objectNavItemColor : undefined;
const secondaryLabel =
isRecord || isViewWithCustomName
@@ -96,6 +96,7 @@ export const NavigationDrawerSectionForWorkspaceItems = ({
return true;
}
if (
type === NavigationMenuItemType.OBJECT ||
type === NavigationMenuItemType.VIEW ||
type === NavigationMenuItemType.RECORD
) {
@@ -123,7 +124,7 @@ export const NavigationDrawerSectionForWorkspaceItems = ({
? () => {
const type = item.itemType;
const objectMetadataItem =
type === 'view' || type === 'record'
type === 'OBJECT' || type === 'VIEW' || type === 'RECORD'
? getObjectMetadataForNavigationMenuItem(
item as ProcessedNavigationMenuItem,
objectMetadataItems,
@@ -8,6 +8,7 @@ export const OBJECT_METADATA_FRAGMENT = gql`
namePlural
labelSingular
labelPlural
color
description
icon
isCustom
@@ -10,6 +10,7 @@ export const CREATE_ONE_OBJECT_METADATA_ITEM = gql`
labelPlural
description
icon
color
isCustom
isActive
isSearchable
@@ -90,6 +91,7 @@ export const UPDATE_ONE_OBJECT_METADATA_ITEM = gql`
labelPlural
description
icon
color
isCustom
isActive
isSearchable
@@ -112,6 +114,7 @@ export const DELETE_ONE_OBJECT_METADATA_ITEM = gql`
labelPlural
description
icon
color
isCustom
isActive
isSearchable
@@ -27,6 +27,7 @@ export const responseData = {
labelPlural: 'View Filters',
description: '',
icon: '',
color: null,
isCustom: false,
isActive: true,
isSearchable: false,
@@ -10,6 +10,7 @@ export const query = gql`
labelPlural
description
icon
color
isCustom
isActive
isSearchable
@@ -32,6 +33,7 @@ export const responseData = {
labelPlural: '',
description: '',
icon: '',
color: null,
isCustom: false,
isActive: true,
isSearchable: false,
@@ -1,11 +1,8 @@
import { isNonEmptyString } from '@sniptt/guards';
import { NavigationMenuItemStyleIcon } from '@/navigation-menu-item/components/NavigationMenuItemStyleIcon';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/hooks/useNavigationMenuItemsData';
import { getStandardObjectIconColor } from '@/navigation-menu-item/utils/getStandardObjectIconColor';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { indexViewIdFromObjectMetadataItemFamilySelector } from '@/views/states/selectors/indexViewIdFromObjectMetadataItemFamilySelector';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
@@ -14,11 +11,6 @@ export const RecordIndexPageHeaderIcon = ({
}: {
objectMetadataItem?: ObjectMetadataItem;
}) => {
const { workspaceNavigationMenuItems } = useNavigationMenuItemsData();
const coreIndexViewId = useAtomFamilySelectorValue(
indexViewIdFromObjectMetadataItemFamilySelector,
{ objectMetadataItemId: objectMetadataItem?.id ?? '' },
);
const { getIcon } = useIcons();
const ObjectIcon = getIcon(objectMetadataItem?.icon);
@@ -26,17 +18,9 @@ export const RecordIndexPageHeaderIcon = ({
return null;
}
const navItem = isDefined(coreIndexViewId)
? workspaceNavigationMenuItems.find(
(item) => item.viewId === coreIndexViewId,
)
: undefined;
const navigationMenuItemColor = isNonEmptyString(navItem?.color)
? navItem.color
: undefined;
const iconColor =
navigationMenuItemColor ??
getStandardObjectIconColor(objectMetadataItem?.nameSingular ?? '');
const iconColor = isNonEmptyString(objectMetadataItem?.color)
? objectMetadataItem.color
: getStandardObjectIconColor(objectMetadataItem?.nameSingular ?? '');
return (
<NavigationMenuItemStyleIcon
@@ -1,3 +1,7 @@
import { isNonEmptyString } from '@sniptt/guards';
import { NavigationMenuItemStyleIcon } from '@/navigation-menu-item/components/NavigationMenuItemStyleIcon';
import { getStandardObjectIconColor } from '@/navigation-menu-item/utils/getStandardObjectIconColor';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
@@ -9,10 +13,9 @@ import { useRecordShowPagePagination } from '@/object-record/record-show/hooks/u
import { RecordTitleCell } from '@/object-record/record-title-cell/components/RecordTitleCell';
import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledEditableTitleContainer = styled.div`
align-items: center;
@@ -52,7 +55,6 @@ export const ObjectRecordShowPageBreadcrumb = ({
objectLabel: string;
labelIdentifierFieldMetadataItem?: FieldMetadataItem;
}) => {
const { theme } = useContext(ThemeContext);
const { loading } = useFindOneRecord({
objectNameSingular,
objectRecordId,
@@ -82,6 +84,11 @@ export const ObjectRecordShowPageBreadcrumb = ({
objectNameSingular,
objectRecordId,
);
const iconColor = isNonEmptyString(objectMetadataItem?.color)
? objectMetadataItem.color
: getStandardObjectIconColor(objectNameSingular);
if (loading) {
return null;
}
@@ -93,7 +100,12 @@ export const ObjectRecordShowPageBreadcrumb = ({
navigateToIndexView();
}}
>
{isDefined(HeaderIcon) && <HeaderIcon size={theme.icon.size.md} />}
{isDefined(HeaderIcon) && (
<NavigationMenuItemStyleIcon
Icon={HeaderIcon}
color={iconColor ?? undefined}
/>
)}
{objectLabel}
<span>{' / '}</span>
</StyledEditableTitlePrefix>
@@ -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
) {
@@ -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>
)}
@@ -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}
@@ -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,
@@ -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,
@@ -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,
}}
@@ -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;
@@ -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;
@@ -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;
@@ -3,6 +3,7 @@ import type { Manifest } from 'twenty-shared/application';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
FieldMetadataType,
NavigationMenuItemType,
RelationOnDeleteAction,
RelationType,
ViewType,
@@ -1569,19 +1570,22 @@ export const EXPECTED_MANIFEST: Manifest = {
],
navigationMenuItems: [
{
type: NavigationMenuItemType.OBJECT,
position: 2,
universalIdentifier: 'c1a2b3c4-0003-4a7b-8c9d-0e1f2a3b4c5d',
viewUniversalIdentifier: 'b1a2b3c4-0003-4a7b-8c9d-0e1f2a3b4c5d',
targetObjectUniversalIdentifier: 'e1a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5e',
},
{
type: NavigationMenuItemType.OBJECT,
position: 0,
universalIdentifier: 'c1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d',
viewUniversalIdentifier: 'b1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d',
targetObjectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
},
{
type: NavigationMenuItemType.OBJECT,
position: 1,
universalIdentifier: 'c1a2b3c4-0002-4a7b-8c9d-0e1f2a3b4c5d',
viewUniversalIdentifier: 'b1a2b3c4-0002-4a7b-8c9d-0e1f2a3b4c5d',
targetObjectUniversalIdentifier: 'd1a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
},
],
logicFunctions: [
@@ -260,7 +260,8 @@ export class EntityAddCommand {
const navFile = getNavigationMenuItemBaseFile({
name: objectName,
viewUniversalIdentifier,
type: 'OBJECT',
targetObjectUniversalIdentifier: this.lastObjectUniversalIdentifier,
});
const navFolderPath = customPath
@@ -19,25 +19,35 @@ describe('getNavigationMenuItemBaseFile', () => {
expect(result).toContain('position: 0');
});
it('should include viewUniversalIdentifier when provided', () => {
it('should include viewUniversalIdentifier when type is VIEW', () => {
const result = getNavigationMenuItemBaseFile({
name: 'linked-item',
type: 'VIEW',
viewUniversalIdentifier: 'view-uuid-123',
});
expect(result).toContain("type: 'VIEW'");
expect(result).toContain("viewUniversalIdentifier: 'view-uuid-123'");
expect(result).not.toContain('// Link to a view:');
});
it('should include commented link options when viewUniversalIdentifier is not provided', () => {
it('should default to VIEW type when no type is provided', () => {
const result = getNavigationMenuItemBaseFile({
name: 'unlinked-item',
});
expect(result).toContain('// Link to a view:');
expect(result).toContain("type: 'VIEW'");
expect(result).toContain('// viewUniversalIdentifier:');
expect(result).toContain('// targetObjectUniversalIdentifier:');
expect(result).toContain('// link:');
});
it('should include targetObjectUniversalIdentifier when type is OBJECT', () => {
const result = getNavigationMenuItemBaseFile({
name: 'object-item',
type: 'OBJECT',
targetObjectUniversalIdentifier: 'obj-uuid-123',
});
expect(result).toContain("type: 'OBJECT'");
expect(result).toContain("targetObjectUniversalIdentifier: 'obj-uuid-123'");
});
it('should generate unique UUID when not provided', () => {
@@ -4,22 +4,35 @@ import { v4 } from 'uuid';
export const getNavigationMenuItemBaseFile = ({
name,
universalIdentifier = v4(),
type,
viewUniversalIdentifier,
targetObjectUniversalIdentifier,
}: {
name: string;
universalIdentifier?: string;
type?: 'OBJECT' | 'VIEW' | 'LINK' | 'FOLDER';
viewUniversalIdentifier?: string;
targetObjectUniversalIdentifier?: string;
}) => {
const kebabCaseName = kebabCase(name);
const linkConfig = viewUniversalIdentifier
? ` viewUniversalIdentifier: '${viewUniversalIdentifier}',`
: ` // Link to a view:
// viewUniversalIdentifier: '...',
// Or link to an object:
// targetObjectUniversalIdentifier: '...',
// Or link to an external URL:
// link: 'https://example.com',`;
let typeAndConfig: string;
if (type === 'OBJECT' && targetObjectUniversalIdentifier) {
typeAndConfig = ` type: 'OBJECT',
targetObjectUniversalIdentifier: '${targetObjectUniversalIdentifier}',`;
} else if (type === 'VIEW' && viewUniversalIdentifier) {
typeAndConfig = ` type: 'VIEW',
viewUniversalIdentifier: '${viewUniversalIdentifier}',`;
} else if (type === 'LINK') {
typeAndConfig = ` type: 'LINK',
link: 'https://example.com',`;
} else if (type === 'FOLDER') {
typeAndConfig = ` type: 'FOLDER',`;
} else {
typeAndConfig = ` type: 'VIEW',
// viewUniversalIdentifier: '...',`;
}
return `import { defineNavigationMenuItem } from 'twenty-sdk';
@@ -28,7 +41,7 @@ export default defineNavigationMenuItem({
name: '${kebabCaseName}',
icon: 'IconList',
position: 0,
${linkConfig}
${typeAndConfig}
});
`;
};
@@ -485,6 +485,7 @@ type ObjectStandardOverrides {
labelPlural: String
description: String
icon: String
color: String
translations: JSON
}
@@ -499,6 +500,7 @@ type Object {
icon: String
standardOverrides: ObjectStandardOverrides
shortcut: String
color: String
isCustom: Boolean!
isRemote: Boolean!
isActive: Boolean!
@@ -1939,6 +1941,7 @@ type NavigationMenuItem {
targetRecordId: UUID
targetObjectMetadataId: UUID
viewId: UUID
type: NavigationMenuItemType!
name: String
link: String
icon: String
@@ -1951,6 +1954,14 @@ type NavigationMenuItem {
targetRecordIdentifier: RecordIdentifier
}
enum NavigationMenuItemType {
VIEW
FOLDER
LINK
OBJECT
RECORD
}
type LogicFunctionExecutionResult {
"""Execution result in JSON format"""
data: JSON
@@ -2245,6 +2256,7 @@ type MinimalObjectMetadata {
labelSingular: String!
labelPlural: String!
icon: String
color: String
isCustom: Boolean!
isActive: Boolean!
isSystem: Boolean!
@@ -3507,6 +3519,7 @@ input CreateObjectInput {
description: String
icon: String
shortcut: String
color: String
skipNameField: Boolean
isRemote: Boolean
primaryKeyColumnType: String
@@ -3534,6 +3547,7 @@ input UpdateObjectPayload {
description: String
icon: String
shortcut: String
color: String
isActive: Boolean
labelIdentifierFieldMetadataId: UUID
imageIdentifierFieldMetadataId: UUID
@@ -3791,6 +3805,7 @@ input CreateNavigationMenuItemInput {
targetRecordId: UUID
targetObjectMetadataId: UUID
viewId: UUID
type: NavigationMenuItemType!
name: String
link: String
icon: String
@@ -344,6 +344,7 @@ export interface ObjectStandardOverrides {
labelPlural?: Scalars['String']
description?: Scalars['String']
icon?: Scalars['String']
color?: Scalars['String']
translations?: Scalars['JSON']
__typename: 'ObjectStandardOverrides'
}
@@ -359,6 +360,7 @@ export interface Object {
icon?: Scalars['String']
standardOverrides?: ObjectStandardOverrides
shortcut?: Scalars['String']
color?: Scalars['String']
isCustom: Scalars['Boolean']
isRemote: Scalars['Boolean']
isActive: Scalars['Boolean']
@@ -1675,6 +1677,7 @@ export interface NavigationMenuItem {
targetRecordId?: Scalars['UUID']
targetObjectMetadataId?: Scalars['UUID']
viewId?: Scalars['UUID']
type: NavigationMenuItemType
name?: Scalars['String']
link?: Scalars['String']
icon?: Scalars['String']
@@ -1688,6 +1691,8 @@ export interface NavigationMenuItem {
__typename: 'NavigationMenuItem'
}
export type NavigationMenuItemType = 'VIEW' | 'FOLDER' | 'LINK' | 'OBJECT' | 'RECORD'
export interface LogicFunctionExecutionResult {
/** Execution result in JSON format */
data?: Scalars['JSON']
@@ -1901,6 +1906,7 @@ export interface MinimalObjectMetadata {
labelSingular: Scalars['String']
labelPlural: Scalars['String']
icon?: Scalars['String']
color?: Scalars['String']
isCustom: Scalars['Boolean']
isActive: Scalars['Boolean']
isSystem: Scalars['Boolean']
@@ -3247,6 +3253,7 @@ export interface ObjectStandardOverridesGenqlSelection{
labelPlural?: boolean | number
description?: boolean | number
icon?: boolean | number
color?: boolean | number
translations?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
@@ -3263,6 +3270,7 @@ export interface ObjectGenqlSelection{
icon?: boolean | number
standardOverrides?: ObjectStandardOverridesGenqlSelection
shortcut?: boolean | number
color?: boolean | number
isCustom?: boolean | number
isRemote?: boolean | number
isActive?: boolean | number
@@ -4666,6 +4674,7 @@ export interface NavigationMenuItemGenqlSelection{
targetRecordId?: boolean | number
targetObjectMetadataId?: boolean | number
viewId?: boolean | number
type?: boolean | number
name?: boolean | number
link?: boolean | number
icon?: boolean | number
@@ -4900,6 +4909,7 @@ export interface MinimalObjectMetadataGenqlSelection{
labelSingular?: boolean | number
labelPlural?: boolean | number
icon?: boolean | number
color?: boolean | number
isCustom?: boolean | number
isActive?: boolean | number
isSystem?: boolean | number
@@ -6006,7 +6016,7 @@ export interface CreateOneObjectInput {
/** The object to create */
object: CreateObjectInput}
export interface CreateObjectInput {nameSingular: Scalars['String'],namePlural: Scalars['String'],labelSingular: Scalars['String'],labelPlural: Scalars['String'],description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortcut?: (Scalars['String'] | null),skipNameField?: (Scalars['Boolean'] | null),isRemote?: (Scalars['Boolean'] | null),primaryKeyColumnType?: (Scalars['String'] | null),primaryKeyFieldMetadataSettings?: (Scalars['JSON'] | null),isLabelSyncedWithName?: (Scalars['Boolean'] | null)}
export interface CreateObjectInput {nameSingular: Scalars['String'],namePlural: Scalars['String'],labelSingular: Scalars['String'],labelPlural: Scalars['String'],description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortcut?: (Scalars['String'] | null),color?: (Scalars['String'] | null),skipNameField?: (Scalars['Boolean'] | null),isRemote?: (Scalars['Boolean'] | null),primaryKeyColumnType?: (Scalars['String'] | null),primaryKeyFieldMetadataSettings?: (Scalars['JSON'] | null),isLabelSyncedWithName?: (Scalars['Boolean'] | null)}
export interface DeleteOneObjectInput {
/** The id of the record to delete. */
@@ -6016,7 +6026,7 @@ export interface UpdateOneObjectInput {update: UpdateObjectPayload,
/** The id of the object to update */
id: Scalars['UUID']}
export interface UpdateObjectPayload {labelSingular?: (Scalars['String'] | null),labelPlural?: (Scalars['String'] | null),nameSingular?: (Scalars['String'] | null),namePlural?: (Scalars['String'] | null),description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortcut?: (Scalars['String'] | null),isActive?: (Scalars['Boolean'] | null),labelIdentifierFieldMetadataId?: (Scalars['UUID'] | null),imageIdentifierFieldMetadataId?: (Scalars['UUID'] | null),isLabelSyncedWithName?: (Scalars['Boolean'] | null)}
export interface UpdateObjectPayload {labelSingular?: (Scalars['String'] | null),labelPlural?: (Scalars['String'] | null),nameSingular?: (Scalars['String'] | null),namePlural?: (Scalars['String'] | null),description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortcut?: (Scalars['String'] | null),color?: (Scalars['String'] | null),isActive?: (Scalars['Boolean'] | null),labelIdentifierFieldMetadataId?: (Scalars['UUID'] | null),imageIdentifierFieldMetadataId?: (Scalars['UUID'] | null),isLabelSyncedWithName?: (Scalars['Boolean'] | null)}
export interface UpdateViewFieldInput {
/** The id of the view field to update */
@@ -6108,7 +6118,7 @@ export interface CreateAgentInput {name?: (Scalars['String'] | null),label: Scal
export interface UpdateAgentInput {id: Scalars['UUID'],name?: (Scalars['String'] | null),label?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),prompt?: (Scalars['String'] | null),modelId?: (Scalars['String'] | null),roleId?: (Scalars['UUID'] | null),responseFormat?: (Scalars['JSON'] | null),modelConfiguration?: (Scalars['JSON'] | null),evaluationInputs?: (Scalars['String'][] | null)}
export interface CreateNavigationMenuItemInput {userWorkspaceId?: (Scalars['UUID'] | null),targetRecordId?: (Scalars['UUID'] | null),targetObjectMetadataId?: (Scalars['UUID'] | null),viewId?: (Scalars['UUID'] | null),name?: (Scalars['String'] | null),link?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),color?: (Scalars['String'] | null),folderId?: (Scalars['UUID'] | null),position?: (Scalars['Float'] | null)}
export interface CreateNavigationMenuItemInput {userWorkspaceId?: (Scalars['UUID'] | null),targetRecordId?: (Scalars['UUID'] | null),targetObjectMetadataId?: (Scalars['UUID'] | null),viewId?: (Scalars['UUID'] | null),type: NavigationMenuItemType,name?: (Scalars['String'] | null),link?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),color?: (Scalars['String'] | null),folderId?: (Scalars['UUID'] | null),position?: (Scalars['Float'] | null)}
export interface UpdateOneNavigationMenuItemInput {
/** The id of the record to update */
@@ -8694,6 +8704,14 @@ export const enumRelationType = {
MANY_TO_ONE: 'MANY_TO_ONE' as const
}
export const enumNavigationMenuItemType = {
VIEW: 'VIEW' as const,
FOLDER: 'FOLDER' as const,
LINK: 'LINK' as const,
OBJECT: 'OBJECT' as const,
RECORD: 'RECORD' as const
}
export const enumLogicFunctionExecutionStatus = {
IDLE: 'IDLE' as const,
SUCCESS: 'SUCCESS' as const,
File diff suppressed because it is too large Load Diff
@@ -24,6 +24,7 @@ import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metada
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
import { FlatNavigationMenuItemMaps } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item-maps.type';
import { FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type';
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@@ -273,6 +274,7 @@ export class MigrateFavoritesToNavigationMenuItemsCommand extends ActiveOrSuspen
const folderToCreate = {
id: navigationMenuItemId,
type: NavigationMenuItemType.FOLDER,
universalIdentifier: navigationMenuItemId,
userWorkspaceId,
targetRecordId: null,
@@ -308,6 +310,7 @@ export class MigrateFavoritesToNavigationMenuItemsCommand extends ActiveOrSuspen
const workspaceLevelFolderToCreate = {
id: workspaceLevelNavigationMenuItemId,
type: NavigationMenuItemType.FOLDER,
universalIdentifier: workspaceLevelNavigationMenuItemId,
userWorkspaceId: null,
targetRecordId: null,
@@ -516,6 +519,7 @@ export class MigrateFavoritesToNavigationMenuItemsCommand extends ActiveOrSuspen
flatNavigationMenuItemsToCreate.push({
id: favorite.id,
type: NavigationMenuItemType.VIEW,
universalIdentifier,
userWorkspaceId,
targetRecordId: null,
@@ -569,6 +573,7 @@ export class MigrateFavoritesToNavigationMenuItemsCommand extends ActiveOrSuspen
flatNavigationMenuItemsToCreate.push({
id: favorite.id,
type: NavigationMenuItemType.RECORD,
universalIdentifier: favorite.id,
userWorkspaceId,
targetRecordId,
@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddColorToObjectMetadata1773655278357
implements MigrationInterface
{
name = 'AddColorToObjectMetadata1773655278357';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."objectMetadata" ADD "color" text`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."objectMetadata" DROP COLUMN "color"`,
);
}
}
@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddTypeToNavigationMenuItem1773681736596
implements MigrationInterface
{
name = 'AddTypeToNavigationMenuItem1773681736596';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TYPE "core"."navigationMenuItem_type_enum" AS ENUM('VIEW', 'FOLDER', 'LINK', 'OBJECT', 'RECORD')`,
);
await queryRunner.query(
`ALTER TABLE "core"."navigationMenuItem" ADD "type" "core"."navigationMenuItem_type_enum" NOT NULL DEFAULT 'VIEW'`,
);
await queryRunner.query(
`ALTER TABLE "core"."navigationMenuItem" DROP CONSTRAINT "CHK_navigation_menu_item_target_fields"`,
);
await queryRunner.query(
`ALTER TABLE "core"."navigationMenuItem" ADD CONSTRAINT "CHK_navigation_menu_item_type_fields" CHECK (
("type" = 'FOLDER')
OR ("type" = 'OBJECT' AND "targetObjectMetadataId" IS NOT NULL)
OR ("type" = 'VIEW')
OR ("type" = 'RECORD' AND "targetRecordId" IS NOT NULL AND "targetObjectMetadataId" IS NOT NULL)
OR ("type" = 'LINK' AND "link" IS NOT NULL)
)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."navigationMenuItem" DROP CONSTRAINT "CHK_navigation_menu_item_type_fields"`,
);
await queryRunner.query(
`ALTER TABLE "core"."navigationMenuItem" ADD CONSTRAINT "CHK_navigation_menu_item_target_fields" CHECK (("targetRecordId" IS NULL AND "targetObjectMetadataId" IS NULL) OR ("targetRecordId" IS NOT NULL AND "targetObjectMetadataId" IS NOT NULL))`,
);
await queryRunner.query(
`ALTER TABLE "core"."navigationMenuItem" DROP COLUMN "type"`,
);
await queryRunner.query(`DROP TYPE "core"."navigationMenuItem_type_enum"`);
}
}
@@ -135,6 +135,7 @@ export const mockPersonFlatObjectMetadata = (
): FlatObjectMetadata => ({
id: objectMetadataId,
icon: 'Icon123',
color: null,
nameSingular: 'person',
namePlural: 'people',
labelSingular: 'Person',
@@ -1,4 +1,5 @@
import { fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem } from 'src/engine/core-modules/application/application-manifest/converters/from-navigation-menu-item-manifest-to-universal-flat-navigation-menu-item.util';
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
describe('fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem', () => {
const now = '2026-01-01T00:00:00.000Z';
@@ -9,6 +10,7 @@ describe('fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem', () =
fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem({
navigationMenuItemManifest: {
universalIdentifier: 'nav-uuid-1',
type: NavigationMenuItemType.VIEW,
position: 0,
},
applicationUniversalIdentifier,
@@ -34,6 +36,7 @@ describe('fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem', () =
fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem({
navigationMenuItemManifest: {
universalIdentifier: 'nav-uuid-2',
type: NavigationMenuItemType.VIEW,
name: 'Recipes Board',
position: 1,
viewUniversalIdentifier: 'view-uuid-1',
@@ -15,6 +15,7 @@ export const fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem =
return {
universalIdentifier: navigationMenuItemManifest.universalIdentifier,
applicationUniversalIdentifier,
type: navigationMenuItemManifest.type,
name: navigationMenuItemManifest.name ?? null,
icon: navigationMenuItemManifest.icon ?? null,
color: navigationMenuItemManifest.color ?? null,
@@ -18,6 +18,7 @@ export const fromObjectManifestToUniversalFlatObjectMetadata = ({
namePlural: objectManifest.namePlural,
labelSingular: objectManifest.labelSingular,
labelPlural: objectManifest.labelPlural,
color: null,
description: objectManifest.description ?? null,
icon: objectManifest.icon ?? null,
standardOverrides: null,
@@ -6,6 +6,7 @@ import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object
const mockObjectMetadata: FlatObjectMetadata = {
id: '1',
icon: 'Icon123',
color: null,
nameSingular: 'Object',
namePlural: 'Objects',
labelSingular: 'Object',
@@ -9,6 +9,7 @@ export const fromObjectMetadataEntityToObjectMetadataDto = (
updatedAt,
description,
icon,
color,
standardOverrides,
shortcut,
duplicateCriteria,
@@ -22,6 +23,7 @@ export const fromObjectMetadataEntityToObjectMetadataDto = (
updatedAt: new Date(updatedAt),
description: description ?? undefined,
icon: icon ?? undefined,
color: color ?? undefined,
standardOverrides: standardOverrides ?? undefined,
shortcut: shortcut ?? undefined,
duplicateCriteria: duplicateCriteria ?? undefined,
@@ -105,6 +105,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
},
"navigationMenuItem": {
"propertiesToCompare": [
"type",
"position",
"folderUniversalIdentifier",
"name",
@@ -116,6 +117,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
},
"objectMetadata": {
"propertiesToCompare": [
"color",
"description",
"icon",
"isActive",
@@ -149,6 +149,11 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toStringify: false,
universalProperty: undefined,
},
color: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
},
description: {
toCompare: true,
toStringify: false,
@@ -1070,6 +1075,11 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
},
},
navigationMenuItem: {
type: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
},
position: {
toCompare: true,
toStringify: false,
@@ -38,6 +38,7 @@ type Assertions = [
Equal<
keyof FlatEntityUpdate<'objectMetadata'>,
| 'icon'
| 'color'
| 'description'
| 'isActive'
| 'standardOverrides'
@@ -69,6 +69,7 @@ export const fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate =
return {
id,
type: createNavigationMenuItemInput.type,
universalIdentifier: id,
userWorkspaceId: createNavigationMenuItemInput.userWorkspaceId ?? null,
targetRecordId: createNavigationMenuItemInput.targetRecordId ?? null,
@@ -5,6 +5,7 @@ export const fromFlatNavigationMenuItemToNavigationMenuItemDto = (
flatNavigationMenuItem: FlatNavigationMenuItem,
): NavigationMenuItemDTO => ({
id: flatNavigationMenuItem.id,
type: flatNavigationMenuItem.type,
userWorkspaceId: flatNavigationMenuItem.userWorkspaceId ?? undefined,
targetRecordId: flatNavigationMenuItem.targetRecordId ?? undefined,
targetObjectMetadataId:
@@ -75,6 +75,7 @@ export const fromNavigationMenuItemEntityToFlatNavigationMenuItem = ({
return {
id: navigationMenuItemEntity.id,
type: navigationMenuItemEntity.type,
userWorkspaceId: navigationMenuItemEntity.userWorkspaceId,
targetRecordId: navigationMenuItemEntity.targetRecordId,
targetObjectMetadataId: navigationMenuItemEntity.targetObjectMetadataId,
@@ -22,6 +22,7 @@ export const getFlatObjectMetadataMock = (
fieldIds: [],
description: 'default flat object metadata description',
icon: 'icon',
color: null,
id: faker.string.uuid(),
imageIdentifierFieldMetadataId,
isActive: true,
@@ -2,6 +2,7 @@ import { type MetadataEntityPropertyName } from 'src/engine/metadata-modules/fla
export const FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES = {
custom: [
'color',
'description',
'icon',
'isActive',
@@ -12,7 +13,14 @@ export const FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES = {
'nameSingular',
'labelIdentifierFieldMetadataId',
],
standard: ['description', 'icon', 'isActive', 'labelPlural', 'labelSingular'],
standard: [
'color',
'description',
'icon',
'isActive',
'labelPlural',
'labelSingular',
],
} as const satisfies Record<
'standard' | 'custom',
MetadataEntityPropertyName<'objectMetadata'>[]
@@ -74,6 +74,7 @@ export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCre
createdAt,
updatedAt: createdAt,
duplicateCriteria: null,
color: createObjectInput.color ?? null,
description: createObjectInput.description ?? null,
icon: createObjectInput.icon ?? null,
isActive: true,
@@ -7,6 +7,7 @@ export const fromFlatObjectMetadataToObjectMetadataDto = (
const {
createdAt,
updatedAt,
color,
description,
icon,
standardOverrides,
@@ -50,6 +51,7 @@ export const fromFlatObjectMetadataToObjectMetadataDto = (
labelIdentifierFieldMetadataId,
createdAt: new Date(createdAt),
updatedAt: new Date(updatedAt),
color: color ?? undefined,
description: description ?? undefined,
icon: icon ?? undefined,
standardOverrides: standardOverrides ?? undefined,
@@ -24,6 +24,9 @@ export class MinimalObjectMetadataDTO {
@Field({ nullable: true })
icon?: string;
@Field({ nullable: true })
color?: string;
@Field()
isCustom: boolean;
@@ -1,8 +1,15 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNumber, IsOptional, IsString, IsUUID } from 'class-validator';
import {
IsEnum,
IsNumber,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
@InputType()
export class CreateNavigationMenuItemInput {
@@ -26,6 +33,10 @@ export class CreateNavigationMenuItemInput {
@Field(() => UUIDScalarType, { nullable: true })
viewId?: string | null;
@IsEnum(NavigationMenuItemType)
@Field(() => NavigationMenuItemType)
type: NavigationMenuItemType;
@IsOptional()
@IsString()
@Field(() => String, { nullable: true })
@@ -10,6 +10,8 @@ import {
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
import { RecordIdentifierDTO } from './record-identifier.dto';
@ObjectType('NavigationMenuItem')
@@ -39,6 +41,10 @@ export class NavigationMenuItemDTO {
@Field(() => UUIDScalarType, { nullable: true })
viewId?: string | null;
@IsNotEmpty()
@Field(() => NavigationMenuItemType)
type: NavigationMenuItemType;
@IsOptional()
@Field(() => String, { nullable: true })
name?: string | null;
@@ -14,6 +14,7 @@ import {
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
@Entity({ name: 'navigationMenuItem', schema: 'core' })
@@ -35,8 +36,12 @@ import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-enti
'workspaceId',
])
@Check(
'CHK_navigation_menu_item_target_fields',
'("targetRecordId" IS NULL AND "targetObjectMetadataId" IS NULL) OR ("targetRecordId" IS NOT NULL AND "targetObjectMetadataId" IS NOT NULL)',
'CHK_navigation_menu_item_type_fields',
`("type" = 'FOLDER')
OR ("type" = 'OBJECT' AND "targetObjectMetadataId" IS NOT NULL)
OR ("type" = 'VIEW')
OR ("type" = 'RECORD' AND "targetRecordId" IS NOT NULL AND "targetObjectMetadataId" IS NOT NULL)
OR ("type" = 'LINK' AND "link" IS NOT NULL)`,
)
export class NavigationMenuItemEntity
extends SyncableEntity
@@ -78,6 +83,14 @@ export class NavigationMenuItemEntity
@JoinColumn({ name: 'targetObjectMetadataId' })
targetObjectMetadata: Relation<ObjectMetadataEntity> | null;
@Column({
nullable: false,
type: 'enum',
enum: NavigationMenuItemType,
default: NavigationMenuItemType.VIEW,
})
type: NavigationMenuItemType;
@Column({ nullable: true, type: 'text' })
name: string | null;
@@ -0,0 +1,9 @@
import { registerEnumType } from '@nestjs/graphql';
import { NavigationMenuItemType } from 'twenty-shared/types';
registerEnumType(NavigationMenuItemType, {
name: 'NavigationMenuItemType',
});
export { NavigationMenuItemType };
@@ -55,6 +55,11 @@ export class CreateObjectInput {
@Field({ nullable: true })
shortcut?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
color?: string;
@HideField()
dataSourceId: string;
@@ -59,6 +59,9 @@ export class ObjectMetadataDTO {
@Field({ nullable: true })
shortcut?: string;
@Field({ nullable: true })
color?: string;
@FilterableField()
isCustom: boolean;
@@ -26,6 +26,11 @@ export class ObjectStandardOverridesDTO {
@Field(() => String, { nullable: true })
icon?: string | null;
@IsString()
@IsOptional()
@Field(() => String, { nullable: true })
color?: string | null;
@IsJSON()
@IsOptional()
@Field(() => GraphQLJSON, {
@@ -52,6 +52,11 @@ export class UpdateObjectPayload {
@Field({ nullable: true })
shortcut?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
color?: string;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
@@ -60,6 +60,9 @@ export class ObjectMetadataEntity
@Column({ nullable: true, type: 'varchar' })
icon: string | null;
@Column({ nullable: true, type: 'text' })
color: string | null;
@Column({ type: 'jsonb', nullable: true })
standardOverrides: JsonbProperty<ObjectStandardOverridesDTO> | null;
@@ -24,6 +24,7 @@ import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules
import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
import { FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type';
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCreate } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-create-object-input-to-flat-object-metadata-and-flat-field-metadatas-to-create.util';
import { fromDeleteObjectInputToFlatFieldMetadatasToDelete } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-delete-object-input-to-flat-field-metadatas-to-delete.util';
@@ -469,7 +470,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
const flatNavigationMenuItemToCreate =
await this.computeFlatNavigationMenuItemToCreate({
view: flatDefaultViewToCreate,
objectMetadata: flatObjectMetadataToCreate,
workspaceId,
workspaceCustomApplicationId: workspaceCustomFlatApplication.id,
workspaceCustomApplicationUniversalIdentifier:
@@ -683,12 +684,12 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
}
private async computeFlatNavigationMenuItemToCreate({
view,
objectMetadata,
workspaceId,
workspaceCustomApplicationId,
workspaceCustomApplicationUniversalIdentifier,
}: {
view: UniversalFlatView & { id: string };
objectMetadata: { id: string; universalIdentifier: string };
workspaceId: string;
workspaceCustomApplicationId: string;
workspaceCustomApplicationUniversalIdentifier: string;
@@ -714,13 +715,15 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
return {
id: newId,
type: NavigationMenuItemType.OBJECT,
universalIdentifier: newId,
userWorkspaceId: null,
targetRecordId: null,
targetObjectMetadataId: null,
targetObjectMetadataUniversalIdentifier: null,
viewId: view.id,
viewUniversalIdentifier: view.universalIdentifier,
targetObjectMetadataId: objectMetadata.id,
targetObjectMetadataUniversalIdentifier:
objectMetadata.universalIdentifier,
viewId: null,
viewUniversalIdentifier: null,
folderId: null,
folderUniversalIdentifier: null,
name: null,

Some files were not shown because too many files have changed in this diff Show More