Redesign Object/Field tables (#16844)

Redesign the data model pages for more clarity / better distinction
between fields and relations
This commit is contained in:
Félix Malfait
2025-12-30 09:49:55 +01:00
committed by GitHub
parent 8d130908c2
commit 4f1f0eb177
16 changed files with 571 additions and 200 deletions
@@ -12,13 +12,12 @@ describe('isObjectMetadataSettingsReadOnly', () => {
isUIReadOnly: false,
isRemote: false,
},
workspaceCustomApplicationId: 'workspaceApplicationId',
});
expect(result).toBe(false);
});
it('should return true if object is managed by application', () => {
it('should return true if object is remote', () => {
const result = isObjectMetadataSettingsReadOnly({
objectPermissions: {
canUpdateObjectRecords: true,
@@ -26,24 +25,36 @@ describe('isObjectMetadataSettingsReadOnly', () => {
restrictedFields: {},
},
objectMetadataItem: {
applicationId: 'applicationId',
isUIReadOnly: false,
isRemote: false,
isRemote: true,
},
workspaceCustomApplicationId: null,
});
expect(result).toBe(true);
});
it('should return false if object is owned by workspace custom application', () => {
it('should return true if object is UI read only', () => {
const result = isObjectMetadataSettingsReadOnly({
objectMetadataItem: {
isUIReadOnly: true,
isRemote: false,
},
});
expect(result).toBe(true);
});
it('should return false for standard/third-party objects (they are editable via standardOverrides)', () => {
const result = isObjectMetadataSettingsReadOnly({
objectPermissions: {
canUpdateObjectRecords: true,
objectMetadataId: '123',
restrictedFields: {},
},
objectMetadataItem: {
isUIReadOnly: false,
isRemote: false,
applicationId: 'workspaceApplicationId',
},
workspaceCustomApplicationId: 'workspaceApplicationId',
});
expect(result).toBe(false);
@@ -1,28 +1,17 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
import { isDefined } from 'twenty-shared/utils';
import { type ObjectPermission } from '~/generated/graphql';
type IsObjectMetadataReadOnlyParams = {
type IsObjectMetadataSettingsReadOnlyParams = {
objectPermissions?: ObjectPermission;
objectMetadataItem?: Pick<
ObjectMetadataItem,
'isUIReadOnly' | 'isRemote' | 'applicationId'
>;
workspaceCustomApplicationId: string | null;
objectMetadataItem?: Pick<ObjectMetadataItem, 'isUIReadOnly' | 'isRemote'>;
};
// Returns true only for remote or UI read-only objects
// Standard and third-party app objects are editable (label/icon/description via standardOverrides)
export const isObjectMetadataSettingsReadOnly = ({
objectPermissions,
objectMetadataItem,
workspaceCustomApplicationId,
}: IsObjectMetadataReadOnlyParams) => {
return (
isObjectMetadataReadOnly({ objectPermissions, objectMetadataItem }) ||
(isDefined(objectMetadataItem?.applicationId)
? isDefined(workspaceCustomApplicationId)
? objectMetadataItem.applicationId !== workspaceCustomApplicationId
: true
: false)
);
}: IsObjectMetadataSettingsReadOnlyParams) => {
return isObjectMetadataReadOnly({ objectPermissions, objectMetadataItem });
};
@@ -1,7 +1,9 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { getItemTagInfo } from '@/settings/data-model/utils/getItemTagInfo';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { Tag } from 'twenty-ui/components';
import { Avatar } from 'twenty-ui/display';
type SettingsItemTypeTagProps = {
item: {
@@ -12,10 +14,18 @@ type SettingsItemTypeTagProps = {
className?: string;
};
const StyledContainer = styled.div`
align-items: center;
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
gap: ${({ theme }) => theme.spacing(1)};
`;
export const SettingsItemTypeTag = ({
className,
item: { isCustom, isRemote, applicationId },
}: SettingsItemTypeTagProps) => {
const theme = useTheme();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const itemTagInfo = getItemTagInfo({
objectMetadataItem: { isCustom, isRemote, applicationId },
@@ -24,11 +34,16 @@ export const SettingsItemTypeTag = ({
});
return (
<Tag
className={className}
color={itemTagInfo.labelColor}
text={itemTagInfo.labelText}
weight="medium"
/>
<StyledContainer className={className}>
<Avatar
placeholder={itemTagInfo.labelText}
placeholderColorSeed={itemTagInfo.labelText}
type="squared"
size="xs"
color={theme.tag.text[itemTagInfo.labelColor]}
backgroundColor={theme.tag.background[itemTagInfo.labelColor]}
/>
{itemTagInfo.labelText}
</StyledContainer>
);
};
@@ -1,8 +1,10 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useDeleteOneFieldMetadataItem } from '@/object-metadata/hooks/useDeleteOneFieldMetadataItem';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
import { isObjectMetadataSettingsReadOnly } from '@/object-record/read-only/utils/isObjectMetadataSettingsReadOnly';
import { SettingsItemTypeTag } from '@/settings/components/SettingsItemTypeTag';
import { RELATION_TYPES } from '@/settings/data-model/constants/RelationTypes';
import { SettingsObjectFieldInactiveActionDropdown } from '@/settings/data-model/object-details/components/SettingsObjectFieldDisabledActionDropdown';
import { settingsObjectFieldsFamilyState } from '@/settings/data-model/object-details/states/settingsObjectFieldsFamilyState';
import { isFieldTypeSupportedInSettings } from '@/settings/data-model/utils/isFieldTypeSupportedInSettings';
@@ -12,7 +14,7 @@ import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useMemo } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useRecoilState } from 'recoil';
import { FieldMetadataType, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import {
@@ -26,9 +28,6 @@ import { UndecoratedLink } from 'twenty-ui/navigation';
import { RelationType } from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { type SettingsObjectDetailTableItem } from '~/pages/settings/data-model/types/SettingsObjectDetailTableItem';
import { isObjectMetadataSettingsReadOnly } from '@/object-record/read-only/utils/isObjectMetadataSettingsReadOnly';
import { RELATION_TYPES } from '@/settings/data-model/constants/RelationTypes';
import { SettingsObjectFieldDataType } from './SettingsObjectFieldDataType';
type SettingsObjectFieldItemTableRowProps = {
@@ -90,20 +89,13 @@ export const SettingsObjectFieldItemTableRow = ({
status,
}: SettingsObjectFieldItemTableRowProps) => {
const { t } = useLingui();
const { fieldMetadataItem, identifierType, objectMetadataItem } =
const { fieldMetadataItem, objectMetadataItem } =
settingsObjectDetailTableItem;
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const readonly = isObjectMetadataSettingsReadOnly({
objectMetadataItem,
workspaceCustomApplicationId:
currentWorkspace?.workspaceCustomApplication?.id,
});
const isRemoteObjectField = objectMetadataItem.isRemote;
const variant = objectMetadataItem.isCustom ? 'identifier' : 'field-type';
const navigate = useNavigateSettings();
const theme = useTheme();
@@ -167,21 +159,6 @@ export const SettingsObjectFieldItemTableRow = ({
});
};
const typeLabel =
variant === 'field-type'
? isRemoteObjectField
? t`Remote`
: fieldMetadataItem.isCustom
? t`Custom`
: t`Standard`
: variant === 'identifier'
? isDefined(identifierType)
? identifierType === 'label'
? t`Record text`
: t`Record image`
: ''
: '';
if (!isFieldTypeSupported) return null;
const isRelatedObjectLinkable =
@@ -224,7 +201,15 @@ export const SettingsObjectFieldItemTableRow = ({
</StyledNameTableCell>
</UndecoratedLink>
<TableCell>{typeLabel}</TableCell>
<TableCell>
<SettingsItemTypeTag
item={{
isCustom: fieldMetadataItem.isCustom ?? undefined,
isRemote: objectMetadataItem.isRemote,
applicationId: objectMetadataItem.applicationId,
}}
/>
</TableCell>
<TableCell>
<SettingsObjectFieldDataType
Icon={RelationIcon}
@@ -0,0 +1,242 @@
import { useDeleteOneFieldMetadataItem } from '@/object-metadata/hooks/useDeleteOneFieldMetadataItem';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isObjectMetadataSettingsReadOnly } from '@/object-record/read-only/utils/isObjectMetadataSettingsReadOnly';
import { SettingsItemTypeTag } from '@/settings/components/SettingsItemTypeTag';
import { RELATION_TYPES } from '@/settings/data-model/constants/RelationTypes';
import { SettingsObjectFieldInactiveActionDropdown } from '@/settings/data-model/object-details/components/SettingsObjectFieldDisabledActionDropdown';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { FieldMetadataType, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { IconChevronRight, useIcons } from 'twenty-ui/display';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
type SettingsObjectRelationItemTableRowProps = {
fieldMetadataItem: FieldMetadataItem;
objectMetadataItem: ObjectMetadataItem;
};
export const StyledObjectRelationTableRow = styled(TableRow)`
grid-template-columns: 180px 100px 148px 36px;
`;
const StyledNameTableCell = styled(TableCell)`
color: ${({ theme }) => theme.font.color.primary};
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledNameContainer = styled.div`
display: flex;
align-items: center;
flex: 1;
min-width: 0;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledNameLabel = styled.div`
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
`;
const StyledInactiveLabel = styled.span`
color: ${({ theme }) => theme.font.color.extraLight};
font-size: ${({ theme }) => theme.font.size.sm};
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex: 0 999 auto;
min-width: 48px;
&::before {
content: '·';
margin-right: ${({ theme }) => theme.spacing(1)};
}
`;
const StyledIconTableCell = styled(TableCell)`
justify-content: center;
padding-right: ${({ theme }) => theme.spacing(1)};
`;
const StyledIconChevronRight = styled(IconChevronRight)`
color: ${({ theme }) => theme.font.color.tertiary};
`;
const StyledRelationType = styled.div`
align-items: center;
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledLink = styled(Link)`
color: ${({ theme }) => theme.font.color.primary};
text-decoration: underline;
text-decoration-color: ${({ theme }) => theme.border.color.strong};
text-underline-offset: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
color: ${({ theme }) => theme.color.blue};
text-decoration-color: ${({ theme }) => theme.color.blue};
}
`;
export const SettingsObjectRelationItemTableRow = ({
fieldMetadataItem,
objectMetadataItem,
}: SettingsObjectRelationItemTableRowProps) => {
const { t } = useLingui();
const navigate = useNavigateSettings();
const theme = useTheme();
const { getIcon } = useIcons();
const Icon = getIcon(fieldMetadataItem.icon);
const getRelationMetadata = useGetRelationMetadata();
const { relationObjectMetadataItem, relationType } =
useMemo(
() => getRelationMetadata({ fieldMetadataItem }),
[fieldMetadataItem, getRelationMetadata],
) ?? {};
const readonly = isObjectMetadataSettingsReadOnly({
objectMetadataItem,
});
const { activateMetadataField } = useFieldMetadataItem();
const { deleteOneFieldMetadataItem } = useDeleteOneFieldMetadataItem();
const linkToNavigate = getSettingsPath(SettingsPath.ObjectFieldEdit, {
objectNamePlural: objectMetadataItem.namePlural,
fieldName: fieldMetadataItem.name,
});
// eslint-disable-next-line @nx/workspace-no-navigate-prefer-link
const navigateToFieldEdit = () =>
navigate(SettingsPath.ObjectFieldEdit, {
objectNamePlural: objectMetadataItem.namePlural,
fieldName: fieldMetadataItem.name,
});
const isRelatedObjectLinkable =
isDefined(relationObjectMetadataItem?.namePlural) &&
!relationObjectMetadataItem.isSystem;
const morphRelationCount = fieldMetadataItem.morphRelations?.length;
const relationTypeLabel = (() => {
if (fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION) {
return t`${morphRelationCount} Objects`;
}
if (isDefined(relationType) === true) {
return RELATION_TYPES[relationType].label;
}
return '';
})();
const RelationIcon = relationType
? RELATION_TYPES[relationType].Icon
: undefined;
const targetObjectLabel =
isRelatedObjectLinkable && relationObjectMetadataItem
? relationObjectMetadataItem.labelPlural
: fieldMetadataItem.label;
return (
// eslint-disable-next-line @nx/workspace-no-navigate-prefer-link
<StyledObjectRelationTableRow onClick={navigateToFieldEdit}>
<StyledNameTableCell>
{!!Icon && (
<Icon
style={{ minWidth: theme.icon.size.md }}
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
)}
<StyledNameContainer>
{isRelatedObjectLinkable ? (
<StyledLink
to={getSettingsPath(SettingsPath.ObjectDetail, {
objectNamePlural: relationObjectMetadataItem.namePlural,
})}
onClick={(event) => event.stopPropagation()}
title={targetObjectLabel}
>
{targetObjectLabel}
</StyledLink>
) : (
<StyledNameLabel title={targetObjectLabel}>
{targetObjectLabel}
</StyledNameLabel>
)}
{!fieldMetadataItem.isActive && (
<StyledInactiveLabel>{t`Deactivated`}</StyledInactiveLabel>
)}
</StyledNameContainer>
</StyledNameTableCell>
<TableCell>
<SettingsItemTypeTag
item={{
isCustom: fieldMetadataItem.isCustom ?? undefined,
isRemote: objectMetadataItem.isRemote,
applicationId: objectMetadataItem.applicationId,
}}
/>
</TableCell>
<TableCell>
<StyledRelationType>
{RelationIcon && (
<RelationIcon
size={theme.icon.size.sm}
stroke={theme.icon.stroke.sm}
/>
)}
{relationTypeLabel}
</StyledRelationType>
</TableCell>
<StyledIconTableCell>
{fieldMetadataItem.isActive ? (
<UndecoratedLink to={linkToNavigate}>
<StyledIconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
</UndecoratedLink>
) : (
<SettingsObjectFieldInactiveActionDropdown
isCustomField={fieldMetadataItem.isCustom === true}
readonly={readonly}
fieldMetadataItemId={fieldMetadataItem.id}
onEdit={navigateToFieldEdit}
onActivate={() =>
activateMetadataField(fieldMetadataItem.id, objectMetadataItem.id)
}
onDelete={() =>
deleteOneFieldMetadataItem({
idToDelete: fieldMetadataItem.id,
objectMetadataId: objectMetadataItem.id,
})
}
/>
)}
</StyledIconTableCell>
</StyledObjectRelationTableRow>
);
};
@@ -0,0 +1,167 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
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 { useSortedArray } from '@/ui/layout/table/hooks/useSortedArray';
import { type TableMetadata } from '@/ui/layout/table/types/TableMetadata';
import styled from '@emotion/styled';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { useMemo, useState } from 'react';
import { FieldMetadataType } from 'twenty-shared/types';
import { IconArchive, IconFilter, IconSearch } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { MenuItemToggle } from 'twenty-ui/navigation';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
import {
SettingsObjectRelationItemTableRow,
StyledObjectRelationTableRow,
} from './SettingsObjectRelationItemTableRow';
const StyledSearchAndFilterContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
padding-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const StyledSearchInput = styled(SettingsTextInput)`
flex: 1;
`;
const SETTINGS_OBJECT_RELATION_TABLE_METADATA: TableMetadata<FieldMetadataItem> =
{
tableId: 'settingsObjectRelations',
fields: [
{
fieldLabel: msg`Name`,
fieldName: 'label',
fieldType: 'string',
align: 'left',
},
{
fieldLabel: msg`App`,
fieldName: 'isCustom',
fieldType: 'string',
align: 'left',
},
{
fieldLabel: msg`Type`,
fieldName: 'type',
fieldType: 'string',
align: 'left',
},
],
initialSort: {
fieldName: 'label',
orderBy: 'AscNullsLast',
},
};
type SettingsObjectRelationsTableProps = {
objectMetadataItem: ObjectMetadataItem;
};
export const SettingsObjectRelationsTable = ({
objectMetadataItem,
}: SettingsObjectRelationsTableProps) => {
const { t } = useLingui();
const [searchTerm, setSearchTerm] = useState('');
const [showInactive, setShowInactive] = useState(true);
const tableMetadata = SETTINGS_OBJECT_RELATION_TABLE_METADATA;
const relationFields = useMemo(() => {
return objectMetadataItem.fields.filter(
(field) =>
!field.isSystem &&
(field.type === FieldMetadataType.RELATION ||
field.type === FieldMetadataType.MORPH_RELATION),
);
}, [objectMetadataItem.fields]);
const sortedRelationFields = useSortedArray(relationFields, tableMetadata);
const filteredRelationFields = useMemo(() => {
const searchNormalized = normalizeSearchText(searchTerm);
return sortedRelationFields.filter((field) => {
const matchesActiveFilter = showInactive || field.isActive;
const matchesSearch = normalizeSearchText(field.label).includes(
searchNormalized,
);
return matchesActiveFilter && matchesSearch;
});
}, [sortedRelationFields, searchTerm, showInactive]);
if (relationFields.length === 0) {
return null;
}
return (
<>
<StyledSearchAndFilterContainer>
<StyledSearchInput
instanceId="object-relation-table-search"
LeftIcon={IconSearch}
placeholder={t`Search a field...`}
value={searchTerm}
onChange={setSearchTerm}
/>
<Dropdown
dropdownId="settings-relations-filter-dropdown"
dropdownPlacement="bottom-end"
dropdownOffset={{ x: 0, y: 8 }}
clickableComponent={
<Button
Icon={IconFilter}
size="medium"
variant="secondary"
accent="default"
ariaLabel={t`Filter`}
/>
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconArchive}
onToggleChange={() => setShowInactive(!showInactive)}
toggled={showInactive}
text={t`Inactive`}
toggleSize="small"
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
</StyledSearchAndFilterContainer>
<Table>
<StyledObjectRelationTableRow>
{tableMetadata.fields.map((item) => (
<SortableTableHeader
key={item.fieldName}
fieldName={item.fieldName}
label={t(item.fieldLabel)}
tableId={tableMetadata.tableId}
initialSort={tableMetadata.initialSort}
/>
))}
<TableHeader></TableHeader>
</StyledObjectRelationTableRow>
{filteredRelationFields.map((fieldMetadataItem) => (
<SettingsObjectRelationItemTableRow
key={fieldMetadataItem.id}
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
/>
))}
</Table>
</>
);
};
@@ -1,4 +1,3 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { SettingsDataModelObjectAboutForm } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm';
@@ -8,7 +7,7 @@ import {
} from '@/settings/data-model/validation-schemas/settingsDataModelObjectAboutFormSchema';
import { zodResolver } from '@hookform/resolvers/zod';
import { FormProvider, useForm } from 'react-hook-form';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useSetRecoilState } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { updatedObjectNamePluralState } from '~/pages/settings/data-model/states/updatedObjectNamePluralState';
@@ -21,11 +20,8 @@ type SettingsUpdateDataModelObjectAboutFormProps = {
export const SettingsUpdateDataModelObjectAboutForm = ({
objectMetadataItem,
}: SettingsUpdateDataModelObjectAboutFormProps) => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const readonly = isObjectMetadataSettingsReadOnly({
objectMetadataItem,
workspaceCustomApplicationId:
currentWorkspace?.workspaceCustomApplication?.id,
});
const navigate = useNavigateSettings();
const setUpdatedObjectNamePlural = useSetRecoilState(
@@ -1,19 +1,17 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { SettingsObjectFieldTable } from '~/pages/settings/data-model/SettingsObjectFieldTable';
import { isObjectMetadataSettingsReadOnly } from '@/object-record/read-only/utils/isObjectMetadataSettingsReadOnly';
import { SettingsObjectRelationsTable } from '@/settings/data-model/object-details/components/SettingsObjectRelationsTable';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { FieldMetadataType, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { H2Title, IconPlus } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { isObjectMetadataSettingsReadOnly } from '@/object-record/read-only/utils/isObjectMetadataSettingsReadOnly';
import { useRecoilValue } from 'recoil';
import { SettingsObjectFieldTable } from '~/pages/settings/data-model/SettingsObjectFieldTable';
const StyledDiv = styled.div`
const StyledButtonContainer = styled.div`
display: flex;
justify-content: flex-end;
padding-top: ${({ theme }) => theme.spacing(2)};
@@ -24,42 +22,78 @@ type ObjectFieldsProps = {
};
export const ObjectFields = ({ objectMetadataItem }: ObjectFieldsProps) => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const readonly = isObjectMetadataSettingsReadOnly({
objectMetadataItem,
workspaceCustomApplicationId:
currentWorkspace?.workspaceCustomApplication?.id,
});
const { t } = useLingui();
const objectLabelSingular = objectMetadataItem.labelSingular;
const hasRelations = objectMetadataItem.fields.some(
(field) =>
!field.isSystem &&
(field.type === FieldMetadataType.RELATION ||
field.type === FieldMetadataType.MORPH_RELATION),
);
return (
<Section>
<H2Title
title={t`Fields`}
description={t`Customise the fields available in the ${objectLabelSingular} views.`}
/>
<SettingsObjectFieldTable
objectMetadataItem={objectMetadataItem}
mode="view"
/>
{!readonly && (
<StyledDiv>
<UndecoratedLink
to={getSettingsPath(SettingsPath.ObjectNewFieldSelect, {
objectNamePlural: objectMetadataItem.namePlural,
})}
>
<Button
Icon={IconPlus}
title={t`Add Field`}
size="small"
variant="secondary"
/>
</UndecoratedLink>
</StyledDiv>
<>
{hasRelations && (
<Section>
<H2Title
title={t`Relations`}
description={t`Relation between this object and other objects`}
/>
<SettingsObjectRelationsTable
objectMetadataItem={objectMetadataItem}
/>
<StyledButtonContainer>
{!readonly && (
<UndecoratedLink
to={getSettingsPath(
SettingsPath.ObjectNewFieldConfigure,
{ objectNamePlural: objectMetadataItem.namePlural },
{ fieldType: FieldMetadataType.MORPH_RELATION },
)}
>
<Button
Icon={IconPlus}
title={t`Add relation`}
size="small"
variant="secondary"
/>
</UndecoratedLink>
)}
</StyledButtonContainer>
</Section>
)}
</Section>
<Section>
<H2Title
title={t`Fields`}
description={t`Customise the fields available in the ${objectLabelSingular} views and their display order in the ${objectLabelSingular} detail view and menus.`}
/>
<SettingsObjectFieldTable
objectMetadataItem={objectMetadataItem}
mode="view"
excludeRelations
/>
<StyledButtonContainer>
{!readonly && (
<UndecoratedLink
to={getSettingsPath(SettingsPath.ObjectNewFieldSelect, {
objectNamePlural: objectMetadataItem.namePlural,
})}
>
<Button
Icon={IconPlus}
title={t`Add Field`}
size="small"
variant="secondary"
/>
</UndecoratedLink>
)}
</StyledButtonContainer>
</Section>
</>
);
};
@@ -1,6 +1,5 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useDeleteOneObjectMetadataItem } from '@/object-metadata/hooks/useDeleteOneObjectMetadataItem';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { isObjectMetadataSettingsReadOnly } from '@/object-record/read-only/utils/isObjectMetadataSettingsReadOnly';
@@ -11,7 +10,6 @@ import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModa
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { H2Title, IconArchive, IconTrash } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
@@ -47,11 +45,8 @@ export const ObjectSettings = ({
setIsDeleting,
}: ObjectSettingsProps) => {
const { t } = useLingui();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const readonly = isObjectMetadataSettingsReadOnly({
objectMetadataItem,
workspaceCustomApplicationId:
currentWorkspace?.workspaceCustomApplication?.id,
});
const navigate = useNavigateSettings();
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
@@ -1,4 +1,3 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getActiveFieldMetadataItems } from '@/object-metadata/utils/getActiveFieldMetadataItems';
@@ -11,7 +10,6 @@ import { t } from '@lingui/core/macro';
import { useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { isLabelIdentifierFieldMetadataTypes } from 'twenty-shared/utils';
import { IconCircleOff, IconPlus, useIcons } from 'twenty-ui/display';
import { type SelectOption } from 'twenty-ui/input';
@@ -44,11 +42,8 @@ const StyledContainer = styled.div`
export const SettingsDataModelObjectIdentifiersForm = ({
objectMetadataItem,
}: SettingsDataModelObjectIdentifiersFormProps) => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const readonly = isObjectMetadataSettingsReadOnly({
objectMetadataItem,
workspaceCustomApplicationId:
currentWorkspace?.workspaceCustomApplication?.id,
});
const formConfig = useForm<SettingsDataModelObjectIdentifiersFormValues>({
mode: 'onTouched',
@@ -14,16 +14,13 @@ import styled from '@emotion/styled';
import { useRecoilState, useRecoilValue } from 'recoil';
import { AppPath, SettingsPath } from 'twenty-shared/types';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { isObjectMetadataSettingsReadOnly } from '@/object-record/read-only/utils/isObjectMetadataSettingsReadOnly';
import { SettingsItemTypeTag } from '@/settings/components/SettingsItemTypeTag';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useTheme } from '@emotion/react';
import { useLingui } from '@lingui/react/macro';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import {
H3Title,
IconCodeCircle,
IconListDetails,
IconPlus,
@@ -43,16 +40,6 @@ const StyledContentContainer = styled.div`
padding-left: 0;
`;
const StyledObjectTypeTag = styled(SettingsItemTypeTag)`
box-sizing: border-box;
height: ${({ theme }) => theme.spacing(5)};
margin-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledTitleContainer = styled.div`
display: flex;
`;
export const SettingsObjectDetailPage = () => {
const navigateApp = useNavigateApp();
const { t } = useLingui();
@@ -70,11 +57,8 @@ export const SettingsObjectDetailPage = () => {
findObjectMetadataItemByNamePlural(objectNamePlural) ??
findObjectMetadataItemByNamePlural(updatedObjectNamePlural);
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const readonly = isObjectMetadataSettingsReadOnly({
objectMetadataItem,
workspaceCustomApplicationId:
currentWorkspace?.workspaceCustomApplication?.id,
});
const activeTabId = useRecoilComponentValue(
@@ -157,12 +141,7 @@ export const SettingsObjectDetailPage = () => {
return (
<>
<SubMenuTopBarContainer
title={
<StyledTitleContainer>
<H3Title title={objectMetadataItem.labelPlural} />
<StyledObjectTypeTag item={objectMetadataItem} />
</StyledTitleContainer>
}
title={objectMetadataItem.labelPlural}
links={[
{
children: t`Workspace`,
@@ -5,7 +5,6 @@ import { FormProvider, useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router-dom';
import { type z } from 'zod';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
@@ -30,7 +29,7 @@ import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMe
import { shouldNavigateBackToMemorizedUrlOnSaveState } from '@/ui/navigation/states/shouldNavigateBackToMemorizedUrlOnSaveState';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useRecoilState } from 'recoil';
import { AppPath, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import {
@@ -84,11 +83,8 @@ export const SettingsObjectFieldEdit = () => {
const objectMetadataItem =
findObjectMetadataItemByNamePlural(objectNamePlural);
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const readonly = isObjectMetadataSettingsReadOnly({
objectMetadataItem,
workspaceCustomApplicationId:
currentWorkspace?.workspaceCustomApplication?.id,
});
const {
@@ -18,6 +18,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { useEffect, useMemo, useState } from 'react';
import { useRecoilState } from 'recoil';
import { FieldMetadataType } from 'twenty-shared/types';
import { IconArchive, IconFilter, IconSearch } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { MenuItemToggle } from 'twenty-ui/navigation';
@@ -25,7 +26,18 @@ import { useMapFieldMetadataItemToSettingsObjectDetailTableItem } from '~/pages/
import { type SettingsObjectDetailTableItem } from '~/pages/settings/data-model/types/SettingsObjectDetailTableItem';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
const GET_SETTINGS_OBJECT_DETAIL_TABLE_METADATA_STANDARD: TableMetadata<SettingsObjectDetailTableItem> =
const StyledSearchAndFilterContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
padding-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const StyledSearchInput = styled(SettingsTextInput)`
flex: 1;
`;
const SETTINGS_OBJECT_FIELD_TABLE_METADATA: TableMetadata<SettingsObjectDetailTableItem> =
{
tableId: 'settingsObjectDetail',
fields: [
@@ -36,7 +48,7 @@ const GET_SETTINGS_OBJECT_DETAIL_TABLE_METADATA_STANDARD: TableMetadata<Settings
align: 'left',
},
{
fieldLabel: msg`Field type`,
fieldLabel: msg`App`,
fieldName: 'fieldType',
fieldType: 'string',
align: 'left',
@@ -54,63 +66,23 @@ const GET_SETTINGS_OBJECT_DETAIL_TABLE_METADATA_STANDARD: TableMetadata<Settings
},
};
const GET_SETTINGS_OBJECT_DETAIL_TABLE_METADATA_CUSTOM: TableMetadata<SettingsObjectDetailTableItem> =
{
tableId: 'settingsObjectDetail',
fields: [
{
fieldLabel: msg`Name`,
fieldName: 'label',
fieldType: 'string',
align: 'left',
},
{
fieldLabel: msg`Identifier`,
fieldName: 'identifierType',
fieldType: 'string',
align: 'left',
},
{
fieldLabel: msg`Data type`,
fieldName: 'dataType',
fieldType: 'string',
align: 'left',
},
],
initialSort: {
fieldName: 'label',
orderBy: 'AscNullsLast',
},
};
const StyledSearchAndFilterContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
padding-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const StyledSearchInput = styled(SettingsTextInput)`
flex: 1;
`;
export type SettingsObjectFieldTableProps = {
objectMetadataItem: ObjectMetadataItem;
mode: 'view' | 'new-field';
excludeRelations?: boolean;
};
// TODO: find another way than using mode which feels like it could be replaced by another pattern
export const SettingsObjectFieldTable = ({
objectMetadataItem,
mode,
excludeRelations = false,
}: SettingsObjectFieldTableProps) => {
const { t } = useLingui();
const [searchTerm, setSearchTerm] = useState('');
const [showInactive, setShowInactive] = useState(true);
const tableMetadata = objectMetadataItem.isCustom
? GET_SETTINGS_OBJECT_DETAIL_TABLE_METADATA_CUSTOM
: GET_SETTINGS_OBJECT_DETAIL_TABLE_METADATA_STANDARD;
const tableMetadata = SETTINGS_OBJECT_FIELD_TABLE_METADATA;
const { mapFieldMetadataItemToSettingsObjectDetailTableItem } =
useMapFieldMetadataItemToSettingsObjectDetailTableItem(objectMetadataItem);
@@ -130,14 +102,23 @@ export const SettingsObjectFieldTable = ({
(fieldMetadataItem) => !fieldMetadataItem.isSystem,
);
const fieldsToDisplay = excludeRelations
? nonSystemFields?.filter(
(fieldMetadataItem) =>
fieldMetadataItem.type !== FieldMetadataType.RELATION &&
fieldMetadataItem.type !== FieldMetadataType.MORPH_RELATION,
)
: nonSystemFields;
return (
nonSystemFields?.map(
fieldsToDisplay?.map(
mapFieldMetadataItemToSettingsObjectDetailTableItem,
) ?? []
);
}, [
settingsObjectFields,
mapFieldMetadataItemToSettingsObjectDetailTableItem,
excludeRelations,
]);
const sortedAllObjectSettingsDetailItems = useSortedArray(
@@ -35,11 +35,6 @@ export const Default: Story = {
const employeeInput = await canvas.findByPlaceholderText('Employees');
await userEvent.type(employeeInput, 'Test');
const descriptionInput = await canvas.findByPlaceholderText(
'Write a description',
);
await userEvent.type(descriptionInput, 'Test description');
const saveButton = await canvas.findByText('Save');
await new Promise((resolve) => setTimeout(resolve, 5000));
await userEvent.click(saveButton);
@@ -13,7 +13,7 @@ export const GET_SETTINGS_OBJECT_TABLE_METADATA: TableMetadata<SettingsObjectTab
align: 'left',
},
{
fieldLabel: msg`Type`,
fieldLabel: msg`App`,
fieldName: 'objectTypeLabel',
fieldType: 'string',
align: 'left',
@@ -4,7 +4,6 @@ import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsDataModelNewFieldBreadcrumbDropDown } from '@/settings/data-model/components/SettingsDataModelNewFieldBreadcrumbDropDown';
import { FIELD_NAME_MAXIMUM_LENGTH } from '@/settings/data-model/constants/FieldNameMaximumLength';
import { SettingsDataModelFieldDescriptionForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldDescriptionForm';
import { SettingsDataModelFieldIconLabelForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldIconLabelForm';
import { SettingsDataModelFieldSettingsFormCard } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard';
import { settingsFieldFormSchema } from '@/settings/data-model/fields/forms/validation-schemas/settingsFieldFormSchema';
@@ -67,7 +66,6 @@ export const SettingsObjectNewFieldConfigure = () => {
icon:
DEFAULT_ICONS_BY_FIELD_TYPE[fieldType] ?? DEFAULT_ICON_FOR_NEW_FIELD,
label: '',
description: '',
name: '',
},
});
@@ -237,13 +235,6 @@ export const SettingsObjectNewFieldConfigure = () => {
objectNameSingular={activeObjectMetadataItem.nameSingular}
/>
</Section>
<Section>
<H2Title
title={t`Description`}
description={t`The description of this field`}
/>
<SettingsDataModelFieldDescriptionForm />
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
</FormProvider>