Compare commits

...
Author SHA1 Message Date
Félix MalfaitandGitHub 2563bf2a32 Merge branch 'main' into feat/settings-layout-discovery-page 2026-05-29 12:02:03 +02:00
Félix Malfait 37c37ddae6 fix(settings-layout): proper loading state + gated customize navigation
Address #20914 review feedback:

- The five detail pages (FrontComponent, Command, SidebarItem, View,
  PageLayout) were inferring loading state from `array.length === 0`,
  which can't distinguish "still loading" from "loaded but empty" and
  leaves the page stuck on the skeleton when the workspace legitimately
  has no items of that kind. Switch to the explicit metadata-store
  status via `metadataStoreStatusFamilySelector` keyed by the
  entity name, treating `'empty'` as loading.
- `useEnterLayoutCustomizationMode` now returns a boolean so the
  Settings/Layout "Customize" button only navigates to `/` when the
  hook actually entered the mode. Previously a rejected entry (e.g.
  unsaved dashboard edits) still triggered the redirect, dropping the
  user out of the page they were warned about.
2026-05-26 20:01:33 +02:00
Félix Malfait 7261300a8e feat(settings): Layout discovery page + manage items + scoped detail pages
Adds /settings/layout (Figma design): a Customize hero that enters layout
customization mode and a "Manage layout items" navigator that surfaces
commands, sidebar items, views, pages, and front components in tabbed
read-only tables linking to detail pages under
/settings/layout/manage/<entity>/:id.

Detail pages read from workspace metadata selectors (commandMenuItems,
navigationMenuItems, views, pageLayouts, frontComponents) rather than the
application manifest, matching the data-model pattern. The Command detail
surfaces availabilityObjectMetadataId ("Object scope: All objects" or the
object label).

Replaces the prior application-tab pages (CommandMenuItem and
FrontComponent details + tabs) and removes the intermediate scaffold +
useApplicationManifest hook in favor of the inline SubMenuTopBarContainer
pattern from SettingsApplicationConnectionDetail.

