[FRONT COMPONENTS] Headless components (#18096)
## Description - Add `isHeadless` field to `FrontComponent` entity so front components can run without rendering UI in the command menu - Introduce headless front component mounting logic: `HeadlessFrontComponentMountRoot` at the application root, `useMountHeadlessFrontComponent`, and `useUnmountHeadlessFrontComponent` hooks to mount/unmount headless components - Expand the SDK with new action components (`Action`, `ActionLink`, `ActionOpenSidePanelPage`) and host communication functions (`openSidePanelPage`, `unmountFrontComponent`) - Move `CommandMenuPages` type to twenty-shared so the SDK can reference it for side panel navigation ## Video QA https://github.com/user-attachments/assets/4f9e3bb1-fcd1-42be-b3f4-a97e80c2add2
This commit is contained in:
+1
-1
@@ -20,7 +20,7 @@ export const FrontComponentRenderer = ({
|
||||
const theme = useTheme();
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const { executionContext, frontComponentHostCommunicationApi } =
|
||||
useFrontComponentExecutionContext();
|
||||
useFrontComponentExecutionContext({ frontComponentId });
|
||||
|
||||
const handleError = useCallback(
|
||||
(error?: Error) => {
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { Suspense, lazy } from 'react';
|
||||
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { mountedHeadlessFrontComponentIdsState } from '@/front-components/states/mountedHeadlessFrontComponentIdsState';
|
||||
|
||||
const FrontComponentRenderer = lazy(() =>
|
||||
import('@/front-components/components/FrontComponentRenderer').then(
|
||||
(module) => ({ default: module.FrontComponentRenderer }),
|
||||
),
|
||||
);
|
||||
|
||||
export const HeadlessFrontComponentMountRoot = () => {
|
||||
const mountedHeadlessFrontComponentIds = useRecoilValue(
|
||||
mountedHeadlessFrontComponentIdsState,
|
||||
);
|
||||
|
||||
if (mountedHeadlessFrontComponentIds.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{[...mountedHeadlessFrontComponentIds].map((frontComponentId) => (
|
||||
<Suspense key={frontComponentId} fallback={null}>
|
||||
<FrontComponentRenderer frontComponentId={frontComponentId} />
|
||||
</Suspense>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+1
@@ -7,6 +7,7 @@ export const FIND_ONE_FRONT_COMPONENT = gql`
|
||||
name
|
||||
applicationId
|
||||
builtComponentChecksum
|
||||
isHeadless
|
||||
applicationTokenPair {
|
||||
applicationAccessToken {
|
||||
token
|
||||
|
||||
+34
-2
@@ -1,4 +1,4 @@
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import {
|
||||
type FrontComponentExecutionContext,
|
||||
type FrontComponentHostCommunicationApi,
|
||||
@@ -6,14 +6,26 @@ import {
|
||||
import { type AppPath } from 'twenty-shared/types';
|
||||
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
|
||||
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
|
||||
import { useUnmountHeadlessFrontComponent } from '@/front-components/hooks/useUnmountHeadlessFrontComponent';
|
||||
import { useIcons } from 'twenty-ui/display';
|
||||
import { useNavigateApp } from '~/hooks/useNavigateApp';
|
||||
|
||||
export const useFrontComponentExecutionContext = (): {
|
||||
export const useFrontComponentExecutionContext = ({
|
||||
frontComponentId,
|
||||
}: {
|
||||
frontComponentId: string;
|
||||
}): {
|
||||
executionContext: FrontComponentExecutionContext;
|
||||
frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi;
|
||||
} => {
|
||||
const currentUser = useRecoilValue(currentUserState);
|
||||
const navigateApp = useNavigateApp();
|
||||
const { navigateCommandMenu } = useNavigateCommandMenu();
|
||||
const setCommandMenuSearchState = useSetRecoilState(commandMenuSearchState);
|
||||
const { getIcon } = useIcons();
|
||||
const unmountHeadlessFrontComponent = useUnmountHeadlessFrontComponent();
|
||||
|
||||
const navigate: FrontComponentHostCommunicationApi['navigate'] = async (
|
||||
to,
|
||||
@@ -29,13 +41,33 @@ export const useFrontComponentExecutionContext = (): {
|
||||
);
|
||||
};
|
||||
|
||||
const openSidePanelPage: FrontComponentHostCommunicationApi['openSidePanelPage'] =
|
||||
async ({ page, pageTitle, pageIcon, shouldResetSearchState }) => {
|
||||
navigateCommandMenu({
|
||||
page,
|
||||
pageTitle,
|
||||
pageIcon: getIcon(pageIcon),
|
||||
});
|
||||
|
||||
if (shouldResetSearchState === true) {
|
||||
setCommandMenuSearchState('');
|
||||
}
|
||||
};
|
||||
|
||||
const executionContext: FrontComponentExecutionContext = {
|
||||
userId: currentUser?.id ?? null,
|
||||
};
|
||||
|
||||
const unmountFrontComponent: FrontComponentHostCommunicationApi['unmountFrontComponent'] =
|
||||
async () => {
|
||||
unmountHeadlessFrontComponent(frontComponentId);
|
||||
};
|
||||
|
||||
const frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi =
|
||||
{
|
||||
navigate,
|
||||
openSidePanelPage,
|
||||
unmountFrontComponent,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
|
||||
import { mountedHeadlessFrontComponentIdsState } from '@/front-components/states/mountedHeadlessFrontComponentIdsState';
|
||||
|
||||
export const useMountHeadlessFrontComponent = () => {
|
||||
const mountHeadlessFrontComponent = useRecoilCallback(
|
||||
({ set }) =>
|
||||
(frontComponentId: string) => {
|
||||
set(mountedHeadlessFrontComponentIdsState, (previousIds) =>
|
||||
new Set(previousIds).add(frontComponentId),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return mountHeadlessFrontComponent;
|
||||
};
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
|
||||
import { mountedHeadlessFrontComponentIdsState } from '@/front-components/states/mountedHeadlessFrontComponentIdsState';
|
||||
|
||||
export const useUnmountHeadlessFrontComponent = () => {
|
||||
const unmountHeadlessFrontComponent = useRecoilCallback(
|
||||
({ set }) =>
|
||||
(frontComponentId: string) => {
|
||||
set(mountedHeadlessFrontComponentIdsState, (previousIds) => {
|
||||
const next = new Set(previousIds);
|
||||
next.delete(frontComponentId);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return unmountHeadlessFrontComponent;
|
||||
};
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const mountedHeadlessFrontComponentIdsState = atom<Set<string>>({
|
||||
key: 'mountedHeadlessFrontComponentIdsState',
|
||||
default: new Set(),
|
||||
});
|
||||
Reference in New Issue
Block a user