Fix app design 6 (#19827)
Unify application display page and isntalled page --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
+6
-1
@@ -112,7 +112,12 @@ export const SettingsAdminApps = () => {
|
||||
mobileGridAutoColumns={TABLE_GRID_MOBILE}
|
||||
isClickable
|
||||
>
|
||||
<TableCell color={themeCssVariables.font.color.primary}>
|
||||
<TableCell
|
||||
color={themeCssVariables.font.color.primary}
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
<OverflowingTextWithTooltip text={registration.name} />
|
||||
</TableCell>
|
||||
<TableCell overflow="hidden" align="right">
|
||||
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { type ComponentType, type ReactNode } from 'react';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBrandNpm,
|
||||
IconLink,
|
||||
IconMail,
|
||||
IconWorld,
|
||||
} from 'twenty-ui/display';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
export type ContentEntry = {
|
||||
icon: ComponentType<{ size?: number }>;
|
||||
count: number;
|
||||
one: string;
|
||||
many: string;
|
||||
};
|
||||
|
||||
export type DeveloperLinks = {
|
||||
websiteUrl?: string;
|
||||
termsUrl?: string;
|
||||
emailSupport?: string;
|
||||
issueReportUrl?: string;
|
||||
sourcePackageUrl?: string;
|
||||
};
|
||||
|
||||
type SettingsApplicationAboutSidebarProps = {
|
||||
actionButton?: ReactNode;
|
||||
author?: string;
|
||||
category?: string;
|
||||
contentEntries?: ContentEntry[];
|
||||
currentVersion?: string;
|
||||
latestAvailableVersion?: string;
|
||||
developerLinks?: DeveloperLinks;
|
||||
};
|
||||
|
||||
const StyledSidebar = styled.div`
|
||||
flex-shrink: 0;
|
||||
width: 140px;
|
||||
`;
|
||||
|
||||
const StyledSidebarSection = styled.div`
|
||||
padding: ${themeCssVariables.spacing[3]} 0;
|
||||
|
||||
&:first-of-type {
|
||||
padding-top: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledSidebarLabel = styled.div`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
margin-bottom: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledSidebarValue = styled.div`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
font-weight: ${themeCssVariables.font.weight.medium};
|
||||
`;
|
||||
|
||||
const StyledContentItem = styled.div`
|
||||
align-items: center;
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
display: flex;
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
margin-bottom: ${themeCssVariables.spacing[2]};
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledLink = styled.a`
|
||||
align-items: center;
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
display: flex;
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
margin-bottom: ${themeCssVariables.spacing[2]};
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export const SettingsApplicationAboutSidebar = ({
|
||||
actionButton,
|
||||
author,
|
||||
category,
|
||||
contentEntries,
|
||||
currentVersion,
|
||||
latestAvailableVersion,
|
||||
developerLinks,
|
||||
}: SettingsApplicationAboutSidebarProps) => {
|
||||
const isSafeUrl = (url: string | undefined): url is string => {
|
||||
if (!isNonEmptyString(url)) return false;
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
|
||||
return ['http:', 'https:', 'mailto:'].includes(parsed.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredContentEntries = (contentEntries ?? []).filter(
|
||||
(entry) => entry.count > 0,
|
||||
);
|
||||
|
||||
const hasDeveloperLinks =
|
||||
isDefined(developerLinks) &&
|
||||
(isNonEmptyString(developerLinks.websiteUrl) ||
|
||||
isNonEmptyString(developerLinks.termsUrl) ||
|
||||
isNonEmptyString(developerLinks.emailSupport) ||
|
||||
isNonEmptyString(developerLinks.issueReportUrl) ||
|
||||
isNonEmptyString(developerLinks.sourcePackageUrl));
|
||||
|
||||
return (
|
||||
<StyledSidebar>
|
||||
{isDefined(actionButton) && (
|
||||
<StyledSidebarSection>{actionButton}</StyledSidebarSection>
|
||||
)}
|
||||
|
||||
{isDefined(author) && (
|
||||
<StyledSidebarSection>
|
||||
<StyledSidebarLabel>{t`Created by`}</StyledSidebarLabel>
|
||||
<StyledSidebarValue>{author}</StyledSidebarValue>
|
||||
</StyledSidebarSection>
|
||||
)}
|
||||
|
||||
{isDefined(category) && (
|
||||
<StyledSidebarSection>
|
||||
<StyledSidebarLabel>{t`Category`}</StyledSidebarLabel>
|
||||
<StyledSidebarValue>{category}</StyledSidebarValue>
|
||||
</StyledSidebarSection>
|
||||
)}
|
||||
|
||||
{filteredContentEntries.length > 0 && (
|
||||
<StyledSidebarSection>
|
||||
<StyledSidebarLabel>{t`Content`}</StyledSidebarLabel>
|
||||
{filteredContentEntries.map((entry) => (
|
||||
<StyledContentItem key={entry.one}>
|
||||
<entry.icon size={16} />
|
||||
{entry.count} {entry.count === 1 ? entry.one : entry.many}
|
||||
</StyledContentItem>
|
||||
))}
|
||||
</StyledSidebarSection>
|
||||
)}
|
||||
|
||||
{isDefined(currentVersion) && (
|
||||
<StyledSidebarSection>
|
||||
<StyledSidebarLabel>{t`Current`}</StyledSidebarLabel>
|
||||
<StyledSidebarValue>{currentVersion}</StyledSidebarValue>
|
||||
</StyledSidebarSection>
|
||||
)}
|
||||
|
||||
{isDefined(latestAvailableVersion) && (
|
||||
<StyledSidebarSection>
|
||||
<StyledSidebarLabel>{t`Latest`}</StyledSidebarLabel>
|
||||
<StyledSidebarValue>{latestAvailableVersion}</StyledSidebarValue>
|
||||
</StyledSidebarSection>
|
||||
)}
|
||||
|
||||
{hasDeveloperLinks && (
|
||||
<StyledSidebarSection>
|
||||
<StyledSidebarLabel>{t`Developers links`}</StyledSidebarLabel>
|
||||
{isSafeUrl(developerLinks.websiteUrl) && (
|
||||
<StyledLink
|
||||
href={developerLinks.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconWorld size={16} />
|
||||
{t`Website`}
|
||||
</StyledLink>
|
||||
)}
|
||||
{isSafeUrl(developerLinks.termsUrl) && (
|
||||
<StyledLink
|
||||
href={developerLinks.termsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconLink size={16} />
|
||||
{t`Terms / Privacy`}
|
||||
</StyledLink>
|
||||
)}
|
||||
{isNonEmptyString(developerLinks.emailSupport) && (
|
||||
<StyledLink
|
||||
href={`mailto:${developerLinks.emailSupport}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconMail size={16} />
|
||||
{t`Email support`}
|
||||
</StyledLink>
|
||||
)}
|
||||
{isSafeUrl(developerLinks.issueReportUrl) && (
|
||||
<StyledLink
|
||||
href={developerLinks.issueReportUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconAlertTriangle size={16} />
|
||||
{t`Report an issue`}
|
||||
</StyledLink>
|
||||
)}
|
||||
{isSafeUrl(developerLinks.sourcePackageUrl) && (
|
||||
<StyledLink
|
||||
href={developerLinks.sourcePackageUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconBrandNpm size={16} />
|
||||
{t`Npm package`}
|
||||
</StyledLink>
|
||||
)}
|
||||
</StyledSidebarSection>
|
||||
)}
|
||||
</StyledSidebar>
|
||||
);
|
||||
};
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useState } from 'react';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
type SettingsApplicationScreenshotGalleryProps = {
|
||||
screenshots: string[];
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
const StyledScreenshotsContainer = styled.div`
|
||||
align-items: center;
|
||||
background-color: ${themeCssVariables.background.secondary};
|
||||
border: 1px solid ${themeCssVariables.border.color.medium};
|
||||
border-radius: ${themeCssVariables.border.radius.md};
|
||||
display: flex;
|
||||
height: 300px;
|
||||
justify-content: center;
|
||||
margin-bottom: ${themeCssVariables.spacing[2]};
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StyledScreenshotImage = styled.img`
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledScreenshotThumbnails = styled.div`
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
margin-bottom: ${themeCssVariables.spacing[6]};
|
||||
`;
|
||||
|
||||
const StyledThumbnail = styled.div<{ isSelected?: boolean }>`
|
||||
align-items: center;
|
||||
background-color: ${themeCssVariables.background.secondary};
|
||||
border: 1px solid
|
||||
${({ isSelected }) =>
|
||||
isSelected
|
||||
? themeCssVariables.color.blue
|
||||
: themeCssVariables.border.color.medium};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
height: 60px;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
border-color: ${themeCssVariables.color.blue};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledThumbnailImage = styled.img`
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const SettingsApplicationScreenshotGallery = ({
|
||||
screenshots,
|
||||
displayName,
|
||||
}: SettingsApplicationScreenshotGalleryProps) => {
|
||||
const [selectedScreenshotIndex, setSelectedScreenshotIndex] = useState(0);
|
||||
|
||||
if (screenshots.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const safeIndex = Math.min(selectedScreenshotIndex, screenshots.length - 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledScreenshotsContainer>
|
||||
<StyledScreenshotImage
|
||||
src={screenshots[safeIndex]}
|
||||
alt={`${displayName} screenshot ${safeIndex + 1}`}
|
||||
/>
|
||||
</StyledScreenshotsContainer>
|
||||
<StyledScreenshotThumbnails>
|
||||
{screenshots.slice(0, 6).map((screenshot, index) => (
|
||||
<StyledThumbnail
|
||||
key={index}
|
||||
isSelected={index === selectedScreenshotIndex}
|
||||
onClick={() => setSelectedScreenshotIndex(index)}
|
||||
>
|
||||
<StyledThumbnailImage
|
||||
src={screenshot}
|
||||
alt={`${displayName} thumbnail ${index + 1}`}
|
||||
/>
|
||||
</StyledThumbnail>
|
||||
))}
|
||||
</StyledScreenshotThumbnails>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useObjectAndFieldRows } from '@/settings/applications/hooks/useObjectAndFieldRows';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
|
||||
import { getTestEnrichedObjectMetadataItemsMock } from '~/testing/utils/getTestEnrichedObjectMetadataItemsMock';
|
||||
|
||||
const mockObjectMetadataItems = getTestEnrichedObjectMetadataItemsMock();
|
||||
|
||||
const personObject = mockObjectMetadataItems.find(
|
||||
(item) => item.nameSingular === 'person',
|
||||
)!;
|
||||
const companyObject = mockObjectMetadataItems.find(
|
||||
(item) => item.nameSingular === 'company',
|
||||
)!;
|
||||
|
||||
const APP_ID = 'test-app-id';
|
||||
|
||||
const wrapper = getJestMetadataAndApolloMocksWrapper({
|
||||
apolloMocks: [],
|
||||
});
|
||||
|
||||
describe('useObjectAndFieldRows', () => {
|
||||
describe('with installed application', () => {
|
||||
it('should return object rows for installed application objects', () => {
|
||||
const installedApplication = {
|
||||
id: APP_ID,
|
||||
objects: [{ id: personObject.id }],
|
||||
name: 'Test App',
|
||||
canBeUninstalled: true,
|
||||
availablePackages: {},
|
||||
applicationVariables: [],
|
||||
agents: [],
|
||||
logicFunctions: [],
|
||||
frontComponents: [],
|
||||
};
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useObjectAndFieldRows({
|
||||
applicationId: APP_ID,
|
||||
installedApplication,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.objectRows).toHaveLength(1);
|
||||
expect(result.current.objectRows[0].key).toBe(personObject.nameSingular);
|
||||
expect(result.current.objectRows[0].labelPlural).toBe(
|
||||
personObject.labelPlural,
|
||||
);
|
||||
expect(result.current.objectRows[0].fieldsCount).toBeGreaterThan(0);
|
||||
expect(result.current.objectRows[0].link).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return empty object rows when application has no objects', () => {
|
||||
const installedApplication = {
|
||||
id: APP_ID,
|
||||
objects: [],
|
||||
name: 'Test App',
|
||||
canBeUninstalled: true,
|
||||
availablePackages: {},
|
||||
applicationVariables: [],
|
||||
agents: [],
|
||||
logicFunctions: [],
|
||||
frontComponents: [],
|
||||
};
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useObjectAndFieldRows({
|
||||
applicationId: APP_ID,
|
||||
installedApplication,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.objectRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return field group rows for fields added to other objects', () => {
|
||||
const fieldBelongingToApp = companyObject.fields[0];
|
||||
|
||||
const installedApplication = {
|
||||
id: fieldBelongingToApp.applicationId ?? APP_ID,
|
||||
objects: [{ id: personObject.id }],
|
||||
name: 'Test App',
|
||||
canBeUninstalled: true,
|
||||
availablePackages: {},
|
||||
applicationVariables: [],
|
||||
agents: [],
|
||||
logicFunctions: [],
|
||||
frontComponents: [],
|
||||
};
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useObjectAndFieldRows({
|
||||
applicationId: installedApplication.id,
|
||||
installedApplication,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Field group rows should not include the app's own objects
|
||||
const hasOwnObject = result.current.fieldGroupRows.some(
|
||||
(row) => row.key === personObject.nameSingular,
|
||||
);
|
||||
expect(hasOwnObject).toBe(false);
|
||||
});
|
||||
|
||||
it('should exclude deny-listed objects from field group rows', () => {
|
||||
const installedApplication = {
|
||||
id: APP_ID,
|
||||
objects: [],
|
||||
name: 'Test App',
|
||||
canBeUninstalled: true,
|
||||
availablePackages: {},
|
||||
applicationVariables: [],
|
||||
agents: [],
|
||||
logicFunctions: [],
|
||||
frontComponents: [],
|
||||
};
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useObjectAndFieldRows({
|
||||
applicationId: APP_ID,
|
||||
installedApplication,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
const hasDeniedObject = result.current.fieldGroupRows.some(
|
||||
(row) => row.key === 'timelineActivity' || row.key === 'favorite',
|
||||
);
|
||||
expect(hasDeniedObject).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with manifest content', () => {
|
||||
it('should return object rows from manifest objects', () => {
|
||||
const manifestContent = {
|
||||
objects: [
|
||||
{
|
||||
universalIdentifier: 'uid-1',
|
||||
nameSingular: 'customObject',
|
||||
namePlural: 'customObjects',
|
||||
labelSingular: 'Custom Object',
|
||||
labelPlural: 'Custom Objects',
|
||||
icon: 'IconBox',
|
||||
fields: [{ name: 'field1' }, { name: 'field2' }],
|
||||
},
|
||||
],
|
||||
fields: [],
|
||||
} as unknown as Manifest;
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useObjectAndFieldRows({
|
||||
applicationId: 'app-uid',
|
||||
manifestContent,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.objectRows).toHaveLength(1);
|
||||
expect(result.current.objectRows[0].key).toBe('customObject');
|
||||
expect(result.current.objectRows[0].labelPlural).toBe('Custom Objects');
|
||||
expect(result.current.objectRows[0].fieldsCount).toBe(2);
|
||||
expect(result.current.objectRows[0].tagItem.applicationId).toBe(
|
||||
'app-uid',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return field group rows grouped by object from manifest fields', () => {
|
||||
const manifestContent = {
|
||||
objects: [
|
||||
{
|
||||
universalIdentifier: 'custom-obj-uid',
|
||||
nameSingular: 'customObj',
|
||||
namePlural: 'customObjs',
|
||||
labelSingular: 'Custom',
|
||||
labelPlural: 'Customs',
|
||||
icon: 'IconBox',
|
||||
fields: [],
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
objectUniversalIdentifier: 'custom-obj-uid',
|
||||
name: 'field1',
|
||||
},
|
||||
{
|
||||
objectUniversalIdentifier: 'custom-obj-uid',
|
||||
name: 'field2',
|
||||
},
|
||||
{
|
||||
objectUniversalIdentifier: 'custom-obj-uid',
|
||||
name: 'field3',
|
||||
},
|
||||
],
|
||||
} as unknown as Manifest;
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useObjectAndFieldRows({
|
||||
applicationId: 'app-uid',
|
||||
manifestContent,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.fieldGroupRows).toHaveLength(1);
|
||||
expect(result.current.fieldGroupRows[0].key).toBe('customObj');
|
||||
expect(result.current.fieldGroupRows[0].fieldsCount).toBe(3);
|
||||
});
|
||||
|
||||
it('should return empty field group rows when manifest has no fields', () => {
|
||||
const manifestContent = {
|
||||
objects: [],
|
||||
fields: [],
|
||||
} as unknown as Manifest;
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useObjectAndFieldRows({
|
||||
applicationId: 'app-uid',
|
||||
manifestContent,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.fieldGroupRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return empty rows when no data is provided', () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useObjectAndFieldRows({
|
||||
applicationId: 'app-uid',
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.objectRows).toHaveLength(0);
|
||||
expect(result.current.fieldGroupRows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data source priority', () => {
|
||||
it('should use installed application data when both sources are provided', () => {
|
||||
const installedApplication = {
|
||||
id: APP_ID,
|
||||
objects: [{ id: personObject.id }],
|
||||
name: 'Test App',
|
||||
canBeUninstalled: true,
|
||||
availablePackages: {},
|
||||
applicationVariables: [],
|
||||
agents: [],
|
||||
logicFunctions: [],
|
||||
frontComponents: [],
|
||||
};
|
||||
|
||||
const manifestContent = {
|
||||
objects: [
|
||||
{
|
||||
universalIdentifier: 'uid-1',
|
||||
nameSingular: 'manifestObj',
|
||||
namePlural: 'manifestObjs',
|
||||
labelSingular: 'Manifest',
|
||||
labelPlural: 'Manifests',
|
||||
icon: 'IconBox',
|
||||
fields: [],
|
||||
},
|
||||
],
|
||||
fields: [],
|
||||
} as unknown as Manifest;
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useObjectAndFieldRows({
|
||||
applicationId: APP_ID,
|
||||
installedApplication,
|
||||
manifestContent,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Should use installed data, not manifest
|
||||
expect(result.current.objectRows[0].key).toBe(personObject.nameSingular);
|
||||
expect(
|
||||
result.current.objectRows.some((r) => r.key === 'manifestObj'),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
|
||||
import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useMemo } from 'react';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
|
||||
import { type Application } from '~/generated-metadata/graphql';
|
||||
import { type ApplicationDataTableRow } from '~/pages/settings/applications/components/SettingsApplicationDataTable';
|
||||
import { findObjectNameByUniversalIdentifier } from '~/pages/settings/applications/utils/findObjectNameByUniversalIdentifier';
|
||||
|
||||
type InstalledApplicationForObjectRows = Omit<
|
||||
Application,
|
||||
'objects' | 'universalIdentifier' | 'frontComponents'
|
||||
> & {
|
||||
objects: { id: string }[];
|
||||
};
|
||||
|
||||
export const useObjectAndFieldRows = ({
|
||||
applicationId,
|
||||
installedApplication,
|
||||
manifestContent,
|
||||
}: {
|
||||
applicationId: string;
|
||||
installedApplication?: InstalledApplicationForObjectRows;
|
||||
manifestContent?: Manifest;
|
||||
}) => {
|
||||
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
|
||||
|
||||
const installedObjectIds = useMemo(
|
||||
() => installedApplication?.objects.map((object) => object.id) ?? [],
|
||||
[installedApplication?.objects],
|
||||
);
|
||||
|
||||
const objectRows = useMemo((): ApplicationDataTableRow[] => {
|
||||
if (isDefined(installedApplication)) {
|
||||
if (installedApplication.objects.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return objectMetadataItems
|
||||
.filter((item) => installedObjectIds.includes(item.id))
|
||||
.map((item) => ({
|
||||
key: item.nameSingular,
|
||||
labelPlural: item.labelPlural,
|
||||
icon: item.icon ?? undefined,
|
||||
fieldsCount: item.fields.filter((f) => !isHiddenSystemField(f))
|
||||
.length,
|
||||
link: getSettingsPath(SettingsPath.ObjectDetail, {
|
||||
objectNamePlural: item.namePlural,
|
||||
}),
|
||||
tagItem: {
|
||||
isCustom: item.isCustom,
|
||||
isRemote: item.isRemote,
|
||||
applicationId: item.applicationId,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
return (manifestContent?.objects ?? []).map((appObject) => ({
|
||||
key: appObject.nameSingular,
|
||||
labelPlural: appObject.labelPlural,
|
||||
icon: appObject.icon ?? undefined,
|
||||
fieldsCount: appObject.fields.length,
|
||||
tagItem: { applicationId },
|
||||
}));
|
||||
}, [
|
||||
installedApplication,
|
||||
manifestContent?.objects,
|
||||
objectMetadataItems,
|
||||
installedObjectIds,
|
||||
applicationId,
|
||||
]);
|
||||
|
||||
const fieldGroupRows = useMemo((): ApplicationDataTableRow[] => {
|
||||
if (isDefined(installedApplication)) {
|
||||
const FIELD_GROUP_DENY_LIST = ['timelineActivity', 'favorite'];
|
||||
|
||||
return objectMetadataItems
|
||||
.filter((item) => {
|
||||
if (installedObjectIds.includes(item.id)) return false;
|
||||
if (FIELD_GROUP_DENY_LIST.includes(item.nameSingular)) return false;
|
||||
|
||||
return item.fields.some(
|
||||
(field) => field.applicationId === installedApplication.id,
|
||||
);
|
||||
})
|
||||
.map((item) => ({
|
||||
key: item.nameSingular,
|
||||
labelPlural: item.labelPlural,
|
||||
icon: item.icon ?? undefined,
|
||||
fieldsCount: item.fields.filter(
|
||||
(field) => field.applicationId === installedApplication.id,
|
||||
).length,
|
||||
link: getSettingsPath(SettingsPath.ObjectDetail, {
|
||||
objectNamePlural: item.namePlural,
|
||||
}),
|
||||
tagItem: {
|
||||
isCustom: item.isCustom,
|
||||
isRemote: item.isRemote,
|
||||
applicationId: item.applicationId,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
const manifestFields = manifestContent?.fields ?? [];
|
||||
const manifestObjects = manifestContent?.objects ?? [];
|
||||
|
||||
if (manifestFields.length === 0) return [];
|
||||
|
||||
const groupMap = new Map<
|
||||
string,
|
||||
{ objectUniversalIdentifier: string; count: number }
|
||||
>();
|
||||
|
||||
for (const field of manifestFields) {
|
||||
const objectUid = field.objectUniversalIdentifier;
|
||||
const existing = groupMap.get(objectUid);
|
||||
|
||||
if (isDefined(existing)) {
|
||||
existing.count++;
|
||||
} else {
|
||||
groupMap.set(objectUid, {
|
||||
objectUniversalIdentifier: objectUid,
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(groupMap.values())
|
||||
.map((group) => {
|
||||
const appObject = manifestObjects.find(
|
||||
(obj) => obj.universalIdentifier === group.objectUniversalIdentifier,
|
||||
);
|
||||
|
||||
if (isDefined(appObject)) {
|
||||
return {
|
||||
key: appObject.nameSingular,
|
||||
labelPlural: appObject.labelPlural,
|
||||
icon: appObject.icon ?? undefined,
|
||||
fieldsCount: group.count,
|
||||
tagItem: { applicationId },
|
||||
};
|
||||
}
|
||||
|
||||
const standardObjectName = findObjectNameByUniversalIdentifier(
|
||||
group.objectUniversalIdentifier,
|
||||
);
|
||||
|
||||
const objectMetadataItem = isDefined(standardObjectName)
|
||||
? objectMetadataItems.find(
|
||||
(item) => item.nameSingular === standardObjectName,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (!isDefined(objectMetadataItem)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
key: objectMetadataItem.nameSingular,
|
||||
labelPlural: objectMetadataItem.labelPlural,
|
||||
icon: objectMetadataItem.icon ?? undefined,
|
||||
fieldsCount: group.count,
|
||||
tagItem: {},
|
||||
};
|
||||
})
|
||||
.filter(isDefined);
|
||||
}, [
|
||||
installedApplication,
|
||||
manifestContent?.fields,
|
||||
manifestContent?.objects,
|
||||
objectMetadataItems,
|
||||
installedObjectIds,
|
||||
applicationId,
|
||||
]);
|
||||
|
||||
return { objectRows, fieldGroupRows };
|
||||
};
|
||||
+17
-43
@@ -1,13 +1,15 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { type LogicFunction } from '~/generated-metadata/graphql';
|
||||
import {
|
||||
type LogicFunctionTableRow,
|
||||
StyledTableRow,
|
||||
} from '@/settings/logic-functions/components/SettingsLogicFunctionsTable';
|
||||
import { TableCell } from '@/ui/layout/table/components/TableCell';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useContext } from 'react';
|
||||
import {
|
||||
IconChevronRight,
|
||||
IconCode,
|
||||
OverflowingTextWithTooltip,
|
||||
} from 'twenty-ui/display';
|
||||
import { StyledTableRow } from '@/settings/logic-functions/components/SettingsLogicFunctionsTable';
|
||||
import { useContext } from 'react';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledIconContainer = styled.span`
|
||||
@@ -21,43 +23,13 @@ const StyledIconChevronRightContainer = styled(StyledIconContainer)`
|
||||
|
||||
export const SettingsLogicFunctionsFieldItemTableRow = ({
|
||||
logicFunction,
|
||||
to,
|
||||
}: {
|
||||
logicFunction: LogicFunction;
|
||||
to: string;
|
||||
logicFunction: LogicFunctionTableRow;
|
||||
}) => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
|
||||
const computeTrigger = () => {
|
||||
const cronTrigger = logicFunction.cronTriggerSettings;
|
||||
|
||||
const routeTrigger = logicFunction.httpRouteTriggerSettings;
|
||||
|
||||
const databaseEventTriggerSettings =
|
||||
logicFunction.databaseEventTriggerSettings;
|
||||
|
||||
const isTool = logicFunction.isTool;
|
||||
|
||||
if (isTool) {
|
||||
return 'Tool';
|
||||
}
|
||||
|
||||
if (cronTrigger) {
|
||||
return 'Cron';
|
||||
}
|
||||
|
||||
if (routeTrigger) {
|
||||
return 'Route';
|
||||
}
|
||||
|
||||
if (databaseEventTriggerSettings) {
|
||||
return databaseEventTriggerSettings.eventName;
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
return (
|
||||
<StyledTableRow to={to}>
|
||||
<StyledTableRow to={logicFunction.link}>
|
||||
<TableCell
|
||||
color={themeCssVariables.font.color.primary}
|
||||
gap={themeCssVariables.spacing[2]}
|
||||
@@ -74,18 +46,20 @@ export const SettingsLogicFunctionsFieldItemTableRow = ({
|
||||
whiteSpace="nowrap"
|
||||
overflow="hidden"
|
||||
>
|
||||
<OverflowingTextWithTooltip text={computeTrigger()} />
|
||||
<OverflowingTextWithTooltip text={logicFunction.trigger} />
|
||||
</TableCell>
|
||||
<TableCell
|
||||
align="center"
|
||||
padding={`0 ${themeCssVariables.spacing[1]} 0 ${themeCssVariables.spacing[2]}`}
|
||||
>
|
||||
<StyledIconChevronRightContainer>
|
||||
<IconChevronRight
|
||||
size={theme.icon.size.md}
|
||||
stroke={theme.icon.stroke.sm}
|
||||
/>
|
||||
</StyledIconChevronRightContainer>
|
||||
{logicFunction.link && (
|
||||
<StyledIconChevronRightContainer>
|
||||
<IconChevronRight
|
||||
size={theme.icon.size.md}
|
||||
stroke={theme.icon.stroke.sm}
|
||||
/>
|
||||
</StyledIconChevronRightContainer>
|
||||
)}
|
||||
</TableCell>
|
||||
</StyledTableRow>
|
||||
);
|
||||
|
||||
+10
-13
@@ -4,14 +4,17 @@ import { TableBody } from '@/ui/layout/table/components/TableBody';
|
||||
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
|
||||
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||
import { styled } from '@linaria/react';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import { type LogicFunction } from '~/generated-metadata/graphql';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
export type LogicFunctionTableRow = {
|
||||
key: string;
|
||||
name: string;
|
||||
trigger: string;
|
||||
link?: string;
|
||||
};
|
||||
|
||||
export const StyledTableRow = (
|
||||
props: React.ComponentProps<typeof TableRow>,
|
||||
) => (
|
||||
@@ -29,10 +32,8 @@ const StyledTableBodyContainer = styled.div`
|
||||
export const SettingsLogicFunctionsTable = ({
|
||||
logicFunctions,
|
||||
}: {
|
||||
logicFunctions: LogicFunction[];
|
||||
logicFunctions: LogicFunctionTableRow[];
|
||||
}) => {
|
||||
const { applicationId = '' } = useParams();
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
if (logicFunctions.length === 0) {
|
||||
@@ -48,14 +49,10 @@ export const SettingsLogicFunctionsTable = ({
|
||||
</StyledTableRow>
|
||||
<StyledTableBodyContainer>
|
||||
<TableBody>
|
||||
{logicFunctions.map((logicFunction: LogicFunction) => (
|
||||
{logicFunctions.map((logicFunction) => (
|
||||
<SettingsLogicFunctionsFieldItemTableRow
|
||||
key={logicFunction.id}
|
||||
key={logicFunction.key}
|
||||
logicFunction={logicFunction}
|
||||
to={getSettingsPath(SettingsPath.ApplicationLogicFunctionDetail, {
|
||||
applicationId,
|
||||
logicFunctionId: logicFunction.id,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
Reference in New Issue
Block a user