Introduce standalone page (#19675)

Add support for standalone pages: a new `PageLayout` type
(`STANDALONE_PAGE`) that can be rendered independently at
`/page/:pageLayoutId`, not tied to any record or object context.

- New `STANDALONE_PAGE` page layout type
- New `PAGE_LAYOUT` navigation menu item type: adds a `pageLayoutId`
foreign key to `NavigationMenuItemEntity`, allowing sidebar items to
link directly to standalone pages
- New `GLOBAL_OBJECT_CONTEXT` command menu availability type: separates
object-context-dependent commands (Create Record, Import, Export, See
Deleted, Create View, Hide Deleted) from truly global ones, so
standalone pages only show relevant commands
- Frontend routing & rendering: adds a `/page/:pageLayoutId` route with
its own page component, header, and command menu
- Widget rendering refactor
- Instance commands: two fast 1.22 migrations: `pageLayoutId` column +
`STANDALONE_PAGE` enum, and `GLOBAL_OBJECT_CONTEXT` availability type
enum
- Workspace command: backfills existing command menu items from `GLOBAL`
to `GLOBAL_OBJECT_CONTEXT` where appropriate
- Dev seeds: adds a sample "Star History" standalone page with an iframe
widget for local development
This commit is contained in:
Raphaël Bosi
2026-04-14 17:52:45 +02:00
committed by GitHub
parent 43249d80e8
commit a88d1f4442
122 changed files with 2250 additions and 836 deletions
@@ -1257,6 +1257,7 @@ enum PageLayoutType {
RECORD_INDEX
RECORD_PAGE
DASHBOARD
STANDALONE_PAGE
}
type Analytics {
@@ -1485,6 +1486,7 @@ type NavigationMenuItem {
icon: String
color: String
folderId: UUID
pageLayoutId: UUID
position: Float!
applicationId: UUID
createdAt: DateTime!
@@ -1498,6 +1500,7 @@ enum NavigationMenuItemType {
LINK
OBJECT
RECORD
PAGE_LAYOUT
}
type ObjectRecordEventProperties {
@@ -2717,6 +2720,7 @@ enum EngineComponentKey {
enum CommandMenuItemAvailabilityType {
GLOBAL
GLOBAL_OBJECT_CONTEXT
RECORD_SELECTION
FALLBACK
}
@@ -3740,6 +3744,7 @@ input CreateNavigationMenuItemInput {
icon: String
color: String
folderId: UUID
pageLayoutId: UUID
position: Float
}
@@ -3758,6 +3763,7 @@ input UpdateNavigationMenuItemInput {
link: String
icon: String
color: String
pageLayoutId: UUID
}
"""The `Upload` scalar type represents a file upload."""
@@ -981,7 +981,7 @@ export interface PageLayout {
__typename: 'PageLayout'
}
export type PageLayoutType = 'RECORD_INDEX' | 'RECORD_PAGE' | 'DASHBOARD'
export type PageLayoutType = 'RECORD_INDEX' | 'RECORD_PAGE' | 'DASHBOARD' | 'STANDALONE_PAGE'
export interface Analytics {
/** Boolean that confirms query was dispatched */
@@ -1210,6 +1210,7 @@ export interface NavigationMenuItem {
icon?: Scalars['String']
color?: Scalars['String']
folderId?: Scalars['UUID']
pageLayoutId?: Scalars['UUID']
position: Scalars['Float']
applicationId?: Scalars['UUID']
createdAt: Scalars['DateTime']
@@ -1218,7 +1219,7 @@ export interface NavigationMenuItem {
__typename: 'NavigationMenuItem'
}
export type NavigationMenuItemType = 'VIEW' | 'FOLDER' | 'LINK' | 'OBJECT' | 'RECORD'
export type NavigationMenuItemType = 'VIEW' | 'FOLDER' | 'LINK' | 'OBJECT' | 'RECORD' | 'PAGE_LAYOUT'
export interface ObjectRecordEventProperties {
updatedFields?: Scalars['String'][]
@@ -2368,7 +2369,7 @@ export interface CommandMenuItem {
export type EngineComponentKey = 'NAVIGATE_TO_NEXT_RECORD' | 'NAVIGATE_TO_PREVIOUS_RECORD' | 'CREATE_NEW_RECORD' | 'DELETE_RECORDS' | 'RESTORE_RECORDS' | 'DESTROY_RECORDS' | 'ADD_TO_FAVORITES' | 'REMOVE_FROM_FAVORITES' | 'EXPORT_NOTE_TO_PDF' | 'EXPORT_RECORDS' | 'UPDATE_MULTIPLE_RECORDS' | 'MERGE_MULTIPLE_RECORDS' | 'IMPORT_RECORDS' | 'EXPORT_VIEW' | 'SEE_DELETED_RECORDS' | 'CREATE_NEW_VIEW' | 'HIDE_DELETED_RECORDS' | 'EDIT_RECORD_PAGE_LAYOUT' | 'EDIT_DASHBOARD_LAYOUT' | 'SAVE_DASHBOARD_LAYOUT' | 'CANCEL_DASHBOARD_LAYOUT' | 'DUPLICATE_DASHBOARD' | 'ACTIVATE_WORKFLOW' | 'DEACTIVATE_WORKFLOW' | 'DISCARD_DRAFT_WORKFLOW' | 'TEST_WORKFLOW' | 'SEE_ACTIVE_VERSION_WORKFLOW' | 'SEE_RUNS_WORKFLOW' | 'SEE_VERSIONS_WORKFLOW' | 'ADD_NODE_WORKFLOW' | 'TIDY_UP_WORKFLOW' | 'DUPLICATE_WORKFLOW' | 'SEE_VERSION_WORKFLOW_RUN' | 'SEE_WORKFLOW_WORKFLOW_RUN' | 'STOP_WORKFLOW_RUN' | 'SEE_RUNS_WORKFLOW_VERSION' | 'SEE_WORKFLOW_WORKFLOW_VERSION' | 'USE_AS_DRAFT_WORKFLOW_VERSION' | 'SEE_VERSIONS_WORKFLOW_VERSION' | 'SEARCH_RECORDS' | 'SEARCH_RECORDS_FALLBACK' | 'ASK_AI' | 'VIEW_PREVIOUS_AI_CHATS' | 'NAVIGATION' | 'TRIGGER_WORKFLOW_VERSION' | 'FRONT_COMPONENT_RENDERER' | 'REPLY_TO_EMAIL_THREAD' | 'COMPOSE_EMAIL' | 'GO_TO_PEOPLE' | 'GO_TO_COMPANIES' | 'GO_TO_DASHBOARDS' | 'GO_TO_OPPORTUNITIES' | 'GO_TO_SETTINGS' | 'GO_TO_TASKS' | 'GO_TO_NOTES' | 'GO_TO_WORKFLOWS' | 'GO_TO_RUNS' | 'DELETE_SINGLE_RECORD' | 'DELETE_MULTIPLE_RECORDS' | 'RESTORE_SINGLE_RECORD' | 'RESTORE_MULTIPLE_RECORDS' | 'DESTROY_SINGLE_RECORD' | 'DESTROY_MULTIPLE_RECORDS' | 'EXPORT_FROM_RECORD_INDEX' | 'EXPORT_FROM_RECORD_SHOW' | 'EXPORT_MULTIPLE_RECORDS'
export type CommandMenuItemAvailabilityType = 'GLOBAL' | 'RECORD_SELECTION' | 'FALLBACK'
export type CommandMenuItemAvailabilityType = 'GLOBAL' | 'GLOBAL_OBJECT_CONTEXT' | 'RECORD_SELECTION' | 'FALLBACK'
export type CommandMenuItemPayload = (PathCommandMenuItemPayload | ObjectMetadataCommandMenuItemPayload) & { __isUnion?: true }
@@ -4459,6 +4460,7 @@ export interface NavigationMenuItemGenqlSelection{
icon?: boolean | number
color?: boolean | number
folderId?: boolean | number
pageLayoutId?: boolean | number
position?: boolean | number
applicationId?: boolean | number
createdAt?: boolean | number
@@ -6567,7 +6569,7 @@ export interface AddQuerySubscriptionInput {eventStreamId: Scalars['String'],que
export interface RemoveQueryFromEventStreamInput {eventStreamId: Scalars['String'],queryId: Scalars['String']}
export interface CreateNavigationMenuItemInput {id?: (Scalars['UUID'] | null),userWorkspaceId?: (Scalars['UUID'] | null),targetRecordId?: (Scalars['UUID'] | null),targetObjectMetadataId?: (Scalars['UUID'] | null),viewId?: (Scalars['UUID'] | null),type: NavigationMenuItemType,name?: (Scalars['String'] | null),link?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),color?: (Scalars['String'] | null),folderId?: (Scalars['UUID'] | null),position?: (Scalars['Float'] | null)}
export interface CreateNavigationMenuItemInput {id?: (Scalars['UUID'] | null),userWorkspaceId?: (Scalars['UUID'] | null),targetRecordId?: (Scalars['UUID'] | null),targetObjectMetadataId?: (Scalars['UUID'] | null),viewId?: (Scalars['UUID'] | null),type: NavigationMenuItemType,name?: (Scalars['String'] | null),link?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),color?: (Scalars['String'] | null),folderId?: (Scalars['UUID'] | null),pageLayoutId?: (Scalars['UUID'] | null),position?: (Scalars['Float'] | null)}
export interface UpdateOneNavigationMenuItemInput {
/** The id of the record to update */
@@ -6575,7 +6577,7 @@ id: Scalars['UUID'],
/** The record to update */
update: UpdateNavigationMenuItemInput}
export interface UpdateNavigationMenuItemInput {folderId?: (Scalars['UUID'] | null),position?: (Scalars['Float'] | null),name?: (Scalars['String'] | null),link?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),color?: (Scalars['String'] | null)}
export interface UpdateNavigationMenuItemInput {folderId?: (Scalars['UUID'] | null),position?: (Scalars['Float'] | null),name?: (Scalars['String'] | null),link?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),color?: (Scalars['String'] | null),pageLayoutId?: (Scalars['UUID'] | null)}
export interface CreateViewFilterGroupInput {id?: (Scalars['UUID'] | null),parentViewFilterGroupId?: (Scalars['UUID'] | null),logicalOperator?: (ViewFilterGroupLogicalOperator | null),positionInViewFilterGroup?: (Scalars['Float'] | null),viewId: Scalars['UUID']}
@@ -9405,7 +9407,8 @@ export const enumFieldDisplayMode = {
export const enumPageLayoutType = {
RECORD_INDEX: 'RECORD_INDEX' as const,
RECORD_PAGE: 'RECORD_PAGE' as const,
DASHBOARD: 'DASHBOARD' as const
DASHBOARD: 'DASHBOARD' as const,
STANDALONE_PAGE: 'STANDALONE_PAGE' as const
}
export const enumBillingPlanKey = {
@@ -9444,7 +9447,8 @@ export const enumNavigationMenuItemType = {
FOLDER: 'FOLDER' as const,
LINK: 'LINK' as const,
OBJECT: 'OBJECT' as const,
RECORD: 'RECORD' as const
RECORD: 'RECORD' as const,
PAGE_LAYOUT: 'PAGE_LAYOUT' as const
}
export const enumMetadataEventAction = {
@@ -9682,6 +9686,7 @@ export const enumEngineComponentKey = {
export const enumCommandMenuItemAvailabilityType = {
GLOBAL: 'GLOBAL' as const,
GLOBAL_OBJECT_CONTEXT: 'GLOBAL_OBJECT_CONTEXT' as const,
RECORD_SELECTION: 'RECORD_SELECTION' as const,
FALLBACK: 'FALLBACK' as const
}
@@ -3056,6 +3056,9 @@ export default {
"folderId": [
3
],
"pageLayoutId": [
3
],
"position": [
11
],
@@ -9692,6 +9695,9 @@ export default {
"folderId": [
3
],
"pageLayoutId": [
3
],
"position": [
11
],
@@ -9729,6 +9735,9 @@ export default {
"color": [
1
],
"pageLayoutId": [
3
],
"__typename": [
1
]
File diff suppressed because one or more lines are too long
@@ -2,12 +2,13 @@ import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair';
import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath';
import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo';
import { useQuery } from '@apollo/client/react';
import { useParams } from 'react-router-dom';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { AppPath, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { OnboardingStatus } from '~/generated-metadata/graphql';
import { OnboardingStatus, PageLayoutType } from '~/generated-metadata/graphql';
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
import { usePageChangeEffectNavigateLocation } from '~/hooks/usePageChangeEffectNavigateLocation';
@@ -58,11 +59,23 @@ const setupMockIsOnAWorkspace = (isOnAWorkspace: boolean) => {
});
};
jest.mock('@apollo/client/react');
const setupMockUseQuery = (result?: { data?: unknown; loading?: boolean }) => {
jest.mocked(useQuery).mockReturnValueOnce({
data: result?.data ?? undefined,
loading: result?.loading ?? false,
} as ReturnType<typeof useQuery>);
};
jest.mock('react-router-dom');
const setupMockUseParams = (objectNamePlural?: string) => {
jest
.mocked(useParams)
.mockReturnValueOnce({ objectNamePlural: objectNamePlural ?? '' });
const setupMockUseParams = (
objectNamePlural?: string,
pageLayoutId?: string,
) => {
jest.mocked(useParams).mockReturnValueOnce({
objectNamePlural: objectNamePlural ?? '',
pageLayoutId,
});
};
jest.mock('@/ui/utilities/state/jotai/hooks/useAtomStateValue');
@@ -92,6 +105,8 @@ const testCases: {
objectNamePluralFromMetadata?: string;
verifyEmailRedirectPath?: string;
returnToPath?: string;
pageLayoutId?: string;
useQueryResult?: { data?: unknown; loading?: boolean };
}[] = [
{ loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
{ loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) },
@@ -277,6 +292,20 @@ const testCases: {
{ loc: AppPath.RecordShowPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
{ loc: AppPath.RecordShowPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) },
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp },
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.CreateWorkspace },
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined, pageLayoutId: 'valid-id', useQueryResult: { loading: true } },
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: AppPath.NotFound, pageLayoutId: 'non-existent-id', useQueryResult: { data: { getPageLayout: null }, loading: false } },
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: AppPath.NotFound, pageLayoutId: 'wrong-type-id', useQueryResult: { data: { getPageLayout: { type: PageLayoutType.RECORD_PAGE } }, loading: false } },
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined, pageLayoutId: 'valid-standalone-id', useQueryResult: { data: { getPageLayout: { type: PageLayoutType.STANDALONE_PAGE } }, loading: false } },
{ loc: AppPath.SettingsCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
{ loc: AppPath.SettingsCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
{ loc: AppPath.SettingsCatchAll, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp },
@@ -350,6 +379,8 @@ describe('usePageChangeEffectNavigateLocation', () => {
objectNamePluralFromMetadata,
verifyEmailRedirectPath,
returnToPath,
pageLayoutId,
useQueryResult,
res,
}) => {
setupMockIsMatchingLocation(loc);
@@ -357,7 +388,8 @@ describe('usePageChangeEffectNavigateLocation', () => {
setupMockIsWorkspaceActivationStatusEqualsTo(isWorkspaceSuspended);
setupMockHasAccessTokenPair(hasAccessTokenPair);
setupMockIsOnAWorkspace(isOnAWorkspace ?? true);
setupMockUseParams(objectNamePluralFromParams);
setupMockUseQuery(useQueryResult);
setupMockUseParams(objectNamePluralFromParams, pageLayoutId);
setupMockState(
objectNamePluralFromMetadata,
verifyEmailRedirectPath,
@@ -379,6 +411,12 @@ describe('usePageChangeEffectNavigateLocation', () => {
['nonExistingObjectInParam', 'existingObjectInParam:false'].length +
['caseWithRedirectionToVerifyEmailRedirectPath', 'caseWithout']
.length +
[
'pageLayout:loading',
'pageLayout:missing',
'pageLayout:wrongType',
'pageLayout:validStandalone',
].length +
['returnToPath:verify', 'returnToPath:signInUp', 'returnToPath:index']
.length +
['notOnWorkspace:verify', 'notOnWorkspace:signInUp'].length,
@@ -11,12 +11,17 @@ import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo';
import { isValidReturnToPath } from '@/auth/utils/isValidReturnToPath';
import { useQuery } from '@apollo/client/react';
import { isNonEmptyString } from '@sniptt/guards';
import { useLocation, useParams } from 'react-router-dom';
import { AppPath, SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { OnboardingStatus } from '~/generated-metadata/graphql';
import {
FindOnePageLayoutTypeDocument,
OnboardingStatus,
PageLayoutType,
} from '~/generated-metadata/graphql';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
const readReturnToPathFromUrlSearchParams = (): string | null => {
@@ -39,11 +44,27 @@ export const usePageChangeEffectNavigateLocation = () => {
const someMatchingLocationOf = (appPaths: AppPath[]): boolean =>
appPaths.some((appPath) => isMatchingLocation(location, appPath));
const objectNamePlural = useParams().objectNamePlural ?? '';
const params = useParams();
const objectNamePlural = params.objectNamePlural ?? '';
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const objectMetadataItem = objectMetadataItems?.find(
(objectMetadataItem) => objectMetadataItem.namePlural === objectNamePlural,
);
const pageLayoutId = params.pageLayoutId;
const isOnPageLayoutPage = isMatchingLocation(
location,
AppPath.PageLayoutPage,
);
const { data: pageLayoutData, loading: isPageLayoutLoading } = useQuery(
FindOnePageLayoutTypeDocument,
{
variables: { id: pageLayoutId ?? '' },
skip: !isOnPageLayoutPage || !isDefined(pageLayoutId),
},
);
const verifyEmailRedirectPath = useAtomStateValue(
verifyEmailRedirectPathState,
);
@@ -157,5 +178,15 @@ export const usePageChangeEffectNavigateLocation = () => {
return AppPath.NotFound;
}
if (
isOnPageLayoutPage &&
isDefined(pageLayoutId) &&
!isPageLayoutLoading &&
(!isDefined(pageLayoutData?.getPageLayout) ||
pageLayoutData.getPageLayout.type !== PageLayoutType.STANDALONE_PAGE)
) {
return AppPath.NotFound;
}
return;
};
@@ -6,9 +6,11 @@ import { type BrowsingContext } from '@/ai/types/BrowsingContext';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreCurrentPageTypeComponentState } from '@/context-store/states/contextStoreCurrentPageTypeComponentState';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ContextStorePageType } from 'twenty-shared/types';
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
@@ -23,6 +25,12 @@ export const useGetBrowsingContext = () => {
const getBrowsingContext = useCallback((): BrowsingContext | null => {
const instanceId = MAIN_CONTEXT_STORE_INSTANCE_ID;
const pageType = store.get(
contextStoreCurrentPageTypeComponentState.atomFamily({
instanceId,
}),
);
const viewType = store.get(
contextStoreCurrentViewTypeComponentState.atomFamily({
instanceId,
@@ -45,7 +53,7 @@ export const useGetBrowsingContext = () => {
return null;
}
if (viewType === ContextStoreViewType.ShowPage) {
if (pageType === ContextStorePageType.Record) {
const targetedRecordsRule = store.get(
contextStoreTargetedRecordsRuleComponentState.atomFamily({
instanceId,
@@ -94,6 +94,12 @@ const BookCall = lazy(() =>
})),
);
const StandalonePageLayoutPage = lazy(() =>
import('~/pages/page-layout/StandalonePageLayoutPage').then((module) => ({
default: module.StandalonePageLayoutPage,
})),
);
const NotFound = lazy(() =>
import('~/pages/not-found/NotFound').then((module) => ({
default: module.NotFound,
@@ -220,6 +226,14 @@ export const useCreateAppRouter = (
</LazyRoute>
}
/>
<Route
path={AppPath.PageLayoutPage}
element={
<LazyRoute>
<StandalonePageLayoutPage />
</LazyRoute>
}
/>
<Route
path={AppPath.SettingsCatchAll}
element={
@@ -0,0 +1,110 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { objectPermissionsFamilySelector } from '@/auth/states/objectPermissionsFamilySelector';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { PinnedCommandMenuItemButtons } from '@/command-menu-item/display/components/PinnedCommandMenuItemButtons';
import { CommandMenuItemEditButton } from '@/command-menu-item/edit/components/CommandMenuItemEditButton';
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/utils/doesCommandMenuItemMatchObjectMetadataId';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useStore } from 'jotai';
import { useMemo } from 'react';
import {
ContextStorePageType,
type CommandMenuContextApi,
} from 'twenty-shared/types';
import { evaluateConditionalAvailabilityExpression } from 'twenty-shared/utils';
import { useIsMobile } from 'twenty-ui/utilities';
import { CommandMenuItemAvailabilityType } from '~/generated-metadata/graphql';
export const StandalonePageCommandMenu = () => {
const store = useStore();
const isMobile = useIsMobile();
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const { objectMetadataItems } = useObjectMetadataItems();
const commandMenuContextApi = useMemo<CommandMenuContextApi>(() => {
const featureFlags: Record<string, boolean> = {};
for (const flag of currentWorkspace?.featureFlags ?? []) {
featureFlags[flag.key] = flag.value === true;
}
const targetObjectReadPermissions: Record<string, boolean> = {};
const targetObjectWritePermissions: Record<string, boolean> = {};
for (const metadataItem of objectMetadataItems) {
const permissions = store.get(
objectPermissionsFamilySelector.selectorFamily({
objectNameSingular: metadataItem.nameSingular,
}),
);
targetObjectReadPermissions[metadataItem.nameSingular] =
permissions.canRead;
targetObjectWritePermissions[metadataItem.nameSingular] =
permissions.canUpdate;
}
return {
pageType: ContextStorePageType.Standalone,
isInSidePanel: false,
isPageInEditMode: false,
favoriteRecordIds: [],
isSelectAll: false,
hasAnySoftDeleteFilterOnView: false,
numberOfSelectedRecords: 0,
objectPermissions: {
canReadObjectRecords: false,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
objectMetadataId: '',
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
selectedRecords: [],
featureFlags,
targetObjectReadPermissions,
targetObjectWritePermissions,
objectMetadataItem: {},
objectMetadataLabel: '',
};
}, [currentWorkspace?.featureFlags, objectMetadataItems, store]);
const filteredCommandMenuItems = useMemo(() => {
return commandMenuItems
.filter(doesCommandMenuItemMatchObjectMetadataId(undefined))
.filter(
(item) =>
item.availabilityType !==
CommandMenuItemAvailabilityType.RECORD_SELECTION &&
item.availabilityType !==
CommandMenuItemAvailabilityType.GLOBAL_OBJECT_CONTEXT,
)
.filter((item) =>
evaluateConditionalAvailabilityExpression(
item.conditionalAvailabilityExpression,
commandMenuContextApi,
),
)
.sort(
(firstItem, secondItem) => firstItem.position - secondItem.position,
);
}, [commandMenuItems, commandMenuContextApi]);
return (
<CommandMenuContext.Provider
value={{
displayType: 'button',
containerType: 'standalone-page-header',
commandMenuItems: filteredCommandMenuItems,
commandMenuContextApi,
}}
>
{!isMobile && <PinnedCommandMenuItemButtons />}
<CommandMenuItemEditButton />
</CommandMenuContext.Provider>
);
};
@@ -1,10 +1,10 @@
import {
CommandMenuContextApiPageType,
ContextStorePageType,
type CommandMenuContextApi,
} from 'twenty-shared/types';
export const EMPTY_COMMAND_MENU_CONTEXT_API: CommandMenuContextApi = {
pageType: CommandMenuContextApiPageType.INDEX_PAGE,
pageType: ContextStorePageType.Index,
isInSidePanel: false,
isPageInEditMode: false,
favoriteRecordIds: [],
@@ -4,6 +4,7 @@ import {
} from '@/command-menu-item/contexts/CommandMenuContext';
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/utils/doesCommandMenuItemMatchObjectMetadataId';
import { doesCommandMenuItemMatchPageType } from '@/command-menu-item/utils/doesCommandMenuItemMatchPageType';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useMemo } from 'react';
import { type CommandMenuContextApi } from 'twenty-shared/types';
@@ -32,6 +33,7 @@ export const CommandMenuContextProviderContent = ({
.filter(
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
)
.filter(doesCommandMenuItemMatchPageType(commandMenuContextApi.pageType))
.filter((item) =>
evaluateConditionalAvailabilityExpression(
item.conditionalAvailabilityExpression,
@@ -20,6 +20,7 @@ export const SidePanelCommandMenuItemDisplayPage = () => {
const sidePanelSearch = useAtomStateValue(sidePanelSearchState);
const { commandMenuItems, commandMenuContextApi } =
useContext(CommandMenuContext);
const commandMenuPinnedInlineLayout = useAtomStateValue(
commandMenuPinnedInlineLayoutState,
);
@@ -62,6 +62,7 @@ export const PinnedCommandMenuItemButtonsEditMode = () => {
() =>
new Set<CommandMenuItemAvailabilityType>([
CommandMenuItemAvailabilityType.GLOBAL,
CommandMenuItemAvailabilityType.GLOBAL_OBJECT_CONTEXT,
mainContextStoreHasSelectedRecords
? CommandMenuItemAvailabilityType.RECORD_SELECTION
: CommandMenuItemAvailabilityType.FALLBACK,
@@ -21,7 +21,7 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState
import { type DropResult } from '@hello-pangea/dnd';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { CommandMenuContextApiPageType } from 'twenty-shared/types';
import { ContextStorePageType } from 'twenty-shared/types';
import {
interpolateCommandMenuItemTemplate,
isDefined,
@@ -71,9 +71,13 @@ export const SidePanelCommandMenuItemEditPage = () => {
const currentObjectMetadataItemId =
commandMenuContextApi.objectMetadataItem.id;
const hasObjectContext = isDefined(currentObjectMetadataItemId);
const isRecordPage =
commandMenuContextApi.pageType ===
CommandMenuContextApiPageType.RECORD_PAGE;
commandMenuContextApi.pageType === ContextStorePageType.Record;
const isIndexPage =
commandMenuContextApi.pageType === ContextStorePageType.Index;
const mainContextStoreHasSelectedRecords = useAtomStateValue(
mainContextStoreHasSelectedRecordsSelector,
@@ -93,6 +97,9 @@ export const SidePanelCommandMenuItemEditPage = () => {
const allowedAvailabilityTypes = new Set<CommandMenuItemAvailabilityType>([
CommandMenuItemAvailabilityType.GLOBAL,
...(isIndexPage || isRecordPage
? [CommandMenuItemAvailabilityType.GLOBAL_OBJECT_CONTEXT]
: []),
mainContextStoreHasSelectedRecords
? CommandMenuItemAvailabilityType.RECORD_SELECTION
: CommandMenuItemAvailabilityType.FALLBACK,
@@ -231,11 +238,13 @@ export const SidePanelCommandMenuItemEditPage = () => {
return (
<StyledContainer data-click-outside-id={COMMAND_MENU_CLICK_OUTSIDE_ID}>
<StyledViewbar>
<CommandMenuItemEditRecordSelectionDropdown
isRecordPage={isRecordPage}
/>
</StyledViewbar>
{hasObjectContext && (
<StyledViewbar>
<CommandMenuItemEditRecordSelectionDropdown
isRecordPage={isRecordPage}
/>
</StyledViewbar>
)}
<StyledContent>
<SidePanelList selectableItemIds={selectableItemIds}>
<SidePanelGroup heading={t`Pinned`}>
@@ -74,6 +74,7 @@ export const buildTriggerWorkflowVersionPayloads = ({
return payloads;
}
case CommandMenuItemAvailabilityTypeEnum.GLOBAL:
case CommandMenuItemAvailabilityTypeEnum.GLOBAL_OBJECT_CONTEXT:
case CommandMenuItemAvailabilityTypeEnum.FALLBACK: {
return payloads;
}
@@ -3,7 +3,7 @@ import { useCloseCommandMenu } from '@/command-menu-item/hooks/useCloseCommandMe
import { type CommandMenuItemContainerType } from '@/command-menu-item/types/CommandMenuItemContainerType';
import { act, renderHook } from '@testing-library/react';
import { type ReactNode } from 'react';
import { CommandMenuContextApiPageType } from 'twenty-shared/types';
import { ContextStorePageType } from 'twenty-shared/types';
const TEST_COMMAND_MENU_ID = 'test-cmd-menu-1';
@@ -44,7 +44,7 @@ const getWrapper =
displayType: 'button',
commandMenuItems: [],
commandMenuContextApi: {
pageType: CommandMenuContextApiPageType.INDEX_PAGE,
pageType: ContextStorePageType.Index,
isInSidePanel,
isPageInEditMode: false,
favoriteRecordIds: [],
@@ -2,17 +2,17 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { objectPermissionsFamilySelector } from '@/auth/states/objectPermissionsFamilySelector';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreCurrentPageTypeComponentState } from '@/context-store/states/contextStoreCurrentPageTypeComponentState';
import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView';
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
import { recordStoreRecordsSelector } from '@/object-record/record-store/states/selectors/recordStoreRecordsSelector';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { SIDE_PANEL_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelComponentInstanceId';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
@@ -22,7 +22,7 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState
import { isNonEmptyArray } from '@sniptt/guards';
import { useStore } from 'jotai';
import {
CommandMenuContextApiPageType,
ContextStorePageType,
type CommandMenuContextApi,
} from 'twenty-shared/types';
import { isDefined, resolveObjectMetadataLabel } from 'twenty-shared/utils';
@@ -93,25 +93,31 @@ export const useCommandMenuContextApi = (): CommandMenuContextApi => {
rowLevelPermissionPredicateGroups: [],
};
const { recordIndexId } = useRecordIndexIdFromCurrentContextStore();
const contextStoreCurrentViewId = useAtomComponentStateValue(
contextStoreCurrentViewIdComponentState,
);
const recordIndexId = getRecordIndexIdFromObjectNamePluralAndViewId(
objectMetadataItem?.namePlural ?? '',
contextStoreCurrentViewId ?? '',
);
const hasAnySoftDeleteFilterOnView = useAtomComponentSelectorValue(
hasAnySoftDeleteFilterOnViewComponentSelector,
recordIndexId,
);
const contextStoreCurrentViewType = useAtomComponentStateValue(
contextStoreCurrentViewTypeComponentState,
const contextStoreCurrentPageType = useAtomComponentStateValue(
contextStoreCurrentPageTypeComponentState,
);
const contextStoreIsPageInEditMode = useAtomComponentStateValue(
contextStoreIsPageInEditModeComponentState,
);
const pageType =
contextStoreCurrentViewType === ContextStoreViewType.ShowPage
? CommandMenuContextApiPageType.RECORD_PAGE
: CommandMenuContextApiPageType.INDEX_PAGE;
const pageType = isDefined(contextStoreCurrentPageType)
? contextStoreCurrentPageType
: ContextStorePageType.Index;
const isSelectAll = contextStoreTargetedRecordsRule.mode === 'exclusion';
@@ -3,4 +3,5 @@ export type CommandMenuItemContainerType =
| 'index-page-header'
| 'index-page-dropdown'
| 'show-page-header'
| 'standalone-page-header'
| 'command-menu-show-page-dropdown';
@@ -0,0 +1,20 @@
import { ContextStorePageType } from 'twenty-shared/types';
import {
CommandMenuItemAvailabilityType,
type CommandMenuItemFieldsFragment,
} from '~/generated-metadata/graphql';
const PAGE_TYPES_WITH_RECORD_CONTEXT = new Set([
ContextStorePageType.Index,
ContextStorePageType.Record,
]);
const AVAILABILITY_TYPES_REQUIRING_RECORD_CONTEXT = new Set([
CommandMenuItemAvailabilityType.GLOBAL_OBJECT_CONTEXT,
CommandMenuItemAvailabilityType.RECORD_SELECTION,
]);
export const doesCommandMenuItemMatchPageType =
(pageType: ContextStorePageType) => (item: CommandMenuItemFieldsFragment) =>
!AVAILABILITY_TYPES_REQUIRING_RECORD_CONTEXT.has(item.availabilityType) ||
PAGE_TYPES_WITH_RECORD_CONTEXT.has(pageType);
@@ -26,15 +26,17 @@ import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceSta
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreCurrentPageTypeComponentState } from '@/context-store/states/contextStoreCurrentPageTypeComponentState';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { ContextStorePageType } from 'twenty-shared/types';
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
import { SidePanelRouter } from '@/side-panel/components/SidePanelRouter';
import { SIDE_PANEL_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelComponentInstanceId';
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
import { type SidePanelRootPage } from '@/side-panel/pages/root/components/SidePanelRootPage';
import { type SidePanelCommandMenuItemDisplayPage } from '@/command-menu-item/display/components/SidePanelCommandMenuItemDisplayPage';
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
import { sidePanelNavigationStackState } from '@/side-panel/states/sidePanelNavigationStackState';
import { sidePanelPageInfoState } from '@/side-panel/states/sidePanelPageInfoState';
@@ -75,7 +77,7 @@ const ContextStoreDecorator: Decorator = (Story) => {
);
};
const meta: Meta<typeof SidePanelRootPage> = {
const meta: Meta<typeof SidePanelCommandMenuItemDisplayPage> = {
title: 'Modules/CommandMenu/CommandMenu',
component: SidePanelRouter,
decorators: [
@@ -131,6 +133,12 @@ const meta: Meta<typeof SidePanelRootPage> = {
}),
ContextStoreViewType.Table,
);
jotaiStore.set(
contextStoreCurrentPageTypeComponentState.atomFamily({
instanceId: MAIN_CONTEXT_STORE_INSTANCE_ID,
}),
ContextStorePageType.Index,
);
return <Story />;
},
@@ -145,7 +153,7 @@ const meta: Meta<typeof SidePanelRootPage> = {
};
export default meta;
type Story = StoryObj<typeof SidePanelRootPage>;
type Story = StoryObj<typeof SidePanelCommandMenuItemDisplayPage>;
export const DefaultWithoutSearch: Story = {
play: async ({ canvasElement }) => {
@@ -47,6 +47,7 @@ export const MainContextStoreProvider = () => {
AppPath.RecordIndexPage,
);
const isRecordShowPage = isMatchingLocation(location, AppPath.RecordShowPage);
const isStandalonePage = isMatchingLocation(location, AppPath.PageLayoutPage);
const isSettingsPage = useIsSettingsPage();
const showAuthModal = useShowAuthModal();
@@ -118,6 +119,7 @@ export const MainContextStoreProvider = () => {
const shouldComputeContextStore =
(isRecordIndexPage ||
isRecordShowPage ||
isStandalonePage ||
isSettingsPage ||
showAuthModal) &&
metadataStore.status === 'up-to-date';
@@ -132,6 +134,7 @@ export const MainContextStoreProvider = () => {
objectMetadataItem={objectMetadataItem}
isRecordIndexPage={isRecordIndexPage}
isRecordShowPage={isRecordShowPage}
isStandalonePage={isStandalonePage}
isSettingsPage={isSettingsPage}
/>
);
@@ -1,7 +1,9 @@
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreCurrentPageTypeComponentState } from '@/context-store/states/contextStoreCurrentPageTypeComponentState';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
import { getPageType } from '@/context-store/utils/getPageType';
import { getViewType } from '@/context-store/utils/getViewType';
import { useSetLastVisitedObjectMetadataId } from '@/navigation/hooks/useSetLastVisitedObjectMetadataId';
import { useSetLastVisitedViewForObjectMetadataNamePlural } from '@/navigation/hooks/useSetLastVisitedViewForObjectMetadataNamePlural';
@@ -16,6 +18,7 @@ type MainContextStoreProviderEffectProps = {
objectMetadataItem?: EnrichedObjectMetadataItem;
isRecordIndexPage: boolean;
isRecordShowPage: boolean;
isStandalonePage: boolean;
isSettingsPage: boolean;
};
@@ -24,6 +27,7 @@ export const MainContextStoreProviderEffect = ({
objectMetadataItem,
isRecordIndexPage,
isRecordShowPage,
isStandalonePage,
isSettingsPage,
}: MainContextStoreProviderEffectProps) => {
const { setLastVisitedViewForObjectMetadataNamePlural } =
@@ -44,6 +48,12 @@ export const MainContextStoreProviderEffect = ({
MAIN_CONTEXT_STORE_INSTANCE_ID,
);
const [contextStoreCurrentPageType, setContextStoreCurrentPageType] =
useAtomComponentState(
contextStoreCurrentPageTypeComponentState,
MAIN_CONTEXT_STORE_INSTANCE_ID,
);
const [
contextStoreCurrentObjectMetadataItemId,
setContextStoreCurrentObjectMetadataItemId,
@@ -100,8 +110,6 @@ export const MainContextStoreProviderEffect = ({
useEffect(() => {
const viewType = getViewType({
isSettingsPage,
isRecordShowPage,
isRecordIndexPage,
view,
});
@@ -113,9 +121,27 @@ export const MainContextStoreProviderEffect = ({
contextStoreCurrentViewType,
setContextStoreCurrentViewType,
view,
isRecordIndexPage,
]);
useEffect(() => {
const pageType = getPageType({
isSettingsPage,
isRecordShowPage,
isRecordIndexPage,
isStandalonePage,
});
if (contextStoreCurrentPageType !== pageType) {
setContextStoreCurrentPageType(pageType);
}
}, [
contextStoreCurrentPageType,
setContextStoreCurrentPageType,
isSettingsPage,
isRecordShowPage,
isRecordIndexPage,
isStandalonePage,
]);
return null;
@@ -0,0 +1,10 @@
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { type ContextStorePageType } from 'twenty-shared/types';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const contextStoreCurrentPageTypeComponentState =
createAtomComponentState<ContextStorePageType | null>({
key: 'contextStoreCurrentPageTypeComponentState',
defaultValue: null,
componentInstanceContext: ContextStoreComponentInstanceContext,
});
@@ -1,5 +1,4 @@
export enum ContextStoreViewType {
Table = 'table',
Kanban = 'kanban',
ShowPage = 'show-page',
}
@@ -0,0 +1,31 @@
import { ContextStorePageType } from 'twenty-shared/types';
export const getPageType = ({
isSettingsPage,
isRecordShowPage,
isRecordIndexPage,
isStandalonePage,
}: {
isSettingsPage: boolean;
isRecordShowPage: boolean;
isRecordIndexPage: boolean;
isStandalonePage: boolean;
}): ContextStorePageType | null => {
if (isSettingsPage) {
return ContextStorePageType.Settings;
}
if (isRecordIndexPage) {
return ContextStorePageType.Index;
}
if (isRecordShowPage) {
return ContextStorePageType.Record;
}
if (isStandalonePage) {
return ContextStorePageType.Standalone;
}
return null;
};
@@ -3,29 +3,17 @@ import { type View } from '@/views/types/View';
import { ViewType } from '@/views/types/ViewType';
export const getViewType = ({
isSettingsPage,
isRecordShowPage,
isRecordIndexPage,
view,
}: {
isSettingsPage: boolean;
isRecordShowPage: boolean;
isRecordIndexPage: boolean;
view?: View;
}) => {
if (isSettingsPage) {
return null;
}
if (isRecordIndexPage) {
return view?.type === ViewType.KANBAN
? ContextStoreViewType.Kanban
: ContextStoreViewType.Table;
}
if (isRecordShowPage) {
return ContextStoreViewType.ShowPage;
}
return null;
};
@@ -0,0 +1,10 @@
import { gql } from '@apollo/client';
export const FIND_ONE_PAGE_LAYOUT_TYPE = gql`
query FindOnePageLayoutType($id: String!) {
getPageLayout(id: $id) {
id
type
}
}
`;
@@ -0,0 +1 @@
export const DEFAULT_NAVIGATION_MENU_ITEM_COLOR_PAGE_LAYOUT = 'blue';
@@ -13,6 +13,7 @@ export const NAVIGATION_MENU_ITEM_FRAGMENT = gql`
link
icon
color
pageLayoutId
position
applicationId
createdAt
@@ -21,6 +21,9 @@ export const filterAndSortNavigationMenuItems = (
if (item.type === NavigationMenuItemType.LINK) {
return true;
}
if (item.type === NavigationMenuItemType.PAGE_LAYOUT) {
return isDefined(item.pageLayoutId);
}
if (item.type === NavigationMenuItemType.OBJECT) {
return (
isDefined(item.targetObjectMetadataId) &&
@@ -2,6 +2,7 @@ import { isNonEmptyString } from '@sniptt/guards';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER } from '@/navigation-menu-item/common/constants/NavigationMenuItemDefaultColorFolder';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK } from '@/navigation-menu-item/common/constants/NavigationMenuItemDefaultColorLink';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_PAGE_LAYOUT } from '@/navigation-menu-item/common/constants/NavigationMenuItemDefaultColorPageLayout';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { getObjectColorWithFallback } from '@/object-metadata/utils/getObjectColorWithFallback';
import { NavigationMenuItemType } from 'twenty-shared/types';
@@ -27,6 +28,12 @@ export const getNavigationMenuItemColor = (
return DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK;
}
if (navigationMenuItem.type === NavigationMenuItemType.PAGE_LAYOUT) {
return isNonEmptyString(navigationMenuItem.color)
? (navigationMenuItem.color as ThemeColor)
: DEFAULT_NAVIGATION_MENU_ITEM_COLOR_PAGE_LAYOUT;
}
if (
navigationMenuItem.type === NavigationMenuItemType.OBJECT ||
navigationMenuItem.type === NavigationMenuItemType.VIEW
@@ -2,6 +2,7 @@ import { NavigationMenuItemType } from 'twenty-shared/types';
import { NavigationMenuItemFolder } from '@/navigation-menu-item/display/folder/components/NavigationMenuItemFolder';
import { NavigationMenuItemLinkDisplay } from '@/navigation-menu-item/display/link/components/NavigationMenuItemLinkDisplay';
import { NavigationMenuItemObjectDisplay } from '@/navigation-menu-item/display/object/components/NavigationMenuItemObjectDisplay';
import { NavigationMenuItemPageLayoutDisplay } from '@/navigation-menu-item/display/page-layout/components/NavigationMenuItemPageLayoutDisplay';
import type { NavigationMenuItemSectionContentProps } from '@/navigation-menu-item/display/sections/types/NavigationMenuItemSectionContentProps';
type NavigationMenuItemDisplayProps = NavigationMenuItemSectionContentProps;
@@ -49,6 +50,21 @@ export const NavigationMenuItemDisplay = ({
readOnly={readOnly}
/>
);
case NavigationMenuItemType.PAGE_LAYOUT:
return (
<NavigationMenuItemPageLayoutDisplay
item={item}
isEditInPlace={isEditInPlace}
editModeProps={editModeProps}
isDragging={isDragging}
folderChildrenById={folderChildrenById}
folderCount={folderCount}
rightOptions={rightOptions}
onNavigationMenuItemClick={onNavigationMenuItemClick}
onActiveObjectMetadataItemClick={onActiveObjectMetadataItemClick}
readOnly={readOnly}
/>
);
default:
return (
<NavigationMenuItemObjectDisplay
@@ -74,6 +74,29 @@ export const NavigationMenuItemIcon = ({
);
}
if (navigationMenuItem.type === NavigationMenuItemType.PAGE_LAYOUT) {
const pageLayoutIcon = isDefined(navigationMenuItem.icon)
? getIcon(navigationMenuItem.icon)
: undefined;
const pageLayoutColor = getNavigationMenuItemColor(navigationMenuItem);
const pageLayoutIconStyle = getIconTileColorShades(pageLayoutColor);
return (
<StyledTintedIconTileContainer
$backgroundColor={pageLayoutIconStyle.backgroundColor}
$borderColor={pageLayoutIconStyle.borderColor}
>
<Avatar
size="sm"
type="icon"
Icon={pageLayoutIcon}
iconColor={pageLayoutIconStyle.iconColor}
placeholder={navigationMenuItem.name ?? ''}
/>
</StyledTintedIconTileContainer>
);
}
if (navigationMenuItem.type === NavigationMenuItemType.LINK) {
const computedLink = getNavigationMenuItemComputedLink(
navigationMenuItem,
@@ -0,0 +1,45 @@
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { NavigationMenuItemIcon } from '@/navigation-menu-item/display/components/NavigationMenuItemIcon';
import { getPageLayoutNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/page-layout/utils/getPageLayoutNavigationMenuItemComputedLink';
import type { NavigationMenuItemSectionContentProps } from '@/navigation-menu-item/display/sections/types/NavigationMenuItemSectionContentProps';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
type NavigationMenuItemPageLayoutDisplayProps =
NavigationMenuItemSectionContentProps;
export const NavigationMenuItemPageLayoutDisplay = ({
item,
editModeProps,
isDragging,
rightOptions,
}: NavigationMenuItemPageLayoutDisplayProps) => {
const isLayoutCustomizationModeEnabled = useAtomStateValue(
isLayoutCustomizationModeEnabledState,
);
const label = item.name ?? '';
const computedLink = getPageLayoutNavigationMenuItemComputedLink(item);
return (
<NavigationDrawerItem
label={label}
to={
isLayoutCustomizationModeEnabled || isDragging
? undefined
: computedLink
}
onClick={
isLayoutCustomizationModeEnabled
? editModeProps?.onEditModeClick
: undefined
}
Icon={() => <NavigationMenuItemIcon navigationMenuItem={item} />}
active={false}
isSelectedInEditMode={editModeProps?.isSelectedInEditMode}
isDragging={isDragging}
triggerEvent="CLICK"
rightOptions={rightOptions}
/>
);
};
@@ -0,0 +1,15 @@
import { AppPath } from 'twenty-shared/types';
import { getAppPath, isDefined } from 'twenty-shared/utils';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
export const getPageLayoutNavigationMenuItemComputedLink = (
item: Pick<NavigationMenuItem, 'pageLayoutId'>,
): string => {
if (!isDefined(item.pageLayoutId)) {
return '';
}
return getAppPath(AppPath.PageLayoutPage, {
pageLayoutId: item.pageLayoutId,
});
};
@@ -86,7 +86,8 @@ export const WorkspaceSectionContainer = ({
const itemType = item.type;
if (
itemType === NavigationMenuItemType.FOLDER ||
itemType === NavigationMenuItemType.LINK
itemType === NavigationMenuItemType.LINK ||
itemType === NavigationMenuItemType.PAGE_LAYOUT
) {
return true;
}
@@ -1,5 +1,6 @@
import { getLinkNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/link/utils/getLinkNavigationMenuItemComputedLink';
import { getObjectNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/object/utils/getObjectNavigationMenuItemComputedLink';
import { getPageLayoutNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/page-layout/utils/getPageLayoutNavigationMenuItemComputedLink';
import { getRecordNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/record/utils/getRecordNavigationMenuItemComputedLink';
import { getViewNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/view/utils/getViewNavigationMenuItemComputedLink';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
@@ -29,6 +30,8 @@ export const getNavigationMenuItemComputedLink = (
return getLinkNavigationMenuItemComputedLink(item);
case NavigationMenuItemType.RECORD:
return getRecordNavigationMenuItemComputedLink(item, objectMetadataItems);
case NavigationMenuItemType.PAGE_LAYOUT:
return getPageLayoutNavigationMenuItemComputedLink(item);
default:
return '';
}
@@ -49,7 +49,10 @@ export const getWorkspaceSidebarOrphanItemsInDisplayOrder = ({
}
const rowSource = isDefined(validItem) ? validItem : item;
if (rowSource.type === NavigationMenuItemType.LINK) {
if (
rowSource.type === NavigationMenuItemType.LINK ||
rowSource.type === NavigationMenuItemType.PAGE_LAYOUT
) {
acc.push(rowSource);
return acc;
}
@@ -7,14 +7,21 @@ import { useCurrentPageLayoutOrThrow } from '@/page-layout/hooks/useCurrentPageL
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
import { usePageLayoutTabWithVisibleWidgetsOrThrow } from '@/page-layout/hooks/usePageLayoutTabWithVisibleWidgetsOrThrow';
import { useReorderPageLayoutWidgets } from '@/page-layout/hooks/useReorderPageLayoutWidgets';
import { StandaloneWidgetPlaceholder } from '@/page-layout/widgets/components/StandaloneWidgetPlaceholder';
import { RecordPageAddWidgetSection } from '@/page-layout/widgets/components/RecordPageAddWidgetSection';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { styled } from '@linaria/react';
import {
FeatureFlagKey,
PageLayoutTabLayoutMode,
PageLayoutType,
} from '~/generated-metadata/graphql';
const StyledEmptyStandalonePageContainer = styled.div`
display: grid;
height: 100%;
`;
export const PageLayoutContent = () => {
const isPageLayoutInEditMode = useIsPageLayoutInEditMode();
@@ -38,6 +45,18 @@ export const PageLayoutContent = () => {
const isCanvasLayout = layoutMode === PageLayoutTabLayoutMode.CANVAS;
const isVerticalList = layoutMode === PageLayoutTabLayoutMode.VERTICAL_LIST;
const isEmptyStandalonePage =
currentPageLayout.type === PageLayoutType.STANDALONE_PAGE &&
activeTab.widgets.length === 0;
if (isEmptyStandalonePage) {
return (
<StyledEmptyStandalonePageContainer>
<StandaloneWidgetPlaceholder />
</StyledEmptyStandalonePageContainer>
);
}
if (isCanvasLayout) {
return <PageLayoutCanvasViewer widgets={activeTab.widgets} />;
}
@@ -1,5 +1,6 @@
import { DashboardPageLayoutEditModeProvider } from '@/page-layout/components/DashboardPageLayoutEditModeProvider';
import { RecordPageLayoutEditModeProvider } from '@/page-layout/components/RecordPageLayoutEditModeProvider';
import { PageLayoutEditModeProviderContext } from '@/page-layout/contexts/PageLayoutEditModeContext';
import { type ReactNode } from 'react';
import { PageLayoutType } from '~/generated-metadata/graphql';
@@ -14,6 +15,14 @@ export const PageLayoutEditModeProvider = ({
pageLayoutId,
children,
}: PageLayoutEditModeProviderProps) => {
if (layoutType === PageLayoutType.STANDALONE_PAGE) {
return (
<PageLayoutEditModeProviderContext value={{ isInEditMode: false }}>
{children}
</PageLayoutEditModeProviderContext>
);
}
if (layoutType === PageLayoutType.RECORD_PAGE) {
return (
<RecordPageLayoutEditModeProvider>
@@ -8,6 +8,7 @@ import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayo
import { type PageLayout } from '@/page-layout/types/PageLayout';
import { convertPageLayoutToTabLayouts } from '@/page-layout/utils/convertPageLayoutToTabLayouts';
import { isPageLayoutEmpty } from '@/page-layout/utils/isPageLayoutEmpty';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
@@ -21,24 +22,16 @@ type PageLayoutInitializationQueryEffectProps = {
pageLayoutId: string;
};
export const PageLayoutInitializationQueryEffect = ({
const PageLayoutInitializationEffect = ({
pageLayoutId,
}: PageLayoutInitializationQueryEffectProps) => {
pageLayout,
}: {
pageLayoutId: string;
pageLayout: PageLayout | undefined;
}) => {
const [pageLayoutIsInitialized, setPageLayoutIsInitialized] =
useAtomComponentState(pageLayoutIsInitializedComponentState);
const featureFlags = useFeatureFlagsMap();
const isRecordPageLayoutEditingEnabled =
featureFlags[FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED];
const basePageLayout = useBasePageLayout(pageLayoutId);
const pageLayoutWithRelationWidgets =
usePageLayoutWithRelationWidgets(basePageLayout);
const pageLayout = isRecordPageLayoutEditingEnabled
? basePageLayout
: pageLayoutWithRelationWidgets;
const { setIsPageLayoutInEditMode } =
useSetIsPageLayoutInEditMode(pageLayoutId);
@@ -55,8 +48,6 @@ export const PageLayoutInitializationQueryEffect = ({
const initializePageLayout = useCallback(
(layout: PageLayout) => {
const isRecordPageLayout = layout.type === PageLayoutType.RECORD_PAGE;
const currentPersisted = store.get(
pageLayoutPersistedComponentCallbackState,
);
@@ -78,7 +69,9 @@ export const PageLayoutInitializationQueryEffect = ({
const tabLayouts = convertPageLayoutToTabLayouts(layout);
store.set(pageLayoutCurrentLayoutsComponentCallbackState, tabLayouts);
if (!isRecordPageLayout) {
const isDashboardLayout = layout.type === PageLayoutType.DASHBOARD;
if (isDashboardLayout) {
const shouldEnterDashboardEditMode = isPageLayoutEmpty(layout);
setIsPageLayoutInEditMode(shouldEnterDashboardEditMode);
}
@@ -106,3 +99,53 @@ export const PageLayoutInitializationQueryEffect = ({
return null;
};
const PageLayoutInitializationWithRelationWidgets = ({
pageLayoutId,
basePageLayout,
}: {
pageLayoutId: string;
basePageLayout: PageLayout | undefined;
}) => {
const pageLayout = usePageLayoutWithRelationWidgets(basePageLayout);
return (
<PageLayoutInitializationEffect
pageLayoutId={pageLayoutId}
pageLayout={pageLayout}
/>
);
};
// oxlint-disable-next-line twenty/effect-components
export const PageLayoutInitializationQueryEffect = ({
pageLayoutId,
}: PageLayoutInitializationQueryEffectProps) => {
const { layoutType } = useLayoutRenderingContext();
const featureFlags = useFeatureFlagsMap();
const isRecordPageLayoutEditingEnabled =
featureFlags[FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED];
const basePageLayout = useBasePageLayout(pageLayoutId);
const needsRelationWidgets =
layoutType === PageLayoutType.RECORD_PAGE &&
!isRecordPageLayoutEditingEnabled;
if (needsRelationWidgets) {
return (
<PageLayoutInitializationWithRelationWidgets
pageLayoutId={pageLayoutId}
basePageLayout={basePageLayout}
/>
);
}
return (
<PageLayoutInitializationEffect
pageLayoutId={pageLayoutId}
pageLayout={basePageLayout}
/>
);
};
@@ -10,7 +10,7 @@ import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/con
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { FeatureFlagKey, PageLayoutType } from '~/generated-metadata/graphql';
type PageLayoutRendererProps = {
pageLayoutId: string;
@@ -48,9 +48,12 @@ export const PageLayoutRenderer = ({
>
<PageLayoutInitializationQueryEffect pageLayoutId={pageLayoutId} />
<PageLayoutRecordPageCustomizationSessionRegistrationEffect />
{!isRecordPageLayoutEditingEnabled && (
<PageLayoutRelationWidgetsSyncEffect pageLayoutId={pageLayoutId} />
)}
{!isRecordPageLayoutEditingEnabled &&
layoutType === PageLayoutType.RECORD_PAGE && (
<PageLayoutRelationWidgetsSyncEffect
pageLayoutId={pageLayoutId}
/>
)}
<PageLayoutRendererContent />
</PageLayoutEditModeProvider>
</TabListComponentInstanceContext.Provider>
@@ -7,6 +7,13 @@ describe('shouldEnableTabEditingFeatures', () => {
expect(result).toBe(true);
});
it('should return true for STANDALONE_PAGE layout type', () => {
const result = shouldEnableTabEditingFeatures(
PageLayoutType.STANDALONE_PAGE,
);
expect(result).toBe(true);
});
it('should return false for RECORD_PAGE layout type without flag', () => {
const result = shouldEnableTabEditingFeatures(PageLayoutType.RECORD_PAGE);
expect(result).toBe(false);
@@ -42,11 +49,15 @@ describe('shouldEnableTabEditingFeatures', () => {
});
describe('behavior validation', () => {
it('should enable tab editing features only for dashboards and record pages with flag', () => {
it('should enable tab editing features only for dashboards, standalone pages, and record pages with flag', () => {
expect(shouldEnableTabEditingFeatures(PageLayoutType.DASHBOARD)).toBe(
true,
);
expect(
shouldEnableTabEditingFeatures(PageLayoutType.STANDALONE_PAGE),
).toBe(true);
expect(shouldEnableTabEditingFeatures(PageLayoutType.RECORD_PAGE)).toBe(
false,
);
@@ -4,7 +4,10 @@ export const shouldEnableTabEditingFeatures = (
pageLayoutType: PageLayoutType,
isRecordPageGlobalEditionEnabled?: boolean,
): boolean => {
if (pageLayoutType === PageLayoutType.DASHBOARD) {
if (
pageLayoutType === PageLayoutType.DASHBOARD ||
pageLayoutType === PageLayoutType.STANDALONE_PAGE
) {
return true;
}
@@ -0,0 +1,39 @@
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { WidgetCardShell } from '@/page-layout/widgets/components/WidgetCardShell';
import { useWidgetRendererState } from '@/page-layout/widgets/hooks/useWidgetRendererState';
type NonRecordPageWidgetRendererProps = {
widget: PageLayoutWidget;
};
export const NonRecordPageWidgetRenderer = ({
widget,
}: NonRecordPageWidgetRendererProps) => {
const state = useWidgetRendererState(widget);
const isCanvasVariant = state.variant === 'canvas';
return (
<WidgetCardShell
widget={widget}
variant={state.variant}
isEditable={state.isPageLayoutInEditMode}
isEditing={state.isEditing}
isDragging={state.isDragging}
isResizing={state.isResizing}
isLastWidget={state.isLastWidget}
showHeader={state.showHeader}
hasAccess={state.hasAccess}
restriction={state.restriction}
actions={[]}
isInVerticalListTab={state.isInVerticalListTab}
isMobile={state.isMobile}
isReorderEnabled={true}
isDeletingWidgetEnabled={true}
onClick={state.isPageLayoutInEditMode ? state.handleClick : undefined}
onRemove={state.handleRemove}
onMouseEnter={isCanvasVariant ? undefined : state.handleMouseEnter}
onMouseLeave={isCanvasVariant ? undefined : state.handleMouseLeave}
/>
);
};
@@ -0,0 +1,88 @@
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { WidgetCardShell } from '@/page-layout/widgets/components/WidgetCardShell';
import { useWidgetActions } from '@/page-layout/widgets/hooks/useWidgetActions';
import { useWidgetRendererState } from '@/page-layout/widgets/hooks/useWidgetRendererState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
FeatureFlagKey,
PageLayoutType,
WidgetType,
} from '~/generated-metadata/graphql';
const StyledEditingWidgetWrapper = styled.div`
padding: ${themeCssVariables.spacing[2]};
`;
type RecordPageWidgetRendererProps = {
widget: PageLayoutWidget;
};
export const RecordPageWidgetRenderer = ({
widget,
}: RecordPageWidgetRendererProps) => {
const state = useWidgetRendererState(widget);
const isRecordPageGlobalEditionEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED,
);
const isRecordPageLayout =
state.currentPageLayout.type === PageLayoutType.RECORD_PAGE;
const isReorderEnabled =
!isRecordPageLayout ||
(isRecordPageLayout && isRecordPageGlobalEditionEnabled);
const isDeletingWidgetEnabled =
!isRecordPageLayout ||
(isRecordPageLayout && isRecordPageGlobalEditionEnabled);
const isWidgetEditable =
state.isPageLayoutInEditMode &&
(!isRecordPageLayout ||
(isRecordPageLayout && isRecordPageGlobalEditionEnabled) ||
widget.type === WidgetType.FIELDS ||
widget.type === WidgetType.FIELD);
const actions = useWidgetActions({ widget });
// TODO: remove once all record page layouts widgets use the editable contain in edit mode
const shouldWrapWithEditingWrapper =
isWidgetEditable &&
state.variant === 'side-column' &&
!isRecordPageGlobalEditionEnabled;
const isCanvasVariant = state.variant === 'canvas';
const shell = (
<WidgetCardShell
widget={widget}
variant={state.variant}
isEditable={isWidgetEditable}
isEditing={state.isEditing}
isDragging={state.isDragging}
isResizing={state.isResizing}
isLastWidget={state.isLastWidget}
showHeader={state.showHeader}
hasAccess={state.hasAccess}
restriction={state.restriction}
actions={actions}
isInVerticalListTab={state.isInVerticalListTab}
isMobile={state.isMobile}
isReorderEnabled={isReorderEnabled}
isDeletingWidgetEnabled={isDeletingWidgetEnabled}
onClick={isWidgetEditable ? state.handleClick : undefined}
onRemove={state.handleRemove}
onMouseEnter={isCanvasVariant ? undefined : state.handleMouseEnter}
onMouseLeave={isCanvasVariant ? undefined : state.handleMouseLeave}
/>
);
if (shouldWrapWithEditingWrapper) {
return <StyledEditingWidgetWrapper>{shell}</StyledEditingWidgetWrapper>;
}
return shell;
};
@@ -0,0 +1,43 @@
import { styled } from '@linaria/react';
import { Trans } from '@lingui/react/macro';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
AnimatedPlaceholder,
AnimatedPlaceholderEmptyContainer,
AnimatedPlaceholderEmptySubTitle,
AnimatedPlaceholderEmptyTextContainer,
AnimatedPlaceholderEmptyTitle,
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
} from 'twenty-ui/layout';
const StyledPlaceholderContainer = styled.div`
background: ${themeCssVariables.background.secondary};
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
padding: ${themeCssVariables.spacing[2]};
position: relative;
width: 100%;
`;
export const StandaloneWidgetPlaceholder = () => {
return (
<StyledPlaceholderContainer className="widget">
<AnimatedPlaceholderEmptyContainer
// oxlint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
>
<AnimatedPlaceholder type="noWidgets" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
<Trans>Nothing to see</Trans>
</AnimatedPlaceholderEmptyTitle>
<AnimatedPlaceholderEmptySubTitle>
<Trans>This page has no content</Trans>
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
</AnimatedPlaceholderEmptyContainer>
</StyledPlaceholderContainer>
);
};
@@ -0,0 +1,140 @@
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { PageLayoutWidgetForbiddenDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetForbiddenDisplay';
import { PageLayoutWidgetInvalidConfigDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetInvalidConfigDisplay';
import { WidgetContentRenderer } from '@/page-layout/widgets/components/WidgetContentRenderer';
import { WidgetComponentInstanceContext } from '@/page-layout/widgets/states/contexts/WidgetComponentInstanceContext';
import { type WidgetAccessDenialInfo } from '@/page-layout/widgets/types/WidgetAccessDenialInfo';
import { type WidgetAction } from '@/page-layout/widgets/types/WidgetAction';
import { type WidgetCardVariant } from '@/page-layout/widgets/types/WidgetCardVariant';
import { WidgetCard } from '@/page-layout/widgets/widget-card/components/WidgetCard';
import { WidgetCardContent } from '@/page-layout/widgets/widget-card/components/WidgetCardContent';
import { WidgetCardHeader } from '@/page-layout/widgets/widget-card/components/WidgetCardHeader';
import { styled } from '@linaria/react';
import { type MouseEvent, useContext } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { IconLock } from 'twenty-ui/display';
import { ThemeContext } from 'twenty-ui/theme-constants';
import { WidgetType } from '~/generated-metadata/graphql';
const StyledNoAccessContainer = styled.div`
align-items: center;
display: flex;
justify-content: center;
`;
type WidgetCardShellProps = {
widget: PageLayoutWidget;
variant: WidgetCardVariant;
isEditable: boolean;
isEditing: boolean;
isDragging: boolean;
isResizing: boolean;
isLastWidget: boolean;
showHeader: boolean;
hasAccess: boolean;
restriction: WidgetAccessDenialInfo;
actions: WidgetAction[];
isInVerticalListTab: boolean;
isMobile: boolean;
isReorderEnabled: boolean;
isDeletingWidgetEnabled: boolean;
onClick?: () => void;
onRemove: (e?: MouseEvent) => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
};
export const WidgetCardShell = ({
widget,
variant,
isEditable,
isEditing,
isDragging,
isResizing,
isLastWidget,
showHeader,
hasAccess,
restriction,
actions,
isInVerticalListTab,
isMobile,
isReorderEnabled,
isDeletingWidgetEnabled,
onClick,
onRemove,
onMouseEnter,
onMouseLeave,
}: WidgetCardShellProps) => {
const { theme } = useContext(ThemeContext);
return (
<WidgetComponentInstanceContext.Provider value={{ instanceId: widget.id }}>
<WidgetCard
headerLess={!showHeader}
variant={variant}
isEditable={isEditable}
onClick={onClick}
isEditing={isEditing}
isDragging={isDragging}
isResizing={isResizing}
isLastWidget={isLastWidget}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
data-widget-id={widget.id}
data-testid={widget.id}
className="widget"
>
{showHeader && (
<WidgetCardHeader
widgetId={widget.id}
variant={variant}
isInEditMode={isEditable}
isResizing={isResizing}
isReorderEnabled={isReorderEnabled}
isDeletingWidgetEnabled={isDeletingWidgetEnabled}
title={widget.title}
onRemove={onRemove}
actions={actions}
forbiddenDisplay={
!hasAccess && (
<PageLayoutWidgetForbiddenDisplay
widgetId={widget.id}
restriction={restriction}
/>
)
}
/>
)}
<WidgetCardContent
variant={variant}
hasHeader={showHeader}
isEditable={isEditable}
isInVerticalListTab={isInVerticalListTab}
isMobile={isMobile}
hasInteractiveContent={widget.type === WidgetType.RECORD_TABLE}
>
{hasAccess ? (
<ErrorBoundary
FallbackComponent={PageLayoutWidgetInvalidConfigDisplay}
resetKeys={[
widget.id,
widget.configuration,
widget.objectMetadataId,
]}
>
<WidgetContentRenderer widget={widget} />
</ErrorBoundary>
) : (
<StyledNoAccessContainer>
<IconLock
color={theme.font.color.tertiary}
stroke={theme.icon.stroke.sm}
/>
</StyledNoAccessContainer>
)}
</WidgetCardContent>
</WidgetCard>
</WidgetComponentInstanceContext.Provider>
);
};
@@ -1,5 +1,6 @@
import { useCurrentPageLayoutOrThrow } from '@/page-layout/hooks/useCurrentPageLayoutOrThrow';
import { DashboardWidgetPlaceholder } from '@/page-layout/widgets/components/DashboardWidgetPlaceholder';
import { StandaloneWidgetPlaceholder } from '@/page-layout/widgets/components/StandaloneWidgetPlaceholder';
import { PageLayoutType } from '~/generated-metadata/graphql';
export const WidgetPlaceholder = () => {
@@ -9,6 +10,10 @@ export const WidgetPlaceholder = () => {
return <DashboardWidgetPlaceholder />;
}
if (currentPageLayout.type === PageLayoutType.STANDALONE_PAGE) {
return <StandaloneWidgetPlaceholder />;
}
// TODO: Implement RecordPageWidgetPlaceholder when needed
return <DashboardWidgetPlaceholder />;
};
@@ -1,246 +1,19 @@
import { usePageLayoutContentContext } from '@/page-layout/contexts/PageLayoutContentContext';
import { useCurrentPageLayoutOrThrow } from '@/page-layout/hooks/useCurrentPageLayoutOrThrow';
import { useDeletePageLayoutWidget } from '@/page-layout/hooks/useDeletePageLayoutWidget';
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
import { pageLayoutDraggingWidgetIdComponentState } from '@/page-layout/states/pageLayoutDraggingWidgetIdComponentState';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { pageLayoutResizingWidgetIdComponentState } from '@/page-layout/states/pageLayoutResizingWidgetIdComponentState';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { PageLayoutWidgetForbiddenDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetForbiddenDisplay';
import { PageLayoutWidgetInvalidConfigDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetInvalidConfigDisplay';
import { WidgetContentRenderer } from '@/page-layout/widgets/components/WidgetContentRenderer';
import { useIsCurrentWidgetLastOfTab } from '@/page-layout/widgets/hooks/useIsCurrentWidgetLastOfTab';
import { useIsInPinnedTab } from '@/page-layout/widgets/hooks/useIsInPinnedTab';
import { useWidgetActions } from '@/page-layout/widgets/hooks/useWidgetActions';
import { useWidgetPermissions } from '@/page-layout/widgets/hooks/useWidgetPermissions';
import { WidgetComponentInstanceContext } from '@/page-layout/widgets/states/contexts/WidgetComponentInstanceContext';
import { widgetCardHoveredComponentFamilyState } from '@/page-layout/widgets/states/widgetCardHoveredComponentFamilyState';
import { getWidgetCardVariant } from '@/page-layout/widgets/utils/getWidgetCardVariant';
import { WidgetCard } from '@/page-layout/widgets/widget-card/components/WidgetCard';
import { WidgetCardContent } from '@/page-layout/widgets/widget-card/components/WidgetCardContent';
import { WidgetCardHeader } from '@/page-layout/widgets/widget-card/components/WidgetCardHeader';
import { useOpenWidgetSettingsInSidePanel } from '@/side-panel/hooks/useOpenWidgetSettingsInSidePanel';
import { NonRecordPageWidgetRenderer } from '@/page-layout/widgets/components/NonRecordPageWidgetRenderer';
import { RecordPageWidgetRenderer } from '@/page-layout/widgets/components/RecordPageWidgetRenderer';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useSetAtomComponentFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentFamilyState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { styled } from '@linaria/react';
import { type MouseEvent, useContext } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { IconLock } from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import {
FeatureFlagKey,
PageLayoutTabLayoutMode,
PageLayoutType,
WidgetType,
} from '~/generated-metadata/graphql';
const StyledEditingWidgetWrapper = styled.div`
padding: ${themeCssVariables.spacing[2]};
`;
const StyledNoAccessContainer = styled.div`
align-items: center;
display: flex;
justify-content: center;
`;
import { PageLayoutType } from '~/generated-metadata/graphql';
type WidgetRendererProps = {
widget: PageLayoutWidget;
};
export const WidgetRenderer = ({ widget }: WidgetRendererProps) => {
const { theme } = useContext(ThemeContext);
const { deletePageLayoutWidget } = useDeletePageLayoutWidget();
const { openWidgetSettingsInSidePanel } = useOpenWidgetSettingsInSidePanel();
const { layoutType } = useLayoutRenderingContext();
const isPageLayoutInEditMode = useIsPageLayoutInEditMode();
if (layoutType === PageLayoutType.RECORD_PAGE) {
return <RecordPageWidgetRenderer widget={widget} />;
}
const pageLayoutDraggingWidgetId = useAtomComponentStateValue(
pageLayoutDraggingWidgetIdComponentState,
);
const pageLayoutResizingWidgetId = useAtomComponentStateValue(
pageLayoutResizingWidgetIdComponentState,
);
const pageLayoutEditingWidgetId = useAtomComponentStateValue(
pageLayoutEditingWidgetIdComponentState,
);
const isEditing = pageLayoutEditingWidgetId === widget.id;
const isDragging = pageLayoutDraggingWidgetId === widget.id;
const isResizing = pageLayoutResizingWidgetId === widget.id;
const { hasAccess, restriction } = useWidgetPermissions(widget);
const { layoutMode } = usePageLayoutContentContext();
const { isInPinnedTab } = useIsInPinnedTab();
const { isInSidePanel } = useLayoutRenderingContext();
const isMobile = useIsMobile();
const { currentPageLayout } = useCurrentPageLayoutOrThrow();
const isRecordPageGlobalEditionEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED,
);
const isRecordPageLayout =
currentPageLayout.type === PageLayoutType.RECORD_PAGE;
const isLastWidget = useIsCurrentWidgetLastOfTab(widget.id);
const isReorderEnabled =
!isRecordPageLayout ||
(isRecordPageLayout && isRecordPageGlobalEditionEnabled);
const isDeletingWidgetEnabled =
!isRecordPageLayout ||
(isRecordPageLayout && isRecordPageGlobalEditionEnabled);
const isWidgetEditable =
isPageLayoutInEditMode &&
(!isRecordPageLayout ||
(isRecordPageLayout && isRecordPageGlobalEditionEnabled) ||
widget.type === WidgetType.FIELDS ||
widget.type === WidgetType.FIELD);
// TODO: when we have more widgets without headers, we should use a more generic approach to hide the header
// each widget type could have metadata (e.g., hasHeader: boolean or headerMode: 'always' | 'editOnly' | 'never')
const isHeaderHiddenInViewMode =
widget.type === WidgetType.STANDALONE_RICH_TEXT ||
widget.type === WidgetType.EMAIL_THREAD;
const hideHeaderInViewMode =
isHeaderHiddenInViewMode && !isPageLayoutInEditMode;
const showHeader =
layoutMode !== PageLayoutTabLayoutMode.CANVAS && !hideHeaderInViewMode;
const handleClick = () => {
openWidgetSettingsInSidePanel({
widgetId: widget.id,
widgetType: widget.type,
});
};
const handleRemove = (e?: MouseEvent) => {
e?.stopPropagation();
deletePageLayoutWidget(widget.id);
};
const setWidgetCardHovered = useSetAtomComponentFamilyState(
widgetCardHoveredComponentFamilyState,
widget.id,
);
const handleMouseEnter = () => {
setWidgetCardHovered(true);
};
const handleMouseLeave = () => {
setWidgetCardHovered(false);
};
const variant = getWidgetCardVariant({
layoutMode,
isInPinnedTab,
pageLayoutType: currentPageLayout.type,
isMobile,
isInSidePanel,
});
const actions = useWidgetActions({ widget });
// TODO: remove once all record page layouts widgets use the editable contain in edit mode
const shouldWrapWithEditingWrapper =
isWidgetEditable &&
variant === 'side-column' &&
!isRecordPageGlobalEditionEnabled;
const isCanvasVariant = variant === 'canvas';
const widgetCard = (
<WidgetCard
headerLess={!showHeader}
variant={variant}
isEditable={isWidgetEditable}
onClick={isWidgetEditable ? handleClick : undefined}
isEditing={isEditing}
isDragging={isDragging}
isResizing={isResizing}
isLastWidget={isLastWidget}
onMouseEnter={isCanvasVariant ? undefined : handleMouseEnter}
onMouseLeave={isCanvasVariant ? undefined : handleMouseLeave}
data-widget-id={widget.id}
data-testid={widget.id}
className="widget"
>
{showHeader && (
<WidgetCardHeader
widgetId={widget.id}
variant={variant}
isInEditMode={isWidgetEditable}
isResizing={isResizing}
isReorderEnabled={isReorderEnabled}
isDeletingWidgetEnabled={isDeletingWidgetEnabled}
title={widget.title}
onRemove={handleRemove}
actions={actions}
forbiddenDisplay={
!hasAccess && (
<PageLayoutWidgetForbiddenDisplay
widgetId={widget.id}
restriction={restriction}
/>
)
}
/>
)}
<WidgetCardContent
variant={variant}
hasHeader={showHeader}
isEditable={isWidgetEditable}
isInVerticalListTab={
layoutMode === PageLayoutTabLayoutMode.VERTICAL_LIST
}
isMobile={isMobile}
hasInteractiveContent={widget.type === WidgetType.RECORD_TABLE}
>
{hasAccess ? (
<ErrorBoundary
FallbackComponent={PageLayoutWidgetInvalidConfigDisplay}
resetKeys={[
widget.id,
widget.configuration,
widget.objectMetadataId,
]}
>
<WidgetContentRenderer widget={widget} />
</ErrorBoundary>
) : (
<StyledNoAccessContainer>
<IconLock
color={theme.font.color.tertiary}
stroke={theme.icon.stroke.sm}
/>
</StyledNoAccessContainer>
)}
</WidgetCardContent>
</WidgetCard>
);
return (
<WidgetComponentInstanceContext.Provider value={{ instanceId: widget.id }}>
{shouldWrapWithEditingWrapper ? (
<StyledEditingWidgetWrapper>{widgetCard}</StyledEditingWidgetWrapper>
) : (
widgetCard
)}
</WidgetComponentInstanceContext.Provider>
);
return <NonRecordPageWidgetRenderer widget={widget} />;
};
@@ -0,0 +1,121 @@
import { usePageLayoutContentContext } from '@/page-layout/contexts/PageLayoutContentContext';
import { useCurrentPageLayoutOrThrow } from '@/page-layout/hooks/useCurrentPageLayoutOrThrow';
import { useDeletePageLayoutWidget } from '@/page-layout/hooks/useDeletePageLayoutWidget';
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
import { pageLayoutDraggingWidgetIdComponentState } from '@/page-layout/states/pageLayoutDraggingWidgetIdComponentState';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { pageLayoutResizingWidgetIdComponentState } from '@/page-layout/states/pageLayoutResizingWidgetIdComponentState';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { useIsCurrentWidgetLastOfTab } from '@/page-layout/widgets/hooks/useIsCurrentWidgetLastOfTab';
import { useIsInPinnedTab } from '@/page-layout/widgets/hooks/useIsInPinnedTab';
import { useWidgetPermissions } from '@/page-layout/widgets/hooks/useWidgetPermissions';
import { widgetCardHoveredComponentFamilyState } from '@/page-layout/widgets/states/widgetCardHoveredComponentFamilyState';
import { getWidgetCardVariant } from '@/page-layout/widgets/utils/getWidgetCardVariant';
import { useOpenWidgetSettingsInSidePanel } from '@/side-panel/hooks/useOpenWidgetSettingsInSidePanel';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useSetAtomComponentFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentFamilyState';
import { type MouseEvent } from 'react';
import {
PageLayoutTabLayoutMode,
WidgetType,
} from '~/generated-metadata/graphql';
export const useWidgetRendererState = (widget: PageLayoutWidget) => {
const { deletePageLayoutWidget } = useDeletePageLayoutWidget();
const { openWidgetSettingsInSidePanel } = useOpenWidgetSettingsInSidePanel();
const isPageLayoutInEditMode = useIsPageLayoutInEditMode();
const pageLayoutDraggingWidgetId = useAtomComponentStateValue(
pageLayoutDraggingWidgetIdComponentState,
);
const pageLayoutResizingWidgetId = useAtomComponentStateValue(
pageLayoutResizingWidgetIdComponentState,
);
const pageLayoutEditingWidgetId = useAtomComponentStateValue(
pageLayoutEditingWidgetIdComponentState,
);
const isEditing = pageLayoutEditingWidgetId === widget.id;
const isDragging = pageLayoutDraggingWidgetId === widget.id;
const isResizing = pageLayoutResizingWidgetId === widget.id;
const { hasAccess, restriction } = useWidgetPermissions(widget);
const { layoutMode } = usePageLayoutContentContext();
const { isInPinnedTab } = useIsInPinnedTab();
const { isInSidePanel } = useLayoutRenderingContext();
const isMobile = useIsMobile();
const { currentPageLayout } = useCurrentPageLayoutOrThrow();
const isLastWidget = useIsCurrentWidgetLastOfTab(widget.id);
const isHeaderHiddenInViewMode =
widget.type === WidgetType.STANDALONE_RICH_TEXT ||
widget.type === WidgetType.EMAIL_THREAD;
const hideHeaderInViewMode =
isHeaderHiddenInViewMode && !isPageLayoutInEditMode;
const showHeader =
layoutMode !== PageLayoutTabLayoutMode.CANVAS && !hideHeaderInViewMode;
const handleClick = () => {
openWidgetSettingsInSidePanel({
widgetId: widget.id,
widgetType: widget.type,
});
};
const handleRemove = (e?: MouseEvent) => {
e?.stopPropagation();
deletePageLayoutWidget(widget.id);
};
const setWidgetCardHovered = useSetAtomComponentFamilyState(
widgetCardHoveredComponentFamilyState,
widget.id,
);
const handleMouseEnter = () => {
setWidgetCardHovered(true);
};
const handleMouseLeave = () => {
setWidgetCardHovered(false);
};
const variant = getWidgetCardVariant({
layoutMode,
isInPinnedTab,
pageLayoutType: currentPageLayout.type,
isMobile,
isInSidePanel,
});
const isInVerticalListTab =
layoutMode === PageLayoutTabLayoutMode.VERTICAL_LIST;
return {
isPageLayoutInEditMode,
isEditing,
isDragging,
isResizing,
hasAccess,
restriction,
currentPageLayout,
isLastWidget,
showHeader,
variant,
isMobile,
isInVerticalListTab,
handleClick,
handleRemove,
handleMouseEnter,
handleMouseLeave,
};
};
@@ -2,4 +2,5 @@ export type WidgetCardVariant =
| 'canvas'
| 'side-column'
| 'dashboard'
| 'standalone'
| 'record-page';
@@ -17,6 +17,30 @@ describe('getWidgetCardVariant', () => {
).toBe('dashboard');
});
it('should return standalone for STANDALONE_PAGE page layout type', () => {
expect(
getWidgetCardVariant({
layoutMode: PageLayoutTabLayoutMode.GRID,
isInPinnedTab: false,
pageLayoutType: PageLayoutType.STANDALONE_PAGE,
isMobile: false,
isInSidePanel: false,
}),
).toBe('standalone');
});
it('should prioritize standalone over canvas', () => {
expect(
getWidgetCardVariant({
layoutMode: PageLayoutTabLayoutMode.CANVAS,
isInPinnedTab: false,
pageLayoutType: PageLayoutType.STANDALONE_PAGE,
isMobile: false,
isInSidePanel: false,
}),
).toBe('standalone');
});
it('should return canvas for CANVAS layout mode', () => {
expect(
getWidgetCardVariant({
@@ -23,6 +23,10 @@ export const getWidgetCardVariant = ({
return 'dashboard';
}
if (pageLayoutType === PageLayoutType.STANDALONE_PAGE) {
return 'standalone';
}
if (layoutMode === PageLayoutTabLayoutMode.CANVAS) {
return 'canvas';
}
@@ -23,7 +23,7 @@ const computeBorderColor = (
if (props.isEditable && (props.isEditing || props.isDragging)) {
return themeCssVariables.color.blue;
}
if (props.variant === 'dashboard') {
if (props.variant === 'dashboard' || props.variant === 'standalone') {
return themeCssVariables.border.color.light;
}
return 'transparent';
@@ -37,6 +37,7 @@ const StyledWidgetCard = styled.div<WidgetCardStyledProps>`
}
if (
props.variant === 'dashboard' ||
props.variant === 'standalone' ||
(props.variant === 'side-column' && props.isEditable)
) {
return themeCssVariables.background.secondary;
@@ -49,6 +50,7 @@ const StyledWidgetCard = styled.div<WidgetCardStyledProps>`
border: ${(props) =>
props.variant === 'dashboard' ||
props.variant === 'standalone' ||
props.variant === 'record-page' ||
props.isEditable
? `1px solid ${computeBorderColor(props)}`
@@ -66,7 +68,10 @@ const StyledWidgetCard = styled.div<WidgetCardStyledProps>`
return `1px solid ${computeBorderColor(props)}`;
}};
border-radius: ${({ variant, isEditable }) =>
variant === 'dashboard' || variant === 'record-page' || isEditable
variant === 'dashboard' ||
variant === 'standalone' ||
variant === 'record-page' ||
isEditable
? themeCssVariables.border.radius.md
: '0'};
@@ -90,8 +95,13 @@ const StyledWidgetCard = styled.div<WidgetCardStyledProps>`
height: 100%;
padding: ${({ variant, isEditable, headerLess }) => {
if (variant === 'dashboard' && headerLess === true) return '0';
if (variant === 'dashboard') return themeCssVariables.spacing[2];
if (
(variant === 'dashboard' || variant === 'standalone') &&
headerLess === true
)
return '0';
if (variant === 'dashboard' || variant === 'standalone')
return themeCssVariables.spacing[2];
if (variant === 'side-column' && !isEditable)
return themeCssVariables.spacing[3];
if (variant === 'record-page' || isEditable)
@@ -35,7 +35,8 @@ const StyledWidgetCardContent = styled.div<WidgetCardContentStyledProps>`
overflow: hidden;
padding: ${({ variant, isEditable }) => {
if (variant === 'dashboard') return themeCssVariables.spacing[2];
if (variant === 'dashboard' || variant === 'standalone')
return themeCssVariables.spacing[2];
if (
variant === 'record-page' ||
(variant === 'side-column' && isEditable)
@@ -1,20 +1,20 @@
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreCurrentPageTypeComponentState } from '@/context-store/states/contextStoreCurrentPageTypeComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { SIDE_PANEL_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelComponentInstanceId';
import { useOpenRecordInSidePanel } from '@/side-panel/hooks/useOpenRecordInSidePanel';
import { viewableRecordIdComponentState } from '@/side-panel/pages/record-page/states/viewableRecordIdComponentState';
import { viewableRecordNameSingularComponentState } from '@/side-panel/pages/record-page/states/viewableRecordNameSingularComponentState';
import { sidePanelNavigationMorphItemsByPageState } from '@/side-panel/states/sidePanelNavigationMorphItemsByPageState';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { SidePanelPages } from 'twenty-shared/types';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { ContextStorePageType, SidePanelPages } from 'twenty-shared/types';
import { useIcons } from 'twenty-ui/display';
import { getJestMetadataAndApolloMocksAndCommandMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndCommandMenuWrapper';
import { getTestEnrichedObjectMetadataItemsMock } from '~/testing/utils/getTestEnrichedObjectMetadataItemsMock';
@@ -85,8 +85,8 @@ const renderHooks = () => {
contextStoreNumberOfSelectedRecordsComponentState,
'mocked-uuid',
);
const contextStoreCurrentViewType = useAtomComponentStateValue(
contextStoreCurrentViewTypeComponentState,
const contextStoreCurrentPageType = useAtomComponentStateValue(
contextStoreCurrentPageTypeComponentState,
'mocked-uuid',
);
const { getIcon } = useIcons();
@@ -98,7 +98,7 @@ const renderHooks = () => {
contextStoreCurrentObjectMetadataItemId,
contextStoreTargetedRecordsRule,
contextStoreNumberOfSelectedRecords,
contextStoreCurrentViewType,
contextStoreCurrentPageType,
getIcon,
};
},
@@ -137,8 +137,8 @@ describe('useOpenRecordInSidePanel', () => {
selectedRecordIds: [recordId],
});
expect(result.current.contextStoreNumberOfSelectedRecords).toBe(1);
expect(result.current.contextStoreCurrentViewType).toBe(
ContextStoreViewType.ShowPage,
expect(result.current.contextStoreCurrentPageType).toBe(
ContextStorePageType.Record,
);
const sidePanelNavigationMorphItemsByPage = jotaiStore.get(
@@ -6,17 +6,20 @@ import { sidePanelNavigationStackState } from '@/side-panel/states/sidePanelNavi
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
import { contextStoreCurrentPageTypeComponentState } from '@/context-store/states/contextStoreCurrentPageTypeComponentState';
import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector';
import { getIconColorForObjectType } from '@/object-metadata/utils/getIconColorForObjectType';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { viewableRecordIdState } from '@/object-record/record-side-panel/states/viewableRecordIdState';
import { useOpenNewRecordTitleCell } from '@/object-record/record-title-cell/hooks/useOpenNewRecordTitleCell';
import { CoreObjectNameSingular, SidePanelPages } from 'twenty-shared/types';
import {
ContextStorePageType,
CoreObjectNameSingular,
SidePanelPages,
} from 'twenty-shared/types';
import { useRunWorkflowRunOpeningInSidePanelEffects } from '@/workflow/hooks/useRunWorkflowRunOpeningInSidePanelEffects';
import { t } from '@lingui/core/macro';
@@ -117,10 +120,10 @@ export const useOpenRecordInSidePanel = () => {
);
store.set(
contextStoreCurrentViewTypeComponentState.atomFamily({
contextStoreCurrentPageTypeComponentState.atomFamily({
instanceId: pageComponentInstanceId,
}),
ContextStoreViewType.ShowPage,
ContextStorePageType.Record,
);
store.set(
@@ -1,92 +0,0 @@
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { CommandMenuItemRenderer } from '@/command-menu-item/display/components/CommandMenuItemRenderer';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
import { useFilterCommandMenuItemsWithSidePanelSearch } from '@/side-panel/pages/root/hooks/useFilterCommandMenuItemsWithSidePanelSearch';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useLingui } from '@lingui/react/macro';
import { useContext, useMemo } from 'react';
import { CommandMenuItemAvailabilityType } from '~/generated-metadata/graphql';
export const SidePanelRootPage = () => {
const { t } = useLingui();
const sidePanelSearch = useAtomStateValue(sidePanelSearchState);
const { commandMenuItems, commandMenuContextApi } =
useContext(CommandMenuContext);
const { filterCommandMenuItemsWithSidePanelSearch } =
useFilterCommandMenuItemsWithSidePanelSearch({
sidePanelSearch,
commandMenuContextApi,
});
const recordSelectionItems = useMemo(
() =>
commandMenuItems.filter(
(item) =>
item.availabilityType ===
CommandMenuItemAvailabilityType.RECORD_SELECTION,
),
[commandMenuItems],
);
const globalItems = useMemo(
() =>
commandMenuItems.filter(
(item) =>
item.availabilityType === CommandMenuItemAvailabilityType.GLOBAL,
),
[commandMenuItems],
);
const fallbackItems = useMemo(
() =>
commandMenuItems.filter(
(item) =>
item.availabilityType === CommandMenuItemAvailabilityType.FALLBACK,
),
[commandMenuItems],
);
const matchingRecordSelectionItems =
filterCommandMenuItemsWithSidePanelSearch(recordSelectionItems);
const matchingGlobalItems =
filterCommandMenuItemsWithSidePanelSearch(globalItems);
const noResults =
!matchingRecordSelectionItems.length && !matchingGlobalItems.length;
const selectableItemIds = [
...matchingRecordSelectionItems,
...matchingGlobalItems,
...(noResults ? fallbackItems : []),
].map((item) => item.id);
return (
<SidePanelList selectableItemIds={selectableItemIds} noResults={noResults}>
{matchingRecordSelectionItems.length > 0 && (
<SidePanelGroup heading={t`Record Selection`}>
{matchingRecordSelectionItems.map((item) => (
<CommandMenuItemRenderer item={item} key={item.id} />
))}
</SidePanelGroup>
)}
{matchingGlobalItems.length > 0 && (
<SidePanelGroup heading={t`Global`}>
{matchingGlobalItems.map((item) => (
<CommandMenuItemRenderer item={item} key={item.id} />
))}
</SidePanelGroup>
)}
{noResults && fallbackItems.length > 0 && (
<SidePanelGroup heading={t`Search ''${sidePanelSearch}'' with...`}>
{fallbackItems.map((item) => (
<CommandMenuItemRenderer item={item} key={item.id} />
))}
</SidePanelGroup>
)}
</SidePanelList>
);
};
@@ -0,0 +1,38 @@
import { StandalonePageCommandMenu } from '@/command-menu-item/components/StandalonePageCommandMenu';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { navigationMenuItemsSelector } from '@/navigation-menu-item/common/states/navigationMenuItemsSelector';
import { SidePanelToggleButton } from '@/side-panel/components/SidePanelToggleButton';
import { PageHeader } from '@/ui/layout/page/components/PageHeader';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
type StandalonePageHeaderProps = {
pageLayoutId: string;
};
export const StandalonePageHeader = ({
pageLayoutId,
}: StandalonePageHeaderProps) => {
const { getIcon } = useIcons();
const navigationMenuItems = useAtomStateValue(navigationMenuItemsSelector);
const isLayoutCustomizationModeEnabled = useAtomStateValue(
isLayoutCustomizationModeEnabledState,
);
const navigationMenuItem = navigationMenuItems.find(
(item) => item.pageLayoutId === pageLayoutId,
);
const title = navigationMenuItem?.name ?? '';
const Icon = isDefined(navigationMenuItem?.icon)
? getIcon(navigationMenuItem.icon)
: undefined;
return (
<PageHeader title={title} Icon={Icon}>
<StandalonePageCommandMenu />
{!isLayoutCustomizationModeEnabled && <SidePanelToggleButton />}
</PageHeader>
);
};
@@ -0,0 +1,45 @@
import { useParams } from 'react-router-dom';
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { MainContainerLayoutWithSidePanel } from '@/object-record/components/MainContainerLayoutWithSidePanel';
import { PageLayoutRenderer } from '@/page-layout/components/PageLayoutRenderer';
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import { PageContainer } from '@/ui/layout/page/components/PageContainer';
import { isDefined } from 'twenty-shared/utils';
import { PageLayoutType } from '~/generated-metadata/graphql';
import { StandalonePageHeader } from '~/pages/page-layout/StandalonePageHeader';
export const StandalonePageLayoutPage = () => {
const { pageLayoutId } = useParams<{ pageLayoutId: string }>();
if (!isDefined(pageLayoutId)) {
return null;
}
return (
<PageContainer>
<ContextStoreComponentInstanceContext.Provider
value={{ instanceId: MAIN_CONTEXT_STORE_INSTANCE_ID }}
>
<CommandMenuComponentInstanceContext.Provider
value={{ instanceId: pageLayoutId }}
>
<StandalonePageHeader pageLayoutId={pageLayoutId} />
<LayoutRenderingProvider
value={{
targetRecordIdentifier: undefined,
layoutType: PageLayoutType.STANDALONE_PAGE,
isInSidePanel: false,
}}
>
<MainContainerLayoutWithSidePanel>
<PageLayoutRenderer pageLayoutId={pageLayoutId} />
</MainContainerLayoutWithSidePanel>
</LayoutRenderingProvider>
</CommandMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</PageContainer>
);
};
@@ -1,6 +1,7 @@
import { type PropsWithChildren, useContext, useEffect, useState } from 'react';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreCurrentPageTypeComponentState } from '@/context-store/states/contextStoreCurrentPageTypeComponentState';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
import { contextStoreFilterGroupsComponentState } from '@/context-store/states/contextStoreFilterGroupsComponentState';
@@ -12,6 +13,7 @@ import {
type ContextStoreTargetedRecordsRule,
contextStoreTargetedRecordsRuleComponentState,
} from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { type ContextStorePageType } from 'twenty-shared/types';
import { type ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { type RecordFilterGroup } from '@/object-record/record-filter-group/types/RecordFilterGroup';
@@ -26,6 +28,7 @@ export type JestContextStoreSetterMocks = {
contextStoreCurrentObjectMetadataNameSingular?: string;
contextStoreCurrentViewId?: string;
contextStoreCurrentViewType?: ContextStoreViewType;
contextStoreCurrentPageType?: ContextStorePageType;
};
type JestContextStoreSetterProps =
@@ -41,6 +44,7 @@ export const JestContextStoreSetter = ({
contextStoreFilters = [],
contextStoreFilterGroups = [],
contextStoreCurrentViewType,
contextStoreCurrentPageType,
children,
}: JestContextStoreSetterProps) => {
const contextStoreInstanceContext = useContext(
@@ -84,6 +88,11 @@ export const JestContextStoreSetter = ({
instanceId,
);
const setContextStoreCurrentPageType = useSetAtomComponentState(
contextStoreCurrentPageTypeComponentState,
instanceId,
);
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: contextStoreCurrentObjectMetadataNameSingular,
});
@@ -99,6 +108,7 @@ export const JestContextStoreSetter = ({
setContextStoreFilters(contextStoreFilters);
setContextStoreFilterGroups(contextStoreFilterGroups);
setContextStoreCurrentViewType(contextStoreCurrentViewType ?? null);
setContextStoreCurrentPageType(contextStoreCurrentPageType ?? null);
setIsLoaded(true);
}, [
setContextStoreTargetedRecordsRule,
@@ -114,6 +124,8 @@ export const JestContextStoreSetter = ({
contextStoreCurrentViewId,
setContextStoreCurrentViewType,
contextStoreCurrentViewType,
setContextStoreCurrentPageType,
contextStoreCurrentPageType,
setContextStoreFilterGroups,
contextStoreFilterGroups,
]);
@@ -26,6 +26,7 @@ export const getJestMetadataAndApolloMocksAndCommandMenuWrapper = ({
contextStoreTargetedRecordsRule,
contextStoreCurrentViewId,
contextStoreCurrentViewType,
contextStoreCurrentPageType,
contextStoreNumberOfSelectedRecords,
contextStoreCurrentObjectMetadataNameSingular,
contextStoreFilters,
@@ -70,6 +71,7 @@ export const getJestMetadataAndApolloMocksAndCommandMenuWrapper = ({
contextStoreCurrentObjectMetadataNameSingular
}
contextStoreCurrentViewType={contextStoreCurrentViewType}
contextStoreCurrentPageType={contextStoreCurrentPageType}
>
{children}
</JestContextStoreSetter>
@@ -3,7 +3,7 @@ import * as path from 'path';
import { transformConditionalAvailabilityExpressionsForEsBuildPlugin } from '@/cli/utilities/build/common/conditional-availability/utils/transform-conditional-availability-expressions';
import {
CommandMenuContextApiPageType,
ContextStorePageType,
type CommandMenuContextApi,
} from 'twenty-shared/types';
import { evaluateConditionalAvailabilityExpression } from 'twenty-shared/utils';
@@ -16,7 +16,7 @@ const readMock = (filename: string): string =>
const buildMockCommandMenuContextApi = (
overrides: Partial<CommandMenuContextApi> = {},
): CommandMenuContextApi => ({
pageType: CommandMenuContextApiPageType.INDEX_PAGE,
pageType: ContextStorePageType.Index,
isInSidePanel: false,
isPageInEditMode: false,
favoriteRecordIds: [],
@@ -190,7 +190,7 @@ describe('transformConditionalAvailabilityExpressionsForEsBuildPlugin', () => {
describe('simple-boolean-front-component', () => {
it('should evaluate pageType when RECORD_PAGE', () => {
const context = buildMockCommandMenuContextApi({
pageType: CommandMenuContextApiPageType.RECORD_PAGE,
pageType: ContextStorePageType.Record,
});
expect(
@@ -203,7 +203,7 @@ describe('transformConditionalAvailabilityExpressionsForEsBuildPlugin', () => {
it('should evaluate pageType when INDEX_PAGE', () => {
const context = buildMockCommandMenuContextApi({
pageType: CommandMenuContextApiPageType.INDEX_PAGE,
pageType: ContextStorePageType.Index,
});
expect(
@@ -348,7 +348,7 @@ describe('transformConditionalAvailabilityExpressionsForEsBuildPlugin', () => {
describe('parenthesized-expression-front-component', () => {
it('should allow when favorite and not remote', () => {
const context = buildMockCommandMenuContextApi({
pageType: CommandMenuContextApiPageType.INDEX_PAGE,
pageType: ContextStorePageType.Index,
favoriteRecordIds: ['rec-1'],
objectMetadataItem: { isRemote: false },
});
@@ -363,7 +363,7 @@ describe('transformConditionalAvailabilityExpressionsForEsBuildPlugin', () => {
it('should deny when remote even if record page', () => {
const context = buildMockCommandMenuContextApi({
pageType: CommandMenuContextApiPageType.RECORD_PAGE,
pageType: ContextStorePageType.Record,
favoriteRecordIds: [],
objectMetadataItem: { isRemote: true },
});
@@ -422,7 +422,7 @@ describe('transformConditionalAvailabilityExpressionsForEsBuildPlugin', () => {
describe('string-comparison-front-component', () => {
it('should match when on record page and company name matches', () => {
const context = buildMockCommandMenuContextApi({
pageType: CommandMenuContextApiPageType.RECORD_PAGE,
pageType: ContextStorePageType.Record,
selectedRecords: [
{
id: 'rec-1',
@@ -444,7 +444,7 @@ describe('transformConditionalAvailabilityExpressionsForEsBuildPlugin', () => {
it('should not match when company name differs', () => {
const context = buildMockCommandMenuContextApi({
pageType: CommandMenuContextApiPageType.RECORD_PAGE,
pageType: ContextStorePageType.Record,
selectedRecords: [
{
id: 'rec-1',
@@ -468,7 +468,7 @@ describe('transformConditionalAvailabilityExpressionsForEsBuildPlugin', () => {
describe('target-permissions-front-component', () => {
it('should allow when on record page with write permission', () => {
const context = buildMockCommandMenuContextApi({
pageType: CommandMenuContextApiPageType.RECORD_PAGE,
pageType: ContextStorePageType.Record,
targetObjectWritePermissions: { person: true },
});
@@ -482,7 +482,7 @@ describe('transformConditionalAvailabilityExpressionsForEsBuildPlugin', () => {
it('should deny when not on record page', () => {
const context = buildMockCommandMenuContextApi({
pageType: CommandMenuContextApiPageType.INDEX_PAGE,
pageType: ContextStorePageType.Index,
targetObjectWritePermissions: { person: true },
});
@@ -0,0 +1,105 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('1.23.0', 1775752781995)
export class AddStandalonePageFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."navigationMenuItem" ADD "pageLayoutId" uuid',
);
await queryRunner.query(
'ALTER TYPE "core"."pageLayout_type_enum" RENAME TO "pageLayout_type_enum_old"',
);
await queryRunner.query(
"CREATE TYPE \"core\".\"pageLayout_type_enum\" AS ENUM('RECORD_INDEX', 'RECORD_PAGE', 'DASHBOARD', 'STANDALONE_PAGE')",
);
await queryRunner.query(
'ALTER TABLE "core"."pageLayout" ALTER COLUMN "type" DROP DEFAULT',
);
await queryRunner.query(
'ALTER TABLE "core"."pageLayout" ALTER COLUMN "type" TYPE "core"."pageLayout_type_enum" USING "type"::"text"::"core"."pageLayout_type_enum"',
);
await queryRunner.query(
'ALTER TABLE "core"."pageLayout" ALTER COLUMN "type" SET DEFAULT \'RECORD_PAGE\'',
);
await queryRunner.query('DROP TYPE "core"."pageLayout_type_enum_old"');
await queryRunner.query(
'ALTER TABLE "core"."navigationMenuItem" DROP CONSTRAINT IF EXISTS "CHK_navigation_menu_item_type_fields"',
);
await queryRunner.query(
'ALTER TYPE "core"."navigationMenuItem_type_enum" RENAME TO "navigationMenuItem_type_enum_old"',
);
await queryRunner.query(
"CREATE TYPE \"core\".\"navigationMenuItem_type_enum\" AS ENUM('VIEW', 'FOLDER', 'LINK', 'OBJECT', 'RECORD', 'PAGE_LAYOUT')",
);
await queryRunner.query(
'ALTER TABLE "core"."navigationMenuItem" ALTER COLUMN "type" TYPE "core"."navigationMenuItem_type_enum" USING "type"::"text"::"core"."navigationMenuItem_type_enum"',
);
await queryRunner.query(
'DROP TYPE "core"."navigationMenuItem_type_enum_old"',
);
await queryRunner.query(
`ALTER TABLE "core"."navigationMenuItem" ADD CONSTRAINT "CHK_navigation_menu_item_type_fields" CHECK (("type" = 'FOLDER') OR ("type" = 'OBJECT' AND "targetObjectMetadataId" IS NOT NULL) OR ("type" = 'VIEW' AND "viewId" IS NOT NULL) OR ("type" = 'RECORD' AND "targetRecordId" IS NOT NULL AND "targetObjectMetadataId" IS NOT NULL) OR ("type" = 'LINK' AND "link" IS NOT NULL) OR ("type" = 'PAGE_LAYOUT' AND "pageLayoutId" IS NOT NULL))`,
);
await queryRunner.query(
'CREATE INDEX "IDX_NAVIGATION_MENU_ITEM_PAGE_LAYOUT_ID_WORKSPACE_ID" ON "core"."navigationMenuItem" ("pageLayoutId", "workspaceId") ',
);
await queryRunner.query(
'ALTER TABLE "core"."navigationMenuItem" ADD CONSTRAINT "FK_4ba3e5e988c4c5f159ec8753ee3" FOREIGN KEY ("pageLayoutId") REFERENCES "core"."pageLayout"("id") ON DELETE CASCADE ON UPDATE NO ACTION',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."navigationMenuItem" DROP CONSTRAINT "FK_4ba3e5e988c4c5f159ec8753ee3"',
);
await queryRunner.query(
'DROP INDEX "core"."IDX_NAVIGATION_MENU_ITEM_PAGE_LAYOUT_ID_WORKSPACE_ID"',
);
await queryRunner.query(
'ALTER TABLE "core"."navigationMenuItem" DROP CONSTRAINT IF EXISTS "CHK_navigation_menu_item_type_fields"',
);
await queryRunner.query(
'DELETE FROM "core"."navigationMenuItem" WHERE "type" = \'PAGE_LAYOUT\'',
);
await queryRunner.query(
"CREATE TYPE \"core\".\"navigationMenuItem_type_enum_old\" AS ENUM('FOLDER', 'LINK', 'OBJECT', 'RECORD', 'VIEW')",
);
await queryRunner.query(
'ALTER TABLE "core"."navigationMenuItem" ALTER COLUMN "type" TYPE "core"."navigationMenuItem_type_enum_old" USING "type"::"text"::"core"."navigationMenuItem_type_enum_old"',
);
await queryRunner.query('DROP TYPE "core"."navigationMenuItem_type_enum"');
await queryRunner.query(
'ALTER TYPE "core"."navigationMenuItem_type_enum_old" RENAME TO "navigationMenuItem_type_enum"',
);
await queryRunner.query(
`ALTER TABLE "core"."navigationMenuItem" ADD CONSTRAINT "CHK_navigation_menu_item_type_fields" CHECK (("type" = 'FOLDER') OR ("type" = 'OBJECT' AND "targetObjectMetadataId" IS NOT NULL) OR ("type" = 'VIEW' AND "viewId" IS NOT NULL) OR ("type" = 'RECORD' AND "targetRecordId" IS NOT NULL AND "targetObjectMetadataId" IS NOT NULL) OR ("type" = 'LINK' AND "link" IS NOT NULL))`,
);
await queryRunner.query(
'DELETE FROM "core"."pageLayout" WHERE "type" = \'STANDALONE_PAGE\'',
);
await queryRunner.query(
"CREATE TYPE \"core\".\"pageLayout_type_enum_old\" AS ENUM('DASHBOARD', 'RECORD_INDEX', 'RECORD_PAGE')",
);
await queryRunner.query(
'ALTER TABLE "core"."pageLayout" ALTER COLUMN "type" DROP DEFAULT',
);
await queryRunner.query(
'ALTER TABLE "core"."pageLayout" ALTER COLUMN "type" TYPE "core"."pageLayout_type_enum_old" USING "type"::"text"::"core"."pageLayout_type_enum_old"',
);
await queryRunner.query(
'ALTER TABLE "core"."pageLayout" ALTER COLUMN "type" SET DEFAULT \'RECORD_PAGE\'',
);
await queryRunner.query('DROP TYPE "core"."pageLayout_type_enum"');
await queryRunner.query(
'ALTER TYPE "core"."pageLayout_type_enum_old" RENAME TO "pageLayout_type_enum"',
);
await queryRunner.query(
'ALTER TABLE "core"."navigationMenuItem" DROP COLUMN "pageLayoutId"',
);
}
}
@@ -0,0 +1,54 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('1.23.0', 1776090711153)
export class AddGlobalObjectContextToCommandMenuItemAvailabilityTypeFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TYPE "core"."commandMenuItem_availabilitytype_enum" RENAME TO "commandMenuItem_availabilitytype_enum_old"',
);
await queryRunner.query(
"CREATE TYPE \"core\".\"commandMenuItem_availabilitytype_enum\" AS ENUM('GLOBAL', 'GLOBAL_OBJECT_CONTEXT', 'RECORD_SELECTION', 'FALLBACK')",
);
await queryRunner.query(
'ALTER TABLE "core"."commandMenuItem" ALTER COLUMN "availabilityType" DROP DEFAULT',
);
await queryRunner.query(
'ALTER TABLE "core"."commandMenuItem" ALTER COLUMN "availabilityType" TYPE "core"."commandMenuItem_availabilitytype_enum" USING "availabilityType"::"text"::"core"."commandMenuItem_availabilitytype_enum"',
);
await queryRunner.query(
'ALTER TABLE "core"."commandMenuItem" ALTER COLUMN "availabilityType" SET DEFAULT \'GLOBAL\'',
);
await queryRunner.query(
'DROP TYPE "core"."commandMenuItem_availabilitytype_enum_old"',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"CREATE TYPE \"core\".\"commandMenuItem_availabilitytype_enum_old\" AS ENUM('FALLBACK', 'GLOBAL', 'RECORD_SELECTION')",
);
await queryRunner.query(
'ALTER TABLE "core"."commandMenuItem" ALTER COLUMN "availabilityType" DROP DEFAULT',
);
await queryRunner.query(
`UPDATE "core"."commandMenuItem" SET "availabilityType" = 'GLOBAL' WHERE "availabilityType" = 'GLOBAL_OBJECT_CONTEXT'`,
);
await queryRunner.query(
'ALTER TABLE "core"."commandMenuItem" ALTER COLUMN "availabilityType" TYPE "core"."commandMenuItem_availabilitytype_enum_old" USING "availabilityType"::"text"::"core"."commandMenuItem_availabilitytype_enum_old"',
);
await queryRunner.query(
'ALTER TABLE "core"."commandMenuItem" ALTER COLUMN "availabilityType" SET DEFAULT \'GLOBAL\'',
);
await queryRunner.query(
'DROP TYPE "core"."commandMenuItem_availabilitytype_enum"',
);
await queryRunner.query(
'ALTER TYPE "core"."commandMenuItem_availabilitytype_enum_old" RENAME TO "commandMenuItem_availabilitytype_enum"',
);
}
}
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-workspace-command-1780000001000-backfill-page-layouts-and-fields-widget-view-fields.command';
import { UpdateGlobalObjectContextCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-workspace-command-1780000005000-update-global-object-context-command-menu-items.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@@ -15,6 +16,9 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
WorkspaceIteratorModule,
WorkspaceMigrationModule,
],
providers: [BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand],
providers: [
BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
UpdateGlobalObjectContextCommandMenuItemsCommand,
],
})
export class V1_23_UpgradeVersionCommandModule {}
@@ -0,0 +1,143 @@
import { Command } from 'nest-commander';
import { isDefined } from 'twenty-shared/utils';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { STANDARD_COMMAND_MENU_ITEMS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant';
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
const UNIVERSAL_IDENTIFIERS_TO_FIX = new Set<string>([
STANDARD_COMMAND_MENU_ITEMS.createNewRecord.universalIdentifier,
STANDARD_COMMAND_MENU_ITEMS.importRecords.universalIdentifier,
STANDARD_COMMAND_MENU_ITEMS.exportView.universalIdentifier,
STANDARD_COMMAND_MENU_ITEMS.seeDeletedRecords.universalIdentifier,
STANDARD_COMMAND_MENU_ITEMS.createNewView.universalIdentifier,
STANDARD_COMMAND_MENU_ITEMS.hideDeletedRecords.universalIdentifier,
]);
@RegisteredWorkspaceCommand('1.23.0', 1780000005000)
@Command({
name: 'upgrade:1-23:update-global-object-context-command-menu-items',
description:
'Update command menu items that require object context from GLOBAL to GLOBAL_OBJECT_CONTEXT',
})
export class UpdateGlobalObjectContextCommandMenuItemsCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Starting GLOBAL_OBJECT_CONTEXT availability type update for workspace ${workspaceId}`,
);
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const { flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatCommandMenuItemMaps',
]);
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
computeTwentyStandardApplicationAllFlatEntityMaps({
shouldIncludeRecordPageLayouts: true,
now: new Date().toISOString(),
workspaceId,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
});
const itemsToUpdate = [...UNIVERSAL_IDENTIFIERS_TO_FIX]
.map((universalIdentifier) => {
const standardItem =
standardAllFlatEntityMaps.flatCommandMenuItemMaps
.byUniversalIdentifier[universalIdentifier];
const existingItem =
existingFlatCommandMenuItemMaps.byUniversalIdentifier[
universalIdentifier
];
if (
!isDefined(standardItem) ||
!isDefined(existingItem) ||
existingItem.availabilityType === standardItem.availabilityType
) {
return undefined;
}
return {
...existingItem,
availabilityType: standardItem.availabilityType,
updatedAt: new Date().toISOString(),
};
})
.filter(isDefined);
if (itemsToUpdate.length === 0) {
this.logger.log(
`Command menu item availability types already up to date for workspace ${workspaceId}`,
);
return;
}
this.logger.log(
`Found ${itemsToUpdate.length} command menu item(s) to update for workspace ${workspaceId}`,
);
if (isDryRun) {
this.logger.log(
`[DRY RUN] Would update ${itemsToUpdate.length} command menu item availability type(s) for workspace ${workspaceId}`,
);
return;
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
commandMenuItem: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: itemsToUpdate,
},
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
this.logger.error(
`Failed to update command menu item availability types:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
);
throw new Error(
`Failed to update command menu item availability types for workspace ${workspaceId}`,
);
}
this.logger.log(
`Successfully updated ${itemsToUpdate.length} command menu item availability type(s) for workspace ${workspaceId}`,
);
}
}
@@ -3,6 +3,7 @@
import { AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-instance-command-fast-1775129420309-add-view-field-group-id-index-on-view-field';
import { MigrateMessagingCalendarToCoreFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-instance-command-fast-1775165049548-migrate-messaging-calendar-to-core';
import { AddEmailThreadWidgetTypeFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-instance-command-fast-1775200000000-add-email-thread-widget-type';
import { AddStandalonePageFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1775752781995-add-standalone-page';
import { AddPermissionFlagRoleIdIndexFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-instance-command-fast-1775749486425-add-permission-flag-role-id-index';
import { AddWorkspaceIdToIndirectEntitiesFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-instance-command-fast-1775758621017-add-workspace-id-to-indirect-entities';
import { AddWorkspaceIdIndexesAndFksFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-instance-command-fast-1775761294897-add-workspace-id-indexes-and-fks-to-indirect-entities';
@@ -10,11 +11,13 @@ import { DropObjectMetadataDataSourceFkFastInstanceCommand } from 'src/database/
import { AddCreditBalanceToBillingCustomerFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-instance-command-fast-1776078919203-add-credit-balance-to-billing-customer';
import { BackfillWorkspaceIdOnIndirectEntitiesSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-instance-command-slow-1775758621018-backfill-workspace-id-on-indirect-entities';
import { DropWorkspaceVersionColumnFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1785000000000-drop-workspace-version-column';
import { AddGlobalObjectContextToCommandMenuItemAvailabilityTypeFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1776090711153-add-global-object-context-to-command-menu-item-availability-type';
export const INSTANCE_COMMANDS = [
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
MigrateMessagingCalendarToCoreFastInstanceCommand,
AddEmailThreadWidgetTypeFastInstanceCommand,
AddStandalonePageFastInstanceCommand,
AddPermissionFlagRoleIdIndexFastInstanceCommand,
AddWorkspaceIdToIndirectEntitiesFastInstanceCommand,
BackfillWorkspaceIdOnIndirectEntitiesSlowInstanceCommand,
@@ -22,4 +25,5 @@ export const INSTANCE_COMMANDS = [
DropObjectMetadataDataSourceFkFastInstanceCommand,
AddCreditBalanceToBillingCustomerFastInstanceCommand,
DropWorkspaceVersionColumnFastInstanceCommand,
AddGlobalObjectContextToCommandMenuItemAvailabilityTypeFastInstanceCommand,
];
@@ -9,6 +9,7 @@ const AVAILABILITY_TYPE_MAP: Record<
CommandMenuItemAvailabilityType
> = {
GLOBAL: CommandMenuItemAvailabilityType.GLOBAL,
GLOBAL_OBJECT_CONTEXT: CommandMenuItemAvailabilityType.GLOBAL_OBJECT_CONTEXT,
RECORD_SELECTION: CommandMenuItemAvailabilityType.RECORD_SELECTION,
FALLBACK: CommandMenuItemAvailabilityType.FALLBACK,
};
@@ -27,6 +27,8 @@ export const fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem =
navigationMenuItemManifest.folderUniversalIdentifier ?? null,
targetObjectMetadataUniversalIdentifier:
navigationMenuItemManifest.targetObjectUniversalIdentifier ?? null,
pageLayoutUniversalIdentifier:
navigationMenuItemManifest.pageLayoutUniversalIdentifier ?? null,
targetRecordId: null,
userWorkspaceId: null,
createdAt: now,
@@ -1100,7 +1100,12 @@ export const computeMetadataSchemaComponents = (
name: { type: 'string' },
type: {
type: 'string',
enum: ['RECORD_INDEX', 'RECORD_PAGE', 'DASHBOARD'],
enum: [
'RECORD_INDEX',
'RECORD_PAGE',
'DASHBOARD',
'STANDALONE_PAGE',
],
default: 'RECORD_PAGE',
},
objectMetadataId: { type: 'string', format: 'uuid' },
@@ -1121,7 +1126,12 @@ export const computeMetadataSchemaComponents = (
name: { type: 'string' },
type: {
type: 'string',
enum: ['RECORD_INDEX', 'RECORD_PAGE', 'DASHBOARD'],
enum: [
'RECORD_INDEX',
'RECORD_PAGE',
'DASHBOARD',
'STANDALONE_PAGE',
],
},
objectMetadataId: { type: 'string', format: 'uuid' },
},
@@ -1134,7 +1144,12 @@ export const computeMetadataSchemaComponents = (
name: { type: 'string' },
type: {
type: 'string',
enum: ['RECORD_INDEX', 'RECORD_PAGE', 'DASHBOARD'],
enum: [
'RECORD_INDEX',
'RECORD_PAGE',
'DASHBOARD',
'STANDALONE_PAGE',
],
},
objectMetadataId: { type: 'string', format: 'uuid' },
tabs: {
@@ -2,6 +2,7 @@ import { registerEnumType } from '@nestjs/graphql';
export enum CommandMenuItemAvailabilityType {
GLOBAL = 'GLOBAL',
GLOBAL_OBJECT_CONTEXT = 'GLOBAL_OBJECT_CONTEXT',
RECORD_SELECTION = 'RECORD_SELECTION',
FALLBACK = 'FALLBACK',
}
@@ -1169,6 +1169,11 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toStringify: false,
universalProperty: 'targetObjectMetadataUniversalIdentifier',
},
pageLayoutId: {
toCompare: false,
toStringify: false,
universalProperty: 'pageLayoutUniversalIdentifier',
},
},
permissionFlag: {
flag: {
@@ -64,6 +64,9 @@ export const ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY = {
view: {
foreignKey: 'viewId',
},
pageLayout: {
foreignKey: 'pageLayoutId',
},
},
fieldMetadata: {
object: {
@@ -112,6 +112,13 @@ export const ALL_MANY_TO_ONE_METADATA_RELATIONS = {
isNullable: true,
universalForeignKey: 'viewUniversalIdentifier',
},
pageLayout: {
metadataName: 'pageLayout',
foreignKey: 'pageLayoutId',
inverseOneToManyProperty: null,
isNullable: true,
universalForeignKey: 'pageLayoutUniversalIdentifier',
},
},
fieldMetadata: {
object: {
@@ -73,6 +73,7 @@ export const ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION = {
navigationMenuItem: {
objectMetadata: true,
view: true,
pageLayout: true,
},
permissionFlag: {
role: true,
@@ -44,6 +44,7 @@ exports[`getMetadataRelatedMetadataNames should return related metadata names fo
"objectMetadata",
"navigationMenuItem",
"view",
"pageLayout",
]
`;
@@ -3,8 +3,8 @@
exports[`sortMetadataNamesChildrenFirst should return metadata names sorted with children first (most manyToOne relations first) 1`] = `
[
"rowLevelPermissionPredicate",
"fieldPermission",
"navigationMenuItem",
"fieldPermission",
"viewField",
"viewFilter",
"commandMenuItem",
@@ -7,4 +7,5 @@ export const FLAT_NAVIGATION_MENU_ITEM_EDITABLE_PROPERTIES = [
'link',
'icon',
'color',
'pageLayoutId',
] as const satisfies MetadataEntityPropertyName<'navigationMenuItem'>[];
@@ -6,6 +6,7 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata
import { WorkspaceFlatNavigationMenuItemMapCacheService } from 'src/engine/metadata-modules/flat-navigation-menu-item/services/workspace-flat-navigation-menu-item-map-cache.service';
import { NavigationMenuItemEntity } from 'src/engine/metadata-modules/navigation-menu-item/entities/navigation-menu-item.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PageLayoutEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout.entity';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
@Module({
@@ -14,6 +15,7 @@ import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entit
NavigationMenuItemEntity,
ApplicationEntity,
ObjectMetadataEntity,
PageLayoutEntity,
ViewEntity,
]),
WorkspaceManyOrAllFlatEntityMapsCacheModule,
@@ -12,6 +12,7 @@ import { addFlatNavigationMenuItemToMapsAndUpdateIndex } from 'src/engine/metada
import { fromNavigationMenuItemEntityToFlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/utils/from-navigation-menu-item-entity-to-flat-navigation-menu-item.util';
import { NavigationMenuItemEntity } from 'src/engine/metadata-modules/navigation-menu-item/entities/navigation-menu-item.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PageLayoutEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout.entity';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
import { createIdToUniversalIdentifierMap } from 'src/engine/workspace-cache/utils/create-id-to-universal-identifier-map.util';
@@ -28,6 +29,8 @@ export class WorkspaceFlatNavigationMenuItemMapCacheService extends WorkspaceCac
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
@InjectRepository(ViewEntity)
private readonly viewRepository: Repository<ViewEntity>,
@InjectRepository(PageLayoutEntity)
private readonly pageLayoutRepository: Repository<PageLayoutEntity>,
) {
super();
}
@@ -35,28 +38,38 @@ export class WorkspaceFlatNavigationMenuItemMapCacheService extends WorkspaceCac
async computeForCache(
workspaceId: string,
): Promise<FlatNavigationMenuItemMaps> {
const [navigationMenuItems, applications, objectMetadatas, views] =
await Promise.all([
this.navigationMenuItemRepository.find({
where: { workspaceId },
withDeleted: true,
}),
this.applicationRepository.find({
where: { workspaceId },
select: ['id', 'universalIdentifier'],
withDeleted: true,
}),
this.objectMetadataRepository.find({
where: { workspaceId },
select: ['id', 'universalIdentifier'],
withDeleted: true,
}),
this.viewRepository.find({
where: { workspaceId },
select: ['id', 'universalIdentifier'],
withDeleted: true,
}),
]);
const [
navigationMenuItems,
applications,
objectMetadatas,
views,
pageLayouts,
] = await Promise.all([
this.navigationMenuItemRepository.find({
where: { workspaceId },
withDeleted: true,
}),
this.applicationRepository.find({
where: { workspaceId },
select: ['id', 'universalIdentifier'],
withDeleted: true,
}),
this.objectMetadataRepository.find({
where: { workspaceId },
select: ['id', 'universalIdentifier'],
withDeleted: true,
}),
this.viewRepository.find({
where: { workspaceId },
select: ['id', 'universalIdentifier'],
withDeleted: true,
}),
this.pageLayoutRepository.find({
where: { workspaceId },
select: ['id', 'universalIdentifier'],
withDeleted: true,
}),
]);
const applicationIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(applications);
@@ -66,6 +79,8 @@ export class WorkspaceFlatNavigationMenuItemMapCacheService extends WorkspaceCac
createIdToUniversalIdentifierMap(navigationMenuItems);
const viewIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(views);
const pageLayoutIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(pageLayouts);
const flatNavigationMenuItemMaps = {
...createEmptyFlatEntityMaps(),
@@ -80,6 +95,7 @@ export class WorkspaceFlatNavigationMenuItemMapCacheService extends WorkspaceCac
objectMetadataIdToUniversalIdentifierMap,
navigationMenuItemIdToUniversalIdentifierMap,
viewIdToUniversalIdentifierMap,
pageLayoutIdToUniversalIdentifierMap,
});
addFlatNavigationMenuItemToMapsAndUpdateIndex({
@@ -16,6 +16,7 @@ export const fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate =
flatNavigationMenuItemMaps,
flatObjectMetadataMaps,
flatViewMaps,
flatPageLayoutMaps,
}: {
createNavigationMenuItemInput: CreateNavigationMenuItemInput;
workspaceId: string;
@@ -23,7 +24,7 @@ export const fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate =
flatNavigationMenuItemMaps: FlatNavigationMenuItemMaps;
} & Pick<
AllFlatEntityMaps,
'flatObjectMetadataMaps' | 'flatViewMaps'
'flatObjectMetadataMaps' | 'flatViewMaps' | 'flatPageLayoutMaps'
>): FlatNavigationMenuItem => {
const id = createNavigationMenuItemInput.id ?? uuidv4();
const now = new Date().toISOString();
@@ -52,6 +53,7 @@ export const fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate =
targetObjectMetadataUniversalIdentifier,
viewUniversalIdentifier,
folderUniversalIdentifier,
pageLayoutUniversalIdentifier,
} = resolveEntityRelationUniversalIdentifiers({
metadataName: 'navigationMenuItem',
foreignKeyValues: {
@@ -59,11 +61,13 @@ export const fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate =
createNavigationMenuItemInput.targetObjectMetadataId,
viewId: createNavigationMenuItemInput.viewId,
folderId: createNavigationMenuItemInput.folderId,
pageLayoutId: createNavigationMenuItemInput.pageLayoutId,
},
flatEntityMaps: {
flatObjectMetadataMaps,
flatViewMaps,
flatNavigationMenuItemMaps,
flatPageLayoutMaps,
},
});
@@ -80,6 +84,8 @@ export const fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate =
viewUniversalIdentifier,
folderId: createNavigationMenuItemInput.folderId ?? null,
folderUniversalIdentifier,
pageLayoutId: createNavigationMenuItemInput.pageLayoutId ?? null,
pageLayoutUniversalIdentifier,
name: createNavigationMenuItemInput.name ?? null,
link: createNavigationMenuItemInput.link ?? null,
icon: createNavigationMenuItemInput.icon ?? null,
@@ -16,6 +16,7 @@ export const fromFlatNavigationMenuItemToNavigationMenuItemDto = (
link: flatNavigationMenuItem.link ?? undefined,
icon: flatNavigationMenuItem.icon ?? undefined,
color: flatNavigationMenuItem.color ?? undefined,
pageLayoutId: flatNavigationMenuItem.pageLayoutId ?? undefined,
position: flatNavigationMenuItem.position,
workspaceId: flatNavigationMenuItem.workspaceId,
applicationId: flatNavigationMenuItem.applicationId ?? undefined,
@@ -13,6 +13,7 @@ export const fromNavigationMenuItemEntityToFlatNavigationMenuItem = ({
objectMetadataIdToUniversalIdentifierMap,
navigationMenuItemIdToUniversalIdentifierMap,
viewIdToUniversalIdentifierMap,
pageLayoutIdToUniversalIdentifierMap,
}: FromEntityToFlatEntityArgs<'navigationMenuItem'>): FlatNavigationMenuItem => {
const applicationUniversalIdentifier =
applicationIdToUniversalIdentifierMap.get(
@@ -73,6 +74,22 @@ export const fromNavigationMenuItemEntityToFlatNavigationMenuItem = ({
}
}
let pageLayoutUniversalIdentifier: string | null = null;
if (isDefined(navigationMenuItemEntity.pageLayoutId)) {
pageLayoutUniversalIdentifier =
pageLayoutIdToUniversalIdentifierMap.get(
navigationMenuItemEntity.pageLayoutId,
) ?? null;
if (!isDefined(pageLayoutUniversalIdentifier)) {
throw new FlatEntityMapsException(
`PageLayout with id ${navigationMenuItemEntity.pageLayoutId} not found for navigationMenuItem ${navigationMenuItemEntity.id}`,
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
);
}
}
return {
id: navigationMenuItemEntity.id,
type: navigationMenuItemEntity.type,
@@ -85,6 +102,7 @@ export const fromNavigationMenuItemEntityToFlatNavigationMenuItem = ({
link: navigationMenuItemEntity.link,
icon: navigationMenuItemEntity.icon,
color: navigationMenuItemEntity.color,
pageLayoutId: navigationMenuItemEntity.pageLayoutId,
position: navigationMenuItemEntity.position,
workspaceId: navigationMenuItemEntity.workspaceId,
universalIdentifier: navigationMenuItemEntity.universalIdentifier,
@@ -95,5 +113,6 @@ export const fromNavigationMenuItemEntityToFlatNavigationMenuItem = ({
targetObjectMetadataUniversalIdentifier,
folderUniversalIdentifier,
viewUniversalIdentifier,
pageLayoutUniversalIdentifier,
};
};
@@ -1,5 +1,6 @@
import { isDefined } from 'twenty-shared/utils';
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { resolveEntityRelationUniversalIdentifiers } from 'src/engine/metadata-modules/flat-entity/utils/resolve-entity-relation-universal-identifiers.util';
import { FLAT_NAVIGATION_MENU_ITEM_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-navigation-menu-item/constants/flat-navigation-menu-item-editable-properties.constant';
@@ -15,13 +16,17 @@ import { mergeUpdateInExistingRecord } from 'src/utils/merge-update-in-existing-
export const fromUpdateNavigationMenuItemInputToFlatNavigationMenuItemToUpdateOrThrow =
({
flatNavigationMenuItemMaps,
flatPageLayoutMaps,
updateNavigationMenuItemInput,
}: {
flatNavigationMenuItemMaps: FlatNavigationMenuItemMaps;
updateNavigationMenuItemInput: UpdateNavigationMenuItemInput & {
id: string;
};
}): FlatNavigationMenuItem => {
} & Pick<
AllFlatEntityMaps,
'flatPageLayoutMaps'
>): FlatNavigationMenuItem => {
const existingFlatNavigationMenuItem = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: updateNavigationMenuItemInput.id,
flatEntityMaps: flatNavigationMenuItemMaps,
@@ -59,5 +64,19 @@ export const fromUpdateNavigationMenuItemInputToFlatNavigationMenuItemToUpdateOr
folderUniversalIdentifier;
}
if (updates.pageLayoutId !== undefined) {
const { pageLayoutUniversalIdentifier } =
resolveEntityRelationUniversalIdentifiers({
metadataName: 'navigationMenuItem',
foreignKeyValues: {
pageLayoutId: flatNavigationMenuItemToUpdate.pageLayoutId,
},
flatEntityMaps: { flatPageLayoutMaps },
});
flatNavigationMenuItemToUpdate.pageLayoutUniversalIdentifier =
pageLayoutUniversalIdentifier;
}
return flatNavigationMenuItemToUpdate;
};
@@ -67,6 +67,11 @@ export class CreateNavigationMenuItemInput {
@Field(() => UUIDScalarType, { nullable: true })
folderId?: string;
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
pageLayoutId?: string | null;
@IsNumber()
@IsOptional()
@Field({ nullable: true })
@@ -66,6 +66,11 @@ export class NavigationMenuItemDTO {
@Field(() => UUIDScalarType, { nullable: true })
folderId?: string;
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
pageLayoutId?: string | null;
@IsNumber()
@IsNotEmpty()
@Field()
@@ -43,6 +43,11 @@ export class UpdateNavigationMenuItemInput {
@IsString()
@Field(() => String, { nullable: true })
color?: string | null;
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
pageLayoutId?: string | null;
}
@InputType()
@@ -13,6 +13,7 @@ import {
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PageLayoutEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout.entity';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
@@ -35,13 +36,18 @@ import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-enti
'viewId',
'workspaceId',
])
@Index('IDX_NAVIGATION_MENU_ITEM_PAGE_LAYOUT_ID_WORKSPACE_ID', [
'pageLayoutId',
'workspaceId',
])
@Check(
'CHK_navigation_menu_item_type_fields',
`("type" = 'FOLDER')
OR ("type" = 'OBJECT' AND "targetObjectMetadataId" IS NOT NULL)
OR ("type" = 'VIEW' AND "viewId" IS NOT NULL)
OR ("type" = 'RECORD' AND "targetRecordId" IS NOT NULL AND "targetObjectMetadataId" IS NOT NULL)
OR ("type" = 'LINK' AND "link" IS NOT NULL)`,
OR ("type" = 'LINK' AND "link" IS NOT NULL)
OR ("type" = 'PAGE_LAYOUT' AND "pageLayoutId" IS NOT NULL)`,
)
export class NavigationMenuItemEntity
extends SyncableEntity
@@ -112,6 +118,16 @@ export class NavigationMenuItemEntity
@Column({ nullable: true, type: 'uuid' })
folderId: string | null;
@ManyToOne(() => PageLayoutEntity, {
onDelete: 'CASCADE',
nullable: true,
})
@JoinColumn({ name: 'pageLayoutId' })
pageLayout: Relation<PageLayoutEntity> | null;
@Column({ nullable: true, type: 'uuid' })
pageLayoutId: string | null;
@Column({ nullable: false, type: 'double precision' })
position: number;
@@ -186,6 +186,7 @@ export class NavigationMenuItemService {
flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps,
flatObjectMetadataMaps,
flatViewMaps,
flatPageLayoutMaps,
} = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
@@ -193,6 +194,7 @@ export class NavigationMenuItemService {
'flatNavigationMenuItemMaps',
'flatObjectMetadataMaps',
'flatViewMaps',
'flatPageLayoutMaps',
],
},
);
@@ -230,6 +232,7 @@ export class NavigationMenuItemService {
flatNavigationMenuItemMaps: optimisticFlatNavigationMenuItemMaps,
flatObjectMetadataMaps,
flatViewMaps,
flatPageLayoutMaps,
});
addFlatNavigationMenuItemToMapsAndUpdateIndex({
@@ -341,11 +344,14 @@ export class NavigationMenuItemService {
{ workspaceId },
);
const { flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps } =
const {
flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps,
flatPageLayoutMaps,
} =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatNavigationMenuItemMaps'],
flatMapsKeys: ['flatNavigationMenuItemMaps', 'flatPageLayoutMaps'],
},
);
@@ -378,6 +384,7 @@ export class NavigationMenuItemService {
fromUpdateNavigationMenuItemInputToFlatNavigationMenuItemToUpdateOrThrow(
{
flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps,
flatPageLayoutMaps,
updateNavigationMenuItemInput: updateInput,
},
),
@@ -835,6 +835,8 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
viewUniversalIdentifier: null,
folderId: null,
folderUniversalIdentifier: null,
pageLayoutId: null,
pageLayoutUniversalIdentifier: null,
name: null,
link: null,
icon: null,
@@ -2,4 +2,5 @@ export enum PageLayoutType {
RECORD_INDEX = 'RECORD_INDEX',
RECORD_PAGE = 'RECORD_PAGE',
DASHBOARD = 'DASHBOARD',
STANDALONE_PAGE = 'STANDALONE_PAGE',
}
@@ -0,0 +1,3 @@
export const NAVIGATION_MENU_ITEM_SEEDS = {
DOCUMENTATION_PAGE: 'DOCUMENTATION_PAGE_NAV_ITEM',
};
@@ -2,4 +2,5 @@ export const PAGE_LAYOUT_SEEDS = {
SALES_DASHBOARD: 'SALES_DASHBOARD',
CUSTOMER_DASHBOARD: 'CUSTOMER_DASHBOARD',
TEAM_DASHBOARD: 'TEAM_DASHBOARD',
DOCUMENTATION_STANDALONE_PAGE: 'DOCUMENTATION_STANDALONE_PAGE',
};
@@ -5,4 +5,5 @@ export const PAGE_LAYOUT_TAB_SEEDS = {
CUSTOMER_ANALYTICS: 'CUSTOMER_ANALYTICS_TAB',
TEAM_OVERVIEW: 'TEAM_OVERVIEW_TAB',
TEAM_METRICS: 'TEAM_METRICS_TAB',
DOCUMENTATION: 'DOCUMENTATION',
};
@@ -21,4 +21,5 @@ export const PAGE_LAYOUT_WIDGET_SEEDS = {
TEAM_OPEN_TASKS: 'TEAM_OPEN_TASKS_WIDGET',
FRONT_COMPONENT: 'FRONT_COMPONENT_WIDGET',
DOCUMENTATION_IFRAME: 'DOCUMENTATION_IFRAME_WIDGET',
};
@@ -0,0 +1,56 @@
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { type FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type';
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
import { NAVIGATION_MENU_ITEM_SEEDS } from 'src/engine/workspace-manager/dev-seeder/core/constants/navigation-menu-item-seeds.constant';
import { PAGE_LAYOUT_SEEDS } from 'src/engine/workspace-manager/dev-seeder/core/constants/page-layout-seeds.constant';
import { generateSeedId } from 'src/engine/workspace-manager/dev-seeder/core/utils/generate-seed-id.util';
export const getNavigationMenuItemFlatEntitySeeds = ({
workspaceId,
flatApplication,
}: {
workspaceId: string;
flatApplication: FlatApplication;
}): FlatNavigationMenuItem[] => {
const now = new Date().toISOString();
return [
{
id: generateSeedId(
workspaceId,
NAVIGATION_MENU_ITEM_SEEDS.DOCUMENTATION_PAGE,
),
universalIdentifier: generateSeedId(
workspaceId,
NAVIGATION_MENU_ITEM_SEEDS.DOCUMENTATION_PAGE,
),
applicationId: flatApplication.id,
applicationUniversalIdentifier: flatApplication.universalIdentifier,
workspaceId,
type: NavigationMenuItemType.PAGE_LAYOUT,
name: 'Star History',
icon: 'IconStar',
color: 'yellow',
position: 9999,
link: null,
userWorkspaceId: null,
targetRecordId: null,
targetObjectMetadataId: null,
targetObjectMetadataUniversalIdentifier: null,
viewId: null,
viewUniversalIdentifier: null,
folderId: null,
folderUniversalIdentifier: null,
pageLayoutId: generateSeedId(
workspaceId,
PAGE_LAYOUT_SEEDS.DOCUMENTATION_STANDALONE_PAGE,
),
pageLayoutUniversalIdentifier: generateSeedId(
workspaceId,
PAGE_LAYOUT_SEEDS.DOCUMENTATION_STANDALONE_PAGE,
),
createdAt: now,
updatedAt: now,
},
];
};
@@ -1,48 +1,105 @@
import { v4 } from 'uuid';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { type FlatPageLayout } from 'src/engine/metadata-modules/flat-page-layout/types/flat-page-layout.type';
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
import { PAGE_LAYOUT_SEEDS } from 'src/engine/workspace-manager/dev-seeder/core/constants/page-layout-seeds.constant';
import { generateSeedId } from 'src/engine/workspace-manager/dev-seeder/core/utils/generate-seed-id.util';
type PageLayoutSeed = {
id: string;
name: string;
type: PageLayoutType;
objectMetadataId: string | null;
export const getPageLayoutFlatEntitySeeds = ({
workspaceId,
flatApplication,
}: {
workspaceId: string;
universalIdentifier: string;
applicationId: string;
};
flatApplication: FlatApplication;
}): FlatPageLayout[] => {
const now = new Date().toISOString();
export const getPageLayoutDataSeeds = (
workspaceId: string,
applicationId: string,
): PageLayoutSeed[] => [
{
id: generateSeedId(workspaceId, PAGE_LAYOUT_SEEDS.SALES_DASHBOARD),
name: 'Sales Dashboard Layout',
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
workspaceId,
universalIdentifier: v4(),
applicationId,
},
{
id: generateSeedId(workspaceId, PAGE_LAYOUT_SEEDS.CUSTOMER_DASHBOARD),
name: 'Customer Dashboard Layout',
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
workspaceId,
universalIdentifier: v4(),
applicationId,
},
{
id: generateSeedId(workspaceId, PAGE_LAYOUT_SEEDS.TEAM_DASHBOARD),
name: 'Team Dashboard Layout',
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
workspaceId,
universalIdentifier: v4(),
applicationId,
},
];
return [
{
id: generateSeedId(workspaceId, PAGE_LAYOUT_SEEDS.SALES_DASHBOARD),
universalIdentifier: generateSeedId(
workspaceId,
PAGE_LAYOUT_SEEDS.SALES_DASHBOARD,
),
applicationId: flatApplication.id,
applicationUniversalIdentifier: flatApplication.universalIdentifier,
workspaceId,
name: 'Sales Dashboard Layout',
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
objectMetadataUniversalIdentifier: null,
tabIds: [],
tabUniversalIdentifiers: [],
defaultTabToFocusOnMobileAndSidePanelId: null,
defaultTabToFocusOnMobileAndSidePanelUniversalIdentifier: null,
createdAt: now,
updatedAt: now,
deletedAt: null,
},
{
id: generateSeedId(workspaceId, PAGE_LAYOUT_SEEDS.CUSTOMER_DASHBOARD),
universalIdentifier: generateSeedId(
workspaceId,
PAGE_LAYOUT_SEEDS.CUSTOMER_DASHBOARD,
),
applicationId: flatApplication.id,
applicationUniversalIdentifier: flatApplication.universalIdentifier,
workspaceId,
name: 'Customer Dashboard Layout',
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
objectMetadataUniversalIdentifier: null,
tabIds: [],
tabUniversalIdentifiers: [],
defaultTabToFocusOnMobileAndSidePanelId: null,
defaultTabToFocusOnMobileAndSidePanelUniversalIdentifier: null,
createdAt: now,
updatedAt: now,
deletedAt: null,
},
{
id: generateSeedId(workspaceId, PAGE_LAYOUT_SEEDS.TEAM_DASHBOARD),
universalIdentifier: generateSeedId(
workspaceId,
PAGE_LAYOUT_SEEDS.TEAM_DASHBOARD,
),
applicationId: flatApplication.id,
applicationUniversalIdentifier: flatApplication.universalIdentifier,
workspaceId,
name: 'Team Dashboard Layout',
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
objectMetadataUniversalIdentifier: null,
tabIds: [],
tabUniversalIdentifiers: [],
defaultTabToFocusOnMobileAndSidePanelId: null,
defaultTabToFocusOnMobileAndSidePanelUniversalIdentifier: null,
createdAt: now,
updatedAt: now,
deletedAt: null,
},
{
id: generateSeedId(
workspaceId,
PAGE_LAYOUT_SEEDS.DOCUMENTATION_STANDALONE_PAGE,
),
universalIdentifier: generateSeedId(
workspaceId,
PAGE_LAYOUT_SEEDS.DOCUMENTATION_STANDALONE_PAGE,
),
applicationId: flatApplication.id,
applicationUniversalIdentifier: flatApplication.universalIdentifier,
workspaceId,
name: 'Documentation',
type: PageLayoutType.STANDALONE_PAGE,
objectMetadataId: null,
objectMetadataUniversalIdentifier: null,
tabIds: [],
tabUniversalIdentifiers: [],
defaultTabToFocusOnMobileAndSidePanelId: null,
defaultTabToFocusOnMobileAndSidePanelUniversalIdentifier: null,
createdAt: now,
updatedAt: now,
deletedAt: null,
},
];
};
@@ -1,98 +1,88 @@
import { v4 } from 'uuid';
import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { type FlatPageLayoutTab } from 'src/engine/metadata-modules/flat-page-layout-tab/types/flat-page-layout-tab.type';
import { PAGE_LAYOUT_SEEDS } from 'src/engine/workspace-manager/dev-seeder/core/constants/page-layout-seeds.constant';
import { PAGE_LAYOUT_TAB_SEEDS } from 'src/engine/workspace-manager/dev-seeder/core/constants/page-layout-tab-seeds.constant';
import { generateSeedId } from 'src/engine/workspace-manager/dev-seeder/core/utils/generate-seed-id.util';
type PageLayoutTabSeed = {
id: string;
title: string;
position: number;
pageLayoutId: string;
workspaceId: string;
} & Pick<
FlatPageLayoutTab,
'applicationId' | 'universalIdentifier' | 'overrides'
>;
export const getPageLayoutTabDataSeeds = ({
applicationId,
export const getPageLayoutTabFlatEntitySeeds = ({
workspaceId,
flatApplication,
}: {
workspaceId: string;
applicationId: string;
}): PageLayoutTabSeed[] => [
{
id: generateSeedId(workspaceId, PAGE_LAYOUT_TAB_SEEDS.SALES_OVERVIEW),
title: 'Overview',
position: 0,
pageLayoutId: generateSeedId(
workspaceId,
flatApplication: FlatApplication;
}): FlatPageLayoutTab[] => {
const now = new Date().toISOString();
const buildTab = (
tabSeed: string,
title: string,
position: number,
pageLayoutSeed: string,
): FlatPageLayoutTab => ({
id: generateSeedId(workspaceId, tabSeed),
universalIdentifier: generateSeedId(workspaceId, tabSeed),
applicationId: flatApplication.id,
applicationUniversalIdentifier: flatApplication.universalIdentifier,
workspaceId,
title,
position,
pageLayoutId: generateSeedId(workspaceId, pageLayoutSeed),
pageLayoutUniversalIdentifier: generateSeedId(workspaceId, pageLayoutSeed),
widgetIds: [],
widgetUniversalIdentifiers: [],
isActive: true,
icon: null,
layoutMode: PageLayoutTabLayoutMode.GRID,
overrides: null,
createdAt: now,
updatedAt: now,
deletedAt: null,
});
return [
buildTab(
PAGE_LAYOUT_TAB_SEEDS.SALES_OVERVIEW,
'Overview',
0,
PAGE_LAYOUT_SEEDS.SALES_DASHBOARD,
),
workspaceId,
universalIdentifier: v4(),
applicationId,
overrides: null,
},
{
id: generateSeedId(workspaceId, PAGE_LAYOUT_TAB_SEEDS.SALES_DETAILS),
title: 'Details',
position: 1,
pageLayoutId: generateSeedId(
workspaceId,
buildTab(
PAGE_LAYOUT_TAB_SEEDS.SALES_DETAILS,
'Details',
1,
PAGE_LAYOUT_SEEDS.SALES_DASHBOARD,
),
workspaceId,
universalIdentifier: v4(),
applicationId,
overrides: null,
},
{
id: generateSeedId(workspaceId, PAGE_LAYOUT_TAB_SEEDS.CUSTOMER_OVERVIEW),
title: 'Overview',
position: 0,
pageLayoutId: generateSeedId(
workspaceId,
buildTab(
PAGE_LAYOUT_TAB_SEEDS.CUSTOMER_OVERVIEW,
'Overview',
0,
PAGE_LAYOUT_SEEDS.CUSTOMER_DASHBOARD,
),
workspaceId,
universalIdentifier: v4(),
applicationId,
overrides: null,
},
{
id: generateSeedId(workspaceId, PAGE_LAYOUT_TAB_SEEDS.CUSTOMER_ANALYTICS),
title: 'Analytics',
position: 1,
pageLayoutId: generateSeedId(
workspaceId,
buildTab(
PAGE_LAYOUT_TAB_SEEDS.CUSTOMER_ANALYTICS,
'Analytics',
1,
PAGE_LAYOUT_SEEDS.CUSTOMER_DASHBOARD,
),
workspaceId,
universalIdentifier: v4(),
applicationId,
overrides: null,
},
{
id: generateSeedId(workspaceId, PAGE_LAYOUT_TAB_SEEDS.TEAM_OVERVIEW),
title: 'Team & People',
position: 0,
pageLayoutId: generateSeedId(workspaceId, PAGE_LAYOUT_SEEDS.TEAM_DASHBOARD),
workspaceId,
universalIdentifier: v4(),
applicationId,
overrides: null,
},
{
id: generateSeedId(workspaceId, PAGE_LAYOUT_TAB_SEEDS.TEAM_METRICS),
title: 'Tasks & Activity',
position: 1,
pageLayoutId: generateSeedId(workspaceId, PAGE_LAYOUT_SEEDS.TEAM_DASHBOARD),
workspaceId,
universalIdentifier: v4(),
applicationId,
overrides: null,
},
];
buildTab(
PAGE_LAYOUT_TAB_SEEDS.TEAM_OVERVIEW,
'Team & People',
0,
PAGE_LAYOUT_SEEDS.TEAM_DASHBOARD,
),
buildTab(
PAGE_LAYOUT_TAB_SEEDS.TEAM_METRICS,
'Tasks & Activity',
1,
PAGE_LAYOUT_SEEDS.TEAM_DASHBOARD,
),
buildTab(
PAGE_LAYOUT_TAB_SEEDS.DOCUMENTATION,
'Documentation',
0,
PAGE_LAYOUT_SEEDS.DOCUMENTATION_STANDALONE_PAGE,
),
];
};

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