[Issue-5772] Add sort feature on settings tables (#5787)

## Proposed Changes
-  Introduce  a new custom hook - useTableSort to sort table content
-  Add test cases for the new custom hook
- Integrate useTableSort hook on to the table in settings object and
settings object field pages

## Related Issue

https://github.com/twentyhq/twenty/issues/5772

## Evidence


https://github.com/twentyhq/twenty/assets/87609792/8be456ce-2fa5-44ec-8bbd-70fb6c8fdb30

## Evidence after addressing review comments


https://github.com/twentyhq/twenty/assets/87609792/c267e3da-72f9-4c0e-8c94-a38122d6395e

## Further comments

Apologies for the large PR. Looking forward for the review

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Anand Krishnan M J
2024-08-14 20:41:17 +05:30
committed by GitHub
parent 0f75e14ab2
commit 59e14fabb4
40 changed files with 1229 additions and 445 deletions

View File

@ -1,237 +0,0 @@
import styled from '@emotion/styled';
import { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { H2Title, IconPlus, IconSettings } from 'twenty-ui';
import { LABEL_IDENTIFIER_FIELD_METADATA_TYPES } from '@/object-metadata/constants/LabelIdentifierFieldMetadataTypes';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getActiveFieldMetadataItems } from '@/object-metadata/utils/getActiveFieldMetadataItems';
import { getDisabledFieldMetadataItems } from '@/object-metadata/utils/getDisabledFieldMetadataItems';
import { getFieldSlug } from '@/object-metadata/utils/getFieldSlug';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsObjectFieldActiveActionDropdown } from '@/settings/data-model/object-details/components/SettingsObjectFieldActiveActionDropdown';
import { SettingsObjectFieldInactiveActionDropdown } from '@/settings/data-model/object-details/components/SettingsObjectFieldDisabledActionDropdown';
import {
SettingsObjectFieldItemTableRow,
StyledObjectFieldTableRow,
} from '@/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow';
import { SettingsObjectSummaryCard } from '@/settings/data-model/object-details/components/SettingsObjectSummaryCard';
import { getFieldIdentifierType } from '@/settings/data-model/utils/getFieldIdentifierType';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { AppPath } from '@/types/AppPath';
import { SettingsPath } from '@/types/SettingsPath';
import { Button } from '@/ui/input/button/components/Button';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableSection } from '@/ui/layout/table/components/TableSection';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
const StyledDiv = styled.div`
display: flex;
justify-content: flex-end;
padding-top: ${({ theme }) => theme.spacing(2)};
`;
export const SettingsObjectDetail = () => {
const navigate = useNavigate();
const { objectSlug = '' } = useParams();
const { findActiveObjectMetadataItemBySlug } =
useFilteredObjectMetadataItems();
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
const activeObjectMetadataItem =
findActiveObjectMetadataItemBySlug(objectSlug);
useEffect(() => {
if (!activeObjectMetadataItem) navigate(AppPath.NotFound);
}, [activeObjectMetadataItem, navigate]);
const {
activateMetadataField,
deactivateMetadataField,
deleteMetadataField,
} = useFieldMetadataItem();
if (!activeObjectMetadataItem) return null;
const activeMetadataFields = getActiveFieldMetadataItems(
activeObjectMetadataItem,
);
const deactivatedMetadataFields = getDisabledFieldMetadataItems(
activeObjectMetadataItem,
);
const handleDisableObject = async () => {
await updateOneObjectMetadataItem({
idToUpdate: activeObjectMetadataItem.id,
updatePayload: { isActive: false },
});
navigate(getSettingsPagePath(SettingsPath.Objects));
};
const handleDisableField = (activeFieldMetadatItem: FieldMetadataItem) => {
deactivateMetadataField(activeFieldMetadatItem);
};
const handleSetLabelIdentifierField = (
activeFieldMetadatItem: FieldMetadataItem,
) =>
updateOneObjectMetadataItem({
idToUpdate: activeObjectMetadataItem.id,
updatePayload: {
labelIdentifierFieldMetadataId: activeFieldMetadatItem.id,
},
});
const shouldDisplayAddFieldButton = !activeObjectMetadataItem.isRemote;
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<Breadcrumb
links={[
{ children: 'Objects', href: '/settings/objects' },
{ children: activeObjectMetadataItem.labelPlural },
]}
/>
<Section>
<H2Title title="About" description="Manage your object" />
<SettingsObjectSummaryCard
iconKey={activeObjectMetadataItem.icon ?? undefined}
name={activeObjectMetadataItem.labelPlural || ''}
objectMetadataItem={activeObjectMetadataItem}
onDeactivate={handleDisableObject}
onEdit={() => navigate('./edit')}
/>
</Section>
<Section>
<H2Title
title="Fields"
description={`Customise the fields available in the ${activeObjectMetadataItem.labelSingular} views and their display order in the ${activeObjectMetadataItem.labelSingular} detail view and menus.`}
/>
<Table>
<StyledObjectFieldTableRow>
<TableHeader>Name</TableHeader>
<TableHeader>
{activeObjectMetadataItem.isCustom
? 'Identifier'
: 'Field type'}
</TableHeader>
<TableHeader>Data type</TableHeader>
<TableHeader></TableHeader>
</StyledObjectFieldTableRow>
{!!activeMetadataFields.length && (
<TableSection title="Active">
{activeMetadataFields.map((activeMetadataField) => {
const isLabelIdentifier = isLabelIdentifierField({
fieldMetadataItem: activeMetadataField,
objectMetadataItem: activeObjectMetadataItem,
});
const canBeSetAsLabelIdentifier =
activeObjectMetadataItem.isCustom &&
!isLabelIdentifier &&
LABEL_IDENTIFIER_FIELD_METADATA_TYPES.includes(
activeMetadataField.type,
);
return (
<SettingsObjectFieldItemTableRow
key={activeMetadataField.id}
identifierType={getFieldIdentifierType(
activeMetadataField,
activeObjectMetadataItem,
)}
variant={
activeObjectMetadataItem.isCustom
? 'identifier'
: 'field-type'
}
fieldMetadataItem={activeMetadataField}
isRemoteObjectField={activeObjectMetadataItem.isRemote}
// to={`./${getFieldSlug(activeMetadataField)}`}
ActionIcon={
<SettingsObjectFieldActiveActionDropdown
isCustomField={!!activeMetadataField.isCustom}
scopeKey={activeMetadataField.id}
onEdit={() =>
navigate(`./${getFieldSlug(activeMetadataField)}`)
}
onSetAsLabelIdentifier={
canBeSetAsLabelIdentifier
? () =>
handleSetLabelIdentifierField(
activeMetadataField,
)
: undefined
}
onDeactivate={
isLabelIdentifier
? undefined
: () => handleDisableField(activeMetadataField)
}
/>
}
/>
);
})}
</TableSection>
)}
{!!deactivatedMetadataFields.length && (
<TableSection isInitiallyExpanded={false} title="Inactive">
{deactivatedMetadataFields.map((deactivatedMetadataField) => (
<SettingsObjectFieldItemTableRow
key={deactivatedMetadataField.id}
variant={
activeObjectMetadataItem.isCustom
? 'identifier'
: 'field-type'
}
fieldMetadataItem={deactivatedMetadataField}
ActionIcon={
<SettingsObjectFieldInactiveActionDropdown
isCustomField={!!deactivatedMetadataField.isCustom}
scopeKey={deactivatedMetadataField.id}
onActivate={() =>
activateMetadataField(deactivatedMetadataField)
}
onDelete={() =>
deleteMetadataField(deactivatedMetadataField)
}
/>
}
/>
))}
</TableSection>
)}
</Table>
{shouldDisplayAddFieldButton && (
<StyledDiv>
<UndecoratedLink
to={
deactivatedMetadataFields.length
? './new-field/step-1'
: './new-field/step-2'
}
>
<Button
Icon={IconPlus}
title="Add Field"
size="small"
variant="secondary"
/>
</UndecoratedLink>
</StyledDiv>
)}
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -0,0 +1,30 @@
import { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { AppPath } from '@/types/AppPath';
import { isDefined } from 'twenty-ui';
import { SettingsObjectDetailPageContent } from '~/pages/settings/data-model/SettingsObjectDetailPageContent';
export const SettingsObjectDetailPage = () => {
const navigate = useNavigate();
const { objectSlug = '' } = useParams();
const { findActiveObjectMetadataItemBySlug } =
useFilteredObjectMetadataItems();
const activeObjectMetadataItem =
findActiveObjectMetadataItemBySlug(objectSlug);
useEffect(() => {
if (!activeObjectMetadataItem) navigate(AppPath.NotFound);
}, [activeObjectMetadataItem, navigate]);
if (!isDefined(activeObjectMetadataItem)) return <></>;
return (
<SettingsObjectDetailPageContent
objectMetadataItem={activeObjectMetadataItem}
/>
);
};

View File

@ -0,0 +1,101 @@
import styled from '@emotion/styled';
import { useNavigate } from 'react-router-dom';
import { H2Title, IconPlus, IconSettings } from 'twenty-ui';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { getDisabledFieldMetadataItems } from '@/object-metadata/utils/getDisabledFieldMetadataItems';
import { SettingsObjectSummaryCard } from '@/settings/data-model/object-details/components/SettingsObjectSummaryCard';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
import { Button } from '@/ui/input/button/components/Button';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
import { isNonEmptyArray } from '@sniptt/guards';
import { SettingsObjectFieldTable } from '~/pages/settings/data-model/SettingsObjectFieldTable';
const StyledDiv = styled.div`
display: flex;
justify-content: flex-end;
padding-top: ${({ theme }) => theme.spacing(2)};
`;
export type SettingsObjectDetailPageContentProps = {
objectMetadataItem: ObjectMetadataItem;
};
export const SettingsObjectDetailPageContent = ({
objectMetadataItem,
}: SettingsObjectDetailPageContentProps) => {
const navigate = useNavigate();
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
const handleDisableObject = async () => {
await updateOneObjectMetadataItem({
idToUpdate: objectMetadataItem.id,
updatePayload: { isActive: false },
});
navigate(getSettingsPagePath(SettingsPath.Objects));
};
const disabledFieldMetadataItems =
getDisabledFieldMetadataItems(objectMetadataItem);
const shouldDisplayAddFieldButton = !objectMetadataItem.isRemote;
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<Breadcrumb
links={[
{ children: 'Objects', href: '/settings/objects' },
{ children: objectMetadataItem.labelPlural },
]}
/>
<Section>
<H2Title title="About" description="Manage your object" />
<SettingsObjectSummaryCard
iconKey={objectMetadataItem.icon ?? undefined}
name={objectMetadataItem.labelPlural || ''}
objectMetadataItem={objectMetadataItem}
onDeactivate={handleDisableObject}
onEdit={() => navigate('./edit')}
/>
</Section>
<Section>
<H2Title
title="Fields"
description={`Customise the fields available in the ${objectMetadataItem.labelSingular} views and their display order in the ${objectMetadataItem.labelSingular} detail view and menus.`}
/>
<SettingsObjectFieldTable
objectMetadataItem={objectMetadataItem}
mode="view"
/>
{shouldDisplayAddFieldButton && (
<StyledDiv>
<UndecoratedLink
to={
isNonEmptyArray(disabledFieldMetadataItems)
? './new-field/step-1'
: './new-field/step-2'
}
>
<Button
Icon={IconPlus}
title="Add Field"
size="small"
variant="secondary"
/>
</UndecoratedLink>
</StyledDiv>
)}
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -0,0 +1,194 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
SettingsObjectFieldItemTableRow,
StyledObjectFieldTableRow,
} from '@/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow';
import { settingsObjectFieldsFamilyState } from '@/settings/data-model/object-details/states/settingsObjectFieldsFamilyState';
import { SortableTableHeader } from '@/ui/layout/table/components/SortableTableHeader';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableSection } from '@/ui/layout/table/components/TableSection';
import { useSortedArray } from '@/ui/layout/table/hooks/useSortedArray';
import { TableMetadata } from '@/ui/layout/table/types/TableMetadata';
import { isNonEmptyArray } from '@sniptt/guards';
import { useEffect, useMemo } from 'react';
import { useRecoilState } from 'recoil';
import { useMapFieldMetadataItemToSettingsObjectDetailTableItem } from '~/pages/settings/data-model/hooks/useMapFieldMetadataItemToSettingsObjectDetailTableItem';
import { SettingsObjectDetailTableItem } from '~/pages/settings/data-model/types/SettingsObjectDetailTableItem';
const SETTINGS_OBJECT_DETAIL_TABLE_METADATA_STANDARD: TableMetadata<SettingsObjectDetailTableItem> =
{
tableId: 'settingsObjectDetail',
fields: [
{
fieldLabel: 'Name',
fieldName: 'label',
fieldType: 'string',
align: 'left',
},
{
fieldLabel: 'Field type',
fieldName: 'fieldType',
fieldType: 'string',
align: 'left',
},
{
fieldLabel: 'Data type',
fieldName: 'dataType',
fieldType: 'string',
align: 'left',
},
],
initialSort: {
fieldName: 'label',
orderBy: 'AscNullsLast',
},
};
const SETTINGS_OBJECT_DETAIL_TABLE_METADATA_CUSTOM: TableMetadata<SettingsObjectDetailTableItem> =
{
tableId: 'settingsObjectDetail',
fields: [
{
fieldLabel: 'Name',
fieldName: 'label',
fieldType: 'string',
align: 'left',
},
{
fieldLabel: 'Identifier',
fieldName: 'identifierType',
fieldType: 'string',
align: 'left',
},
{
fieldLabel: 'Data type',
fieldName: 'dataType',
fieldType: 'string',
align: 'left',
},
],
initialSort: {
fieldName: 'label',
orderBy: 'AscNullsLast',
},
};
export type SettingsObjectFieldTableProps = {
objectMetadataItem: ObjectMetadataItem;
mode: 'view' | 'new-field';
};
// TODO: find another way than using mode which feels like it could be replaced by another pattern
export const SettingsObjectFieldTable = ({
objectMetadataItem,
mode,
}: SettingsObjectFieldTableProps) => {
const tableMetadata = objectMetadataItem.isCustom
? SETTINGS_OBJECT_DETAIL_TABLE_METADATA_CUSTOM
: SETTINGS_OBJECT_DETAIL_TABLE_METADATA_STANDARD;
const { mapFieldMetadataItemToSettingsObjectDetailTableItem } =
useMapFieldMetadataItemToSettingsObjectDetailTableItem(objectMetadataItem);
const [settingsObjectFields, setSettingsObjectFields] = useRecoilState(
settingsObjectFieldsFamilyState({
objectMetadataItemId: objectMetadataItem.id,
}),
);
useEffect(() => {
setSettingsObjectFields(objectMetadataItem.fields);
}, [objectMetadataItem, setSettingsObjectFields]);
const activeObjectSettingsDetailItems = useMemo(() => {
const activeMetadataFields = settingsObjectFields?.filter(
(fieldMetadataItem) =>
fieldMetadataItem.isActive && !fieldMetadataItem.isSystem,
);
return (
activeMetadataFields?.map(
mapFieldMetadataItemToSettingsObjectDetailTableItem,
) ?? []
);
}, [
settingsObjectFields,
mapFieldMetadataItemToSettingsObjectDetailTableItem,
]);
const disabledObjectSettingsDetailItems = useMemo(() => {
const disabledFieldMetadataItems = settingsObjectFields?.filter(
(fieldMetadataItem) =>
!fieldMetadataItem.isActive && !fieldMetadataItem.isSystem,
);
return (
disabledFieldMetadataItems?.map(
mapFieldMetadataItemToSettingsObjectDetailTableItem,
) ?? []
);
}, [
settingsObjectFields,
mapFieldMetadataItemToSettingsObjectDetailTableItem,
]);
const sortedActiveObjectSettingsDetailItems = useSortedArray(
activeObjectSettingsDetailItems,
tableMetadata,
);
const sortedDisabledObjectSettingsDetailItems = useSortedArray(
disabledObjectSettingsDetailItems,
tableMetadata,
);
return (
<Table>
<StyledObjectFieldTableRow>
{tableMetadata.fields.map((item) => (
<SortableTableHeader
key={item.fieldName}
fieldName={item.fieldName}
label={item.fieldLabel}
tableId={tableMetadata.tableId}
initialSort={tableMetadata.initialSort}
/>
))}
<TableHeader></TableHeader>
</StyledObjectFieldTableRow>
{isNonEmptyArray(sortedActiveObjectSettingsDetailItems) && (
<TableSection title="Active">
{sortedActiveObjectSettingsDetailItems.map(
(objectSettingsDetailItem) => (
<SettingsObjectFieldItemTableRow
key={objectSettingsDetailItem.fieldMetadataItem.id}
settingsObjectDetailTableItem={objectSettingsDetailItem}
status="active"
mode={mode}
/>
),
)}
</TableSection>
)}
{isNonEmptyArray(sortedDisabledObjectSettingsDetailItems) && (
<TableSection
isInitiallyExpanded={mode === 'new-field' ? true : false}
title="Inactive"
>
{sortedDisabledObjectSettingsDetailItems.map(
(objectSettingsDetailItem) => (
<SettingsObjectFieldItemTableRow
key={objectSettingsDetailItem.fieldMetadataItem.id}
settingsObjectDetailTableItem={objectSettingsDetailItem}
status="disabled"
mode={mode}
/>
),
)}
</TableSection>
)}
</Table>
);
};

View File

@ -1,27 +1,22 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import styled from '@emotion/styled';
import { H2Title, IconMinus, IconPlus, IconSettings } from 'twenty-ui';
import { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { H2Title, IconPlus, IconSettings } from 'twenty-ui';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import {
SettingsObjectFieldItemTableRow,
StyledObjectFieldTableRow,
} from '@/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { settingsObjectFieldsFamilyState } from '@/settings/data-model/object-details/states/settingsObjectFieldsFamilyState';
import { AppPath } from '@/types/AppPath';
import { Button } from '@/ui/input/button/components/Button';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableSection } from '@/ui/layout/table/components/TableSection';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { useRecoilState } from 'recoil';
import { SettingsObjectFieldTable } from '~/pages/settings/data-model/SettingsObjectFieldTable';
const StyledSection = styled(Section)`
display: flex;
@ -43,62 +38,52 @@ export const SettingsObjectNewFieldStep1 = () => {
const activeObjectMetadataItem =
findActiveObjectMetadataItemBySlug(objectSlug);
const [settingsObjectFields] = useRecoilState(
settingsObjectFieldsFamilyState({
objectMetadataItemId: activeObjectMetadataItem?.id,
}),
);
const { activateMetadataField, deactivateMetadataField } =
useFieldMetadataItem();
const [metadataFields, setMetadataFields] = useState(
activeObjectMetadataItem?.fields ?? [],
);
const activeMetadataFields = metadataFields.filter((field) => field.isActive);
const deactivatedMetadataFields = metadataFields.filter(
(field) => !field.isActive,
);
const canSave = metadataFields.some(
const canSave = settingsObjectFields?.some(
(field, index) =>
field.isActive !== activeObjectMetadataItem?.fields[index].isActive,
);
const handleSave = async () => {
if (!activeObjectMetadataItem || !settingsObjectFields) {
return;
}
await Promise.all(
settingsObjectFields.map((fieldMetadataItem, index) => {
if (
fieldMetadataItem.isActive ===
activeObjectMetadataItem.fields[index].isActive
) {
return undefined;
}
return fieldMetadataItem.isActive
? activateMetadataField(fieldMetadataItem)
: deactivateMetadataField(fieldMetadataItem);
}),
);
navigate(`/settings/objects/${objectSlug}`);
};
useEffect(() => {
if (!activeObjectMetadataItem) {
navigate(AppPath.NotFound);
return;
}
if (!metadataFields.length)
setMetadataFields(activeObjectMetadataItem.fields);
}, [activeObjectMetadataItem, metadataFields.length, navigate]);
}, [activeObjectMetadataItem, navigate]);
if (!activeObjectMetadataItem) return null;
const handleToggleField = (fieldMetadataId: string) =>
setMetadataFields((previousFields) =>
previousFields.map((field) =>
field.id === fieldMetadataId
? { ...field, isActive: !field.isActive }
: field,
),
);
const handleSave = async () => {
await Promise.all(
metadataFields.map((metadataField, index) => {
if (
metadataField.isActive ===
activeObjectMetadataItem.fields[index].isActive
) {
return undefined;
}
return metadataField.isActive
? activateMetadataField(metadataField)
: deactivateMetadataField(metadataField);
}),
);
navigate(`/settings/objects/${objectSlug}`);
};
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
@ -126,58 +111,10 @@ export const SettingsObjectNewFieldStep1 = () => {
title="Check deactivated fields"
description="Before creating a custom field, check if it already exists in the deactivated section."
/>
<Table>
<StyledObjectFieldTableRow>
<TableHeader>Name</TableHeader>
<TableHeader>Field type</TableHeader>
<TableHeader>Data type</TableHeader>
<TableHeader></TableHeader>
</StyledObjectFieldTableRow>
{!!activeMetadataFields.length && (
<TableSection isInitiallyExpanded={false} title="Active">
{activeMetadataFields.map((activeMetadataField) => (
<SettingsObjectFieldItemTableRow
key={activeMetadataField.id}
fieldMetadataItem={activeMetadataField}
isRemoteObjectField={activeObjectMetadataItem.isRemote}
ActionIcon={
isLabelIdentifierField({
fieldMetadataItem: activeMetadataField,
objectMetadataItem: activeObjectMetadataItem,
}) ? undefined : (
<LightIconButton
Icon={IconMinus}
accent="tertiary"
onClick={() =>
handleToggleField(activeMetadataField.id)
}
/>
)
}
/>
))}
</TableSection>
)}
{!!deactivatedMetadataFields.length && (
<TableSection title="Disabled">
{deactivatedMetadataFields.map((deactivatedMetadataField) => (
<SettingsObjectFieldItemTableRow
key={deactivatedMetadataField.name}
fieldMetadataItem={deactivatedMetadataField}
ActionIcon={
<LightIconButton
Icon={IconPlus}
accent="tertiary"
onClick={() =>
handleToggleField(deactivatedMetadataField.id)
}
/>
}
/>
))}
</TableSection>
)}
</Table>
<SettingsObjectFieldTable
objectMetadataItem={activeObjectMetadataItem}
mode="new-field"
/>
<StyledAddCustomFieldButton
Icon={IconPlus}
title="Add Custom Field"

View File

@ -12,23 +12,31 @@ import { useDeleteOneObjectMetadataItem } from '@/object-metadata/hooks/useDelet
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug';
import { useCombinedGetTotalCount } from '@/object-record/multiple-objects/hooks/useCombinedGetTotalCount';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import {
SettingsObjectItemTableRow,
SettingsObjectMetadataItemTableRow,
StyledObjectTableRow,
} from '@/settings/data-model/object-details/components/SettingsObjectItemTableRow';
import { SettingsObjectCoverImage } from '@/settings/data-model/objects/SettingsObjectCoverImage';
import { SettingsObjectInactiveMenuDropDown } from '@/settings/data-model/objects/SettingsObjectInactiveMenuDropDown';
import { getObjectTypeLabel } from '@/settings/data-model/utils/getObjectTypeLabel';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
import { Button } from '@/ui/input/button/components/Button';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { SortableTableHeader } from '@/ui/layout/table/components/SortableTableHeader';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableSection } from '@/ui/layout/table/components/TableSection';
import { useSortedArray } from '@/ui/layout/table/hooks/useSortedArray';
import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
import { isNonEmptyArray } from '@sniptt/guards';
import { useMemo } from 'react';
import { SETTINGS_OBJECT_TABLE_METADATA } from '~/pages/settings/data-model/constants/SettingsObjectTableMetadata';
import { SettingsObjectTableItem } from '~/pages/settings/data-model/types/SettingsObjectTableItem';
const StyledIconChevronRight = styled(IconChevronRight)`
color: ${({ theme }) => theme.font.color.tertiary};
@ -41,11 +49,71 @@ const StyledH1Title = styled(H1Title)`
export const SettingsObjects = () => {
const theme = useTheme();
const { activeObjectMetadataItems, inactiveObjectMetadataItems } =
useFilteredObjectMetadataItems();
const { deleteOneObjectMetadataItem } = useDeleteOneObjectMetadataItem();
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
const { activeObjectMetadataItems, inactiveObjectMetadataItems } =
useFilteredObjectMetadataItems();
const { totalCountByObjectMetadataItemNamePlural } = useCombinedGetTotalCount(
{
objectMetadataItems: [
...activeObjectMetadataItems,
...inactiveObjectMetadataItems,
],
},
);
const activeObjectSettingsArray = useMemo(
() =>
activeObjectMetadataItems.map(
(objectMetadataItem) =>
({
objectMetadataItem,
labelPlural: objectMetadataItem.labelPlural,
objectTypeLabel: getObjectTypeLabel(objectMetadataItem).labelText,
fieldsCount: objectMetadataItem.fields.filter(
(field) => !field.isSystem,
).length,
totalObjectCount:
totalCountByObjectMetadataItemNamePlural[
objectMetadataItem.namePlural
] ?? 0,
}) satisfies SettingsObjectTableItem,
),
[activeObjectMetadataItems, totalCountByObjectMetadataItemNamePlural],
);
const inactiveObjectSettingsArray = useMemo(
() =>
inactiveObjectMetadataItems.map(
(objectMetadataItem) =>
({
objectMetadataItem,
labelPlural: objectMetadataItem.labelPlural,
objectTypeLabel: getObjectTypeLabel(objectMetadataItem).labelText,
fieldsCount: objectMetadataItem.fields.filter(
(field) => !field.isSystem,
).length,
totalObjectCount:
totalCountByObjectMetadataItemNamePlural[
objectMetadataItem.namePlural
] ?? 0,
}) satisfies SettingsObjectTableItem,
),
[inactiveObjectMetadataItems, totalCountByObjectMetadataItemNamePlural],
);
const sortedActiveObjectSettingsItems = useSortedArray(
activeObjectSettingsArray,
SETTINGS_OBJECT_TABLE_METADATA,
);
const sortedInactiveObjectSettingsItems = useSortedArray(
inactiveObjectSettingsArray,
SETTINGS_OBJECT_TABLE_METADATA,
);
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
@ -66,51 +134,67 @@ export const SettingsObjects = () => {
<H2Title title="Existing objects" />
<Table>
<StyledObjectTableRow>
<TableHeader>Name</TableHeader>
<TableHeader>Type</TableHeader>
<TableHeader align="right">Fields</TableHeader>
<TableHeader align="right">Instances</TableHeader>
{SETTINGS_OBJECT_TABLE_METADATA.fields.map(
(settingsObjectsTableMetadataField) => (
<SortableTableHeader
fieldName={settingsObjectsTableMetadataField.fieldName}
label={settingsObjectsTableMetadataField.fieldLabel}
tableId={SETTINGS_OBJECT_TABLE_METADATA.tableId}
align={settingsObjectsTableMetadataField.align}
initialSort={SETTINGS_OBJECT_TABLE_METADATA.initialSort}
/>
),
)}
<TableHeader></TableHeader>
</StyledObjectTableRow>
{!!activeObjectMetadataItems.length && (
{isNonEmptyArray(sortedActiveObjectSettingsItems) && (
<TableSection title="Active">
{activeObjectMetadataItems.map((activeObjectMetadataItem) => (
<SettingsObjectItemTableRow
key={activeObjectMetadataItem.namePlural}
objectItem={activeObjectMetadataItem}
{sortedActiveObjectSettingsItems.map((objectSettingsItem) => (
<SettingsObjectMetadataItemTableRow
key={objectSettingsItem.objectMetadataItem.namePlural}
objectMetadataItem={objectSettingsItem.objectMetadataItem}
totalObjectCount={objectSettingsItem.totalObjectCount}
action={
<StyledIconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
}
to={`/settings/objects/${getObjectSlug(
activeObjectMetadataItem,
link={`/settings/objects/${getObjectSlug(
objectSettingsItem.objectMetadataItem,
)}`}
/>
))}
</TableSection>
)}
{!!inactiveObjectMetadataItems.length && (
{isNonEmptyArray(inactiveObjectMetadataItems) && (
<TableSection title="Inactive">
{inactiveObjectMetadataItems.map(
(inactiveObjectMetadataItem) => (
<SettingsObjectItemTableRow
key={inactiveObjectMetadataItem.namePlural}
objectItem={inactiveObjectMetadataItem}
{sortedInactiveObjectSettingsItems.map(
(objectSettingsItem) => (
<SettingsObjectMetadataItemTableRow
key={objectSettingsItem.objectMetadataItem.namePlural}
objectMetadataItem={
objectSettingsItem.objectMetadataItem
}
totalObjectCount={objectSettingsItem.totalObjectCount}
action={
<SettingsObjectInactiveMenuDropDown
isCustomObject={inactiveObjectMetadataItem.isCustom}
scopeKey={inactiveObjectMetadataItem.namePlural}
isCustomObject={
objectSettingsItem.objectMetadataItem.isCustom
}
scopeKey={
objectSettingsItem.objectMetadataItem.namePlural
}
onActivate={() =>
updateOneObjectMetadataItem({
idToUpdate: inactiveObjectMetadataItem.id,
idToUpdate:
objectSettingsItem.objectMetadataItem.id,
updatePayload: { isActive: true },
})
}
onDelete={() =>
deleteOneObjectMetadataItem(
inactiveObjectMetadataItem.id,
objectSettingsItem.objectMetadataItem.id,
)
}
/>

View File

@ -8,11 +8,11 @@ import {
import { graphqlMocks } from '~/testing/graphqlMocks';
import { sleep } from '~/utils/sleep';
import { SettingsObjectDetail } from '../SettingsObjectDetail';
import { SettingsObjectDetailPage } from '../SettingsObjectDetailPage';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/DataModel/SettingsObjectDetail',
component: SettingsObjectDetail,
component: SettingsObjectDetailPage,
decorators: [PageDecorator],
args: {
routePath: '/settings/objects/:objectSlug',
@ -25,7 +25,7 @@ const meta: Meta<PageDecoratorArgs> = {
export default meta;
export type Story = StoryObj<typeof SettingsObjectDetail>;
export type Story = StoryObj<typeof SettingsObjectDetailPage>;
export const StandardObject: Story = {
play: async () => {

View File

@ -0,0 +1,37 @@
import { TableMetadata } from '@/ui/layout/table/types/TableMetadata';
import { SettingsObjectTableItem } from '~/pages/settings/data-model/types/SettingsObjectTableItem';
export const SETTINGS_OBJECT_TABLE_METADATA: TableMetadata<SettingsObjectTableItem> =
{
tableId: 'settingsObject',
fields: [
{
fieldLabel: 'Name',
fieldName: 'labelPlural',
fieldType: 'string',
align: 'left',
},
{
fieldLabel: 'Type',
fieldName: 'objectTypeLabel',
fieldType: 'string',
align: 'left',
},
{
fieldLabel: 'Fields',
fieldName: 'fieldsCount',
fieldType: 'number',
align: 'right',
},
{
fieldLabel: 'Instances',
fieldName: 'totalObjectCount',
fieldType: 'number',
align: 'right',
},
],
initialSort: {
fieldName: 'labelPlural',
orderBy: 'AscNullsLast',
},
};

View File

@ -0,0 +1,46 @@
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getFieldIdentifierType } from '@/settings/data-model/utils/getFieldIdentifierType';
import { getSettingsFieldTypeConfig } from '@/settings/data-model/utils/getSettingsFieldTypeConfig';
import { SettingsObjectDetailTableItem } from '~/pages/settings/data-model/types/SettingsObjectDetailTableItem';
import { getSettingsObjectFieldType } from '~/pages/settings/data-model/utils/getSettingsObjectFieldType';
export const useMapFieldMetadataItemToSettingsObjectDetailTableItem = (
objectMetadataItem: ObjectMetadataItem,
) => {
const getRelationMetadata = useGetRelationMetadata();
const mapFieldMetadataItemToSettingsObjectDetailTableItem = (
fieldMetadataItem: FieldMetadataItem,
): SettingsObjectDetailTableItem => {
const fieldType = getSettingsObjectFieldType(
objectMetadataItem,
fieldMetadataItem,
);
const { relationObjectMetadataItem } =
getRelationMetadata({
fieldMetadataItem,
}) ?? {};
const identifierType = getFieldIdentifierType(
fieldMetadataItem,
objectMetadataItem,
);
return {
fieldMetadataItem,
fieldType: fieldType ?? '',
dataType:
relationObjectMetadataItem?.labelPlural ??
getSettingsFieldTypeConfig(fieldMetadataItem.type)?.label ??
'',
label: fieldMetadataItem.label,
identifierType: identifierType,
objectMetadataItem,
} satisfies SettingsObjectDetailTableItem;
};
return { mapFieldMetadataItemToSettingsObjectDetailTableItem };
};

View File

@ -0,0 +1,12 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { FieldIdentifierType } from '@/settings/data-model/types/FieldIdentifierType';
export type SettingsObjectDetailTableItem = {
fieldMetadataItem: FieldMetadataItem;
objectMetadataItem: ObjectMetadataItem;
fieldType: string | boolean;
label: string;
dataType: string;
identifierType?: FieldIdentifierType;
};

View File

@ -0,0 +1,9 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export type SettingsObjectTableItem = {
objectMetadataItem: ObjectMetadataItem;
totalObjectCount: number;
fieldsCount: number;
objectTypeLabel: string;
labelPlural: string;
};

View File

@ -0,0 +1,30 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getFieldIdentifierType } from '@/settings/data-model/utils/getFieldIdentifierType';
import { isDefined } from 'twenty-ui';
export const getSettingsObjectFieldType = (
objectMetadataItem: ObjectMetadataItem,
fieldMetadataItem: FieldMetadataItem,
) => {
const variant = objectMetadataItem.isCustom ? 'identifier' : 'field-type';
const identifierType = getFieldIdentifierType(
fieldMetadataItem,
objectMetadataItem,
);
if (variant === 'field-type') {
return objectMetadataItem.isRemote
? 'Remote'
: fieldMetadataItem.isCustom
? 'Custom'
: 'Standard';
} else {
return isDefined(identifierType)
? identifierType === 'label'
? 'Record text'
: 'Record image'
: null;
}
};