Move open-record-in to object metadata and member preference (#23614)

Replaces the per-view "Open in" setting with a two-level model,
following up on #23422 / #23424 and superseding the closed #23446 and
#23457:

- `objectMetadata.openRecordIn`: `SIDE_PANEL` | `RECORD_PAGE` |
`USER_CHOICE` (default `USER_CHOICE`)
- `workspaceMember.openRecordIn`: `SIDE_PANEL` | `RECORD_PAGE` (default
`SIDE_PANEL`), editable in Settings > Experience

The rule: records open where the member prefers, unless the object pins
them, and never in a panel there is no room for (mobile always resolves
to the record page).

## Why

Having the setting on views, objects and members at once was heavy, and
view-level resolution was fragile: a chip rendered outside a view
(notes, front components, kanban cards pointing at another object) had
no view to read from, which is the class of bug behind #23422.
Resolution is now context-free: it needs only the object, the current
member and the viewport, so chips behave identically everywhere by
construction.

## Changes

**Object level**
- New `openRecordIn` enum column on `objectMetadata`, editable through
`updateOneObject` and surfaced in Settings > Data model > Object >
Layout ("Open records in": Member preference / Side Panel / Record Page)
- Standard definitions pin `workflow`, `workflowVersion`, `dashboard`
and `messageCampaign` to the record page (matching the previously
hardcoded list) and `calendarEvent` to the side panel (it has no curated
record page); everything else, including `workflowRun`, follows the
member preference
- Apps can set it in `defineObject()` via the object manifest

**Member level**
- New `openRecordIn` standard field on `workspaceMember`, persisted
through the existing settings path (same as `colorScheme`) and exposed
in Settings > Experience

**View level (deprecated)**
- `view.openRecordIn` is no longer read or written by the frontend; the
"Open in" entry is gone from the view options dropdown
- The column, DTO field and inputs are kept for one release for API
compatibility: the output field carries a `deprecationReason`, the
inputs keep accepting the value with a `Deprecated:` description (NestJS
silently drops input fields that have a `deprecationReason`, which would
have been a breaking change)

**Upgrade (2.27)**
- Fast instance command adds the `objectMetadata.openRecordIn` column
defaulting to `USER_CHOICE`
- Workspace command adds the `workspaceMember.openRecordIn` field
- Workspace command seeds the object column from the standard
definitions (any non-`USER_CHOICE` value), then lifts deliberate
per-view record page choices onto objects the definitions don't pin

**Debt removed**
- `canOpenObjectInSidePanel` hardcoded object list and its test
- `ObjectOptionsDropdownLayoutOpenInContent` and the `layoutOpenIn`
dropdown wiring
- `DefaultViewOpenRecordIn`
- Context-store/view-based resolution in `useResolveOpenRecordIn` (now
reads object metadata + member + viewport)
- Front components no longer guess from the current view: an explicit
side-panel call honours a pinned object and the viewport, nothing else

## Verification

- Ran the three upgrade commands against a live database: column
created, the pinned standard objects seeded per workspace (record page
pins plus calendarEvent to side panel), member field backfilled to
`SIDE_PANEL`; seed rerun is a no-op
- Seed command verified on a simulated pre-upgrade workspace (index view
set to record page on company): pins the standard objects plus company,
idempotent on rerun
- Both packages typecheck and lint clean; affected unit suites and the
application sync, view creation and metadata cache integration specs
pass

---------

Co-authored-by: Thomas des Francs <tdesfrancs@gmail.com>
This commit is contained in:
Félix Malfait
2026-07-31 18:28:55 +02:00
committed by GitHub
parent 6f361a9bf2
commit f663cd3c68
116 changed files with 2395 additions and 1544 deletions
@@ -1,10 +1,9 @@
import { useOpenRecordInSidePanel } from '@/side-panel/hooks/useOpenRecordInSidePanel';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { getLinkToShowPage } from '@/object-metadata/utils/getLinkToShowPage';
import { useRecordChipData } from '@/object-record/hooks/useRecordChipData';
import { useResolveOpenRecordIn } from '@/object-record/record-index/hooks/useResolveOpenRecordIn';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ViewOpenRecordIn } from '~/generated-metadata/graphql';
import { CoreObjectNameSingular, OpenRecordIn } from 'twenty-shared/types';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
import { t } from '@lingui/core/macro';
import { type MouseEvent } from 'react';
@@ -60,7 +59,7 @@ export const RecordChip = ({
const handleCustomClick = isDefined(onClick)
? onClick
: openRecordIn === ViewOpenRecordIn.SIDE_PANEL
: openRecordIn === OpenRecordIn.SIDE_PANEL
? (_event: MouseEvent<HTMLElement>) => {
openRecordInSidePanel({
recordId: record.id,
@@ -1,3 +1,4 @@
import { ObjectOpenRecordIn } from 'twenty-shared/types';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
@@ -56,6 +57,7 @@ const mockObjectMetadataItem: EnrichedObjectMetadataItem = {
isSystem: false,
isUIEditable: true,
isUICreatable: true,
openRecordIn: ObjectOpenRecordIn.USER_CHOICE,
};
const Wrapper = getJestMetadataAndApolloMocksWrapper({
@@ -7,7 +7,6 @@ import { ObjectOptionsDropdownFieldsContent } from '@/object-record/object-optio
import { ObjectOptionsDropdownHiddenFieldsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenFieldsContent';
import { ObjectOptionsDropdownHiddenRecordGroupsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenRecordGroupsContent';
import { ObjectOptionsDropdownLayoutContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownLayoutContent';
import { ObjectOptionsDropdownLayoutOpenInContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownLayoutOpenInContent';
import { ObjectOptionsDropdownMenuContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownMenuContent';
import { ObjectOptionsDropdownRecordGroupFieldsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupFieldsContent';
import { ObjectOptionsDropdownRecordGroupsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupsContent';
@@ -26,8 +25,6 @@ export const ObjectOptionsDropdownContent = () => {
switch (currentContentId) {
case 'layout':
return <ObjectOptionsDropdownLayoutContent />;
case 'layoutOpenIn':
return <ObjectOptionsDropdownLayoutOpenInContent />;
case 'fields':
return <ObjectOptionsDropdownFieldsContent />;
case 'hiddenFields':
@@ -34,8 +34,6 @@ import {
IconCalendarWeek,
IconChevronLeft,
IconLayoutList,
IconLayoutNavbar,
IconLayoutSidebarRight,
IconTable,
} from 'twenty-ui/icon';
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
@@ -43,7 +41,6 @@ import { MenuItem, MenuItemSelect, MenuItemToggle } from 'twenty-ui/navigation';
import {
FeatureFlagKey,
ViewCalendarLayout,
ViewOpenRecordIn,
} from '~/generated-metadata/graphql';
export const ObjectOptionsDropdownLayoutContent = () => {
@@ -129,7 +126,6 @@ export const ObjectOptionsDropdownLayoutContent = () => {
ViewType.TABLE,
...(isDefaultView ? [] : [ViewType.KANBAN]),
...(!isDefaultView ? [ViewType.CALENDAR] : []),
ViewOpenRecordIn.SIDE_PANEL,
...(currentView?.type === ViewType.KANBAN ? ['Group'] : []),
...(currentView?.type === ViewType.CALENDAR
? [
@@ -285,32 +281,6 @@ export const ObjectOptionsDropdownLayoutContent = () => {
</SelectableListItem>
</>
)}
<SelectableListItem
itemId={ViewOpenRecordIn.SIDE_PANEL}
onEnter={() => {
onContentChange('layoutOpenIn');
}}
>
<MenuItem
focused={selectedItemId === ViewOpenRecordIn.SIDE_PANEL}
LeftIcon={
currentView?.openRecordIn === ViewOpenRecordIn.SIDE_PANEL
? IconLayoutSidebarRight
: IconLayoutNavbar
}
text={t`Open in`}
onClick={() => {
onContentChange('layoutOpenIn');
}}
contextualText={
currentView?.openRecordIn === ViewOpenRecordIn.SIDE_PANEL
? t`Side Panel`
: t`Record Page`
}
contextualTextPosition="right"
hasSubMenu
/>
</SelectableListItem>
{currentView?.type === ViewType.KANBAN && (
<SelectableListItem
itemId="Group"
@@ -1,121 +0,0 @@
import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId';
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
import { useUpdateObjectViewOptions } from '@/object-record/object-options-dropdown/hooks/useUpdateObjectViewOptions';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { canOpenObjectInSidePanel } from '@/object-record/utils/canOpenObjectInSidePanel';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { ViewOpenRecordIn } from '~/generated-metadata/graphql';
import { t } from '@lingui/core/macro';
import {
IconChevronLeft,
IconLayoutNavbar,
IconLayoutSidebarRight,
} from 'twenty-ui/icon';
import { MenuItemSelect } from 'twenty-ui/navigation';
export const ObjectOptionsDropdownLayoutOpenInContent = () => {
const { onContentChange } = useObjectOptionsDropdown();
const { currentView } = useGetCurrentViewOnly();
const { setAndPersistOpenRecordIn } = useUpdateObjectViewOptions();
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const canOpenInSidePanel = canOpenObjectInSidePanel(
objectMetadataItem.nameSingular,
);
const selectedItemId = useAtomComponentStateValue(
selectedItemIdComponentState,
OBJECT_OPTIONS_DROPDOWN_ID,
);
const selectableItemIdArray = [
ViewOpenRecordIn.SIDE_PANEL,
ViewOpenRecordIn.RECORD_PAGE,
];
return (
<DropdownContent>
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={() => onContentChange('layout')}
Icon={IconChevronLeft}
/>
}
>
{t`Open in`}
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={OBJECT_OPTIONS_DROPDOWN_ID}
focusId={OBJECT_OPTIONS_DROPDOWN_ID}
selectableItemIdArray={selectableItemIdArray}
>
<SelectableListItem
itemId={ViewOpenRecordIn.SIDE_PANEL}
onEnter={() => {
if (!canOpenInSidePanel) {
return;
}
setAndPersistOpenRecordIn(
ViewOpenRecordIn.SIDE_PANEL,
currentView,
);
}}
>
<MenuItemSelect
LeftIcon={IconLayoutSidebarRight}
text={t`Side Panel`}
selected={
currentView?.openRecordIn === ViewOpenRecordIn.SIDE_PANEL
}
focused={selectedItemId === ViewOpenRecordIn.SIDE_PANEL}
onClick={() => {
if (!canOpenInSidePanel) {
return;
}
setAndPersistOpenRecordIn(
ViewOpenRecordIn.SIDE_PANEL,
currentView,
);
}}
disabled={!canOpenInSidePanel}
/>
</SelectableListItem>
<SelectableListItem
itemId={ViewOpenRecordIn.RECORD_PAGE}
onEnter={() =>
setAndPersistOpenRecordIn(
ViewOpenRecordIn.RECORD_PAGE,
currentView,
)
}
>
<MenuItemSelect
LeftIcon={IconLayoutNavbar}
text={t`Record Page`}
selected={
currentView?.openRecordIn === ViewOpenRecordIn.RECORD_PAGE
}
onClick={() =>
setAndPersistOpenRecordIn(
ViewOpenRecordIn.RECORD_PAGE,
currentView,
)
}
focused={selectedItemId === ViewOpenRecordIn.RECORD_PAGE}
/>
</SelectableListItem>
</SelectableList>
</DropdownMenuItemsContainer>
</DropdownContent>
);
};
@@ -1,7 +1,6 @@
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { useUpdateCurrentView } from '@/views/hooks/useUpdateCurrentView';
import { type GraphQLView } from '@/views/types/GraphQLView';
import { type ViewOpenRecordIn } from '~/generated-metadata/graphql';
import { viewPickerInputNameComponentState } from '@/views/view-picker/states/viewPickerInputNameComponentState';
import { viewPickerSelectedIconComponentState } from '@/views/view-picker/states/viewPickerSelectedIconComponentState';
import { useCallback } from 'react';
@@ -17,16 +16,6 @@ export const useUpdateObjectViewOptions = () => {
const { updateCurrentView } = useUpdateCurrentView();
const setAndPersistOpenRecordIn = useCallback(
(openRecordIn: ViewOpenRecordIn, view: GraphQLView | undefined) => {
if (!view) return;
updateCurrentView({
openRecordIn,
});
},
[updateCurrentView],
);
const setAndPersistViewName = useCallback(
(viewName: string, view: GraphQLView | undefined) => {
if (!view) return;
@@ -50,7 +39,6 @@ export const useUpdateObjectViewOptions = () => {
);
return {
setAndPersistOpenRecordIn,
setAndPersistViewName,
setAndPersistViewIcon,
};
@@ -1,6 +1,5 @@
export type ObjectOptionsContentId =
| 'layout'
| 'layoutOpenIn'
| 'fields'
| 'hiddenFields'
| 'recordGroups'
@@ -1,6 +1,9 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { type RecordGqlOperationOrderBy } from 'twenty-shared/types';
import {
type RecordGqlOperationOrderBy,
ObjectOpenRecordIn,
} from 'twenty-shared/types';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { type RecordSort } from '@/object-record/record-sort/types/RecordSort';
import { type EachTestingContext } from 'twenty-shared/testing';
@@ -40,6 +43,7 @@ const objectMetadataItemWithPositionField: EnrichedObjectMetadataItem = {
isSystem: false,
isUIEditable: true,
isUICreatable: true,
openRecordIn: ObjectOpenRecordIn.USER_CHOICE,
isRemote: false,
isSearchable: false,
labelPlural: 'object1s',
@@ -203,6 +207,7 @@ describe('turnSortsIntoOrderBy', () => {
isSystem: false,
isUIEditable: true,
isUICreatable: true,
openRecordIn: ObjectOpenRecordIn.USER_CHOICE,
isRemote: false,
isSearchable: false,
labelPlural: 'Companies',
@@ -254,6 +259,7 @@ describe('turnSortsIntoOrderBy', () => {
isSystem: false,
isUIEditable: true,
isUICreatable: true,
openRecordIn: ObjectOpenRecordIn.USER_CHOICE,
isRemote: false,
isSearchable: false,
labelPlural: 'People',
@@ -16,7 +16,7 @@ import { useAtomComponentFamilyState } from '@/ui/utilities/state/jotai/hooks/us
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { ViewOpenRecordIn } from '~/generated-metadata/graphql';
import { OpenRecordIn } from 'twenty-shared/types';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -76,7 +76,7 @@ export const RecordBoardCardHeader = () => {
const recordStore = useAtomFamilyStateValue(recordStoreFamilyState, recordId);
const triggerEvent =
openRecordIn === ViewOpenRecordIn.SIDE_PANEL || isTouchDevice
openRecordIn === OpenRecordIn.SIDE_PANEL || isTouchDevice
? 'CLICK'
: 'MOUSE_DOWN';
@@ -1,3 +1,4 @@
import { ObjectOpenRecordIn } from 'twenty-shared/types';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { buildRecordGqlFieldsAggregateForView } from '@/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregateForView';
@@ -40,6 +41,7 @@ describe('buildRecordGqlFieldsAggregateForView', () => {
isSystem: false,
isUIEditable: true,
isUICreatable: true,
openRecordIn: ObjectOpenRecordIn.USER_CHOICE,
isRemote: false,
isSearchable: false,
labelIdentifierFieldMetadataId: '06b33746-5293-4d07-9f7f-ebf5ad396064',
@@ -0,0 +1,3 @@
import { OpenRecordIn } from 'twenty-shared/types';
export const DEFAULT_OPEN_RECORD_IN_PREFERENCE = OpenRecordIn.SIDE_PANEL;
@@ -1,5 +0,0 @@
import { ViewOpenRecordIn } from '~/generated-metadata/graphql';
// Used where no view is in scope, so there is no setting to honour: a record
// chip in the command menu or in a mention has no list behind it.
export const DEFAULT_VIEW_OPEN_RECORD_IN = ViewOpenRecordIn.SIDE_PANEL;
@@ -1,11 +1,11 @@
import { renderHook } from '@testing-library/react';
import { Provider as JotaiProvider } from 'jotai';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useResolveOpenRecordIn } from '@/object-record/record-index/hooks/useResolveOpenRecordIn';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { ViewOpenRecordIn } from '~/generated-metadata/graphql';
import { act } from 'react';
import { ObjectOpenRecordIn, OpenRecordIn } from 'twenty-shared/types';
jest.mock('react-responsive', () => ({
useMediaQuery: jest.fn().mockReturnValue(false),
@@ -22,65 +22,66 @@ const mockUseAtomFamilySelectorValue = jest.requireMock(
'@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue',
).useAtomFamilySelectorValue as jest.Mock;
// Stands in for the views store: only the view the hook actually asks for
// comes back, so a hook reading the wrong view id resolves to nothing.
mockUseAtomFamilySelectorValue.mockImplementation(
(_selector: unknown, { viewId }: { viewId: string }) =>
viewId === 'test-view-id'
? { id: viewId, openRecordIn: ViewOpenRecordIn.RECORD_PAGE }
: undefined,
const setObjectOpenRecordIn = (
openRecordIn: ObjectOpenRecordIn | undefined,
) => {
mockUseAtomFamilySelectorValue.mockImplementation(
(_selector: unknown, { objectName }: { objectName: string }) =>
objectName === 'company' && openRecordIn !== undefined
? { id: 'company-id', nameSingular: 'company', openRecordIn }
: undefined,
);
};
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<JotaiProvider store={jotaiStore}>{children}</JotaiProvider>
);
const WrapperWithoutContextStore = ({
children,
}: {
children: React.ReactNode;
}) => <JotaiProvider store={jotaiStore}>{children}</JotaiProvider>;
const WrapperWithContextStore = ({
children,
}: {
children: React.ReactNode;
}) => (
<JotaiProvider store={jotaiStore}>
<ContextStoreComponentInstanceContext.Provider
value={{ instanceId: 'test-context-store' }}
>
{children}
</ContextStoreComponentInstanceContext.Provider>
</JotaiProvider>
);
const setMemberPreference = (openRecordIn: OpenRecordIn | undefined) => {
act(() => {
jotaiStore.set(
currentWorkspaceMemberState.atom,
openRecordIn === undefined
? null
: ({ id: 'member-id', openRecordIn } as never),
);
});
};
describe('useResolveOpenRecordIn', () => {
afterEach(() => {
jotaiStore.set(
contextStoreCurrentViewIdComponentState.atomFamily({
instanceId: 'test-context-store',
}),
undefined,
);
setMemberPreference(undefined);
});
it('falls back to the default where no context store is mounted', () => {
it('follows the member preference when the object leaves the choice open', () => {
setObjectOpenRecordIn(ObjectOpenRecordIn.USER_CHOICE);
setMemberPreference(OpenRecordIn.RECORD_PAGE);
const { result } = renderHook(() => useResolveOpenRecordIn('company'), {
wrapper: WrapperWithoutContextStore,
wrapper: Wrapper,
});
expect(result.current).toBe(ViewOpenRecordIn.SIDE_PANEL);
expect(result.current).toBe(OpenRecordIn.RECORD_PAGE);
});
it('follows the current view of the surrounding context store', () => {
jotaiStore.set(
contextStoreCurrentViewIdComponentState.atomFamily({
instanceId: 'test-context-store',
}),
'test-view-id',
);
it('lets the object pin its records over the member preference', () => {
setObjectOpenRecordIn(ObjectOpenRecordIn.RECORD_PAGE);
setMemberPreference(OpenRecordIn.SIDE_PANEL);
const { result } = renderHook(() => useResolveOpenRecordIn('company'), {
wrapper: WrapperWithContextStore,
wrapper: Wrapper,
});
expect(result.current).toBe(ViewOpenRecordIn.RECORD_PAGE);
expect(result.current).toBe(OpenRecordIn.RECORD_PAGE);
});
it('falls back to the side panel default with no metadata and no member', () => {
setObjectOpenRecordIn(undefined);
const { result } = renderHook(() => useResolveOpenRecordIn('company'), {
wrapper: Wrapper,
});
expect(result.current).toBe(OpenRecordIn.SIDE_PANEL);
});
});
@@ -9,10 +9,9 @@ import { useRecordIndexContextOrThrow } from '@/object-record/record-index/conte
import { useResolveOpenRecordIn } from '@/object-record/record-index/hooks/useResolveOpenRecordIn';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { ViewOpenRecordIn } from '~/generated-metadata/graphql';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { AppPath, SidePanelPages } from 'twenty-shared/types';
import { AppPath, OpenRecordIn, SidePanelPages } from 'twenty-shared/types';
import { useNavigateApp } from '~/hooks/useNavigateApp';
export const useOpenRecordFromIndexView = () => {
@@ -65,7 +64,7 @@ export const useOpenRecordFromIndexView = () => {
},
);
if (openRecordIn === ViewOpenRecordIn.SIDE_PANEL) {
if (openRecordIn === OpenRecordIn.SIDE_PANEL) {
openRecordInSidePanel({
recordId,
objectNameSingular,
@@ -1,36 +1,29 @@
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { DEFAULT_VIEW_OPEN_RECORD_IN } from '@/object-record/record-index/constants/DefaultViewOpenRecordIn';
import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector';
import { resolveOpenRecordIn } from '@/object-record/record-index/utils/resolveOpenRecordIn';
import { useAvailableComponentInstanceId } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceId';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { viewFromViewIdFamilySelector } from '@/views/states/selectors/viewFromViewIdFamilySelector';
import { useAtomValue } from 'jotai';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { openRecordInPreferenceState } from '@/workspace-member/states/openRecordInPreferenceState';
import { ObjectOpenRecordIn } from 'twenty-shared/types';
import { useIsMobile } from 'twenty-ui/utilities';
export const useResolveOpenRecordIn = (objectNameSingular: string) => {
// Record chips also render where no context store is mounted at all, such as
// a mention inside a note, and those have no view to take a setting from.
const contextStoreInstanceId = useAvailableComponentInstanceId(
ContextStoreComponentInstanceContext,
// Non-throwing on purpose: a chip must not crash while metadata is loading.
const objectMetadataItem = useAtomFamilySelectorValue(
objectMetadataItemFamilySelector,
{
objectName: objectNameSingular,
objectNameType: 'singular',
},
);
const contextStoreCurrentViewId = useAtomValue(
contextStoreCurrentViewIdComponentState.atomFamily({
instanceId: contextStoreInstanceId ?? '',
}),
);
const currentView = useAtomFamilySelectorValue(viewFromViewIdFamilySelector, {
viewId: contextStoreCurrentViewId ?? '',
});
const openRecordInPreference = useAtomStateValue(openRecordInPreferenceState);
const isMobile = useIsMobile();
return resolveOpenRecordIn({
openRecordInViewSetting:
currentView?.openRecordIn ?? DEFAULT_VIEW_OPEN_RECORD_IN,
objectNameSingular,
objectOpenRecordIn:
objectMetadataItem?.openRecordIn ?? ObjectOpenRecordIn.USER_CHOICE,
openRecordInPreference,
canDisplaySidePanel: !isMobile,
});
};
@@ -1,44 +1,59 @@
import { resolveOpenRecordIn } from '@/object-record/record-index/utils/resolveOpenRecordIn';
import { ViewOpenRecordIn } from '~/generated-metadata/graphql';
import { ObjectOpenRecordIn, OpenRecordIn } from 'twenty-shared/types';
const resolve = (
overrides: Partial<Parameters<typeof resolveOpenRecordIn>[0]>,
) =>
resolveOpenRecordIn({
objectOpenRecordIn: ObjectOpenRecordIn.USER_CHOICE,
openRecordInPreference: OpenRecordIn.SIDE_PANEL,
canDisplaySidePanel: true,
...overrides,
});
describe('resolveOpenRecordIn', () => {
it('opens in the side panel when the view asks for it and it can be displayed', () => {
expect(
resolveOpenRecordIn({
openRecordInViewSetting: ViewOpenRecordIn.SIDE_PANEL,
objectNameSingular: 'company',
canDisplaySidePanel: true,
}),
).toBe(ViewOpenRecordIn.SIDE_PANEL);
describe('when the object leaves the choice to the member', () => {
it('follows a side panel preference', () => {
expect(resolve({ openRecordInPreference: OpenRecordIn.SIDE_PANEL })).toBe(
OpenRecordIn.SIDE_PANEL,
);
});
it('follows a record page preference', () => {
expect(
resolve({ openRecordInPreference: OpenRecordIn.RECORD_PAGE }),
).toBe(OpenRecordIn.RECORD_PAGE);
});
});
it('falls back to the record page when there is no room for a side panel', () => {
expect(
resolveOpenRecordIn({
openRecordInViewSetting: ViewOpenRecordIn.SIDE_PANEL,
objectNameSingular: 'company',
canDisplaySidePanel: false,
}),
).toBe(ViewOpenRecordIn.RECORD_PAGE);
describe('when the object pins a destination', () => {
it('ignores the member preference for a pinned record page', () => {
expect(
resolve({
objectOpenRecordIn: ObjectOpenRecordIn.RECORD_PAGE,
openRecordInPreference: OpenRecordIn.SIDE_PANEL,
}),
).toBe(OpenRecordIn.RECORD_PAGE);
});
it('ignores the member preference for a pinned side panel', () => {
expect(
resolve({
objectOpenRecordIn: ObjectOpenRecordIn.SIDE_PANEL,
openRecordInPreference: OpenRecordIn.RECORD_PAGE,
}),
).toBe(OpenRecordIn.SIDE_PANEL);
});
});
it('falls back to the record page for objects without a side panel', () => {
expect(
resolveOpenRecordIn({
openRecordInViewSetting: ViewOpenRecordIn.SIDE_PANEL,
objectNameSingular: 'workflow',
canDisplaySidePanel: true,
}),
).toBe(ViewOpenRecordIn.RECORD_PAGE);
});
it('keeps the record page when the view asks for it', () => {
expect(
resolveOpenRecordIn({
openRecordInViewSetting: ViewOpenRecordIn.RECORD_PAGE,
objectNameSingular: 'company',
canDisplaySidePanel: true,
}),
).toBe(ViewOpenRecordIn.RECORD_PAGE);
describe('when there is no room for a panel', () => {
it.each([ObjectOpenRecordIn.SIDE_PANEL, ObjectOpenRecordIn.USER_CHOICE])(
'falls back to the record page (%s)',
(objectOpenRecordIn) => {
expect(
resolve({ objectOpenRecordIn, canDisplaySidePanel: false }),
).toBe(OpenRecordIn.RECORD_PAGE);
},
);
});
});
@@ -1,22 +1,25 @@
import { canOpenObjectInSidePanel } from '@/object-record/utils/canOpenObjectInSidePanel';
import { ViewOpenRecordIn } from '~/generated-metadata/graphql';
import { ObjectOpenRecordIn, OpenRecordIn } from 'twenty-shared/types';
type ResolveOpenRecordInArgs = {
openRecordInViewSetting: ViewOpenRecordIn;
objectNameSingular: string;
objectOpenRecordIn: ObjectOpenRecordIn;
openRecordInPreference: OpenRecordIn;
canDisplaySidePanel: boolean;
};
// The view setting is an intent, not a decision: the side panel is only a real
// destination when there is room to display it next to the record list, and
// when the object has a side panel to display at all.
export const resolveOpenRecordIn = ({
openRecordInViewSetting,
objectNameSingular,
objectOpenRecordIn,
openRecordInPreference,
canDisplaySidePanel,
}: ResolveOpenRecordInArgs): ViewOpenRecordIn =>
openRecordInViewSetting === ViewOpenRecordIn.SIDE_PANEL &&
canDisplaySidePanel &&
canOpenObjectInSidePanel(objectNameSingular)
? ViewOpenRecordIn.SIDE_PANEL
: ViewOpenRecordIn.RECORD_PAGE;
}: ResolveOpenRecordInArgs): OpenRecordIn => {
const requestedOpenRecordIn =
objectOpenRecordIn === ObjectOpenRecordIn.USER_CHOICE
? openRecordInPreference
: objectOpenRecordIn === ObjectOpenRecordIn.SIDE_PANEL
? OpenRecordIn.SIDE_PANEL
: OpenRecordIn.RECORD_PAGE;
return requestedOpenRecordIn === OpenRecordIn.SIDE_PANEL &&
canDisplaySidePanel
? OpenRecordIn.SIDE_PANEL
: OpenRecordIn.RECORD_PAGE;
};
@@ -15,7 +15,7 @@ import { RECORD_TABLE_COLUMN_MIN_WIDTH } from '@/object-record/record-table/cons
import { RecordTableUpdateContext } from '@/object-record/record-table/contexts/RecordTableUpdateContext';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useIsTouchDevice } from 'twenty-ui/utilities';
import { ViewOpenRecordIn } from '~/generated-metadata/graphql';
import { OpenRecordIn } from 'twenty-shared/types';
type RecordTableContextProviderProps = {
viewBarId: string;
@@ -66,7 +66,7 @@ export const RecordTableContextProvider = ({
// Navigating on mouse down only buys a frame on a real pointer: a tap
// synthesises its mouse events after the finger is already gone.
const triggerEvent =
openRecordIn === ViewOpenRecordIn.SIDE_PANEL || isTouchDevice
openRecordIn === OpenRecordIn.SIDE_PANEL || isTouchDevice
? 'CLICK'
: 'MOUSE_DOWN';
@@ -18,10 +18,9 @@ import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { AppPath } from 'twenty-shared/types';
import { AppPath, OpenRecordIn } from 'twenty-shared/types';
import { findByProperty, isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { ViewOpenRecordIn } from '~/generated-metadata/graphql';
import { useNavigateApp } from '~/hooks/useNavigateApp';
type UseCreateNewIndexRecordProps = {
@@ -92,7 +91,7 @@ export const useCreateNewIndexRecord = ({
...mergedRecordInput,
});
if (openRecordIn === ViewOpenRecordIn.SIDE_PANEL) {
if (openRecordIn === OpenRecordIn.SIDE_PANEL) {
openRecordInSidePanel({
recordId,
objectNameSingular: objectMetadataItem.nameSingular,
@@ -31,7 +31,7 @@ import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentTyp
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { ViewOpenRecordIn } from '~/generated-metadata/graphql';
import { OpenRecordIn } from 'twenty-shared/types';
export type OpenTableCellArgs = {
initialValue?: string;
@@ -122,7 +122,7 @@ export const useOpenRecordTableCell = (recordTableId: string) => {
if ((isFirstColumnCell && !isEmpty) || isNavigating) {
leaveTableFocus();
if (openRecordIn === ViewOpenRecordIn.SIDE_PANEL) {
if (openRecordIn === OpenRecordIn.SIDE_PANEL) {
activateRecordTableRow(cellPosition.row);
unfocusRecordTableRow();
}
@@ -1,16 +0,0 @@
import { canOpenObjectInSidePanel } from '@/object-record/utils/canOpenObjectInSidePanel';
describe('canOpenObjectInSidePanel', () => {
it('should return false for workflow objects', () => {
expect(canOpenObjectInSidePanel('workflow')).toBe(false);
expect(canOpenObjectInSidePanel('workflowVersion')).toBe(false);
expect(canOpenObjectInSidePanel('dashboard')).toBe(false);
expect(canOpenObjectInSidePanel('messageCampaign')).toBe(false);
});
it('should return true for other objects', () => {
expect(canOpenObjectInSidePanel('person')).toBe(true);
expect(canOpenObjectInSidePanel('company')).toBe(true);
expect(canOpenObjectInSidePanel('task')).toBe(true);
});
});
@@ -1,3 +1,4 @@
import { ObjectOpenRecordIn } from 'twenty-shared/types';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { generateAggregateQuery } from '@/object-record/utils/generateAggregateQuery';
@@ -25,6 +26,7 @@ describe('generateAggregateQuery', () => {
isSystem: false,
isUIEditable: true,
isUICreatable: true,
openRecordIn: ObjectOpenRecordIn.USER_CHOICE,
};
const mockRecordGqlFields = {
@@ -69,6 +71,7 @@ describe('generateAggregateQuery', () => {
isSystem: false,
isUIEditable: true,
isUICreatable: true,
openRecordIn: ObjectOpenRecordIn.USER_CHOICE,
};
const mockRecordGqlFields = {
@@ -1,7 +0,0 @@
export const canOpenObjectInSidePanel = (objectNameSingular: string) =>
!(
objectNameSingular === 'workflow' ||
objectNameSingular === 'workflowVersion' ||
objectNameSingular === 'dashboard' ||
objectNameSingular === 'messageCampaign'
);