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:
File diff suppressed because one or more lines are too long
+2
-2
@@ -23,7 +23,7 @@ import { t } from '@lingui/core/macro';
|
||||
import { useStore } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconBrowserMaximize } from 'twenty-ui/icon';
|
||||
import { IconAddressBook } from 'twenty-ui/icon';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { getOsControlSymbol } from 'twenty-ui/utilities';
|
||||
import { useNavigateApp } from '~/hooks/useNavigateApp';
|
||||
@@ -143,7 +143,7 @@ export const RecordShowSidePanelOpenRecordButton = ({
|
||||
variant="primary"
|
||||
accent="blue"
|
||||
size="small"
|
||||
Icon={IconBrowserMaximize}
|
||||
Icon={IconAddressBook}
|
||||
hotkeys={[getOsControlSymbol(), '⏎']}
|
||||
onClick={handleOpenRecord}
|
||||
/>
|
||||
|
||||
+10
-1
@@ -9,6 +9,15 @@ import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainCo
|
||||
import { contextStoreRecordShowParentViewComponentState } from '@/context-store/states/contextStoreRecordShowParentViewComponentState';
|
||||
import { useFrontComponentExecutionContext } from '@/front-components/hooks/useFrontComponentExecutionContext';
|
||||
|
||||
jest.mock('@/object-metadata/hooks/useObjectMetadataItems', () => ({
|
||||
useObjectMetadataItems: () => ({
|
||||
objectMetadataItems: [
|
||||
{ nameSingular: 'workflow', openRecordIn: 'RECORD_PAGE' },
|
||||
{ nameSingular: 'lead', openRecordIn: 'USER_CHOICE' },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockNavigateApp = jest.fn();
|
||||
const mockRequestAccessTokenRefresh = jest.fn();
|
||||
const mockOpenConfirmationModal = jest.fn();
|
||||
@@ -486,7 +495,7 @@ describe('useFrontComponentExecutionContext', () => {
|
||||
expect(mockOpenRecordInSidePanel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fall back to full-page navigation when the object cannot open in the side panel', async () => {
|
||||
it('should fall back to full-page navigation when the object is pinned to the record page', async () => {
|
||||
const { result } = renderUseFrontComponentExecutionContext({
|
||||
frontComponentId: FRONT_COMPONENT_ID,
|
||||
});
|
||||
|
||||
+17
-2
@@ -1,3 +1,5 @@
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { resolveOpenRecordIn } from '@/object-record/record-index/utils/resolveOpenRecordIn';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
@@ -8,6 +10,8 @@ import {
|
||||
} from 'twenty-front-component-renderer';
|
||||
import {
|
||||
AppPath,
|
||||
ObjectOpenRecordIn,
|
||||
OpenRecordIn,
|
||||
SidePanelPages,
|
||||
type EnqueueSnackbarParams,
|
||||
} from 'twenty-shared/types';
|
||||
@@ -21,7 +25,6 @@ import { commandMenuItemProgressFamilyState } from '@/command-menu-item/states/c
|
||||
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
|
||||
import { contextStoreRecordShowParentViewComponentState } from '@/context-store/states/contextStoreRecordShowParentViewComponentState';
|
||||
import { useRequestApplicationTokenRefresh } from '@/front-components/hooks/useRequestApplicationTokenRefresh';
|
||||
import { canOpenObjectInSidePanel } from '@/object-record/utils/canOpenObjectInSidePanel';
|
||||
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
|
||||
import { useOpenComposeEmailInSidePanel } from '@/side-panel/hooks/useOpenComposeEmailInSidePanel';
|
||||
import { useOpenFrontComponentInSidePanel } from '@/side-panel/hooks/useOpenFrontComponentInSidePanel';
|
||||
@@ -73,6 +76,7 @@ export const useFrontComponentExecutionContext = ({
|
||||
const { openComposeEmailInSidePanel } = useOpenComposeEmailInSidePanel();
|
||||
const { openFrontComponentInSidePanel } = useOpenFrontComponentInSidePanel();
|
||||
const isMobile = useIsMobile();
|
||||
const { objectMetadataItems } = useObjectMetadataItems();
|
||||
const setSidePanelSearch = useSetAtomState(sidePanelSearchState);
|
||||
const { getIcon } = useIcons();
|
||||
const unmountEngineCommand = useUnmountCommand();
|
||||
@@ -133,7 +137,18 @@ export const useFrontComponentExecutionContext = ({
|
||||
const { recordId, objectNameSingular, tab, resetNavigationStack } =
|
||||
params;
|
||||
|
||||
if (isMobile || !canOpenObjectInSidePanel(objectNameSingular)) {
|
||||
const objectMetadataItem = objectMetadataItems.find(
|
||||
(item) => item.nameSingular === objectNameSingular,
|
||||
);
|
||||
|
||||
const resolvedOpenRecordIn = resolveOpenRecordIn({
|
||||
objectOpenRecordIn:
|
||||
objectMetadataItem?.openRecordIn ?? ObjectOpenRecordIn.USER_CHOICE,
|
||||
openRecordInPreference: OpenRecordIn.SIDE_PANEL,
|
||||
canDisplaySidePanel: !isMobile,
|
||||
});
|
||||
|
||||
if (resolvedOpenRecordIn === OpenRecordIn.RECORD_PAGE) {
|
||||
if (isDefined(tab)) {
|
||||
setRecordPageActiveTabId({
|
||||
recordId,
|
||||
|
||||
+2
@@ -1,3 +1,4 @@
|
||||
import { toOpenRecordInPreference } from '@/workspace-member/utils/toOpenRecordInPreference';
|
||||
import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair';
|
||||
import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
@@ -129,6 +130,7 @@ export const UserMetadataProviderInitialEffect = () => {
|
||||
return {
|
||||
...workspaceMember,
|
||||
colorScheme: (workspaceMember.colorScheme as ColorScheme) ?? 'System',
|
||||
openRecordIn: toOpenRecordInPreference(workspaceMember.openRecordIn),
|
||||
locale:
|
||||
(workspaceMember.locale as keyof typeof APP_LOCALES) ?? SOURCE_LOCALE,
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ export const OBJECT_METADATA_FRAGMENT = gql`
|
||||
shortcut
|
||||
isLabelSyncedWithName
|
||||
isSearchable
|
||||
openRecordIn
|
||||
duplicateCriteria
|
||||
searchFieldMetadataList {
|
||||
id
|
||||
|
||||
@@ -18,6 +18,7 @@ export const CREATE_ONE_OBJECT_METADATA_ITEM = gql`
|
||||
isUIEditable
|
||||
isUICreatable
|
||||
isSearchable
|
||||
openRecordIn
|
||||
shortcut
|
||||
duplicateCriteria
|
||||
createdAt
|
||||
@@ -205,6 +206,7 @@ export const UPDATE_ONE_OBJECT_METADATA_ITEM = gql`
|
||||
color
|
||||
isActive
|
||||
isSearchable
|
||||
openRecordIn
|
||||
createdAt
|
||||
updatedAt
|
||||
labelIdentifierFieldMetadataId
|
||||
@@ -228,6 +230,7 @@ export const DELETE_ONE_OBJECT_METADATA_ITEM = gql`
|
||||
color
|
||||
isActive
|
||||
isSearchable
|
||||
openRecordIn
|
||||
createdAt
|
||||
updatedAt
|
||||
labelIdentifierFieldMetadataId
|
||||
|
||||
+3
@@ -1,4 +1,5 @@
|
||||
import { gql } from '@apollo/client';
|
||||
import { ObjectOpenRecordIn } from 'twenty-shared/types';
|
||||
|
||||
export const query = gql`
|
||||
mutation DeleteOneObjectMetadataItem($idToDelete: UUID!) {
|
||||
@@ -13,6 +14,7 @@ export const query = gql`
|
||||
color
|
||||
isActive
|
||||
isSearchable
|
||||
openRecordIn
|
||||
createdAt
|
||||
updatedAt
|
||||
labelIdentifierFieldMetadataId
|
||||
@@ -36,6 +38,7 @@ export const responseData = {
|
||||
color: null,
|
||||
isActive: true,
|
||||
isSearchable: false,
|
||||
openRecordIn: ObjectOpenRecordIn.USER_CHOICE,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
labelIdentifierFieldMetadataId: '20202020-72ba-4e11-a36d-e17b544541e1',
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
@@ -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({
|
||||
|
||||
-3
@@ -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':
|
||||
|
||||
-30
@@ -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"
|
||||
|
||||
-121
@@ -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>
|
||||
);
|
||||
};
|
||||
-12
@@ -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
@@ -1,6 +1,5 @@
|
||||
export type ObjectOptionsContentId =
|
||||
| 'layout'
|
||||
| 'layoutOpenIn'
|
||||
| 'fields'
|
||||
| 'hiddenFields'
|
||||
| 'recordGroups'
|
||||
|
||||
+7
-1
@@ -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',
|
||||
|
||||
+2
-2
@@ -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';
|
||||
|
||||
|
||||
+2
@@ -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',
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import { OpenRecordIn } from 'twenty-shared/types';
|
||||
|
||||
export const DEFAULT_OPEN_RECORD_IN_PREFERENCE = OpenRecordIn.SIDE_PANEL;
|
||||
-5
@@ -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;
|
||||
+48
-47
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+2
-3
@@ -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,
|
||||
|
||||
+15
-22
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
+50
-35
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+18
-15
@@ -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;
|
||||
};
|
||||
|
||||
+2
-2
@@ -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';
|
||||
|
||||
|
||||
+2
-3
@@ -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,
|
||||
|
||||
+2
-2
@@ -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();
|
||||
}
|
||||
|
||||
-16
@@ -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);
|
||||
});
|
||||
});
|
||||
+3
@@ -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'
|
||||
);
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { SettingsAccountsRadioSettingsCard } from '@/settings/accounts/components/SettingsAccountsRadioSettingsCard';
|
||||
import { SettingsAccountsVisibilityIcon } from '@/settings/accounts/components/SettingsAccountsVisibilityIcon';
|
||||
import { SettingsRadioSettingsCard } from '@/settings/components/SettingsRadioSettingsCard';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { CalendarChannelVisibility } from '~/generated/graphql';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
@@ -13,7 +13,7 @@ type SettingsAccountsEventVisibilitySettingsCardProps = {
|
||||
|
||||
const StyledCardMediaContainer = styled.div`
|
||||
> * {
|
||||
height: ${themeCssVariables.spacing[6]};
|
||||
height: ${themeCssVariables.spacing[8]};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -44,7 +44,7 @@ export const SettingsAccountsEventVisibilitySettingsCard = ({
|
||||
onChange,
|
||||
value = CalendarChannelVisibility.SHARE_EVERYTHING,
|
||||
}: SettingsAccountsEventVisibilitySettingsCardProps) => (
|
||||
<SettingsAccountsRadioSettingsCard
|
||||
<SettingsRadioSettingsCard
|
||||
name="event-visibility"
|
||||
options={eventSettingsVisibilityOptions}
|
||||
value={value}
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { SettingsAccountsMessageAutoCreationIcon } from '@/settings/accounts/components/SettingsAccountsMessageAutoCreationIcon';
|
||||
import { SettingsAccountsRadioSettingsCard } from '@/settings/accounts/components/SettingsAccountsRadioSettingsCard';
|
||||
import { SettingsRadioSettingsCard } from '@/settings/components/SettingsRadioSettingsCard';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { MessageChannelContactAutoCreationPolicy } from 'twenty-shared/types';
|
||||
|
||||
@@ -40,7 +40,7 @@ export const SettingsAccountsMessageAutoCreationCard = ({
|
||||
onChange,
|
||||
value = MessageChannelContactAutoCreationPolicy.SENT_AND_RECEIVED,
|
||||
}: SettingsAccountsMessageAutoCreationCardProps) => (
|
||||
<SettingsAccountsRadioSettingsCard
|
||||
<SettingsRadioSettingsCard
|
||||
name="message-auto-creation"
|
||||
options={autoCreationOptions}
|
||||
value={value}
|
||||
|
||||
+30
-12
@@ -1,6 +1,7 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { IconArrowDown, IconArrowUp } from 'twenty-ui/icon';
|
||||
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { themeCssVariables, useTheme } from 'twenty-ui/theme-constants';
|
||||
|
||||
type SettingsAccountsMessageAutoCreationIconProps = {
|
||||
className?: string;
|
||||
@@ -12,32 +13,49 @@ const StyledIconContainer = styled.div`
|
||||
align-items: stretch;
|
||||
border: 2px solid ${themeCssVariables.border.color.medium};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
box-sizing: border-box;
|
||||
color: ${themeCssVariables.font.color.light};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing['0.5']};
|
||||
height: ${themeCssVariables.spacing[8]};
|
||||
height: 40px;
|
||||
justify-content: center;
|
||||
padding: ${themeCssVariables.spacing['0.5']};
|
||||
width: ${themeCssVariables.spacing[6]};
|
||||
width: 32px;
|
||||
`;
|
||||
|
||||
const StyledDirectionSkeleton = styled.div<{ isActive?: boolean }>`
|
||||
align-items: center;
|
||||
background-color: ${({ isActive }) =>
|
||||
isActive
|
||||
? themeCssVariables.accent.accent4060
|
||||
: themeCssVariables.background.quaternary};
|
||||
? themeCssVariables.accent.accent7
|
||||
: themeCssVariables.border.color.medium};
|
||||
border-radius: 1px;
|
||||
height: 24px;
|
||||
color: ${themeCssVariables.font.color.inverted};
|
||||
display: flex;
|
||||
flex: 1 0 0;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
export const SettingsAccountsMessageAutoCreationIcon = ({
|
||||
className,
|
||||
isSentActive,
|
||||
isReceivedActive,
|
||||
}: SettingsAccountsMessageAutoCreationIconProps) => (
|
||||
<StyledIconContainer className={className}>
|
||||
<StyledDirectionSkeleton isActive={isSentActive} />
|
||||
<StyledDirectionSkeleton isActive={isReceivedActive} />
|
||||
</StyledIconContainer>
|
||||
);
|
||||
}: SettingsAccountsMessageAutoCreationIconProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<StyledIconContainer className={className}>
|
||||
<StyledDirectionSkeleton isActive={isSentActive}>
|
||||
<IconArrowUp size={theme.icon.size.sm} stroke={theme.icon.stroke.md} />
|
||||
</StyledDirectionSkeleton>
|
||||
<StyledDirectionSkeleton isActive={isReceivedActive}>
|
||||
<IconArrowDown
|
||||
size={theme.icon.size.sm}
|
||||
stroke={theme.icon.stroke.md}
|
||||
/>
|
||||
</StyledDirectionSkeleton>
|
||||
</StyledIconContainer>
|
||||
);
|
||||
};
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { SettingsAccountsMessageFoldersCard } from '@/settings/accounts/components/message-folders/SettingsAccountsMessageFoldersCard';
|
||||
import { SettingsAccountsMessageFolderIcon } from '@/settings/accounts/components/SettingsAccountsMessageFolderIcon';
|
||||
import { SettingsAccountsRadioSettingsCard } from '@/settings/accounts/components/SettingsAccountsRadioSettingsCard';
|
||||
import { SettingsRadioSettingsCard } from '@/settings/components/SettingsRadioSettingsCard';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { MessageFolderImportPolicy } from 'twenty-shared/types';
|
||||
|
||||
@@ -37,7 +37,7 @@ export const SettingsAccountsMessageFolderCard = ({
|
||||
onChange,
|
||||
value = MessageFolderImportPolicy.SELECTED_FOLDERS,
|
||||
}: SettingsAccountsMessageFolderCardProps) => (
|
||||
<SettingsAccountsRadioSettingsCard
|
||||
<SettingsRadioSettingsCard
|
||||
name="message-folder-import-policy"
|
||||
options={INBOX_SETTINGS_VISIBILITY_OPTIONS}
|
||||
value={value}
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { SettingsAccountsRadioSettingsCard } from '@/settings/accounts/components/SettingsAccountsRadioSettingsCard';
|
||||
import { SettingsAccountsVisibilityIcon } from '@/settings/accounts/components/SettingsAccountsVisibilityIcon';
|
||||
import { SettingsRadioSettingsCard } from '@/settings/components/SettingsRadioSettingsCard';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { MessageChannelVisibility } from '~/generated/graphql';
|
||||
|
||||
@@ -51,7 +51,7 @@ export const SettingsAccountsMessageVisibilityCard = ({
|
||||
onChange,
|
||||
value = MessageChannelVisibility.SHARE_EVERYTHING,
|
||||
}: SettingsAccountsMessageVisibilityCardProps) => (
|
||||
<SettingsAccountsRadioSettingsCard
|
||||
<SettingsRadioSettingsCard
|
||||
name="message-visibility"
|
||||
options={inboxSettingsVisibilityOptions}
|
||||
value={value}
|
||||
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { Trans } from '@lingui/react';
|
||||
import { type ReactNode } from 'react';
|
||||
import { Radio } from 'twenty-ui/input';
|
||||
import { Card, CardContent } from 'twenty-ui/surfaces';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
type SettingsAccountsRadioSettingsCardProps<Option extends { value: string }> =
|
||||
{
|
||||
onChange: (nextValue: Option['value']) => void;
|
||||
options: Option[];
|
||||
value: Option['value'];
|
||||
name: string;
|
||||
};
|
||||
|
||||
const StyledCardContentContainer = styled.div`
|
||||
> * {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: ${themeCssVariables.background.transparent.lighter};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledOptionHeader = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
const StyledTitle = styled.div`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-weight: ${themeCssVariables.font.weight.medium};
|
||||
margin-bottom: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledDescription = styled.div`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
`;
|
||||
|
||||
const StyledRadioContainer = styled.span`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
`;
|
||||
|
||||
const StyledExpandedContent = styled.div`
|
||||
margin-top: ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
export const SettingsAccountsRadioSettingsCard = <
|
||||
Option extends {
|
||||
cardMedia: ReactNode;
|
||||
description: MessageDescriptor;
|
||||
title: MessageDescriptor;
|
||||
value: string;
|
||||
cardContentExpanded?: ReactNode;
|
||||
},
|
||||
>({
|
||||
onChange,
|
||||
options,
|
||||
value,
|
||||
name,
|
||||
}: SettingsAccountsRadioSettingsCardProps<Option>) => (
|
||||
<Card rounded>
|
||||
{options.map((option, index) => (
|
||||
<StyledCardContentContainer key={option.value}>
|
||||
<CardContent
|
||||
divider={index < options.length - 1}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
<StyledOptionHeader>
|
||||
{option.cardMedia}
|
||||
<div>
|
||||
<StyledTitle>
|
||||
<Trans id={option.title.id} />
|
||||
</StyledTitle>
|
||||
<StyledDescription>
|
||||
<Trans id={option.description.id} />
|
||||
</StyledDescription>
|
||||
</div>
|
||||
<StyledRadioContainer>
|
||||
<Radio
|
||||
name={name}
|
||||
value={option.value}
|
||||
onCheckedChange={() => onChange(option.value)}
|
||||
checked={value === option.value}
|
||||
/>
|
||||
</StyledRadioContainer>
|
||||
</StyledOptionHeader>
|
||||
{isDefined(option.cardContentExpanded) && value === option.value && (
|
||||
<StyledExpandedContent>
|
||||
{option.cardContentExpanded}
|
||||
</StyledExpandedContent>
|
||||
)}
|
||||
</CardContent>
|
||||
</StyledCardContentContainer>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
+6
-5
@@ -15,20 +15,21 @@ const StyledCardMedia = styled.div`
|
||||
align-items: stretch;
|
||||
border: 2px solid ${themeCssVariables.border.color.medium};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
box-sizing: border-box;
|
||||
color: ${themeCssVariables.font.color.light};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing['0.5']};
|
||||
height: ${themeCssVariables.spacing[8]};
|
||||
height: 40px;
|
||||
justify-content: center;
|
||||
padding: ${themeCssVariables.spacing['0.5']};
|
||||
width: ${themeCssVariables.spacing[6]};
|
||||
width: 32px;
|
||||
`;
|
||||
|
||||
const StyledSubjectSkeleton = styled.div<{ isActive?: boolean }>`
|
||||
background-color: ${({ isActive }) =>
|
||||
isActive
|
||||
? themeCssVariables.accent.accent4060
|
||||
? themeCssVariables.accent.accent7
|
||||
: themeCssVariables.background.quaternary};
|
||||
border-radius: 1px;
|
||||
height: 3px;
|
||||
@@ -37,7 +38,7 @@ const StyledSubjectSkeleton = styled.div<{ isActive?: boolean }>`
|
||||
const StyledMetadataSkeleton = styled.div<{ isActive?: boolean }>`
|
||||
background-color: ${({ isActive }) =>
|
||||
isActive
|
||||
? themeCssVariables.accent.accent4060
|
||||
? themeCssVariables.accent.accent7
|
||||
: themeCssVariables.background.quaternary};
|
||||
border-radius: 1px;
|
||||
height: 3px;
|
||||
@@ -47,7 +48,7 @@ const StyledMetadataSkeleton = styled.div<{ isActive?: boolean }>`
|
||||
const StyledBodySkeleton = styled.div<{ isActive?: boolean }>`
|
||||
background-color: ${({ isActive }) =>
|
||||
isActive
|
||||
? themeCssVariables.accent.accent4060
|
||||
? themeCssVariables.accent.accent7
|
||||
: themeCssVariables.background.quaternary};
|
||||
border-radius: ${themeCssVariables.border.radius.xs};
|
||||
flex: 1 0 auto;
|
||||
|
||||
+4
-3
@@ -20,11 +20,12 @@ export const StyledSettingsCardIcon = styled.div`
|
||||
background-color: ${themeCssVariables.background.primary};
|
||||
border: 2px solid ${themeCssVariables.border.color.light};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
height: ${themeCssVariables.spacing[7]};
|
||||
height: ${themeCssVariables.spacing[8]};
|
||||
justify-content: center;
|
||||
min-width: ${themeCssVariables.icon.size.md};
|
||||
width: ${themeCssVariables.spacing[7]};
|
||||
min-width: ${themeCssVariables.spacing[8]};
|
||||
width: ${themeCssVariables.spacing[8]};
|
||||
`;
|
||||
|
||||
export const StyledSettingsCardTitle = styled.div`
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { type KeyboardEvent, type ReactNode } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Radio } from 'twenty-ui/input';
|
||||
import { Card, CardContent } from 'twenty-ui/surfaces';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
type SettingsRadioSettingsCardProps<Option extends { value: string }> = {
|
||||
name: string;
|
||||
onChange: (nextValue: Option['value']) => void;
|
||||
options: Option[];
|
||||
value: Option['value'];
|
||||
};
|
||||
|
||||
const StyledCardContentContainer = styled.div`
|
||||
> * {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: ${themeCssVariables.background.transparent.lighter};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledOptionHeader = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
const StyledTextContainer = styled.div`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const StyledTitle = styled.div`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-weight: ${themeCssVariables.font.weight.medium};
|
||||
margin-bottom: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledDescription = styled.div`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
`;
|
||||
|
||||
const StyledRadioContainer = styled.span`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
`;
|
||||
|
||||
const StyledExpandedContent = styled.div`
|
||||
margin-top: ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
export const SettingsRadioSettingsCard = <
|
||||
Option extends {
|
||||
cardMedia: ReactNode;
|
||||
description: MessageDescriptor;
|
||||
title: MessageDescriptor;
|
||||
value: string;
|
||||
cardContentExpanded?: ReactNode;
|
||||
},
|
||||
>({
|
||||
name,
|
||||
onChange,
|
||||
options,
|
||||
value,
|
||||
}: SettingsRadioSettingsCardProps<Option>) => {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const handleKeyDown = (
|
||||
event: KeyboardEvent<HTMLDivElement>,
|
||||
optionValue: Option['value'],
|
||||
) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
onChange(optionValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card fullWidth rounded role="radiogroup">
|
||||
{options.map((option, index) => {
|
||||
const isSelected = value === option.value;
|
||||
|
||||
return (
|
||||
<StyledCardContentContainer key={option.value}>
|
||||
<CardContent
|
||||
aria-checked={isSelected}
|
||||
divider={index < options.length - 1}
|
||||
onClick={() => onChange(option.value)}
|
||||
onKeyDown={(event) => handleKeyDown(event, option.value)}
|
||||
role="radio"
|
||||
tabIndex={0}
|
||||
>
|
||||
<StyledOptionHeader>
|
||||
{option.cardMedia}
|
||||
<StyledTextContainer>
|
||||
<StyledTitle>{i18n._(option.title)}</StyledTitle>
|
||||
<StyledDescription>
|
||||
{i18n._(option.description)}
|
||||
</StyledDescription>
|
||||
</StyledTextContainer>
|
||||
<StyledRadioContainer
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Radio
|
||||
checked={isSelected}
|
||||
name={name}
|
||||
onCheckedChange={() => onChange(option.value)}
|
||||
value={option.value}
|
||||
/>
|
||||
</StyledRadioContainer>
|
||||
</StyledOptionHeader>
|
||||
{isDefined(option.cardContentExpanded) && isSelected && (
|
||||
<StyledExpandedContent>
|
||||
{option.cardContentExpanded}
|
||||
</StyledExpandedContent>
|
||||
)}
|
||||
</CardContent>
|
||||
</StyledCardContentContainer>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
+13
-4
@@ -4,7 +4,7 @@ import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { AppPath } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconLayoutDashboard, IconReload } from 'twenty-ui/icon';
|
||||
import { IconAddressBook, IconReload } from 'twenty-ui/icon';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
@@ -16,6 +16,7 @@ import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||
import { useResetPageLayoutToDefault } from '@/page-layout/hooks/useResetPageLayoutToDefault';
|
||||
import { recordPageLayoutByObjectMetadataIdFamilySelector } from '@/page-layout/states/selectors/recordPageLayoutByObjectMetadataIdFamilySelector';
|
||||
import { SettingsCard } from '@/settings/components/SettingsCard';
|
||||
import { ObjectOpenRecordInPicker } from '@/settings/data-model/object-details/components/tabs/ObjectOpenRecordInPicker';
|
||||
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
|
||||
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
@@ -91,16 +92,24 @@ export const ObjectLayout = ({ objectMetadataItem }: ObjectLayoutProps) => {
|
||||
<StyledContentContainer>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Customize`}
|
||||
description={t`Customize the layout for this role`}
|
||||
title={t`Record page`}
|
||||
description={t`Customize the workspace record page`}
|
||||
/>
|
||||
<SettingsCard
|
||||
title={t`Customize record page`}
|
||||
Icon={<IconLayoutDashboard size={theme.icon.size.md} />}
|
||||
description={t`Customize how your record page looks.`}
|
||||
Icon={<IconAddressBook size={theme.icon.size.md} />}
|
||||
onClick={handleCustomizeRecordPage}
|
||||
disabled={!hasLayoutsPermission || !isDefined(firstRecord)}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Navigation`}
|
||||
description={t`Where records of this object open`}
|
||||
/>
|
||||
<ObjectOpenRecordInPicker objectMetadataItem={objectMetadataItem} />
|
||||
</Section>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Reset`}
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { SettingsRadioSettingsCard } from '@/settings/components/SettingsRadioSettingsCard';
|
||||
import { OpenRecordInCardMedia } from '@/settings/experience/components/OpenRecordInCardMedia';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { ObjectOpenRecordIn } from 'twenty-shared/types';
|
||||
|
||||
type ObjectOpenRecordInPickerProps = {
|
||||
objectMetadataItem: EnrichedObjectMetadataItem;
|
||||
};
|
||||
|
||||
const objectOpenRecordInOptions = [
|
||||
{
|
||||
value: ObjectOpenRecordIn.USER_CHOICE,
|
||||
title: msg`Member preference`,
|
||||
description: msg`Let each member decide for themselves`,
|
||||
cardMedia: <OpenRecordInCardMedia type="member-preference" />,
|
||||
},
|
||||
{
|
||||
value: ObjectOpenRecordIn.SIDE_PANEL,
|
||||
title: msg`Side panel`,
|
||||
description: msg`Open records alongside the current page`,
|
||||
cardMedia: <OpenRecordInCardMedia type="side-panel" />,
|
||||
},
|
||||
{
|
||||
value: ObjectOpenRecordIn.RECORD_PAGE,
|
||||
title: msg`Full page`,
|
||||
description: msg`Open records on a dedicated page`,
|
||||
cardMedia: <OpenRecordInCardMedia type="full-page" />,
|
||||
},
|
||||
];
|
||||
|
||||
export const ObjectOpenRecordInPicker = ({
|
||||
objectMetadataItem,
|
||||
}: ObjectOpenRecordInPickerProps) => {
|
||||
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
|
||||
|
||||
const handleChange = (openRecordIn: ObjectOpenRecordIn) => {
|
||||
void updateOneObjectMetadataItem({
|
||||
idToUpdate: objectMetadataItem.id,
|
||||
updatePayload: { openRecordIn },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsRadioSettingsCard
|
||||
name="object-open-record-in"
|
||||
onChange={handleChange}
|
||||
options={objectOpenRecordInOptions}
|
||||
value={objectMetadataItem.openRecordIn}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { IconArrowsDiagonal, IconUserCircle } from 'twenty-ui/icon';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
type OpenRecordInCardMediaProps = {
|
||||
type: 'member-preference' | 'side-panel' | 'full-page';
|
||||
};
|
||||
|
||||
const StyledPreviewFrame = styled.div`
|
||||
background-color: ${themeCssVariables.border.color.medium};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
height: 40px;
|
||||
padding: 2px;
|
||||
width: 32px;
|
||||
`;
|
||||
|
||||
const StyledPreviewCanvas = styled.div`
|
||||
background-color: ${themeCssVariables.background.secondary};
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 2px;
|
||||
`;
|
||||
|
||||
const StyledMemberPreferenceCanvas = styled(StyledPreviewCanvas)`
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledMemberPreferenceIcon = styled.div`
|
||||
align-items: center;
|
||||
background-color: ${themeCssVariables.background.secondary};
|
||||
border-radius: ${themeCssVariables.border.radius.rounded};
|
||||
box-sizing: border-box;
|
||||
color: ${themeCssVariables.accent.accent7};
|
||||
corner-shape: round;
|
||||
display: flex;
|
||||
height: 16px;
|
||||
justify-content: center;
|
||||
left: 50%;
|
||||
padding: 1px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 16px;
|
||||
`;
|
||||
|
||||
const StyledSidePanelPreview = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 2px;
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
const StyledSidePanelContent = styled.div`
|
||||
background-color: ${themeCssVariables.border.color.medium};
|
||||
border-radius: 1px;
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const StyledSidePanel = styled.div`
|
||||
background-color: ${themeCssVariables.accent.accent7};
|
||||
border-radius: 1px;
|
||||
width: 6px;
|
||||
`;
|
||||
|
||||
const StyledFullPage = styled.div`
|
||||
align-items: center;
|
||||
background-color: ${themeCssVariables.accent.accent7};
|
||||
border-radius: 1px;
|
||||
color: ${themeCssVariables.font.color.inverted};
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
const SidePanelPreview = () => (
|
||||
<StyledSidePanelPreview>
|
||||
<StyledSidePanelContent />
|
||||
<StyledSidePanel />
|
||||
</StyledSidePanelPreview>
|
||||
);
|
||||
|
||||
export const OpenRecordInCardMedia = ({ type }: OpenRecordInCardMediaProps) => {
|
||||
if (type === 'member-preference') {
|
||||
return (
|
||||
<StyledPreviewFrame>
|
||||
<StyledMemberPreferenceCanvas>
|
||||
<SidePanelPreview />
|
||||
<StyledFullPage />
|
||||
<StyledMemberPreferenceIcon>
|
||||
<IconUserCircle size={14} />
|
||||
</StyledMemberPreferenceIcon>
|
||||
</StyledMemberPreferenceCanvas>
|
||||
</StyledPreviewFrame>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledPreviewFrame>
|
||||
<StyledPreviewCanvas>
|
||||
{type === 'side-panel' ? (
|
||||
<SidePanelPreview />
|
||||
) : (
|
||||
<StyledFullPage>
|
||||
<IconArrowsDiagonal size={14} />
|
||||
</StyledFullPage>
|
||||
)}
|
||||
</StyledPreviewCanvas>
|
||||
</StyledPreviewFrame>
|
||||
);
|
||||
};
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { SettingsRadioSettingsCard } from '@/settings/components/SettingsRadioSettingsCard';
|
||||
import { OpenRecordInCardMedia } from '@/settings/experience/components/OpenRecordInCardMedia';
|
||||
import { useOpenRecordInPreference } from '@/settings/experience/hooks/useOpenRecordInPreference';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { OpenRecordIn } from 'twenty-shared/types';
|
||||
|
||||
const openRecordInPreferenceOptions = [
|
||||
{
|
||||
value: OpenRecordIn.SIDE_PANEL,
|
||||
title: msg`Side panel`,
|
||||
description: msg`Open records alongside the current page`,
|
||||
cardMedia: <OpenRecordInCardMedia type="side-panel" />,
|
||||
},
|
||||
{
|
||||
value: OpenRecordIn.RECORD_PAGE,
|
||||
title: msg`Full page`,
|
||||
description: msg`Open records on a dedicated page`,
|
||||
cardMedia: <OpenRecordInCardMedia type="full-page" />,
|
||||
},
|
||||
];
|
||||
|
||||
export const OpenRecordInPreferencePicker = () => {
|
||||
const { openRecordInPreference, setOpenRecordInPreference } =
|
||||
useOpenRecordInPreference();
|
||||
|
||||
const handleChange = (value: OpenRecordIn) => {
|
||||
void setOpenRecordInPreference(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsRadioSettingsCard
|
||||
name="open-record-in-preference"
|
||||
onChange={handleChange}
|
||||
options={openRecordInPreferenceOptions}
|
||||
value={openRecordInPreference}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { DEFAULT_OPEN_RECORD_IN_PREFERENCE } from '@/object-record/record-index/constants/DefaultOpenRecordInPreference';
|
||||
import { useUpdateWorkspaceMemberSettings } from '@/settings/profile/hooks/useUpdateWorkspaceMemberSettings';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useCallback } from 'react';
|
||||
import { type OpenRecordIn } from 'twenty-shared/types';
|
||||
|
||||
export const useOpenRecordInPreference = () => {
|
||||
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
|
||||
|
||||
const { updateWorkspaceMemberSettings } = useUpdateWorkspaceMemberSettings();
|
||||
|
||||
const openRecordInPreference =
|
||||
currentWorkspaceMember?.openRecordIn ?? DEFAULT_OPEN_RECORD_IN_PREFERENCE;
|
||||
|
||||
const setOpenRecordInPreference = useCallback(
|
||||
async (value: OpenRecordIn) => {
|
||||
if (!currentWorkspaceMember) {
|
||||
return;
|
||||
}
|
||||
|
||||
await updateWorkspaceMemberSettings({
|
||||
workspaceMemberId: currentWorkspaceMember.id,
|
||||
update: {
|
||||
openRecordIn: value,
|
||||
},
|
||||
});
|
||||
},
|
||||
[currentWorkspaceMember, updateWorkspaceMemberSettings],
|
||||
);
|
||||
|
||||
return {
|
||||
openRecordInPreference,
|
||||
setOpenRecordInPreference,
|
||||
};
|
||||
};
|
||||
+7
@@ -2,6 +2,8 @@ import { isNull, isNumber, isString } from '@sniptt/guards';
|
||||
|
||||
import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { type ColorScheme } from '@/workspace-member/types/WorkspaceMember';
|
||||
import { isOpenRecordIn } from '@/workspace-member/utils/toOpenRecordInPreference';
|
||||
import { type OpenRecordIn } from 'twenty-shared/types';
|
||||
import { isDefined, isPlainObject } from 'twenty-shared/utils';
|
||||
import {
|
||||
WorkspaceMemberDateFormatEnum,
|
||||
@@ -18,6 +20,7 @@ export type WorkspaceMemberSettingsUpdateInput = {
|
||||
name?: WorkspaceMemberNameUpdate;
|
||||
jobTitle?: string | null;
|
||||
colorScheme?: string;
|
||||
openRecordIn?: OpenRecordIn;
|
||||
avatarUrl?: string | null;
|
||||
locale?: string;
|
||||
calendarStartDay?: number;
|
||||
@@ -111,6 +114,10 @@ export const mergeWorkspaceMemberSettingsIntoCurrent = (
|
||||
}
|
||||
}
|
||||
|
||||
if ('openRecordIn' in payload && isOpenRecordIn(payload.openRecordIn)) {
|
||||
next = { ...next, openRecordIn: payload.openRecordIn };
|
||||
}
|
||||
|
||||
if ('avatarUrl' in payload) {
|
||||
const value = payload.avatarUrl;
|
||||
if (value === '' || isNull(value)) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type Role, type WorkspaceMember } from '~/generated-metadata/graphql';
|
||||
export type PartialWorkspaceMember = Omit<
|
||||
WorkspaceMember,
|
||||
| 'colorScheme'
|
||||
| 'openRecordIn'
|
||||
| 'locale'
|
||||
| 'timeZone'
|
||||
| 'dateFormat'
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useCallback } from 'react';
|
||||
import { SOURCE_LOCALE, type APP_LOCALES } from 'twenty-shared/translations';
|
||||
import { type ObjectPermissions } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { toOpenRecordInPreference } from '@/workspace-member/utils/toOpenRecordInPreference';
|
||||
import { type ColorScheme } from 'twenty-ui/input';
|
||||
import { useApolloClient } from '@apollo/client/react';
|
||||
import { GetCurrentUserDocument } from '~/generated-metadata/graphql';
|
||||
@@ -88,6 +89,9 @@ export const useLoadCurrentUser = () => {
|
||||
workspaceMember = {
|
||||
...user.workspaceMember,
|
||||
colorScheme: user.workspaceMember?.colorScheme as ColorScheme,
|
||||
openRecordIn: toOpenRecordInPreference(
|
||||
user.workspaceMember?.openRecordIn,
|
||||
),
|
||||
locale: user.workspaceMember?.locale ?? SOURCE_LOCALE,
|
||||
};
|
||||
|
||||
|
||||
+2
@@ -1,3 +1,4 @@
|
||||
import { ObjectOpenRecordIn } from 'twenty-shared/types';
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { WorkflowFieldsMultiSelect } from '@/workflow/components/WorkflowEditUpdateEventFieldsMultiSelect';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
@@ -72,6 +73,7 @@ const mockObjectMetadataItem: EnrichedObjectMetadataItem = {
|
||||
isSystem: false,
|
||||
isUIEditable: true,
|
||||
isUICreatable: true,
|
||||
openRecordIn: ObjectOpenRecordIn.USER_CHOICE,
|
||||
isActive: true,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
|
||||
+1
@@ -8,6 +8,7 @@ export const WORKSPACE_MEMBER_QUERY_FRAGMENT = gql`
|
||||
lastName
|
||||
}
|
||||
colorScheme
|
||||
openRecordIn
|
||||
avatarUrl
|
||||
locale
|
||||
userEmail
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { DEFAULT_OPEN_RECORD_IN_PREFERENCE } from '@/object-record/record-index/constants/DefaultOpenRecordInPreference';
|
||||
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
|
||||
import { type OpenRecordIn } from 'twenty-shared/types';
|
||||
|
||||
// Narrowed so chips don't re-render on unrelated member changes.
|
||||
export const openRecordInPreferenceState = createAtomSelector<OpenRecordIn>({
|
||||
key: 'openRecordInPreferenceState',
|
||||
get: ({ get }) =>
|
||||
get(currentWorkspaceMemberState)?.openRecordIn ??
|
||||
DEFAULT_OPEN_RECORD_IN_PREFERENCE,
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { type OpenRecordIn } from 'twenty-shared/types';
|
||||
import {
|
||||
type WorkspaceMemberDateFormatEnum,
|
||||
type WorkspaceMemberNumberFormatEnum,
|
||||
@@ -17,6 +18,7 @@ export type WorkspaceMember = {
|
||||
avatarUrl?: string | null;
|
||||
locale: string | null;
|
||||
colorScheme: ColorScheme;
|
||||
openRecordIn?: OpenRecordIn;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
userEmail: string;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { DEFAULT_OPEN_RECORD_IN_PREFERENCE } from '@/object-record/record-index/constants/DefaultOpenRecordInPreference';
|
||||
import { OpenRecordIn } from 'twenty-shared/types';
|
||||
|
||||
export const isOpenRecordIn = (value: unknown): value is OpenRecordIn =>
|
||||
value === OpenRecordIn.SIDE_PANEL || value === OpenRecordIn.RECORD_PAGE;
|
||||
|
||||
export const toOpenRecordInPreference = (
|
||||
openRecordIn: string | null | undefined,
|
||||
): OpenRecordIn =>
|
||||
isOpenRecordIn(openRecordIn)
|
||||
? openRecordIn
|
||||
: DEFAULT_OPEN_RECORD_IN_PREFERENCE;
|
||||
+2
@@ -1,3 +1,4 @@
|
||||
import { ObjectOpenRecordIn } from 'twenty-shared/types';
|
||||
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
@@ -244,6 +245,7 @@ const buildObjectMetadataItemsFromMarketplaceApp = (
|
||||
isSearchable: false,
|
||||
isUIEditable: true,
|
||||
isUICreatable: true,
|
||||
openRecordIn: ObjectOpenRecordIn.USER_CHOICE,
|
||||
isLabelSyncedWithName: false,
|
||||
labelIdentifierFieldMetadataId: '',
|
||||
fields,
|
||||
|
||||
+9
@@ -1,5 +1,6 @@
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { FormatPreferencesSettings } from '@/settings/experience/components/FormatPreferencesSettings';
|
||||
import { OpenRecordInPreferencePicker } from '@/settings/experience/components/OpenRecordInPreferencePicker';
|
||||
import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLayout';
|
||||
import { useColorScheme } from '@/ui/theme/hooks/useColorScheme';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
@@ -37,6 +38,14 @@ export const SettingsExperience = () => {
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Navigation`}
|
||||
description={t`Choose where records open by default. Some objects may use a workspace setting`}
|
||||
/>
|
||||
<OpenRecordInPreferencePicker />
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Language`}
|
||||
|
||||
Reference in New Issue
Block a user