Drops the "New" chip from Layout, Apps, and AI settings entries.
2026-05-26 13:07:20 +02:00
33 changed files with 1739 additions and 1121 deletions
@@ -204,18 +204,26 @@ const SettingsApplicationConnectionDetail = lazy(() =>
),
);
const SettingsApplicationFrontComponentDetail = lazy(() =>
import('~/pages/settings/applications/SettingsApplicationFrontComponentDetail').then(
const SettingsLayoutCommandDetail = lazy(() =>
import('~/pages/settings/layout/SettingsLayoutCommandDetail').then(
(module) => ({
default: module.SettingsApplicationFrontComponentDetail,
default: module.SettingsLayoutCommandDetail,
}),
),
);
const SettingsApplicationCommandMenuItemDetail = lazy(() =>
import('~/pages/settings/applications/SettingsApplicationCommandMenuItemDetail').then(
const SettingsLayoutSidebarItemDetail = lazy(() =>
import('~/pages/settings/layout/SettingsLayoutSidebarItemDetail').then(
(module) => ({
default: module.SettingsApplicationCommandMenuItemDetail,
default: module.SettingsLayoutSidebarItemDetail,
}),
),
);
const SettingsLayoutFrontComponentDetail = lazy(() =>
import('~/pages/settings/layout/SettingsLayoutFrontComponentDetail').then(
(module) => ({
default: module.SettingsLayoutFrontComponentDetail,
}),
),
);
@@ -356,6 +364,20 @@ const SettingsObjects = lazy(() =>
})),
);
const SettingsLayout = lazy(() =>
import('~/pages/settings/layout/SettingsLayout').then((module) => ({
default: module.SettingsLayout,
})),
);
const SettingsLayoutManageItems = lazy(() =>
import('~/pages/settings/layout/SettingsLayoutManageItems').then(
(module) => ({
default: module.SettingsLayoutManageItems,
}),
),
);
const SettingsDevelopersWebhookNew = lazy(() =>
import('~/pages/settings/developers/webhooks/components/SettingsDevelopersWebhookNew').then(
(module) => ({
@@ -642,6 +664,31 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
}
>
<Route path={SettingsPath.Workspace} element={<SettingsWorkspace />} />
<Route path={SettingsPath.Layout} element={<SettingsLayout />} />
<Route
path={SettingsPath.LayoutManageItems}
element={<SettingsLayoutManageItems />}
/>
<Route
path={SettingsPath.LayoutManageItemCommandDetail}
element={<SettingsLayoutCommandDetail />}
/>
<Route
path={SettingsPath.LayoutManageItemSidebarItemDetail}
element={<SettingsLayoutSidebarItemDetail />}
/>
<Route
path={SettingsPath.LayoutManageItemFrontComponentDetail}
element={<SettingsLayoutFrontComponentDetail />}
/>
<Route
path={SettingsPath.LayoutManageItemViewDetail}
element={<SettingsLayoutViewDetail />}
/>
<Route
path={SettingsPath.LayoutManageItemPageLayoutDetail}
element={<SettingsLayoutPageLayoutDetail />}
/>
<Route
path={SettingsPath.WorkspaceEmail}
element={<SettingsWorkspaceEmail />}
@@ -863,22 +910,6 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
path={SettingsPath.ApplicationLogicFunctionDetail}
element={<SettingsLogicFunctionDetail />}
/>
<Route
path={SettingsPath.ApplicationFrontComponentDetail}
element={<SettingsApplicationFrontComponentDetail />}
/>
<Route
path={SettingsPath.ApplicationCommandMenuItemDetail}
element={<SettingsApplicationCommandMenuItemDetail />}
/>
<Route
path={SettingsPath.ApplicationViewDetail}
element={<SettingsLayoutViewDetail />}
/>
<Route
path={SettingsPath.ApplicationPageLayoutDetail}
element={<SettingsLayoutPageLayoutDetail />}
/>
<Route
path={SettingsPath.ApplicationRegistrationConfigVariableDetails}
element={<SettingsApplicationRegistrationConfigVariableDetail />}
@@ -0,0 +1,12 @@
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { type FlatFrontComponent } from '@/metadata-store/types/FlatFrontComponent';
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
export const frontComponentsSelector = createAtomSelector<FlatFrontComponent[]>(
{
key: 'frontComponentsSelector',
get: ({ get }) =>
get(metadataStoreState, 'frontComponents')
.current as FlatFrontComponent[],
},
);
@@ -24,13 +24,13 @@ export const useEnterLayoutCustomizationMode = () => {
const { navigateSidePanel } = useNavigateSidePanel();
const { enqueueWarningSnackBar } = useSnackBar();
const enterLayoutCustomizationMode = useCallback(() => {
const enterLayoutCustomizationMode = useCallback((): boolean => {
const isLayoutCustomizationModeAlreadyEnabled = store.get(
isLayoutCustomizationModeEnabledState.atom,
);
if (isLayoutCustomizationModeAlreadyEnabled) {
return;
return true;
}
const dashboardPageLayoutIdInEditMode = store.get(
@@ -49,7 +49,7 @@ export const useEnterLayoutCustomizationMode = () => {
message: t`Save or cancel dashboard changes before editing the layout.`,
});
return;
return false;
}
}
@@ -82,6 +82,8 @@ export const useEnterLayoutCustomizationMode = () => {
resetNavigationStack: true,
});
}
return true;
}, [enqueueWarningSnackBar, navigateSidePanel, store]);
return { enterLayoutCustomizationMode };
@@ -56,31 +56,6 @@ describe('useComputeApplicationContentForLayoutAndLogic', () => {
expect(row.secondary).toContain('2 tabs');
expect(row.link).toBeUndefined();
});
it('exposes a link to the layout detail page when an installed app is provided', () => {
const manifestContent = {
...baseManifest,
objects: [],
pageLayouts: [
{
universalIdentifier: 'pl-1',
name: 'Layout',
tabs: [],
},
],
} as unknown as Manifest;
const { result } = renderHook(
() =>
useComputeApplicationContentForLayoutAndLogic({
installedApplication: { id: APP_ID, agents: [] },
manifestContent,
}),
{ wrapper },
);
expect(result.current.pageLayoutRows[0].link).toBeDefined();
});
});
describe('viewRows', () => {
@@ -18,8 +18,6 @@ export const useComputeApplicationContentForLayoutAndLogic = ({
}) => {
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const installedAppId = installedApplication?.id;
// Workspace metadata covers standard + installed-app objects; the manifest
// fallback only matters when previewing an uninstalled marketplace app.
const resolveLabel = (uid: string | undefined | null) => {
@@ -48,12 +46,6 @@ export const useComputeApplicationContentForLayoutAndLogic = ({
key: layout.universalIdentifier,
name: layout.name,
secondary: parts.length > 0 ? parts.join(' · ') : undefined,
link: isDefined(installedAppId)
? getSettingsPath(SettingsPath.ApplicationPageLayoutDetail, {
applicationId: installedAppId,
pageLayoutUniversalIdentifier: layout.universalIdentifier,
})
: undefined,
};
});
@@ -69,12 +61,6 @@ export const useComputeApplicationContentForLayoutAndLogic = ({
secondary: isDefined(objectLabel)
? t`${formattedType} of ${objectLabel}`
: formattedType,
link: isDefined(installedAppId)
? getSettingsPath(SettingsPath.ApplicationViewDetail, {
applicationId: installedAppId,
viewUniversalIdentifier: view.universalIdentifier,
})
: undefined,
};
},
);
@@ -27,6 +27,7 @@ import {
IconHelpCircle,
IconHierarchy2,
IconKey,
IconLayout,
IconMail,
IconMessage,
IconPlug,
@@ -138,6 +139,12 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
Icon: IconHierarchy2,
isHidden: !permissionMap[PermissionFlagType.DATA_MODEL],
},
{
label: t`Layout`,
path: SettingsPath.Layout,
Icon: IconLayout,
isHidden: !permissionMap[PermissionFlagType.WORKSPACE],
},
{
label: t`Members`,
path: SettingsPath.WorkspaceMembersPage,
@@ -169,14 +176,12 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
path: SettingsPath.Applications,
Icon: IconPlug,
isHidden: !permissionMap[PermissionFlagType.APPLICATIONS],
modifier: 'new',
},
{
label: t`AI`,
path: SettingsPath.AI,
Icon: IconSparkles,
isHidden: !permissionMap[PermissionFlagType.WORKSPACE],
modifier: 'new',
},
{
label: t`Security`,
@@ -0,0 +1,38 @@
import { styled } from '@linaria/react';
import { useContext } from 'react';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import customizeIllustrationDark from '~/pages/settings/layout/assets/customize-illustration-dark.png';
import customizeIllustrationLight from '~/pages/settings/layout/assets/customize-illustration-light.png';
const StyledContainer = styled.div`
background: ${themeCssVariables.background.secondary};
border-bottom: 1px solid ${themeCssVariables.border.color.light};
box-sizing: border-box;
height: 192px;
overflow: hidden;
position: relative;
`;
const StyledImage = styled.img`
display: block;
height: 100%;
inset: 0;
object-fit: cover;
object-position: center top;
position: absolute;
width: 100%;
`;
export const SettingsLayoutCoverImage = () => {
const { theme } = useContext(ThemeContext);
const src =
theme.name === 'light'
? customizeIllustrationLight
: customizeIllustrationDark;
return (
<StyledContainer>
<StyledImage src={src} alt="" aria-hidden />
</StyledContainer>
);
};
@@ -0,0 +1,146 @@
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
import { frontComponentsSelector } from '@/front-components/states/frontComponentsSelector';
import { navigationMenuItemsSelector } from '@/navigation-menu-item/common/states/navigationMenuItemsSelector';
import { pageLayoutsWithRelationsSelector } from '@/page-layout/states/pageLayoutsWithRelationsSelector';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { Fragment, useContext } from 'react';
import {
IconAppWindow,
IconCommand,
type IconComponent,
IconLayoutSidebarLeftExpand,
IconPuzzle,
IconTable,
} from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
type StatRowProps = {
Icon: IconComponent;
label: string;
value: number;
};
const StyledContainer = styled.div`
background: ${themeCssVariables.background.secondary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
display: flex;
gap: ${themeCssVariables.spacing[3]};
padding: ${themeCssVariables.spacing[2]};
`;
const StyledColumn = styled.div`
display: flex;
flex: 1 1 0;
flex-direction: column;
gap: ${themeCssVariables.spacing[1]};
min-width: 0;
`;
const StyledDivider = styled.div`
align-self: stretch;
background: ${themeCssVariables.border.color.light};
width: 1px;
`;
const StyledRow = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
height: ${themeCssVariables.spacing[6]};
`;
const StyledLabel = styled.div`
color: ${themeCssVariables.font.color.tertiary};
flex: 1 1 0;
font-size: ${themeCssVariables.font.size.sm};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledValue = styled.div`
color: ${themeCssVariables.font.color.primary};
padding: 0 ${themeCssVariables.spacing[1]};
`;
const StatRow = ({ Icon, label, value }: StatRowProps) => {
const { theme } = useContext(ThemeContext);
return (
<StyledRow>
<Icon size={theme.icon.size.md} color={theme.font.color.tertiary} />
<StyledLabel>{label}</StyledLabel>
<StyledValue>{value}</StyledValue>
</StyledRow>
);
};
export const SettingsLayoutItemsStats = () => {
const { t } = useLingui();
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
const navigationMenuItems = useAtomStateValue(navigationMenuItemsSelector);
const views = useAtomStateValue(viewsSelector);
const pageLayoutsWithRelations = useAtomStateValue(
pageLayoutsWithRelationsSelector,
);
const frontComponents = useAtomStateValue(frontComponentsSelector);
const columns: StatRowProps[][] = [
[
{
Icon: IconCommand,
label: t`Commands`,
value: commandMenuItems.length,
},
{
Icon: IconLayoutSidebarLeftExpand,
label: t`Sidebar items`,
value: navigationMenuItems.length,
},
],
[
{
Icon: IconTable,
label: t`Views`,
value: views.length,
},
{
Icon: IconAppWindow,
label: t`Pages`,
value: pageLayoutsWithRelations.length,
},
],
[
{
Icon: IconPuzzle,
label: t`Front components`,
value: frontComponents.length,
},
],
];
return (
<StyledContainer>
{columns.map((column, index) => (
<Fragment key={index}>
{index > 0 && <StyledDivider />}
<StyledColumn>
{column.map((stat) => (
<StatRow
key={stat.label}
Icon={stat.Icon}
label={stat.label}
value={stat.value}
/>
))}
</StyledColumn>
</Fragment>
))}
</StyledContainer>
);
};
@@ -0,0 +1,142 @@
import { AppChip } from '@/applications/components/AppChip';
import { type LayoutItemRow } from '@/settings/layout/types/LayoutItemRow';
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
IconChevronRight,
OverflowingTextWithTooltip,
useIcons,
} from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
type SecondaryColumnKind = 'object' | 'type';
type SettingsLayoutManageItemsTableProps = {
rows: LayoutItemRow[];
fallbackApplicationId?: string | null;
secondaryColumn?: SecondaryColumnKind;
};
const GRID_TEMPLATE_COLUMNS_TWO = '1fr 160px 36px';
const GRID_TEMPLATE_COLUMNS_THREE = '1fr 160px 160px 36px';
const StyledNameCell = styled(TableCell)`
gap: ${themeCssVariables.spacing[2]};
overflow: hidden;
`;
const StyledNameLabel = styled.div`
color: ${themeCssVariables.font.color.secondary};
flex: 1;
font-weight: ${themeCssVariables.font.weight.medium};
min-width: 0;
`;
const StyledSecondaryWithIcon = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.secondary};
display: inline-flex;
font-size: ${themeCssVariables.font.size.sm};
gap: ${themeCssVariables.spacing[1]};
min-width: 0;
overflow: hidden;
`;
const StyledEmptyState = styled.div`
color: ${themeCssVariables.font.color.tertiary};
padding: ${themeCssVariables.spacing[6]} ${themeCssVariables.spacing[2]};
text-align: center;
`;
export const SettingsLayoutManageItemsTable = ({
rows,
fallbackApplicationId,
secondaryColumn,
}: SettingsLayoutManageItemsTableProps) => {
const { t } = useLingui();
const { theme } = useContext(ThemeContext);
const { getIcon } = useIcons();
const hasSecondaryColumn = isDefined(secondaryColumn);
const gridTemplateColumns = hasSecondaryColumn
? GRID_TEMPLATE_COLUMNS_THREE
: GRID_TEMPLATE_COLUMNS_TWO;
const secondaryHeader = secondaryColumn === 'object' ? t`Object` : t`Type`;
return (
<Table>
<TableRow gridTemplateColumns={gridTemplateColumns}>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader>{t`App`}</TableHeader>
{hasSecondaryColumn && <TableHeader>{secondaryHeader}</TableHeader>}
<TableHeader />
</TableRow>
{rows.length === 0 ? (
<StyledEmptyState>{t`No items match your search.`}</StyledEmptyState>
) : (
rows.map((row) => {
const NameIcon = getIcon(row.icon);
const SecondaryIcon =
row.secondary?.kind === 'object'
? getIcon(row.secondary.icon)
: undefined;
return (
<TableRow
key={row.id}
gridTemplateColumns={gridTemplateColumns}
isClickable={isDefined(row.to)}
to={row.to}
>
<StyledNameCell>
<NameIcon
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={theme.font.color.tertiary}
/>
<StyledNameLabel>
<OverflowingTextWithTooltip text={row.name} />
</StyledNameLabel>
</StyledNameCell>
<TableCell>
<AppChip
applicationId={row.applicationId ?? fallbackApplicationId}
/>
</TableCell>
{hasSecondaryColumn && (
<TableCell>
{isDefined(row.secondary) && (
<StyledSecondaryWithIcon>
{isDefined(SecondaryIcon) && (
<SecondaryIcon
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={theme.font.color.tertiary}
/>
)}
<OverflowingTextWithTooltip text={row.secondary.label} />
</StyledSecondaryWithIcon>
)}
</TableCell>
)}
<TableCell align="right">
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={theme.font.color.light}
/>
</TableCell>
</TableRow>
);
})
)}
</Table>
);
};
@@ -0,0 +1,12 @@
export type LayoutItemRowSecondary =
| { kind: 'object'; label: string; icon?: string }
| { kind: 'type'; label: string };
export type LayoutItemRow = {
id: string;
name: string;
icon?: string;
applicationId?: string | null;
secondary?: LayoutItemRowSecondary;
to?: string;
};
@@ -0,0 +1,9 @@
export const LAYOUT_ITEM_TAB_IDS = [
'commands',
'sidebar',
'views',
'pages',
'front-components',
] as const;
export type LayoutItemTabId = (typeof LAYOUT_ITEM_TAB_IDS)[number];
@@ -0,0 +1,13 @@
import { msg } from '@lingui/core/macro';
import { i18n } from '@lingui/core';
import { PageLayoutType } from '~/generated-metadata/graphql';
const PAGE_LAYOUT_TYPE_LABELS = {
[PageLayoutType.DASHBOARD]: msg`Dashboard`,
[PageLayoutType.RECORD_INDEX]: msg`Record index`,
[PageLayoutType.RECORD_PAGE]: msg`Record page`,
[PageLayoutType.STANDALONE_PAGE]: msg`Standalone page`,
} as const;
export const getPageLayoutTypeLabel = (type: PageLayoutType): string =>
i18n._(PAGE_LAYOUT_TYPE_LABELS[type]);
@@ -0,0 +1,11 @@
import { type CurrentWorkspace } from '@/auth/states/currentWorkspaceState';
import { TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER } from 'twenty-shared/application';
export const getStandardApplicationId = (
workspace: CurrentWorkspace | null,
): string | null =>
workspace?.installedApplications.find(
(application) =>
application.universalIdentifier ===
TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER,
)?.id ?? null;
@@ -1,81 +0,0 @@
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useQuery } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { useParams } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { FindOneApplicationDocument } from '~/generated-metadata/graphql';
import { SettingsApplicationCommandMenuItemSettingsTab } from '~/pages/settings/applications/tabs/SettingsApplicationCommandMenuItemSettingsTab';
export const SettingsApplicationCommandMenuItemDetail = () => {
const { applicationId = '', commandMenuItemId = '' } = useParams<{
applicationId: string;
commandMenuItemId: string;
}>();
const { data, loading } = useQuery(FindOneApplicationDocument, {
variables: { id: applicationId },
skip: !applicationId,
});
const application = data?.findOneApplication;
const commandMenuItem = application?.commandMenuItems?.find(
(item) => item.id === commandMenuItemId,
);
const frontComponent = commandMenuItem?.frontComponentId
? application?.frontComponents?.find(
(fc) => fc.id === commandMenuItem.frontComponentId,
)
: undefined;
const applicationContentHref = getSettingsPath(
SettingsPath.ApplicationDetail,
{ applicationId },
undefined,
'content',
);
const breadcrumbLinks = [
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`Applications`,
href: getSettingsPath(SettingsPath.Applications),
},
{ children: application?.name ?? '', href: applicationContentHref },
{ children: t`Command menu items`, href: applicationContentHref },
{ children: commandMenuItem?.label ?? '' },
];
return (
<SubMenuTopBarContainer
title={commandMenuItem?.label ?? t`Command menu item`}
links={breadcrumbLinks}
>
<SettingsPageContainer>
{loading || !isDefined(commandMenuItem) ? (
<SettingsSectionSkeletonLoader />
) : (
<SettingsApplicationCommandMenuItemSettingsTab
label={commandMenuItem.label}
shortLabel={commandMenuItem.shortLabel}
icon={commandMenuItem.icon}
isPinned={commandMenuItem.isPinned}
availabilityType={commandMenuItem.availabilityType}
conditionalAvailabilityExpression={
commandMenuItem.conditionalAvailabilityExpression
}
frontComponentName={frontComponent?.name}
universalIdentifier={commandMenuItem.universalIdentifier}
createdAt={commandMenuItem.createdAt}
updatedAt={commandMenuItem.updatedAt}
/>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -1,110 +0,0 @@
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useQuery } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { useParams } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { IconEye, IconSettings } from 'twenty-ui/display';
import { FindOneApplicationDocument } from '~/generated-metadata/graphql';
import { SettingsApplicationFrontComponentPreviewTab } from '~/pages/settings/applications/tabs/SettingsApplicationFrontComponentPreviewTab';
import { SettingsApplicationFrontComponentSettingsTab } from '~/pages/settings/applications/tabs/SettingsApplicationFrontComponentSettingsTab';
const FRONT_COMPONENT_DETAIL_ID = 'application-front-component-detail';
export const SettingsApplicationFrontComponentDetail = () => {
const { applicationId = '', frontComponentId = '' } = useParams<{
applicationId: string;
frontComponentId: string;
}>();
const { data, loading } = useQuery(FindOneApplicationDocument, {
variables: { id: applicationId },
skip: !applicationId,
});
const application = data?.findOneApplication;
const frontComponent = application?.frontComponents?.find(
(fc) => fc.id === frontComponentId,
);
const instanceId = `${FRONT_COMPONENT_DETAIL_ID}-${frontComponentId}`;
const activeTabId = useAtomComponentStateValue(
activeTabIdComponentState,
instanceId,
);
const tabs = [
{ id: 'preview', title: t`Preview`, Icon: IconEye },
{ id: 'settings', title: t`Settings`, Icon: IconSettings },
];
const applicationContentHref = getSettingsPath(
SettingsPath.ApplicationDetail,
{ applicationId },
undefined,
'content',
);
const breadcrumbLinks = [
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`Applications`,
href: getSettingsPath(SettingsPath.Applications),
},
{ children: application?.name ?? '', href: applicationContentHref },
{ children: t`Front components`, href: applicationContentHref },
{ children: frontComponent?.name ?? '' },
];
const renderActiveTabContent = () => {
if (!isDefined(frontComponent)) {
return <SettingsSectionSkeletonLoader />;
}
const resolvedTabId = activeTabId ?? 'preview';
switch (resolvedTabId) {
case 'preview':
return (
<SettingsApplicationFrontComponentPreviewTab
frontComponentId={frontComponent.id}
isHeadless={frontComponent.isHeadless}
/>
);
case 'settings':
return (
<SettingsApplicationFrontComponentSettingsTab
description={frontComponent.description}
componentName={frontComponent.componentName}
universalIdentifier={frontComponent.universalIdentifier}
builtComponentChecksum={frontComponent.builtComponentChecksum}
isHeadless={frontComponent.isHeadless}
usesSdkClient={frontComponent.usesSdkClient}
createdAt={frontComponent.createdAt}
updatedAt={frontComponent.updatedAt}
/>
);
default:
return null;
}
};
return (
<SubMenuTopBarContainer
title={frontComponent?.name ?? t`Front component`}
links={breadcrumbLinks}
>
<SettingsPageContainer>
<TabList tabs={tabs} componentInstanceId={instanceId} />
{loading ? <SettingsSectionSkeletonLoader /> : renderActiveTabContent()}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -1,146 +0,0 @@
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { TableSection } from '@/ui/layout/table/components/TableSection';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { type ReactNode } from 'react';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type SettingsApplicationCommandMenuItemSettingsTabProps = {
label: string;
shortLabel?: string | null;
icon?: string | null;
isPinned: boolean;
availabilityType: string;
conditionalAvailabilityExpression?: string | null;
frontComponentName?: string | null;
universalIdentifier?: string | null;
createdAt: string;
updatedAt: string;
};
const StyledMonoText = styled.span`
color: ${themeCssVariables.font.color.primary};
font-family: ${themeCssVariables.code.font.family}, monospace;
font-size: ${themeCssVariables.font.size.sm};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const formatDateTime = (isoString: string): string => {
const date = new Date(isoString);
if (Number.isNaN(date.getTime())) {
return isoString;
}
return date.toLocaleString();
};
const GRID_TEMPLATE = '220px 1fr';
export const SettingsApplicationCommandMenuItemSettingsTab = ({
label,
shortLabel,
icon,
isPinned,
availabilityType,
conditionalAvailabilityExpression,
frontComponentName,
universalIdentifier,
createdAt,
updatedAt,
}: SettingsApplicationCommandMenuItemSettingsTabProps) => {
const detailRows: { key: string; label: string; value: ReactNode }[] = [
{
key: 'label',
label: t`Label`,
value: label,
},
{
key: 'shortLabel',
label: t`Short label`,
value: shortLabel ?? t`Not set`,
},
{
key: 'icon',
label: t`Icon`,
value: icon ? <StyledMonoText>{icon}</StyledMonoText> : t`Not set`,
},
{
key: 'isPinned',
label: t`Pinned`,
value: isPinned ? t`Yes` : t`No`,
},
{
key: 'availabilityType',
label: t`Availability`,
value: <StyledMonoText>{availabilityType}</StyledMonoText>,
},
{
key: 'conditionalAvailabilityExpression',
label: t`Conditional availability`,
value: conditionalAvailabilityExpression ? (
<StyledMonoText>{conditionalAvailabilityExpression}</StyledMonoText>
) : (
t`Not set`
),
},
{
key: 'frontComponent',
label: t`Front component`,
value: frontComponentName ? (
<StyledMonoText>{frontComponentName}</StyledMonoText>
) : (
t`Not set`
),
},
{
key: 'universalIdentifier',
label: t`Universal identifier`,
value: (
<StyledMonoText>{universalIdentifier ?? t`Not set`}</StyledMonoText>
),
},
{
key: 'createdAt',
label: t`Created`,
value: formatDateTime(createdAt),
},
{
key: 'updatedAt',
label: t`Updated`,
value: formatDateTime(updatedAt),
},
];
return (
<Section>
<H2Title
title={t`Details`}
description={t`Configuration of this command menu item`}
/>
<Table>
<TableRow gridTemplateColumns={GRID_TEMPLATE}>
<TableHeader>{t`Property`}</TableHeader>
<TableHeader>{t`Value`}</TableHeader>
</TableRow>
<TableSection title={t`Command menu item`}>
{detailRows.map((row) => (
<TableRow key={row.key} gridTemplateColumns={GRID_TEMPLATE}>
<TableCell color={themeCssVariables.font.color.secondary}>
{row.label}
</TableCell>
<TableCell minWidth="0" overflow="hidden">
{row.value}
</TableCell>
</TableRow>
))}
</TableSection>
</Table>
</Section>
);
};
@@ -118,10 +118,12 @@ export const SettingsApplicationDetailContentTab = ({
key: fc.id,
name: fc.name,
secondary: fc.description ?? undefined,
link: getSettingsPath(SettingsPath.ApplicationFrontComponentDetail, {
applicationId,
frontComponentId: fc.id,
}),
link: getSettingsPath(
SettingsPath.LayoutManageItemFrontComponentDetail,
{
frontComponentId: fc.id,
},
),
}))
: (manifestContent?.frontComponents ?? []).map((fc) => ({
key: fc.universalIdentifier,
@@ -136,8 +138,7 @@ export const SettingsApplicationDetailContentTab = ({
key: item.id,
name: item.label,
secondary: item.shortLabel ?? undefined,
link: getSettingsPath(SettingsPath.ApplicationCommandMenuItemDetail, {
applicationId,
link: getSettingsPath(SettingsPath.LayoutManageItemCommandDetail, {
commandMenuItemId: item.id,
}),
}))
@@ -1,75 +0,0 @@
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { Suspense, lazy } from 'react';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const FrontComponentRenderer = lazy(() =>
import('@/front-components/components/FrontComponentRenderer').then(
(module) => ({ default: module.FrontComponentRenderer }),
),
);
const StyledPreviewFrame = styled.div`
background: ${themeCssVariables.background.primary};
border: 1px solid ${themeCssVariables.border.color.light};
border-radius: ${themeCssVariables.border.radius.md};
display: flex;
height: 600px;
overflow: auto;
width: 100%;
`;
const StyledHeadlessNotice = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.secondary};
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
height: 100%;
justify-content: center;
padding: ${themeCssVariables.spacing[6]};
text-align: center;
width: 100%;
`;
const StyledHeadlessTitle = styled.span`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.medium};
`;
const StyledRendererContainer = styled.div`
flex: 1;
min-height: 0;
min-width: 0;
`;
type SettingsApplicationFrontComponentPreviewTabProps = {
frontComponentId: string;
isHeadless: boolean;
};
export const SettingsApplicationFrontComponentPreviewTab = ({
frontComponentId,
isHeadless,
}: SettingsApplicationFrontComponentPreviewTabProps) => {
return (
<Section>
<StyledPreviewFrame>
{isHeadless ? (
<StyledHeadlessNotice>
<StyledHeadlessTitle>{t`Headless component`}</StyledHeadlessTitle>
<span>{t`This component runs without a UI and renders nothing here.`}</span>
</StyledHeadlessNotice>
) : (
<StyledRendererContainer>
<Suspense fallback={null}>
<FrontComponentRenderer frontComponentId={frontComponentId} />
</Suspense>
</StyledRendererContainer>
)}
</StyledPreviewFrame>
</Section>
);
};
@@ -1,139 +0,0 @@
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { TableSection } from '@/ui/layout/table/components/TableSection';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { type ReactNode } from 'react';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type SettingsApplicationFrontComponentSettingsTabProps = {
description?: string | null;
componentName: string;
universalIdentifier?: string | null;
builtComponentChecksum: string;
isHeadless: boolean;
usesSdkClient: boolean;
createdAt: string;
updatedAt: string;
};
const StyledDescription = styled.div`
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.md};
line-height: 1.5;
white-space: pre-wrap;
`;
const StyledMonoText = styled.span`
color: ${themeCssVariables.font.color.primary};
font-family: ${themeCssVariables.code.font.family}, monospace;
font-size: ${themeCssVariables.font.size.sm};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const formatDateTime = (isoString: string): string => {
const date = new Date(isoString);
if (Number.isNaN(date.getTime())) {
return isoString;
}
return date.toLocaleString();
};
const GRID_TEMPLATE = '220px 1fr';
export const SettingsApplicationFrontComponentSettingsTab = ({
description,
componentName,
universalIdentifier,
builtComponentChecksum,
isHeadless,
usesSdkClient,
createdAt,
updatedAt,
}: SettingsApplicationFrontComponentSettingsTabProps) => {
const trimmedDescription = description?.trim();
const detailRows: { key: string; label: string; value: ReactNode }[] = [
{
key: 'componentName',
label: t`Component name`,
value: <StyledMonoText>{componentName}</StyledMonoText>,
},
{
key: 'universalIdentifier',
label: t`Universal identifier`,
value: (
<StyledMonoText>{universalIdentifier ?? t`Not set`}</StyledMonoText>
),
},
{
key: 'isHeadless',
label: t`Headless`,
value: isHeadless ? t`Yes` : t`No`,
},
{
key: 'usesSdkClient',
label: t`Uses SDK client`,
value: usesSdkClient ? t`Yes` : t`No`,
},
{
key: 'builtComponentChecksum',
label: t`Build checksum`,
value: <StyledMonoText>{builtComponentChecksum}</StyledMonoText>,
},
{
key: 'createdAt',
label: t`Created`,
value: formatDateTime(createdAt),
},
{
key: 'updatedAt',
label: t`Updated`,
value: formatDateTime(updatedAt),
},
];
return (
<>
{trimmedDescription !== undefined && trimmedDescription.length > 0 && (
<Section>
<H2Title
title={t`About`}
description={t`Description provided by the application`}
/>
<StyledDescription>{trimmedDescription}</StyledDescription>
</Section>
)}
<Section>
<H2Title
title={t`Details`}
description={t`Build and runtime metadata for this component`}
/>
<Table>
<TableRow gridTemplateColumns={GRID_TEMPLATE}>
<TableHeader>{t`Property`}</TableHeader>
<TableHeader>{t`Value`}</TableHeader>
</TableRow>
<TableSection title={t`Front component`}>
{detailRows.map((row) => (
<TableRow key={row.key} gridTemplateColumns={GRID_TEMPLATE}>
<TableCell color={themeCssVariables.font.color.secondary}>
{row.label}
</TableCell>
<TableCell minWidth="0" overflow="hidden">
{row.value}
</TableCell>
</TableRow>
))}
</TableSection>
</Table>
</Section>
</>
);
};
@@ -0,0 +1,101 @@
import { useEnterLayoutCustomizationMode } from '@/layout-customization/hooks/useEnterLayoutCustomizationMode';
import { SettingsCard } from '@/settings/components/SettingsCard';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsOptionCardContentButton } from '@/settings/components/SettingsOptions/SettingsOptionCardContentButton';
import { SettingsLayoutCoverImage } from '@/settings/layout/components/SettingsLayoutCoverImage';
import { SettingsLayoutItemsStats } from '@/settings/layout/components/SettingsLayoutItemsStats';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { styled } from '@linaria/react';
import { Trans, useLingui } from '@lingui/react/macro';
import { Link, useNavigate } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import {
H2Title,
IconLayoutDashboard,
IconPencil,
IconSettings,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Card, Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledStackedCards = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[4]};
`;
const StyledNavigationLink = styled(Link)`
color: inherit;
text-decoration: none;
`;
export const SettingsLayout = () => {
const { t } = useLingui();
const navigate = useNavigate();
const { enterLayoutCustomizationMode } = useEnterLayoutCustomizationMode();
const startCustomizing = () => {
if (enterLayoutCustomizationMode()) {
navigate('/');
}
};
return (
<SubMenuTopBarContainer
title={t`Layout`}
links={[
{
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.Workspace),
},
{ children: <Trans>Layout</Trans> },
]}
>
<SettingsPageContainer>
<Section>
<H2Title
title={t`Customize`}
description={t`Customize your sidebar, commands and record pages`}
/>
<Card rounded>
<SettingsLayoutCoverImage />
<SettingsOptionCardContentButton
Icon={IconLayoutDashboard}
title={t`Customize layout`}
description={t`Customize how your workspace looks.`}
Button={
<Button
title={t`Customize`}
variant="primary"
accent="blue"
size="small"
Icon={IconPencil}
onClick={startCustomizing}
/>
}
/>
</Card>
</Section>
<Section>
<H2Title
title={t`Manage`}
description={t`All the layout items declared on your workspace`}
/>
<StyledStackedCards>
<SettingsLayoutItemsStats />
<StyledNavigationLink
to={getSettingsPath(SettingsPath.LayoutManageItems)}
>
<SettingsCard
title={t`Manage layout items`}
Icon={<IconSettings />}
/>
</StyledNavigationLink>
</StyledStackedCards>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -0,0 +1,189 @@
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
import { frontComponentsSelector } from '@/front-components/states/frontComponentsSelector';
import { metadataStoreStatusFamilySelector } from '@/metadata-store/states/metadataStoreStatusFamilySelector';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { Trans, useLingui } from '@lingui/react/macro';
import { type ReactNode } from 'react';
import { useParams } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const DETAIL_GRID_TEMPLATE = '220px 1fr';
const StyledMonoText = styled.span`
color: ${themeCssVariables.font.color.primary};
font-family: ${themeCssVariables.code.font.family}, monospace;
font-size: ${themeCssVariables.font.size.sm};
`;
export const SettingsLayoutCommandDetail = () => {
const { t } = useLingui();
const { commandMenuItemId = '' } = useParams<{
commandMenuItemId: string;
}>();
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
const frontComponents = useAtomStateValue(frontComponentsSelector);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const commandMenuItemsStoreStatus = useAtomFamilySelectorValue(
metadataStoreStatusFamilySelector,
'commandMenuItems',
);
const commandMenuItem = commandMenuItems.find(
(item) => item.id === commandMenuItemId,
);
const isLoading = commandMenuItemsStoreStatus === 'empty';
const frontComponent = isDefined(commandMenuItem?.frontComponentId)
? frontComponents.find((fc) => fc.id === commandMenuItem.frontComponentId)
: undefined;
const availabilityObject = isDefined(
commandMenuItem?.availabilityObjectMetadataId,
)
? objectMetadataItems.find(
(o) => o.id === commandMenuItem.availabilityObjectMetadataId,
)
: undefined;
const detailRows: { key: string; label: string; value: ReactNode }[] =
isDefined(commandMenuItem)
? [
{ key: 'label', label: t`Label`, value: commandMenuItem.label },
{
key: 'shortLabel',
label: t`Short label`,
value: commandMenuItem.shortLabel ?? t`Not set`,
},
{
key: 'icon',
label: t`Icon`,
value: isDefined(commandMenuItem.icon) ? (
<StyledMonoText>{commandMenuItem.icon}</StyledMonoText>
) : (
t`Not set`
),
},
{
key: 'isPinned',
label: t`Pinned`,
value: commandMenuItem.isPinned ? t`Yes` : t`No`,
},
{
key: 'availabilityType',
label: t`Availability`,
value: (
<StyledMonoText>
{commandMenuItem.availabilityType}
</StyledMonoText>
),
},
{
key: 'availabilityObject',
label: t`Object scope`,
value: isDefined(commandMenuItem.availabilityObjectMetadataId)
? (availabilityObject?.labelSingular ??
commandMenuItem.availabilityObjectMetadataId)
: t`All objects`,
},
{
key: 'conditionalAvailabilityExpression',
label: t`Conditional availability`,
value: isDefined(
commandMenuItem.conditionalAvailabilityExpression,
) ? (
<StyledMonoText>
{commandMenuItem.conditionalAvailabilityExpression}
</StyledMonoText>
) : (
t`Not set`
),
},
{
key: 'frontComponent',
label: t`Front component`,
value: isDefined(frontComponent) ? (
<StyledMonoText>{frontComponent.name}</StyledMonoText>
) : (
t`Not set`
),
},
]
: [];
const title = commandMenuItem?.label ?? t`Command`;
return (
<SubMenuTopBarContainer
title={title}
links={[
{
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: <Trans>Layout</Trans>,
href: getSettingsPath(SettingsPath.Layout),
},
{
children: <Trans>Commands</Trans>,
href: getSettingsPath(SettingsPath.LayoutManageItems),
},
{ children: title },
]}
>
<SettingsPageContainer>
{isLoading ? (
<SettingsSectionSkeletonLoader />
) : !isDefined(commandMenuItem) ? (
<Section>
<H2Title
title={t`Command not found`}
description={t`This command does not exist in your workspace.`}
/>
</Section>
) : (
<Section>
<H2Title
title={t`Details`}
description={t`Read-only command definition`}
/>
<Table>
<TableRow gridTemplateColumns={DETAIL_GRID_TEMPLATE}>
<TableHeader>{t`Property`}</TableHeader>
<TableHeader>{t`Value`}</TableHeader>
</TableRow>
{detailRows.map((row) => (
<TableRow
key={row.key}
gridTemplateColumns={DETAIL_GRID_TEMPLATE}
>
<TableCell color={themeCssVariables.font.color.secondary}>
{row.label}
</TableCell>
<TableCell minWidth="0" overflow="hidden">
{row.value}
</TableCell>
</TableRow>
))}
</Table>
</Section>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -0,0 +1,244 @@
import { frontComponentsSelector } from '@/front-components/states/frontComponentsSelector';
import { metadataStoreStatusFamilySelector } from '@/metadata-store/states/metadataStoreStatusFamilySelector';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { Trans, useLingui } from '@lingui/react/macro';
import { type ReactNode, Suspense, lazy } from 'react';
import { useParams } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const FrontComponentRenderer = lazy(() =>
import('@/front-components/components/FrontComponentRenderer').then(
(module) => ({ default: module.FrontComponentRenderer }),
),
);
const DETAIL_GRID_TEMPLATE = '220px 1fr';
const StyledMonoText = styled.span`
color: ${themeCssVariables.font.color.primary};
font-family: ${themeCssVariables.code.font.family}, monospace;
font-size: ${themeCssVariables.font.size.sm};
`;
const StyledPreviewFrame = styled.div`
background: ${themeCssVariables.background.primary};
border: 1px solid ${themeCssVariables.border.color.light};
border-radius: ${themeCssVariables.border.radius.md};
display: flex;
height: 600px;
overflow: auto;
width: 100%;
`;
const StyledHeadlessNotice = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.secondary};
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
height: 100%;
justify-content: center;
padding: ${themeCssVariables.spacing[6]};
text-align: center;
width: 100%;
`;
const StyledHeadlessTitle = styled.span`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.medium};
`;
const StyledRendererContainer = styled.div`
flex: 1;
min-height: 0;
min-width: 0;
`;
const formatDateTime = (isoString?: string | null): string => {
if (isoString === undefined || isoString === null) {
return '-';
}
const date = new Date(isoString);
if (Number.isNaN(date.getTime())) {
return isoString;
}
return date.toLocaleString();
};
export const SettingsLayoutFrontComponentDetail = () => {
const { t } = useLingui();
const { frontComponentId = '' } = useParams<{ frontComponentId: string }>();
const frontComponents = useAtomStateValue(frontComponentsSelector);
const frontComponentsStoreStatus = useAtomFamilySelectorValue(
metadataStoreStatusFamilySelector,
'frontComponents',
);
const frontComponent = frontComponents.find(
(fc) => fc.id === frontComponentId,
);
const isLoading = frontComponentsStoreStatus === 'empty';
const detailRows: { key: string; label: string; value: ReactNode }[] =
isDefined(frontComponent)
? [
{ key: 'name', label: t`Name`, value: frontComponent.name },
{
key: 'componentName',
label: t`Component name`,
value: (
<StyledMonoText>{frontComponent.componentName}</StyledMonoText>
),
},
{
key: 'isHeadless',
label: t`Headless`,
value: frontComponent.isHeadless ? t`Yes` : t`No`,
},
{
key: 'usesSdkClient',
label: t`Uses SDK client`,
value: frontComponent.usesSdkClient ? t`Yes` : t`No`,
},
...(isDefined(frontComponent.description) &&
frontComponent.description !== ''
? [
{
key: 'description',
label: t`Description`,
value: frontComponent.description,
},
]
: []),
{
key: 'universalIdentifier',
label: t`Universal identifier`,
value: (
<StyledMonoText>
{frontComponent.universalIdentifier ?? t`Not set`}
</StyledMonoText>
),
},
{
key: 'builtComponentChecksum',
label: t`Built component checksum`,
value: (
<StyledMonoText>
{frontComponent.builtComponentChecksum}
</StyledMonoText>
),
},
{
key: 'createdAt',
label: t`Created`,
value: formatDateTime(frontComponent.createdAt),
},
{
key: 'updatedAt',
label: t`Updated`,
value: formatDateTime(frontComponent.updatedAt),
},
]
: [];
const title = frontComponent?.name ?? t`Front component`;
return (
<SubMenuTopBarContainer
title={title}
links={[
{
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: <Trans>Layout</Trans>,
href: getSettingsPath(SettingsPath.Layout),
},
{
children: <Trans>Front components</Trans>,
href: getSettingsPath(SettingsPath.LayoutManageItems),
},
{ children: title },
]}
>
<SettingsPageContainer>
{isLoading ? (
<SettingsSectionSkeletonLoader />
) : !isDefined(frontComponent) ? (
<Section>
<H2Title
title={t`Front component not found`}
description={t`This front component does not exist in your workspace.`}
/>
</Section>
) : (
<>
<Section>
<H2Title
title={t`Preview`}
description={t`Live render of the component as the app exposes it`}
/>
<StyledPreviewFrame>
{frontComponent.isHeadless ? (
<StyledHeadlessNotice>
<StyledHeadlessTitle>{t`Headless component`}</StyledHeadlessTitle>
<span>{t`This component runs without a UI and renders nothing here.`}</span>
</StyledHeadlessNotice>
) : (
<StyledRendererContainer>
<Suspense fallback={null}>
<FrontComponentRenderer
frontComponentId={frontComponent.id}
/>
</Suspense>
</StyledRendererContainer>
)}
</StyledPreviewFrame>
</Section>
<Section>
<H2Title
title={t`Details`}
description={t`Read-only front component definition`}
/>
<Table>
<TableRow gridTemplateColumns={DETAIL_GRID_TEMPLATE}>
<TableHeader>{t`Property`}</TableHeader>
<TableHeader>{t`Value`}</TableHeader>
</TableRow>
{detailRows.map((row) => (
<TableRow
key={row.key}
gridTemplateColumns={DETAIL_GRID_TEMPLATE}
>
<TableCell color={themeCssVariables.font.color.secondary}>
{row.label}
</TableCell>
<TableCell minWidth="0" overflow="hidden">
{row.value}
</TableCell>
</TableRow>
))}
</Table>
</Section>
</>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -0,0 +1,222 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
import { frontComponentsSelector } from '@/front-components/states/frontComponentsSelector';
import { navigationMenuItemsSelector } from '@/navigation-menu-item/common/states/navigationMenuItemsSelector';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { pageLayoutsWithRelationsSelector } from '@/page-layout/states/pageLayoutsWithRelationsSelector';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsLayoutManageItemsTable } from '@/settings/layout/components/SettingsLayoutManageItemsTable';
import { type LayoutItemRow } from '@/settings/layout/types/LayoutItemRow';
import { type LayoutItemTabId } from '@/settings/layout/types/LayoutItemTabId';
import { getPageLayoutTypeLabel } from '@/settings/layout/utils/getPageLayoutTypeLabel';
import { getStandardApplicationId } from '@/settings/layout/utils/getStandardApplicationId';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import { styled } from '@linaria/react';
import { Trans, useLingui } from '@lingui/react/macro';
import { useMemo, useState } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import {
IconAppWindow,
IconCommand,
type IconComponent,
IconLayoutSidebarLeftExpand,
IconPuzzle,
IconTable,
} from 'twenty-ui/display';
import { SearchInput } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const SETTINGS_LAYOUT_MANAGE_ITEMS_TABS_ID =
'settings-layout-manage-items-tabs';
const StyledToolbar = styled.div`
display: flex;
margin-bottom: ${themeCssVariables.spacing[2]};
`;
type TabDescriptor = {
id: LayoutItemTabId;
title: string;
searchPlaceholder: string;
Icon: IconComponent;
rows: LayoutItemRow[];
secondaryColumn?: 'object' | 'type';
};
export const SettingsLayoutManageItems = () => {
const { t } = useLingui();
const [activeTabId, setActiveTabId] = useState<LayoutItemTabId>('commands');
const [searchTerm, setSearchTerm] = useState('');
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const standardApplicationId = getStandardApplicationId(currentWorkspace);
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
const navigationMenuItems = useAtomStateValue(navigationMenuItemsSelector);
const views = useAtomStateValue(viewsSelector);
const pageLayoutsWithRelations = useAtomStateValue(
pageLayoutsWithRelationsSelector,
);
const frontComponents = useAtomStateValue(frontComponentsSelector);
const { findObjectMetadataItemById } = useFilteredObjectMetadataItems();
const tabs: TabDescriptor[] = useMemo(() => {
const resolveObjectSecondary = (
objectMetadataId: string | null | undefined,
) => {
if (objectMetadataId === null || objectMetadataId === undefined) {
return undefined;
}
const objectMetadataItem = findObjectMetadataItemById(objectMetadataId);
if (objectMetadataItem === undefined) {
return undefined;
}
return {
kind: 'object' as const,
label: objectMetadataItem.labelPlural,
icon: objectMetadataItem.icon ?? undefined,
};
};
return [
{
id: 'commands',
title: t`Commands`,
searchPlaceholder: t`Search a command...`,
Icon: IconCommand,
rows: commandMenuItems.map((item) => ({
id: item.id,
name: item.label,
icon: item.icon ?? undefined,
to: getSettingsPath(SettingsPath.LayoutManageItemCommandDetail, {
commandMenuItemId: item.id,
}),
})),
},
{
id: 'sidebar',
title: t`Sidebar`,
searchPlaceholder: t`Search a sidebar item...`,
Icon: IconLayoutSidebarLeftExpand,
rows: navigationMenuItems.map((item) => ({
id: item.id,
name: item.name ?? t`Untitled`,
icon: item.icon ?? undefined,
applicationId: item.applicationId,
to: getSettingsPath(SettingsPath.LayoutManageItemSidebarItemDetail, {
sidebarItemId: item.id,
}),
})),
},
{
id: 'views',
title: t`Views`,
searchPlaceholder: t`Search a view...`,
Icon: IconTable,
rows: views.map((view) => ({
id: view.id,
name: view.name,
icon: view.icon,
secondary: resolveObjectSecondary(view.objectMetadataId),
to: getSettingsPath(SettingsPath.LayoutManageItemViewDetail, {
viewId: view.id,
}),
})),
secondaryColumn: 'object',
},
{
id: 'pages',
title: t`Pages`,
searchPlaceholder: t`Search a page...`,
Icon: IconAppWindow,
rows: pageLayoutsWithRelations.map((page) => ({
id: page.id,
name: page.name,
secondary: { kind: 'type', label: getPageLayoutTypeLabel(page.type) },
to: getSettingsPath(SettingsPath.LayoutManageItemPageLayoutDetail, {
pageLayoutId: page.id,
}),
})),
secondaryColumn: 'type',
},
{
id: 'front-components',
title: t`Front components`,
searchPlaceholder: t`Search a front component...`,
Icon: IconPuzzle,
rows: frontComponents.map((frontComponent) => ({
id: frontComponent.id,
name: frontComponent.name,
applicationId: frontComponent.applicationId,
to: getSettingsPath(
SettingsPath.LayoutManageItemFrontComponentDetail,
{ frontComponentId: frontComponent.id },
),
})),
},
];
}, [
commandMenuItems,
navigationMenuItems,
views,
pageLayoutsWithRelations,
frontComponents,
findObjectMetadataItemById,
t,
]);
const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0];
const filteredRows = useMemo(() => {
const normalizedSearch = searchTerm.trim().toLowerCase();
if (normalizedSearch === '') {
return activeTab.rows;
}
return activeTab.rows.filter((row) =>
row.name.toLowerCase().includes(normalizedSearch),
);
}, [activeTab.rows, searchTerm]);
return (
<SubMenuTopBarContainer
title={t`Manage layout items`}
links={[
{
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: <Trans>Layout</Trans>,
href: getSettingsPath(SettingsPath.Layout),
},
{ children: <Trans>Manage layout items</Trans> },
]}
>
<SettingsPageContainer>
<TabList
tabs={tabs.map(({ id, title, Icon }) => ({ id, title, Icon }))}
behaveAsLinks={false}
componentInstanceId={SETTINGS_LAYOUT_MANAGE_ITEMS_TABS_ID}
onChangeTab={(tabId) => setActiveTabId(tabId as LayoutItemTabId)}
/>
<StyledToolbar>
<SearchInput
value={searchTerm}
onChange={setSearchTerm}
placeholder={activeTab.searchPlaceholder}
filterDropdown={(filterButton) => filterButton}
/>
</StyledToolbar>
<SettingsLayoutManageItemsTable
rows={filteredRows}
fallbackApplicationId={standardApplicationId}
secondaryColumn={activeTab.secondaryColumn}
/>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -1,109 +1,191 @@
import { metadataStoreStatusFamilySelector } from '@/metadata-store/states/metadataStoreStatusFamilySelector';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { pageLayoutsWithRelationsSelector } from '@/page-layout/states/pageLayoutsWithRelationsSelector';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
import { getPageLayoutTypeLabel } from '@/settings/layout/utils/getPageLayoutTypeLabel';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { t } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { type ReactNode } from 'react';
import { useParams } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
import { useApplicationManifest } from '~/pages/settings/layout/hooks/useApplicationManifest';
import {
type DetailRow,
SettingsLayoutDetailScaffold,
} from '~/pages/settings/layout/components/SettingsLayoutDetailScaffold';
import { SettingsLayoutItemTable } from '~/pages/settings/layout/components/SettingsLayoutItemTable';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const DETAIL_GRID_TEMPLATE = '220px 1fr';
const WIDGETS_GRID_TEMPLATE = '1fr 160px 180px';
export const SettingsLayoutPageLayoutDetail = () => {
const { applicationId = '', pageLayoutUniversalIdentifier = '' } = useParams<{
applicationId: string;
pageLayoutUniversalIdentifier: string;
}>();
const { application, manifest, isLoading } =
useApplicationManifest(applicationId);
const { t } = useLingui();
const { pageLayoutId = '' } = useParams<{ pageLayoutId: string }>();
const pageLayoutsWithRelations = useAtomStateValue(
pageLayoutsWithRelationsSelector,
);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const findObjectLabel = (uid: string | undefined) =>
isDefined(uid)
? objectMetadataItems.find((o) => o.universalIdentifier === uid)
?.labelSingular
: undefined;
const pageLayout = manifest?.pageLayouts?.find(
(pl) => pl.universalIdentifier === pageLayoutUniversalIdentifier,
const pageLayoutsStoreStatus = useAtomFamilySelectorValue(
metadataStoreStatusFamilySelector,
'pageLayouts',
);
const objectLabel = isDefined(pageLayout)
? findObjectLabel(pageLayout.objectUniversalIdentifier)
: undefined;
const pageLayout = pageLayoutsWithRelations.find(
(p) => p.id === pageLayoutId,
);
const isLoading = pageLayoutsStoreStatus === 'empty';
const detailRows: DetailRow[] = isDefined(pageLayout)
? [
{
key: 'universalIdentifier',
label: t`Universal identifier`,
value: pageLayout.universalIdentifier,
},
{ key: 'type', label: t`Type`, value: pageLayout.type ?? t`Default` },
{
key: 'object',
label: t`Object`,
value: objectLabel ?? pageLayout.objectUniversalIdentifier,
},
]
: [];
const resolveObjectLabel = (id: string | null | undefined) =>
isDefined(id)
? objectMetadataItems.find((o) => o.id === id)?.labelSingular
: undefined;
const detailRows: { key: string; label: string; value: ReactNode }[] =
isDefined(pageLayout)
? [
{ key: 'id', label: t`Identifier`, value: pageLayout.id },
{
key: 'type',
label: t`Type`,
value: getPageLayoutTypeLabel(pageLayout.type),
},
...(isDefined(pageLayout.objectMetadataId)
? [
{
key: 'object',
label: t`Object`,
value:
resolveObjectLabel(pageLayout.objectMetadataId) ??
pageLayout.objectMetadataId,
},
]
: []),
]
: [];
const sortedTabs = [...(pageLayout?.tabs ?? [])].sort(
(a, b) => a.position - b.position,
);
const title = pageLayout?.name ?? t`Page`;
return (
<SettingsLayoutDetailScaffold
applicationId={applicationId}
applicationName={application?.name}
entityName={pageLayout?.name ?? t`Page layout`}
entityTypeLabel={t`page layout`}
categoryLabel={t`Page layouts`}
detailRows={detailRows}
isLoading={isLoading}
<SubMenuTopBarContainer
title={title}
links={[
{
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: <Trans>Layout</Trans>,
href: getSettingsPath(SettingsPath.Layout),
},
{
children: <Trans>Pages</Trans>,
href: getSettingsPath(SettingsPath.LayoutManageItems),
},
{ children: title },
]}
>
{sortedTabs.map((tab, index) => {
const widgets = tab.widgets ?? [];
const tabNumber = index + 1;
<SettingsPageContainer>
{isLoading ? (
<SettingsSectionSkeletonLoader />
) : !isDefined(pageLayout) ? (
<Section>
<H2Title
title={t`Page not found`}
description={t`This page does not exist in your workspace.`}
/>
</Section>
) : (
<>
<Section>
<H2Title
title={t`Details`}
description={t`Read-only page definition`}
/>
<Table>
<TableRow gridTemplateColumns={DETAIL_GRID_TEMPLATE}>
<TableHeader>{t`Property`}</TableHeader>
<TableHeader>{t`Value`}</TableHeader>
</TableRow>
{detailRows.map((row) => (
<TableRow
key={row.key}
gridTemplateColumns={DETAIL_GRID_TEMPLATE}
>
<TableCell color={themeCssVariables.font.color.secondary}>
{row.label}
</TableCell>
<TableCell minWidth="0" overflow="hidden">
{row.value}
</TableCell>
</TableRow>
))}
</Table>
</Section>
{sortedTabs.map((tab, index) => {
const widgets = tab.widgets ?? [];
const tabNumber = index + 1;
const descriptionParts: string[] = [];
if (isDefined(tab.layoutMode)) {
descriptionParts.push(t`Layout mode: ${tab.layoutMode}`);
}
if (widgets.length > 0) {
descriptionParts.push(
widgets.length === 1 ? t`1 widget` : t`${widgets.length} widgets`,
);
}
const description =
descriptionParts.length > 0
? descriptionParts.join(' · ')
: t`Empty tab`;
const descriptionParts: string[] = [];
if (isDefined(tab.layoutMode)) {
descriptionParts.push(t`Layout mode: ${tab.layoutMode}`);
}
if (widgets.length > 0) {
descriptionParts.push(
widgets.length === 1
? t`1 widget`
: t`${widgets.length} widgets`,
);
}
const description =
descriptionParts.length > 0
? descriptionParts.join(' · ')
: t`Empty tab`;
return (
<SettingsLayoutItemTable
key={tab.universalIdentifier}
title={t`Tab ${tabNumber}: ${tab.title}`}
description={description}
columns={[
{ key: 'title', label: t`Widget` },
{ key: 'type', label: t`Type`, width: '160px' },
{ key: 'object', label: t`Object`, width: '180px' },
]}
rows={widgets.map((widget) => ({
key: widget.universalIdentifier,
cells: [
widget.title,
widget.type,
findObjectLabel(widget.objectUniversalIdentifier) ?? '—',
],
}))}
/>
);
})}
</SettingsLayoutDetailScaffold>
return (
<Section key={tab.id}>
<H2Title
title={t`Tab ${tabNumber}: ${tab.title}`}
description={description}
/>
{widgets.length > 0 && (
<Table>
<TableRow gridTemplateColumns={WIDGETS_GRID_TEMPLATE}>
<TableHeader>{t`Widget`}</TableHeader>
<TableHeader>{t`Type`}</TableHeader>
<TableHeader>{t`Object`}</TableHeader>
</TableRow>
{widgets.map((widget) => (
<TableRow
key={widget.id}
gridTemplateColumns={WIDGETS_GRID_TEMPLATE}
>
<TableCell minWidth="0" overflow="hidden">
{widget.title}
</TableCell>
<TableCell>{widget.type}</TableCell>
<TableCell minWidth="0" overflow="hidden">
{resolveObjectLabel(widget.objectMetadataId) ?? '—'}
</TableCell>
</TableRow>
))}
</Table>
)}
</Section>
);
})}
</>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -0,0 +1,149 @@
import { metadataStoreStatusFamilySelector } from '@/metadata-store/states/metadataStoreStatusFamilySelector';
import { navigationMenuItemsSelector } from '@/navigation-menu-item/common/states/navigationMenuItemsSelector';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { pageLayoutsWithRelationsSelector } from '@/page-layout/states/pageLayoutsWithRelationsSelector';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import { Trans, useLingui } from '@lingui/react/macro';
import { type ReactNode } from 'react';
import { useParams } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { NavigationMenuItemType } from '~/generated-metadata/graphql';
const DETAIL_GRID_TEMPLATE = '220px 1fr';
export const SettingsLayoutSidebarItemDetail = () => {
const { t } = useLingui();
const { sidebarItemId = '' } = useParams<{ sidebarItemId: string }>();
const navigationMenuItems = useAtomStateValue(navigationMenuItemsSelector);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const views = useAtomStateValue(viewsSelector);
const pageLayoutsWithRelations = useAtomStateValue(
pageLayoutsWithRelationsSelector,
);
const navigationMenuItemsStoreStatus = useAtomFamilySelectorValue(
metadataStoreStatusFamilySelector,
'navigationMenuItems',
);
const sidebarItem = navigationMenuItems.find((i) => i.id === sidebarItemId);
const isLoading = navigationMenuItemsStoreStatus === 'empty';
const resolveTarget = (): string | undefined => {
if (!isDefined(sidebarItem)) return undefined;
switch (sidebarItem.type) {
case NavigationMenuItemType.FOLDER:
return t`Folder`;
case NavigationMenuItemType.LINK:
return sidebarItem.link ?? undefined;
case NavigationMenuItemType.OBJECT:
return objectMetadataItems.find(
(o) => o.id === sidebarItem.targetObjectMetadataId,
)?.labelPlural;
case NavigationMenuItemType.PAGE_LAYOUT:
return pageLayoutsWithRelations.find(
(p) => p.id === sidebarItem.pageLayoutId,
)?.name;
case NavigationMenuItemType.VIEW:
return views.find((v) => v.id === sidebarItem.viewId)?.name;
case NavigationMenuItemType.RECORD:
return sidebarItem.targetRecordIdentifier?.labelIdentifier ?? undefined;
default:
return undefined;
}
};
const targetLabel = resolveTarget();
const detailRows: { key: string; label: string; value: ReactNode }[] =
isDefined(sidebarItem)
? [
{ key: 'id', label: t`Identifier`, value: sidebarItem.id },
{ key: 'type', label: t`Type`, value: sidebarItem.type },
...(isDefined(targetLabel)
? [{ key: 'target', label: t`Target`, value: targetLabel }]
: []),
...(isDefined(sidebarItem.icon)
? [{ key: 'icon', label: t`Icon`, value: sidebarItem.icon }]
: []),
...(isDefined(sidebarItem.color)
? [{ key: 'color', label: t`Color`, value: sidebarItem.color }]
: []),
]
: [];
const title = sidebarItem?.name ?? t`Sidebar item`;
return (
<SubMenuTopBarContainer
title={title}
links={[
{
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: <Trans>Layout</Trans>,
href: getSettingsPath(SettingsPath.Layout),
},
{
children: <Trans>Sidebar</Trans>,
href: getSettingsPath(SettingsPath.LayoutManageItems),
},
{ children: title },
]}
>
<SettingsPageContainer>
{isLoading ? (
<SettingsSectionSkeletonLoader />
) : !isDefined(sidebarItem) ? (
<Section>
<H2Title
title={t`Sidebar item not found`}
description={t`This sidebar item does not exist in your workspace.`}
/>
</Section>
) : (
<Section>
<H2Title
title={t`Details`}
description={t`Read-only sidebar item definition`}
/>
<Table>
<TableRow gridTemplateColumns={DETAIL_GRID_TEMPLATE}>
<TableHeader>{t`Property`}</TableHeader>
<TableHeader>{t`Value`}</TableHeader>
</TableRow>
{detailRows.map((row) => (
<TableRow
key={row.key}
gridTemplateColumns={DETAIL_GRID_TEMPLATE}
>
<TableCell color={themeCssVariables.font.color.secondary}>
{row.label}
</TableCell>
<TableCell minWidth="0" overflow="hidden">
{row.value}
</TableCell>
</TableRow>
))}
</Table>
</Section>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -1,15 +1,29 @@
import { metadataStoreStatusFamilySelector } from '@/metadata-store/states/metadataStoreStatusFamilySelector';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { t } from '@lingui/core/macro';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import { Trans, useLingui } from '@lingui/react/macro';
import { type ReactNode } from 'react';
import { useParams } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
import { useApplicationManifest } from '~/pages/settings/layout/hooks/useApplicationManifest';
import {
type DetailRow,
SettingsLayoutDetailScaffold,
} from '~/pages/settings/layout/components/SettingsLayoutDetailScaffold';
import { SettingsLayoutItemTable } from '~/pages/settings/layout/components/SettingsLayoutItemTable';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const DETAIL_GRID_TEMPLATE = '220px 1fr';
const FIELDS_GRID_TEMPLATE = '40px 1fr 80px 80px';
const FILTERS_GRID_TEMPLATE = '1fr 160px 1fr';
const SORTS_GRID_TEMPLATE = '1fr 120px';
const formatFilterValue = (value: unknown): string => {
if (typeof value === 'string') return value;
@@ -20,125 +34,202 @@ const formatFilterValue = (value: unknown): string => {
};
export const SettingsLayoutViewDetail = () => {
const { applicationId = '', viewUniversalIdentifier = '' } = useParams<{
applicationId: string;
viewUniversalIdentifier: string;
}>();
const { application, manifest, isLoading } =
useApplicationManifest(applicationId);
const { t } = useLingui();
const { viewId = '' } = useParams<{ viewId: string }>();
const views = useAtomStateValue(viewsSelector);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const view = manifest?.views?.find(
(v) => v.universalIdentifier === viewUniversalIdentifier,
const viewsStoreStatus = useAtomFamilySelectorValue(
metadataStoreStatusFamilySelector,
'views',
);
const view = views.find((v) => v.id === viewId);
const isLoading = viewsStoreStatus === 'empty';
const objectLabel = isDefined(view)
? objectMetadataItems.find(
(o) => o.universalIdentifier === view.objectUniversalIdentifier,
)?.labelSingular
? objectMetadataItems.find((o) => o.id === view.objectMetadataId)
?.labelSingular
: undefined;
const resolveFieldLabel = (uid: string): string =>
flattenedFieldMetadataItems.find((f) => f.universalIdentifier === uid)
?.label ?? uid;
const resolveFieldLabel = (id: string): string =>
flattenedFieldMetadataItems.find((f) => f.id === id)?.label ?? id;
const detailRows: DetailRow[] = isDefined(view)
? [
{
key: 'universalIdentifier',
label: t`Universal identifier`,
value: view.universalIdentifier,
},
{ key: 'type', label: t`Type`, value: view.type ?? t`Table` },
{
key: 'object',
label: t`Object`,
value: objectLabel ?? view.objectUniversalIdentifier,
},
{ key: 'icon', label: t`Icon`, value: view.icon ?? t`Not set` },
{
key: 'visibility',
label: t`Visibility`,
value: view.visibility ?? t`Default`,
},
{
key: 'openRecordIn',
label: t`Open records in`,
value: view.openRecordIn ?? t`Default`,
},
]
: [];
const detailRows: { key: string; label: string; value: ReactNode }[] =
isDefined(view)
? [
{ key: 'id', label: t`Identifier`, value: view.id },
{ key: 'type', label: t`Type`, value: view.type },
{
key: 'object',
label: t`Object`,
value: objectLabel ?? view.objectMetadataId,
},
{ key: 'icon', label: t`Icon`, value: view.icon },
{ key: 'visibility', label: t`Visibility`, value: view.visibility },
{
key: 'openRecordIn',
label: t`Open records in`,
value: view.openRecordIn,
},
]
: [];
const sortedFields = [...(view?.fields ?? [])].sort(
const sortedFields = [...(view?.viewFields ?? [])].sort(
(a, b) => a.position - b.position,
);
const filters = view?.viewFilters ?? [];
const sorts = view?.viewSorts ?? [];
const title = view?.name ?? t`View`;
return (
<SettingsLayoutDetailScaffold
applicationId={applicationId}
applicationName={application?.name}
entityName={view?.name ?? t`View`}
entityTypeLabel={t`view`}
categoryLabel={t`Views`}
detailRows={detailRows}
isLoading={isLoading}
<SubMenuTopBarContainer
title={title}
links={[
{
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: <Trans>Layout</Trans>,
href: getSettingsPath(SettingsPath.Layout),
},
{
children: <Trans>Views</Trans>,
href: getSettingsPath(SettingsPath.LayoutManageItems),
},
{ children: title },
]}
>
<SettingsLayoutItemTable
title={t`Fields`}
description={t`Columns shown in this view, in display order`}
columns={[
{ key: 'position', label: t`#`, width: '40px', align: 'right' },
{ key: 'field', label: t`Field` },
{ key: 'visible', label: t`Visible`, width: '80px' },
{ key: 'size', label: t`Size`, width: '80px', align: 'right' },
]}
rows={sortedFields.map((field) => ({
key: field.universalIdentifier,
cells: [
field.position,
resolveFieldLabel(field.fieldMetadataUniversalIdentifier),
field.isVisible === false ? t`Hidden` : t`Yes`,
field.size ?? '—',
],
}))}
/>
<SettingsLayoutItemTable
title={t`Filters`}
description={t`Conditions applied to records before they appear in this view`}
columns={[
{ key: 'field', label: t`Field` },
{ key: 'operand', label: t`Operand`, width: '160px' },
{ key: 'value', label: t`Value` },
]}
rows={(view?.filters ?? []).map((filter) => ({
key: filter.universalIdentifier,
cells: [
resolveFieldLabel(filter.fieldMetadataUniversalIdentifier),
filter.operand,
formatFilterValue(filter.value),
],
}))}
/>
<SettingsLayoutItemTable
title={t`Sorts`}
description={t`Order in which records are displayed`}
columns={[
{ key: 'field', label: t`Field` },
{ key: 'direction', label: t`Direction`, width: '120px' },
]}
rows={(view?.sorts ?? []).map((sort) => ({
key: sort.universalIdentifier,
cells: [
resolveFieldLabel(sort.fieldMetadataUniversalIdentifier),
sort.direction,
],
}))}
/>
</SettingsLayoutDetailScaffold>
<SettingsPageContainer>
{isLoading ? (
<SettingsSectionSkeletonLoader />
) : !isDefined(view) ? (
<Section>
<H2Title
title={t`View not found`}
description={t`This view does not exist in your workspace.`}
/>
</Section>
) : (
<>
<Section>
<H2Title
title={t`Details`}
description={t`Read-only view definition`}
/>
<Table>
<TableRow gridTemplateColumns={DETAIL_GRID_TEMPLATE}>
<TableHeader>{t`Property`}</TableHeader>
<TableHeader>{t`Value`}</TableHeader>
</TableRow>
{detailRows.map((row) => (
<TableRow
key={row.key}
gridTemplateColumns={DETAIL_GRID_TEMPLATE}
>
<TableCell color={themeCssVariables.font.color.secondary}>
{row.label}
</TableCell>
<TableCell minWidth="0" overflow="hidden">
{row.value}
</TableCell>
</TableRow>
))}
</Table>
</Section>
{sortedFields.length > 0 && (
<Section>
<H2Title
title={t`Fields`}
description={t`Columns shown in this view, in display order`}
/>
<Table>
<TableRow gridTemplateColumns={FIELDS_GRID_TEMPLATE}>
<TableHeader align="right">{t`#`}</TableHeader>
<TableHeader>{t`Field`}</TableHeader>
<TableHeader>{t`Visible`}</TableHeader>
<TableHeader align="right">{t`Size`}</TableHeader>
</TableRow>
{sortedFields.map((field) => (
<TableRow
key={field.id}
gridTemplateColumns={FIELDS_GRID_TEMPLATE}
>
<TableCell align="right">{field.position}</TableCell>
<TableCell minWidth="0" overflow="hidden">
{resolveFieldLabel(field.fieldMetadataId)}
</TableCell>
<TableCell>
{field.isVisible ? t`Yes` : t`Hidden`}
</TableCell>
<TableCell align="right">{field.size}</TableCell>
</TableRow>
))}
</Table>
</Section>
)}
{filters.length > 0 && (
<Section>
<H2Title
title={t`Filters`}
description={t`Conditions applied before records appear in this view`}
/>
<Table>
<TableRow gridTemplateColumns={FILTERS_GRID_TEMPLATE}>
<TableHeader>{t`Field`}</TableHeader>
<TableHeader>{t`Operand`}</TableHeader>
<TableHeader>{t`Value`}</TableHeader>
</TableRow>
{filters.map((filter) => (
<TableRow
key={filter.id}
gridTemplateColumns={FILTERS_GRID_TEMPLATE}
>
<TableCell minWidth="0" overflow="hidden">
{resolveFieldLabel(filter.fieldMetadataId)}
</TableCell>
<TableCell>{filter.operand}</TableCell>
<TableCell minWidth="0" overflow="hidden">
{formatFilterValue(filter.value)}
</TableCell>
</TableRow>
))}
</Table>
</Section>
)}
{sorts.length > 0 && (
<Section>
<H2Title
title={t`Sorts`}
description={t`Order in which records are displayed`}
/>
<Table>
<TableRow gridTemplateColumns={SORTS_GRID_TEMPLATE}>
<TableHeader>{t`Field`}</TableHeader>
<TableHeader>{t`Direction`}</TableHeader>
</TableRow>
{sorts.map((sort) => (
<TableRow
key={sort.id}
gridTemplateColumns={SORTS_GRID_TEMPLATE}
>
<TableCell minWidth="0" overflow="hidden">
{resolveFieldLabel(sort.fieldMetadataId)}
</TableCell>
<TableCell>{sort.direction}</TableCell>
</TableRow>
))}
</Table>
</Section>
)}
</>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -1,116 +0,0 @@
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { type ReactNode } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export type DetailRow = { key: string; label: string; value: ReactNode };
const StyledDescription = styled.div`
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.md};
line-height: 1.5;
white-space: pre-wrap;
`;
const GRID_TEMPLATE = '220px 1fr';
export const SettingsLayoutDetailScaffold = ({
applicationId,
applicationName,
entityName,
entityTypeLabel,
categoryLabel,
description,
detailRows,
isLoading,
children,
}: {
applicationId: string;
applicationName: string | undefined;
entityName: string;
entityTypeLabel: string;
categoryLabel: string;
description?: string | null;
detailRows: DetailRow[];
isLoading: boolean;
children?: ReactNode;
}) => {
const trimmedDescription = description?.trim();
const applicationContentHref = getSettingsPath(
SettingsPath.ApplicationDetail,
{ applicationId },
undefined,
'content',
);
const breadcrumbLinks = [
{ children: t`Workspace`, href: getSettingsPath(SettingsPath.Workspace) },
{
children: t`Applications`,
href: getSettingsPath(SettingsPath.Applications),
},
{ children: applicationName ?? '', href: applicationContentHref },
{ children: categoryLabel, href: applicationContentHref },
{ children: entityName },
];
return (
<SubMenuTopBarContainer title={entityName} links={breadcrumbLinks}>
<SettingsPageContainer>
{isLoading ? (
<SettingsSectionSkeletonLoader />
) : (
<>
{isDefined(trimmedDescription) && trimmedDescription.length > 0 && (
<Section>
<H2Title
title={t`About`}
description={t`Description provided by the application`}
/>
<StyledDescription>{trimmedDescription}</StyledDescription>
</Section>
)}
<Section>
<H2Title
title={t`Details`}
description={t`Read-only ${entityTypeLabel} definition shipped by this app`}
/>
<Table>
<TableRow gridTemplateColumns={GRID_TEMPLATE}>
<TableHeader>{t`Property`}</TableHeader>
<TableHeader>{t`Value`}</TableHeader>
</TableRow>
<TableBody>
{detailRows.map((row) => (
<TableRow key={row.key} gridTemplateColumns={GRID_TEMPLATE}>
<TableCell color={themeCssVariables.font.color.secondary}>
{row.label}
</TableCell>
<TableCell minWidth="0" overflow="hidden">
{row.value}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Section>
{children}
</>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -1,71 +0,0 @@
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { type ReactNode } from 'react';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type Column = {
key: string;
label: string;
align?: 'left' | 'right' | 'center';
width?: string;
};
type Row = {
key: string;
cells: ReactNode[];
};
export const SettingsLayoutItemTable = ({
title,
description,
columns,
rows,
}: {
title: string;
description?: string;
columns: Column[];
rows: Row[];
}) => {
if (rows.length === 0) {
return null;
}
const gridTemplate = columns.map((c) => c.width ?? '1fr').join(' ');
return (
<Section>
<H2Title title={title} description={description} />
<Table>
<TableRow gridTemplateColumns={gridTemplate}>
{columns.map((col) => (
<TableHeader key={col.key} align={col.align ?? 'left'}>
{col.label}
</TableHeader>
))}
</TableRow>
<TableBody>
{rows.map((row) => (
<TableRow key={row.key} gridTemplateColumns={gridTemplate}>
{row.cells.map((cell, index) => (
<TableCell
key={columns[index]?.key ?? index}
align={columns[index]?.align ?? 'left'}
color={themeCssVariables.font.color.primary}
minWidth="0"
overflow="hidden"
>
{cell}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</Section>
);
};
@@ -1,66 +0,0 @@
import { renderHook, waitFor } from '@testing-library/react';
import { type MockedResponse } from '@apollo/client/testing';
import { useApplicationManifest } from '~/pages/settings/layout/hooks/useApplicationManifest';
import {
FindMarketplaceAppDetailDocument,
FindOneApplicationDocument,
} from '~/generated-metadata/graphql';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
const APP_ID = 'app-1';
const APP_UID = 'uid-1';
const findOneApplicationMock = (
application: { id: string; universalIdentifier: string; name: string } | null,
): MockedResponse => ({
request: {
query: FindOneApplicationDocument,
variables: { id: APP_ID },
},
result: { data: { findOneApplication: application } },
});
const findMarketplaceAppDetailMock = (
manifest: object | null,
): MockedResponse => ({
request: {
query: FindMarketplaceAppDetailDocument,
variables: { universalIdentifier: APP_UID },
},
result: {
data: {
findMarketplaceAppDetail: manifest === null ? null : { manifest },
},
},
});
describe('useApplicationManifest', () => {
it('skips both queries when applicationId is empty', () => {
const wrapper = getJestMetadataAndApolloMocksWrapper({ apolloMocks: [] });
const { result } = renderHook(() => useApplicationManifest(''), {
wrapper,
});
expect(result.current.application).toBeUndefined();
expect(result.current.manifest).toBeUndefined();
});
it('exposes an undefined manifest when findMarketplaceAppDetail returns null', async () => {
const wrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [
findOneApplicationMock({
id: APP_ID,
universalIdentifier: APP_UID,
name: 'My App',
}),
findMarketplaceAppDetailMock(null),
],
});
const { result } = renderHook(() => useApplicationManifest(APP_ID), {
wrapper,
});
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.manifest).toBeUndefined();
});
});
@@ -1,42 +0,0 @@
import { useQuery } from '@apollo/client/react';
import { type Manifest } from 'twenty-shared/application';
import {
FindMarketplaceAppDetailDocument,
FindOneApplicationDocument,
} from '~/generated-metadata/graphql';
// Loads the app + its marketplace manifest. Both queries are cache-first since
// the application detail page already issues them.
export const useApplicationManifest = (applicationId: string) => {
const { data: appData, loading: appLoading } = useQuery(
FindOneApplicationDocument,
{
variables: { id: applicationId },
fetchPolicy: 'cache-first',
skip: !applicationId,
},
);
const application = appData?.findOneApplication;
const { data: detailData, loading: detailLoading } = useQuery(
FindMarketplaceAppDetailDocument,
{
variables: {
universalIdentifier: application?.universalIdentifier ?? '',
},
fetchPolicy: 'cache-first',
skip: !application?.universalIdentifier,
},
);
const manifest = detailData?.findMarketplaceAppDetail?.manifest as
| Manifest
| undefined;
return {
application,
manifest,
isLoading: appLoading || detailLoading,
};
};
@@ -15,6 +15,13 @@ export enum SettingsPath {
Enterprise = 'enterprise',
Objects = 'objects',
ObjectOverview = 'objects/overview',
Layout = 'layout',
LayoutManageItems = 'layout/manage',
LayoutManageItemCommandDetail = 'layout/manage/commands/:commandMenuItemId',
LayoutManageItemSidebarItemDetail = 'layout/manage/sidebar/:sidebarItemId',
LayoutManageItemFrontComponentDetail = 'layout/manage/front-components/:frontComponentId',
LayoutManageItemViewDetail = 'layout/manage/views/:viewId',
LayoutManageItemPageLayoutDetail = 'layout/manage/pages/:pageLayoutId',
ObjectDetail = 'objects/:objectNamePlural',
ObjectNewFieldSelect = 'objects/:objectNamePlural/new-field/select',
ObjectNewFieldConfigure = 'objects/:objectNamePlural/new-field/configure',
@@ -47,10 +54,6 @@ export enum SettingsPath {
ApplicationDetail = 'applications/:applicationId',
ApplicationConnectionDetail = 'applications/:applicationId/connections/:connectedAccountId',
ApplicationLogicFunctionDetail = 'applications/:applicationId/logicFunctions/:logicFunctionId',
ApplicationFrontComponentDetail = 'applications/:applicationId/frontComponents/:frontComponentId',
ApplicationCommandMenuItemDetail = 'applications/:applicationId/commandMenuItems/:commandMenuItemId',
ApplicationViewDetail = 'applications/:applicationId/views/:viewUniversalIdentifier',
ApplicationPageLayoutDetail = 'applications/:applicationId/pageLayouts/:pageLayoutUniversalIdentifier',
AvailableApplicationDetail = 'applications/available/:availableApplicationId',
ApplicationRegistrationDetail = 'applications/registrations/:applicationRegistrationId',
ApplicationRegistrationConfigVariableDetails = 'applications/registrations/:applicationRegistrationId/config-variables/:variableKey',