Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2563bf2a32 | ||
|
|
37c37ddae6 | ||
|
|
7261300a8e |
@@ -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[],
|
||||
},
|
||||
);
|
||||
+5
-3
@@ -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 };
|
||||
|
||||
-25
@@ -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', () => {
|
||||
|
||||
-14
@@ -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`,
|
||||
|
||||
+38
@@ -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>
|
||||
);
|
||||
};
|
||||
+146
@@ -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>
|
||||
);
|
||||
};
|
||||
+142
@@ -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;
|
||||
-81
@@ -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>
|
||||
);
|
||||
};
|
||||
-110
@@ -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>
|
||||
);
|
||||
};
|
||||
-146
@@ -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>
|
||||
);
|
||||
};
|
||||
+7
-6
@@ -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,
|
||||
}),
|
||||
}))
|
||||
|
||||
-75
@@ -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>
|
||||
);
|
||||
};
|
||||
-139
@@ -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>
|
||||
);
|
||||
};
|
||||
+244
@@ -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>
|
||||
);
|
||||
};
|
||||
+170
-88
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
-116
@@ -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>
|
||||
);
|
||||
};
|
||||
-71
@@ -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>
|
||||
);
|
||||
};
|
||||
-66
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user