f663cd3c68
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>
382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
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';
|
|
import { useRef } from 'react';
|
|
import {
|
|
type FrontComponentExecutionContext,
|
|
type FrontComponentHostCommunicationApi,
|
|
} from 'twenty-front-component-renderer';
|
|
import {
|
|
AppPath,
|
|
ObjectOpenRecordIn,
|
|
OpenRecordIn,
|
|
SidePanelPages,
|
|
type EnqueueSnackbarParams,
|
|
} from 'twenty-shared/types';
|
|
import { type AppLocale } from 'twenty-shared/translations';
|
|
|
|
import { useOpenAskAiPageWithPreprompt } from '@/ai/hooks/useOpenAskAiPageWithPreprompt';
|
|
import { currentUserState } from '@/auth/states/currentUserState';
|
|
import { useCommandMenuConfirmationModal } from '@/command-menu-item/confirmation-modal/hooks/useCommandMenuConfirmationModal';
|
|
import { useUnmountCommand } from '@/command-menu-item/engine-command/hooks/useUnmountEngineCommand';
|
|
import { commandMenuItemProgressFamilyState } from '@/command-menu-item/states/commandMenuItemProgressFamilyState';
|
|
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 { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
|
|
import { useOpenComposeEmailInSidePanel } from '@/side-panel/hooks/useOpenComposeEmailInSidePanel';
|
|
import { useOpenFrontComponentInSidePanel } from '@/side-panel/hooks/useOpenFrontComponentInSidePanel';
|
|
import { useOpenRecordInSidePanel } from '@/side-panel/hooks/useOpenRecordInSidePanel';
|
|
import { useOpenRichTextInSidePanel } from '@/side-panel/hooks/useOpenRichTextInSidePanel';
|
|
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
|
|
import { setRecordPageActiveTabId } from '@/page-layout/utils/setRecordPageActiveTabId';
|
|
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
|
|
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
|
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
|
import { useSetAtomFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomFamilyState';
|
|
import { useStore } from 'jotai';
|
|
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
|
|
import { useIcons } from 'twenty-ui/icon';
|
|
import { useIsMobile } from 'twenty-ui/utilities';
|
|
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
|
|
import { useNavigateApp } from '~/hooks/useNavigateApp';
|
|
|
|
const FRONT_COMPONENT_CLIPBOARD_MAX_LENGTH = 64 * 1024;
|
|
const FRONT_COMPONENT_CLIPBOARD_RATE_LIMIT_MS = 1000;
|
|
const FRONT_COMPONENT_CLIPBOARD_PREVIEW_LENGTH = 30;
|
|
|
|
export const useFrontComponentExecutionContext = ({
|
|
frontComponentId,
|
|
commandMenuItemId,
|
|
selectedRecordIds,
|
|
colorScheme,
|
|
}: {
|
|
frontComponentId: string;
|
|
commandMenuItemId?: string;
|
|
selectedRecordIds?: string[];
|
|
colorScheme: 'light' | 'dark';
|
|
}): {
|
|
executionContext: FrontComponentExecutionContext;
|
|
frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi;
|
|
} => {
|
|
const currentUser = useAtomStateValue(currentUserState);
|
|
const navigateApp = useNavigateApp();
|
|
const store = useStore();
|
|
const { requestAccessTokenRefresh } = useRequestApplicationTokenRefresh({
|
|
frontComponentId,
|
|
});
|
|
const { openConfirmationModal } = useCommandMenuConfirmationModal();
|
|
const { openAskAiPageWithPreprompt } = useOpenAskAiPageWithPreprompt();
|
|
const { navigateSidePanel } = useNavigateSidePanel();
|
|
const { openRecordInSidePanel: openRecordInSidePanelInternal } =
|
|
useOpenRecordInSidePanel();
|
|
const { openRichTextInSidePanel } = useOpenRichTextInSidePanel();
|
|
const { openComposeEmailInSidePanel } = useOpenComposeEmailInSidePanel();
|
|
const { openFrontComponentInSidePanel } = useOpenFrontComponentInSidePanel();
|
|
const isMobile = useIsMobile();
|
|
const { objectMetadataItems } = useObjectMetadataItems();
|
|
const setSidePanelSearch = useSetAtomState(sidePanelSearchState);
|
|
const { getIcon } = useIcons();
|
|
const unmountEngineCommand = useUnmountCommand();
|
|
const {
|
|
enqueueSuccessSnackBar,
|
|
enqueueErrorSnackBar,
|
|
enqueueInfoSnackBar,
|
|
enqueueWarningSnackBar,
|
|
} = useSnackBar();
|
|
const { closeSidePanelMenu } = useSidePanelMenu();
|
|
const { copyToClipboard: copyToClipboardWithSnackbar } = useCopyToClipboard();
|
|
const { t, i18n } = useLingui();
|
|
// oxlint-disable-next-line twenty/no-state-useref
|
|
const lastCopyToClipboardCallAtRef = useRef<number>(Number.NEGATIVE_INFINITY);
|
|
const setCommandMenuItemProgress = useSetAtomFamilyState(
|
|
commandMenuItemProgressFamilyState,
|
|
commandMenuItemId ?? '',
|
|
);
|
|
|
|
const navigate: FrontComponentHostCommunicationApi['navigate'] = async (
|
|
to,
|
|
params,
|
|
queryParams,
|
|
options,
|
|
) => {
|
|
if (to === AppPath.RecordShowPage) {
|
|
const targetObjectNameSingular = (
|
|
params as { objectNameSingular?: string | null } | undefined
|
|
)?.objectNameSingular;
|
|
|
|
const parentViewAtom =
|
|
contextStoreRecordShowParentViewComponentState.atomFamily({
|
|
instanceId: MAIN_CONTEXT_STORE_INSTANCE_ID,
|
|
});
|
|
|
|
const parentView = store.get(parentViewAtom);
|
|
|
|
if (
|
|
isDefined(parentView) &&
|
|
isDefined(targetObjectNameSingular) &&
|
|
parentView.parentViewObjectNameSingular !== targetObjectNameSingular
|
|
) {
|
|
store.set(parentViewAtom, undefined);
|
|
}
|
|
}
|
|
|
|
navigateApp(
|
|
to as AppPath,
|
|
params as Parameters<typeof navigateApp>[1],
|
|
queryParams,
|
|
options,
|
|
);
|
|
};
|
|
|
|
const openSidePanelPage: FrontComponentHostCommunicationApi['openSidePanelPage'] =
|
|
async (params) => {
|
|
if (params.page === SidePanelPages.ViewRecord) {
|
|
const { recordId, objectNameSingular, tab, resetNavigationStack } =
|
|
params;
|
|
|
|
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,
|
|
objectNameSingular,
|
|
tabId: tab,
|
|
store,
|
|
});
|
|
}
|
|
|
|
await navigate(AppPath.RecordShowPage, {
|
|
objectNameSingular,
|
|
objectRecordId: recordId,
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
openRecordInSidePanelInternal({
|
|
recordId,
|
|
objectNameSingular,
|
|
tab,
|
|
resetNavigationStack,
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
if (params.page === SidePanelPages.EditRichText) {
|
|
openRichTextInSidePanel(
|
|
params.recordId,
|
|
params.objectNameSingular,
|
|
params.fieldName,
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
if (params.page === SidePanelPages.ComposeEmail) {
|
|
openComposeEmailInSidePanel({
|
|
connectedAccountId: params.connectedAccountId,
|
|
threadId: params.threadId,
|
|
defaultTo: params.defaultTo,
|
|
defaultSubject: params.defaultSubject,
|
|
defaultInReplyTo: params.defaultInReplyTo,
|
|
pageTitle: params.pageTitle,
|
|
pageIcon: isDefined(params.pageIcon)
|
|
? getIcon(params.pageIcon)
|
|
: undefined,
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
if (params.page === SidePanelPages.ViewFrontComponent) {
|
|
const recordContext =
|
|
isDefined(params.recordId) && isDefined(params.objectNameSingular)
|
|
? {
|
|
recordId: params.recordId,
|
|
objectNameSingular: params.objectNameSingular,
|
|
}
|
|
: undefined;
|
|
|
|
openFrontComponentInSidePanel({
|
|
frontComponentId: params.frontComponentId,
|
|
pageTitle: params.pageTitle,
|
|
pageIcon: getIcon(params.pageIcon),
|
|
resetNavigationStack: params.resetNavigationStack,
|
|
recordContext,
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
if (
|
|
params.page === SidePanelPages.AskAI &&
|
|
isDefined(params.preprompt) &&
|
|
isNonEmptyString(params.preprompt.text)
|
|
) {
|
|
openAskAiPageWithPreprompt({
|
|
text: params.preprompt.text,
|
|
mode: params.preprompt.mode,
|
|
model: params.preprompt.model,
|
|
});
|
|
|
|
if (params.shouldResetSearchState === true) {
|
|
setSidePanelSearch('');
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
navigateSidePanel({
|
|
page: params.page,
|
|
pageTitle: params.pageTitle,
|
|
pageIcon: getIcon(params.pageIcon),
|
|
});
|
|
|
|
if (params.shouldResetSearchState === true) {
|
|
setSidePanelSearch('');
|
|
}
|
|
};
|
|
|
|
const openCommandConfirmationModal: FrontComponentHostCommunicationApi['openCommandConfirmationModal'] =
|
|
async ({
|
|
title,
|
|
subtitle,
|
|
confirmButtonText,
|
|
confirmButtonAccent = 'danger',
|
|
}) => {
|
|
openConfirmationModal({
|
|
caller: { type: 'frontComponent', frontComponentId },
|
|
title,
|
|
subtitle,
|
|
confirmButtonText,
|
|
confirmButtonAccent,
|
|
});
|
|
};
|
|
|
|
const enqueueSnackbar: FrontComponentHostCommunicationApi['enqueueSnackbar'] =
|
|
async ({
|
|
message,
|
|
variant,
|
|
duration,
|
|
detailedMessage,
|
|
dedupeKey,
|
|
}: EnqueueSnackbarParams) => {
|
|
const snackBarOptions = {
|
|
duration,
|
|
detailedMessage,
|
|
dedupeKey,
|
|
};
|
|
|
|
switch (variant) {
|
|
case 'error':
|
|
enqueueErrorSnackBar({ message, options: snackBarOptions });
|
|
break;
|
|
case 'info':
|
|
enqueueInfoSnackBar({ message, options: snackBarOptions });
|
|
break;
|
|
case 'warning':
|
|
enqueueWarningSnackBar({ message, options: snackBarOptions });
|
|
break;
|
|
case 'success':
|
|
enqueueSuccessSnackBar({ message, options: snackBarOptions });
|
|
break;
|
|
default:
|
|
assertUnreachable(variant);
|
|
}
|
|
};
|
|
|
|
const executionContext: FrontComponentExecutionContext = {
|
|
frontComponentId,
|
|
userId: currentUser?.id ?? null,
|
|
recordId: selectedRecordIds?.length === 1 ? selectedRecordIds[0] : null,
|
|
selectedRecordIds: selectedRecordIds ?? [],
|
|
colorScheme,
|
|
// i18n.locale is a Lingui string; the host is always configured with the
|
|
// APP_LOCALES set, so it is a valid AppLocale.
|
|
locale: i18n.locale as AppLocale,
|
|
};
|
|
|
|
const unmountFrontComponent: FrontComponentHostCommunicationApi['unmountFrontComponent'] =
|
|
async () => {
|
|
if (isDefined(commandMenuItemId)) {
|
|
unmountEngineCommand(commandMenuItemId);
|
|
}
|
|
};
|
|
|
|
const closeSidePanel: FrontComponentHostCommunicationApi['closeSidePanel'] =
|
|
async () => {
|
|
closeSidePanelMenu();
|
|
};
|
|
|
|
const updateProgress: FrontComponentHostCommunicationApi['updateProgress'] =
|
|
async (progress) => {
|
|
if (!isDefined(commandMenuItemId)) {
|
|
return;
|
|
}
|
|
|
|
setCommandMenuItemProgress(Math.max(0, Math.min(100, progress)));
|
|
};
|
|
|
|
const copyToClipboard: FrontComponentHostCommunicationApi['copyToClipboard'] =
|
|
async (text) => {
|
|
if (!isNonEmptyString(text)) {
|
|
return;
|
|
}
|
|
|
|
if (text.length > FRONT_COMPONENT_CLIPBOARD_MAX_LENGTH) {
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
if (
|
|
now - lastCopyToClipboardCallAtRef.current <
|
|
FRONT_COMPONENT_CLIPBOARD_RATE_LIMIT_MS
|
|
) {
|
|
return;
|
|
}
|
|
lastCopyToClipboardCallAtRef.current = now;
|
|
|
|
const preview =
|
|
text.length > FRONT_COMPONENT_CLIPBOARD_PREVIEW_LENGTH
|
|
? `${text.slice(0, FRONT_COMPONENT_CLIPBOARD_PREVIEW_LENGTH)}…`
|
|
: text;
|
|
|
|
await copyToClipboardWithSnackbar(
|
|
text,
|
|
t`Application copied "${preview}" to your clipboard`,
|
|
);
|
|
};
|
|
|
|
const frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi =
|
|
{
|
|
navigate,
|
|
requestAccessTokenRefresh,
|
|
openSidePanelPage,
|
|
openCommandConfirmationModal,
|
|
enqueueSnackbar,
|
|
unmountFrontComponent,
|
|
closeSidePanel,
|
|
updateProgress,
|
|
copyToClipboard,
|
|
};
|
|
|
|
return {
|
|
executionContext,
|
|
frontComponentHostCommunicationApi,
|
|
};
|
|
};
|