From ee080607981a2114976a195ab3ff6245b44cf2ca Mon Sep 17 00:00:00 2001
From: "Abdullah." <125115953+mabdullahabaid@users.noreply.github.com>
Date: Mon, 1 Dec 2025 20:04:36 +0500
Subject: [PATCH] Improve deactivated objects & fields behaviors. (#16090)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes [1918](https://github.com/twentyhq/core-team-issues/issues/1918).
- For the first point in the issue, we just show the deactivated entries
along with the deactivated text.
---
- For the second point, we show a banner and control the
enabled/disabled state of save button depending on whether we're
allowing the user to create table with the typed name.
- For example, we do not want to allow the user to create a table with
reserved name, so we disable the save button without showing a banner.
- Similarly, we do not want the user to create a table with a name that
already exists in the database. In this case, we show a banner and we
also disable the save button.
- Finally, we do not want to allow the user to create a table where
singular and plural name are the same. Therefore, we disable the save
button for names like `works`.
---
- For the third point, if we add the delete button, it logically means
that we allow the user to delete a custom object/field even it has not
been deactivated yet, so did that.
- Upon deleting the object/field, if we wait for the metadata to refetch
before we navigate, this is what we see because the path does not exist
any longer after deletion and we're waiting for refetch on the path
until we navigate away.
https://github.com/user-attachments/assets/dbe0569c-db88-4285-851f-22551b1ca81e
- To avoid this page from appearing, I replaced awaiting refetch to not
awaiting refetch and redirecting while the refetch happens in the
background.
- Therefore, when we delete something, there is a slight delay for when
it is actually cleared out from the list, but the Not Found view does
not appear on the screen.
https://github.com/user-attachments/assets/47f49579-ce51-4d6a-b857-72046247bb4b
- I tried optimistically removing the object/field from the metadata,
but it leads to some issues (crashes the app) and I have not been able
to find a solution for it yet.
- Therefore, instead of getting stuck at perfection and blocking myself,
I stopped getting into the issue further and created this PR by ensuring
that the desired functionality works.
---
> [!NOTE]
> Display deactivated objects/fields by default, add delete actions with
confirmation, and unify metadata name computation (auto-suffix reserved
keywords) across front/back with conflict checks in object creation.
>
> - **Frontend (Settings/Data Model)**:
> - **Visibility/UX**: Show `Deactivated` labels for objects/fields;
filters default to include inactive (`showDeactivated`/`showInactive`
true); replace field action dropdown with chevron link.
> - **Delete flows**: Add delete buttons for custom objects/fields with
confirmation modals and background refetch to avoid Not Found flashes.
> - **Creation/Edit validation**: Add name conflict detection banner in
`SettingsDataModelObjectAboutForm` and disable Save on conflicts;
simplify `metadataLabelSchema` to use computed name; form fields
validate on change and sync API names.
> - **Shared (twenty-shared/metadata)**:
> - Add `computeMetadataNameFromLabel` util (slugify+camelCase) and
`RESERVED_METADATA_NAME_KEYWORDS`; auto-append `Custom` to reserved
names; export constants/utilities.
> - **Backend**:
> - Migrate to shared `computeMetadataNameFromLabel`; update validators
to use shared reserved keywords with new messages; allow deletion of
active custom fields/objects (keep standard guards); adjust
services/decorators accordingly.
> - **Tests/Stories**:
> - Update unit/integration snapshots for new reserved-name messages and
behaviors; add missing i18n/router decorators in stories.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
5b126155606f6dbc8f7f91e2192cffb7bd2ebd2c. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---------
Co-authored-by: Félix Malfait
Co-authored-by: Félix Malfait
---
.../validation-schemas/metadataLabelSchema.ts | 25 ++-
...ettingsObjectFieldActiveActionDropdown.tsx | 90 -----------
.../SettingsObjectFieldItemTableRow.tsx | 153 +++++++-----------
.../components/SettingsObjectItemTableRow.tsx | 38 ++++-
.../components/tabs/ObjectSettings.tsx | 82 +++++++++-
.../SettingsDataModelObjectAboutForm.tsx | 70 +++++++-
...ttingsDataModelObjectAboutForm.stories.tsx | 3 +-
.../utils/getConflictingObjectMetadataItem.ts | 37 +++++
...ttingsApiKeysFieldItemTableRow.stories.tsx | 3 +-
.../settings/data-model/SettingsNewObject.tsx | 24 ++-
.../data-model/SettingsObjectDetailPage.tsx | 23 ++-
.../data-model/SettingsObjectFieldEdit.tsx | 125 +++++++++++---
.../data-model/SettingsObjectFieldTable.tsx | 2 +-
.../data-model/SettingsObjectTable.tsx | 2 +-
.../SettingsObjectDetail.stories.tsx | 16 +-
.../computeMetadataNameFromLabel.test.ts | 16 ++
.../utils/computeMetadataNameFromLabel.ts | 6 +-
.../computeMetadataNameFromLabelOrThrow.ts | 22 ---
.../ai/ai-agent/agent.service.ts | 2 +-
.../field-metadata-morph-relation.service.ts | 2 +-
.../field-metadata-relation.service.ts | 2 +-
...-relation-flat-field-metadata-pair.util.ts | 2 +-
...ld-metadata-name-synced-with-label.util.ts | 3 +-
...-metadata-names-synced-with-labels.util.ts | 3 +-
...pute-metadata-name-from-label.util.spec.ts | 30 +++-
.../compute-metadata-name-from-label.util.ts | 45 ------
...name-flat-metadata-validators.constants.ts | 7 +-
...e-metadata-name-is-not-reserved-keyword.ts | 71 +-------
...e-name-and-label-are-sync-or-throw.util.ts | 56 +++----
.../decorators/workspace-field.decorator.ts | 6 +-
.../workspace-relation.decorator.ts | 2 +-
.../flat-field-metadata-validator.service.ts | 12 --
.../flat-object-metadata-validator.service.ts | 8 -
...bject-metadata-v2.integration-spec.ts.snap | 24 +--
packages/twenty-shared/package.json | 2 +
.../compute-metadata-name-from-label.util.ts | 26 +++
packages/twenty-shared/src/metadata/index.ts | 6 +
...eserved-metadata-name-keywords.constant.ts | 69 ++++++++
.../sanitize-reserved-keyword.util.ts | 10 ++
39 files changed, 644 insertions(+), 481 deletions(-)
delete mode 100644 packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectFieldActiveActionDropdown.tsx
create mode 100644 packages/twenty-front/src/modules/settings/data-model/utils/getConflictingObjectMetadataItem.ts
delete mode 100644 packages/twenty-front/src/pages/settings/data-model/utils/computeMetadataNameFromLabelOrThrow.ts
delete mode 100644 packages/twenty-server/src/engine/metadata-modules/utils/compute-metadata-name-from-label.util.ts
create mode 100644 packages/twenty-shared/src/metadata/compute-metadata-name-from-label.util.ts
create mode 100644 packages/twenty-shared/src/metadata/reserved-metadata-name-keywords.constant.ts
create mode 100644 packages/twenty-shared/src/metadata/sanitize-reserved-keyword.util.ts
diff --git a/packages/twenty-front/src/modules/object-metadata/validation-schemas/metadataLabelSchema.ts b/packages/twenty-front/src/modules/object-metadata/validation-schemas/metadataLabelSchema.ts
index 1e2a63af9c..622eaeda49 100644
--- a/packages/twenty-front/src/modules/object-metadata/validation-schemas/metadataLabelSchema.ts
+++ b/packages/twenty-front/src/modules/object-metadata/validation-schemas/metadataLabelSchema.ts
@@ -1,8 +1,8 @@
import { errors } from '@/settings/data-model/fields/forms/utils/errorMessages';
import { z } from 'zod';
-import { computeMetadataNameFromLabelOrThrow } from '~/pages/settings/data-model/utils/computeMetadataNameFromLabelOrThrow';
import { METADATA_LABEL_VALID_PATTERN } from '~/pages/settings/data-model/constants/MetadataLabelValidPattern';
+import { computeMetadataNameFromLabel } from '~/pages/settings/data-model/utils/computeMetadataNameFromLabel';
export const metadataLabelSchema = (existingLabels?: string[]) => {
return z
.string()
@@ -11,12 +11,9 @@ export const metadataLabelSchema = (existingLabels?: string[]) => {
.regex(METADATA_LABEL_VALID_PATTERN, errors.LabelNotFormattable)
.refine(
(label) => {
- try {
- computeMetadataNameFromLabelOrThrow(label);
- return true;
- } catch {
- return false;
- }
+ const computedName = computeMetadataNameFromLabel(label);
+
+ return computedName !== '';
},
{
message: errors.LabelNotFormattable,
@@ -24,16 +21,12 @@ export const metadataLabelSchema = (existingLabels?: string[]) => {
)
.refine(
(label) => {
- try {
- if (!existingLabels || !label?.length) {
- return true;
- }
- return !existingLabels.includes(
- computeMetadataNameFromLabelOrThrow(label),
- );
- } catch {
- return false;
+ if (!existingLabels || !label?.length) {
+ return true;
}
+ const computedName = computeMetadataNameFromLabel(label);
+
+ return computedName !== '' && !existingLabels.includes(computedName);
},
{
message: errors.LabelNotUnique,
diff --git a/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectFieldActiveActionDropdown.tsx b/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectFieldActiveActionDropdown.tsx
deleted file mode 100644
index e1a945b6bd..0000000000
--- a/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectFieldActiveActionDropdown.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-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 { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
-import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
-import { isDefined } from 'twenty-shared/utils';
-import {
- IconArchive,
- IconDotsVertical,
- IconEye,
- IconPencil,
- IconTextSize,
-} from 'twenty-ui/display';
-import { LightIconButton } from 'twenty-ui/input';
-import { MenuItem } from 'twenty-ui/navigation';
-
-type SettingsObjectFieldActiveActionDropdownProps = {
- isCustomField?: boolean;
- onDeactivate?: () => void;
- onEdit: () => void;
- onSetAsLabelIdentifier?: () => void;
- fieldMetadataItemId: string;
- readonly?: boolean;
-};
-
-export const SettingsObjectFieldActiveActionDropdown = ({
- isCustomField,
- readonly = false,
- onDeactivate,
- onEdit,
- onSetAsLabelIdentifier,
- fieldMetadataItemId,
-}: SettingsObjectFieldActiveActionDropdownProps) => {
- const dropdownId = `${fieldMetadataItemId}-settings-field-active-action-dropdown`;
-
- const { closeDropdown } = useCloseDropdown();
-
- const handleEdit = () => {
- onEdit();
- closeDropdown(dropdownId);
- };
-
- const handleDeactivate = () => {
- onDeactivate?.();
- closeDropdown(dropdownId);
- };
-
- const handleSetAsLabelIdentifier = () => {
- onSetAsLabelIdentifier?.();
- closeDropdown(dropdownId);
- };
-
- return (
-
- }
- dropdownComponents={
-
-
-
- {isDefined(onSetAsLabelIdentifier) && !readonly && (
-
- )}
- {isDefined(onDeactivate) && !readonly && (
-
- )}
-
-
- }
- />
- );
-};
diff --git a/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow.tsx b/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow.tsx
index 785dcaa27c..fa6a39b3b5 100644
--- a/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow.tsx
+++ b/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow.tsx
@@ -2,36 +2,34 @@ 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 { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
-import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
-import { SettingsObjectFieldActiveActionDropdown } from '@/settings/data-model/object-details/components/SettingsObjectFieldActiveActionDropdown';
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';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
-import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
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 { FieldMetadataType, SettingsPath } from 'twenty-shared/types';
+import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import {
- getSettingsPath,
- isDefined,
- isLabelIdentifierFieldMetadataTypes,
-} from 'twenty-shared/utils';
-import { IconMinus, IconPlus, useIcons } from 'twenty-ui/display';
+ IconChevronRight,
+ IconMinus,
+ IconPlus,
+ useIcons,
+} from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
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 '../../constants/RelationTypes';
import { SettingsObjectFieldDataType } from './SettingsObjectFieldDataType';
-import { isObjectMetadataSettingsReadOnly } from '@/object-record/read-only/utils/isObjectMetadataSettingsReadOnly';
type SettingsObjectFieldItemTableRowProps = {
settingsObjectDetailTableItem: SettingsObjectDetailTableItem;
@@ -48,22 +46,50 @@ const StyledNameTableCell = styled(TableCell)`
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};
+`;
+
export const SettingsObjectFieldItemTableRow = ({
settingsObjectDetailTableItem,
mode,
status,
}: SettingsObjectFieldItemTableRowProps) => {
+ const { t } = useLingui();
const { fieldMetadataItem, identifierType, objectMetadataItem } =
settingsObjectDetailTableItem;
@@ -80,10 +106,6 @@ export const SettingsObjectFieldItemTableRow = ({
const navigate = useNavigateSettings();
- const [navigationMemorizedUrl, setNavigationMemorizedUrl] = useRecoilState(
- navigationMemorizedUrlState,
- );
-
const theme = useTheme();
const { getIcon } = useIcons();
const Icon = getIcon(fieldMetadataItem.icon);
@@ -109,73 +131,15 @@ export const SettingsObjectFieldItemTableRow = ({
const canToggleField = !isLabelIdentifier;
- const canBeSetAsLabelIdentifier =
- objectMetadataItem.isCustom &&
- !isLabelIdentifier &&
- isLabelIdentifierFieldMetadataTypes(fieldMetadataItem.type);
-
const linkToNavigate = getSettingsPath(SettingsPath.ObjectFieldEdit, {
objectNamePlural: objectMetadataItem.namePlural,
fieldName: fieldMetadataItem.name,
});
- const { activateMetadataField, deactivateMetadataField } =
- useFieldMetadataItem();
+ const { activateMetadataField } = useFieldMetadataItem();
const { deleteOneFieldMetadataItem } = useDeleteOneFieldMetadataItem();
- const handleDisableField = async (
- activeFieldMetadatItem: FieldMetadataItem,
- ) => {
- if (readonly) {
- return;
- }
-
- const deactivationResult = await deactivateMetadataField(
- activeFieldMetadatItem.id,
- objectMetadataItem.id,
- );
-
- if (deactivationResult.status === 'failed') {
- return;
- }
-
- // TODO: Add optimistic rendering for core views
- const deletedViewIds: string[] = [];
-
- const [baseUrl, queryParams] = navigationMemorizedUrl.includes('?')
- ? navigationMemorizedUrl.split('?')
- : [navigationMemorizedUrl, ''];
-
- const params = new URLSearchParams(queryParams);
- const currentViewId = params.get('view');
-
- if (isDefined(currentViewId) && deletedViewIds.includes(currentViewId)) {
- params.delete('view');
- const updatedUrl = params.toString()
- ? `${baseUrl}?${params.toString()}`
- : baseUrl;
- setNavigationMemorizedUrl(updatedUrl);
- }
- };
-
- const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
-
- const handleSetLabelIdentifierField = (
- activeFieldMetadatItem: FieldMetadataItem,
- ) => {
- if (readonly) {
- return;
- }
-
- updateOneObjectMetadataItem({
- idToUpdate: objectMetadataItem.id,
- updatePayload: {
- labelIdentifierFieldMetadataId: activeFieldMetadatItem.id,
- },
- });
- };
-
const [, setActiveSettingsObjectFields] = useRecoilState(
settingsObjectFieldsFamilyState({
objectMetadataItemId: objectMetadataItem.id,
@@ -249,9 +213,14 @@ export const SettingsObjectFieldItemTableRow = ({
stroke={theme.icon.stroke.sm}
/>
)}
-
- {fieldMetadataItem.label}
-
+
+
+ {fieldMetadataItem.label}
+
+ {!fieldMetadataItem.isActive && (
+ {t`Deactivated`}
+ )}
+
@@ -281,27 +250,17 @@ export const SettingsObjectFieldItemTableRow = ({
{status === 'active' ? (
mode === 'view' ? (
-
- navigate(SettingsPath.ObjectFieldEdit, {
- objectNamePlural: objectMetadataItem.namePlural,
- fieldName: fieldMetadataItem.name,
- })
- }
- onSetAsLabelIdentifier={
- canBeSetAsLabelIdentifier
- ? () => handleSetLabelIdentifierField(fieldMetadataItem)
- : undefined
- }
- onDeactivate={
- isLabelIdentifier
- ? undefined
- : () => handleDisableField(fieldMetadataItem)
- }
- />
+
+
+
) : (
canToggleField && (
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 StyledActionTableCell = styled(TableCell)`
justify-content: center;
padding-right: ${({ theme }) => theme.spacing(1)};
@@ -41,6 +65,7 @@ export const SettingsObjectMetadataItemTableRow = ({
link,
totalObjectCount,
}: SettingsObjectMetadataItemTableRowProps) => {
+ const { t } = useLingui();
const theme = useTheme();
const { getIcon } = useIcons();
@@ -56,9 +81,14 @@ export const SettingsObjectMetadataItemTableRow = ({
stroke={theme.icon.stroke.sm}
/>
)}
-
- {objectMetadataItem.labelPlural}
-
+
+
+ {objectMetadataItem.labelPlural}
+
+ {!objectMetadataItem.isActive && (
+ {t`Deactivated`}
+ )}
+
diff --git a/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectSettings.tsx b/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectSettings.tsx
index 44b2338aaa..7a1c42a93f 100644
--- a/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectSettings.tsx
+++ b/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectSettings.tsx
@@ -1,21 +1,27 @@
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';
import { SettingsUpdateDataModelObjectAboutForm } from '@/settings/data-model/object-details/components/SettingsUpdateDataModelObjectAboutForm';
import { SettingsDataModelObjectSettingsFormCard } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectSettingsFormCard';
+import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
+import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
+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 } from 'twenty-ui/display';
+import { H2Title, IconArchive, IconTrash } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
type ObjectSettingsProps = {
objectMetadataItem: ObjectMetadataItem;
+ isDeleting: boolean;
+ setIsDeleting: (isDeleting: boolean) => void;
};
const StyledContentContainer = styled.div`
@@ -28,7 +34,18 @@ const StyledFormSection = styled(Section)`
padding-left: 0 !important;
`;
-export const ObjectSettings = ({ objectMetadataItem }: ObjectSettingsProps) => {
+const StyledDangerButtonsContainer = styled.div`
+ display: flex;
+ gap: ${({ theme }) => theme.spacing(2)};
+`;
+
+const DELETE_OBJECT_MODAL_ID = 'delete-object-confirmation-modal';
+
+export const ObjectSettings = ({
+ objectMetadataItem,
+ isDeleting,
+ setIsDeleting,
+}: ObjectSettingsProps) => {
const { t } = useLingui();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const readonly = isObjectMetadataSettingsReadOnly({
@@ -38,6 +55,9 @@ export const ObjectSettings = ({ objectMetadataItem }: ObjectSettingsProps) => {
});
const navigate = useNavigateSettings();
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
+ const { deleteOneObjectMetadataItem } = useDeleteOneObjectMetadataItem();
+ const { enqueueSuccessSnackBar } = useSnackBar();
+ const { openModal, closeModal } = useModal();
const handleDisable = async () => {
const result = await updateOneObjectMetadataItem({
@@ -50,6 +70,29 @@ export const ObjectSettings = ({ objectMetadataItem }: ObjectSettingsProps) => {
}
};
+ const handleDelete = () => {
+ openModal(DELETE_OBJECT_MODAL_ID);
+ };
+
+ const confirmDelete = async () => {
+ setIsDeleting(true);
+ const result = await deleteOneObjectMetadataItem(objectMetadataItem.id);
+
+ if (result.status === 'successful') {
+ enqueueSuccessSnackBar({
+ message: t`Object deleted`,
+ });
+ closeModal(DELETE_OBJECT_MODAL_ID);
+ navigate(SettingsPath.Objects);
+ return;
+ }
+
+ setIsDeleting(false);
+ closeModal(DELETE_OBJECT_MODAL_ID);
+ };
+
+ const objectLabel = objectMetadataItem.labelPlural;
+
return (
@@ -79,15 +122,38 @@ export const ObjectSettings = ({ objectMetadataItem }: ObjectSettingsProps) => {
title={t`Danger zone`}
description={t`Deactivate object`}
/>
-
+
+
+ {objectMetadataItem.isCustom && (
+
+ )}
+
)}
+ closeModal(DELETE_OBJECT_MODAL_ID)}
+ confirmationValue="yes"
+ confirmationPlaceholder="yes"
+ loading={isDeleting}
+ />
);
};
diff --git a/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm.tsx b/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm.tsx
index a075554cea..cfadc1245a 100644
--- a/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm.tsx
+++ b/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm.tsx
@@ -11,6 +11,7 @@ import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { plural } from 'pluralize';
import { Controller, useFormContext } from 'react-hook-form';
+import { SettingsPath } from 'twenty-shared/types';
import { capitalize, isDefined } from 'twenty-shared/utils';
import {
AppTooltip,
@@ -18,14 +19,17 @@ import {
IconRefresh,
TooltipDelay,
} from 'twenty-ui/display';
+import { Button } from 'twenty-ui/input';
import { Card } from 'twenty-ui/layout';
import { type StringKeyOf } from 'type-fest';
+import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { computeMetadataNameFromLabel } from '~/pages/settings/data-model/utils/computeMetadataNameFromLabel';
type SettingsDataModelObjectAboutFormProps = {
disableEdition?: boolean;
objectMetadataItem?: ObjectMetadataItem;
onNewDirtyField?: () => void;
+ conflictingObjectMetadataItem?: ObjectMetadataItem;
};
const StyledInputsContainer = styled.div`
@@ -66,25 +70,59 @@ const StyledLabel = styled.span`
margin-bottom: ${({ theme }) => theme.spacing(1)};
`;
+const StyledConflictBanner = styled.div`
+ align-items: center;
+ background-color: ${({ theme }) => theme.accent.secondary};
+ border-radius: ${({ theme }) => theme.border.radius.md};
+ box-sizing: border-box;
+ display: flex;
+ gap: ${({ theme }) => theme.spacing(2)};
+ margin-bottom: ${({ theme }) => theme.spacing(2)};
+ padding: ${({ theme }) => theme.spacing(2)};
+`;
+
+const StyledBannerContent = styled.div`
+ align-items: center;
+ display: flex;
+ gap: ${({ theme }) => theme.spacing(2)};
+ flex: 1;
+`;
+
+const StyledBannerText = styled.span`
+ color: ${({ theme }) => theme.color.blue};
+ flex: 1;
+`;
+
+const StyledConflictButton = styled(Button)`
+ border-color: ${({ theme }) => theme.color.blue};
+ color: ${({ theme }) => theme.color.blue};
+ &:hover {
+ background: ${({ theme }) => theme.accent.secondary};
+ }
+ &:focus-visible {
+ box-shadow: 0 0 0 3px ${({ theme }) => theme.accent.tertiary};
+ }
+`;
+
const infoCircleElementId = 'info-circle-id';
export const SettingsDataModelObjectAboutForm = ({
disableEdition = false,
onNewDirtyField,
objectMetadataItem,
+ conflictingObjectMetadataItem,
}: SettingsDataModelObjectAboutFormProps) => {
const { control, watch, setValue } =
useFormContext();
const { t } = useLingui();
const theme = useTheme();
+ const navigateSettings = useNavigateSettings();
const isLabelSyncedWithName = watch('isLabelSyncedWithName');
const labelSingular = watch('labelSingular');
const labelPlural = watch('labelPlural');
const isStandardObject =
isDefined(objectMetadataItem?.isCustom) && !objectMetadataItem.isCustom;
- watch('nameSingular');
- watch('namePlural');
watch('description');
watch('icon');
@@ -101,6 +139,7 @@ export const SettingsDataModelObjectAboutForm = ({
const labelPluralFromSingularLabel = plural(labelSingular);
setValue('labelPlural', labelPluralFromSingularLabel, {
shouldDirty: true,
+ shouldValidate: true,
});
if (isLabelSyncedWithName) {
fillNamePluralFromLabelPlural(labelPluralFromSingularLabel);
@@ -114,6 +153,7 @@ export const SettingsDataModelObjectAboutForm = ({
setValue('nameSingular', computeMetadataNameFromLabel(labelSingular), {
shouldDirty: true,
+ shouldValidate: true,
});
};
@@ -122,6 +162,7 @@ export const SettingsDataModelObjectAboutForm = ({
setValue('namePlural', computeMetadataNameFromLabel(labelPlural), {
shouldDirty: true,
+ shouldValidate: true,
});
};
@@ -227,6 +268,31 @@ export const SettingsDataModelObjectAboutForm = ({
+ {isDefined(conflictingObjectMetadataItem) && (
+
+
+
+
+ {t`An object with this name already exists`}
+
+
+
+ navigateSettings(SettingsPath.ObjectDetail, {
+ objectNamePlural:
+ conflictingObjectMetadataItem.namePlural,
+ })
+ }
+ />
+
+ )}
{[
{
label: t`API Name (Singular)`,
diff --git a/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/__stories__/SettingsDataModelObjectAboutForm.stories.tsx b/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/__stories__/SettingsDataModelObjectAboutForm.stories.tsx
index 66ace113d8..a371d5b71c 100644
--- a/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/__stories__/SettingsDataModelObjectAboutForm.stories.tsx
+++ b/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/__stories__/SettingsDataModelObjectAboutForm.stories.tsx
@@ -1,8 +1,8 @@
import styled from '@emotion/styled';
import { type Meta, type StoryObj } from '@storybook/react';
-
import { FormProviderDecorator } from '~/testing/decorators/FormProviderDecorator';
import { IconsProviderDecorator } from '~/testing/decorators/IconsProviderDecorator';
+import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { ComponentDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
@@ -31,6 +31,7 @@ const meta: Meta = {
FormProviderDecorator,
IconsProviderDecorator,
ComponentDecorator,
+ MemoryRouterDecorator,
],
parameters: {
container: { width: 520 },
diff --git a/packages/twenty-front/src/modules/settings/data-model/utils/getConflictingObjectMetadataItem.ts b/packages/twenty-front/src/modules/settings/data-model/utils/getConflictingObjectMetadataItem.ts
new file mode 100644
index 0000000000..7b41f6e3f2
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/data-model/utils/getConflictingObjectMetadataItem.ts
@@ -0,0 +1,37 @@
+import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
+import { isDefined } from 'twenty-shared/utils';
+
+type GetConflictingObjectMetadataItemParams = {
+ objectMetadataItems: ObjectMetadataItem[];
+ nameSingular?: string;
+ namePlural?: string;
+ excludeObjectId?: string;
+};
+
+export const getConflictingObjectMetadataItem = ({
+ objectMetadataItems,
+ nameSingular,
+ namePlural,
+ excludeObjectId,
+}: GetConflictingObjectMetadataItemParams): ObjectMetadataItem | undefined => {
+ if (!isDefined(nameSingular) && !isDefined(namePlural)) {
+ return undefined;
+ }
+
+ return objectMetadataItems.find((objectMetadataItem) => {
+ if (
+ isDefined(excludeObjectId) &&
+ objectMetadataItem.id === excludeObjectId
+ ) {
+ return false;
+ }
+
+ const hasSingularConflict =
+ isDefined(nameSingular) &&
+ nameSingular === objectMetadataItem.nameSingular;
+ const hasPluralConflict =
+ isDefined(namePlural) && namePlural === objectMetadataItem.namePlural;
+
+ return hasSingularConflict || hasPluralConflict;
+ });
+};
diff --git a/packages/twenty-front/src/modules/settings/developers/components/__stories__/SettingsApiKeysFieldItemTableRow.stories.tsx b/packages/twenty-front/src/modules/settings/developers/components/__stories__/SettingsApiKeysFieldItemTableRow.stories.tsx
index 6b63d263b2..4626438347 100644
--- a/packages/twenty-front/src/modules/settings/developers/components/__stories__/SettingsApiKeysFieldItemTableRow.stories.tsx
+++ b/packages/twenty-front/src/modules/settings/developers/components/__stories__/SettingsApiKeysFieldItemTableRow.stories.tsx
@@ -2,11 +2,12 @@ import { type Meta, type StoryObj } from '@storybook/react';
import { SettingsApiKeysFieldItemTableRow } from '@/settings/developers/components/SettingsApiKeysFieldItemTableRow';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
+import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
const meta: Meta = {
title: 'Modules/Settings/Developers/ApiKeys/SettingsApiKeysFieldItemTableRow',
component: SettingsApiKeysFieldItemTableRow,
- decorators: [ComponentDecorator, RouterDecorator],
+ decorators: [ComponentDecorator, RouterDecorator, I18nFrontDecorator],
args: {
apiKey: {
id: '3f4a42e8-b81f-4f8c-9c20-1602e6b34791',
diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsNewObject.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsNewObject.tsx
index ab0dd3db9b..423fd6b997 100644
--- a/packages/twenty-front/src/pages/settings/data-model/SettingsNewObject.tsx
+++ b/packages/twenty-front/src/pages/settings/data-model/SettingsNewObject.tsx
@@ -1,8 +1,10 @@
import { useCreateOneObjectMetadataItem } from '@/object-metadata/hooks/useCreateOneObjectMetadataItem';
+import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SETTINGS_OBJECT_MODEL_IS_LABEL_SYNCED_WITH_NAME_LABEL_DEFAULT_VALUE } from '@/settings/constants/SettingsObjectModel';
import { SettingsDataModelObjectAboutForm } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm';
+import { getConflictingObjectMetadataItem } from '@/settings/data-model/utils/getConflictingObjectMetadataItem';
import {
type SettingsDataModelObjectAboutFormValues,
settingsDataModelObjectAboutFormSchema,
@@ -12,8 +14,9 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
+import { useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
-import { getSettingsPath } from 'twenty-shared/utils';
+import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
@@ -23,9 +26,10 @@ export const SettingsNewObject = () => {
const navigate = useNavigateSettings();
const [isLoading, setIsLoading] = useState(false);
const { createOneObjectMetadataItem } = useCreateOneObjectMetadataItem();
+ const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const formConfig = useForm({
- mode: 'onSubmit',
+ mode: 'onChange',
resolver: zodResolver(settingsDataModelObjectAboutFormSchema),
defaultValues: {
isLabelSyncedWithName:
@@ -33,8 +37,19 @@ export const SettingsNewObject = () => {
},
});
+ const nameSingular = formConfig.watch('nameSingular');
+ const namePlural = formConfig.watch('namePlural');
+
+ const conflictingObjectMetadataItem = getConflictingObjectMetadataItem({
+ objectMetadataItems,
+ nameSingular,
+ namePlural,
+ });
+
+ const hasNameConflict = isDefined(conflictingObjectMetadataItem);
+
const { isValid, isSubmitting } = formConfig.formState;
- const canSave = isValid && !isSubmitting;
+ const canSave = isValid && !isSubmitting && !hasNameConflict;
const handleSave = async (
formValues: SettingsDataModelObjectAboutFormValues,
@@ -90,6 +105,9 @@ export const SettingsNewObject = () => {
/>
formConfig.trigger()}
+ conflictingObjectMetadataItem={
+ !isLoading ? conflictingObjectMetadataItem : undefined
+ }
/>
diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectDetailPage.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectDetailPage.tsx
index 7ebbabc062..aedb9ab584 100644
--- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectDetailPage.tsx
+++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectDetailPage.tsx
@@ -1,4 +1,4 @@
-import { useEffect } from 'react';
+import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
@@ -56,6 +56,7 @@ const StyledTitleContainer = styled.div`
export const SettingsObjectDetailPage = () => {
const navigateApp = useNavigateApp();
const { t } = useLingui();
+ const theme = useTheme();
const { objectNamePlural = '' } = useParams();
const { findObjectMetadataItemByNamePlural } =
@@ -85,21 +86,25 @@ export const SettingsObjectDetailPage = () => {
FeatureFlagKey.IS_UNIQUE_INDEXES_ENABLED,
);
+ const [isDeleting, setIsDeleting] = useState(false);
+
useEffect(() => {
if (objectNamePlural === updatedObjectNamePlural)
setUpdatedObjectNamePlural('');
- if (!isDefined(objectMetadataItem)) navigateApp(AppPath.NotFound);
+ if (!isDeleting && !isDefined(objectMetadataItem))
+ navigateApp(AppPath.NotFound);
}, [
objectMetadataItem,
navigateApp,
objectNamePlural,
updatedObjectNamePlural,
setUpdatedObjectNamePlural,
+ isDeleting,
]);
- const theme = useTheme();
-
- if (!isDefined(objectMetadataItem)) return <>>;
+ if (!isDefined(objectMetadataItem)) {
+ return null;
+ }
const tabs = [
{
@@ -134,7 +139,13 @@ export const SettingsObjectDetailPage = () => {
case SETTINGS_OBJECT_DETAIL_TABS.TABS_IDS.FIELDS:
return ;
case SETTINGS_OBJECT_DETAIL_TABS.TABS_IDS.SETTINGS:
- return ;
+ return (
+
+ );
case SETTINGS_OBJECT_DETAIL_TABS.TABS_IDS.INDEXES:
return ;
default:
diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx
index 4be7821506..6688041a2a 100644
--- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx
+++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx
@@ -22,14 +22,23 @@ import { SettingsDataModelFieldIconLabelForm } from '@/settings/data-model/field
import { SettingsDataModelFieldSettingsFormCard } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard';
import { settingsFieldFormSchema } from '@/settings/data-model/fields/forms/validation-schemas/settingsFieldFormSchema';
import { type SettingsFieldType } from '@/settings/data-model/types/SettingsFieldType';
+import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
+import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
+import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
import { shouldNavigateBackToMemorizedUrlOnSaveState } from '@/ui/navigation/states/shouldNavigateBackToMemorizedUrlOnSaveState';
+import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useRecoilState, useRecoilValue } from 'recoil';
import { AppPath, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
-import { H2Title, IconArchive, IconArchiveOff } from 'twenty-ui/display';
+import {
+ H2Title,
+ IconArchive,
+ IconArchiveOff,
+ IconTrash,
+} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { FieldMetadataType } from '~/generated-metadata/graphql';
@@ -42,11 +51,20 @@ export type SettingsDataModelFieldEditFormValues = z.infer<
> &
any;
+const DELETE_FIELD_MODAL_ID = 'delete-field-confirmation-modal';
+const StyledDangerButtons = styled.div`
+ display: flex;
+ gap: ${({ theme }) => theme.spacing(2)};
+`;
+
export const SettingsObjectFieldEdit = () => {
const navigateSettings = useNavigateSettings();
const navigateApp = useNavigateApp();
const { t } = useLingui();
+ const { openModal, closeModal } = useModal();
+ const { enqueueSuccessSnackBar } = useSnackBar();
+
const navigate = useNavigate();
const [navigationMemorizedUrl, setNavigationMemorizedUrl] = useRecoilState(
@@ -72,12 +90,16 @@ export const SettingsObjectFieldEdit = () => {
currentWorkspace?.workspaceCustomApplication?.id,
});
- const { deactivateMetadataField, activateMetadataField } =
- useFieldMetadataItem();
+ const {
+ deactivateMetadataField,
+ activateMetadataField,
+ deleteMetadataField,
+ } = useFieldMetadataItem();
const [newNameDuringSave, setNewNameDuringSave] = useState(
null,
);
+ const [isDeleting, setIsDeleting] = useState(false);
const fieldMetadataItem = objectMetadataItem?.fields.find(
(fieldMetadataItem) =>
@@ -101,10 +123,10 @@ export const SettingsObjectFieldEdit = () => {
});
useEffect(() => {
- if (!objectMetadataItem || !fieldMetadataItem) {
+ if (!isDeleting && (!objectMetadataItem || !fieldMetadataItem)) {
navigateApp(AppPath.NotFound);
}
- }, [navigateApp, objectMetadataItem, fieldMetadataItem]);
+ }, [navigateApp, objectMetadataItem, fieldMetadataItem, isDeleting]);
const { isDirty, isValid, isSubmitting } = formConfig.formState;
@@ -114,6 +136,9 @@ export const SettingsObjectFieldEdit = () => {
return null;
}
+ const fieldLabel = fieldMetadataItem.label;
+ const objectLabel = objectMetadataItem.labelPlural;
+
const isLabelIdentifier = isLabelIdentifierField({
fieldMetadataItem: fieldMetadataItem,
objectMetadataItem: objectMetadataItem,
@@ -228,6 +253,41 @@ export const SettingsObjectFieldEdit = () => {
}
};
+ const handleDelete = () => {
+ if (readonly || !fieldMetadataItem?.isCustom) {
+ return;
+ }
+
+ openModal(DELETE_FIELD_MODAL_ID);
+ };
+
+ const confirmDelete = async () => {
+ if (!isDefined(objectMetadataItem) || !isDefined(fieldMetadataItem)) {
+ return;
+ }
+
+ setIsDeleting(true);
+
+ const deleteResult = await deleteMetadataField({
+ idToDelete: fieldMetadataItem.id,
+ objectMetadataId: objectMetadataItem.id,
+ });
+
+ if (deleteResult.status === 'successful') {
+ enqueueSuccessSnackBar({
+ message: t`Field deleted`,
+ });
+ closeModal(DELETE_FIELD_MODAL_ID);
+ navigateSettings(SettingsPath.ObjectDetail, {
+ objectNamePlural,
+ });
+ return;
+ }
+
+ setIsDeleting(false);
+ closeModal(DELETE_FIELD_MODAL_ID);
+ };
+
return (
<>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
@@ -320,26 +380,51 @@ export const SettingsObjectFieldEdit = () => {
title={t`Danger zone`}
description={t`Deactivate this field`}
/>
-
+
+
+ {fieldMetadataItem.isCustom && (
+
+ )}
+
)}
+ {fieldMetadataItem?.isCustom && (
+ closeModal(DELETE_FIELD_MODAL_ID)}
+ loading={isDeleting}
+ />
+ )}
>
);
};
diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldTable.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldTable.tsx
index d3b65eeee1..95406a2e61 100644
--- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldTable.tsx
+++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldTable.tsx
@@ -106,7 +106,7 @@ export const SettingsObjectFieldTable = ({
}: SettingsObjectFieldTableProps) => {
const { t } = useLingui();
const [searchTerm, setSearchTerm] = useState('');
- const [showInactive, setShowInactive] = useState(mode === 'new-field');
+ const [showInactive, setShowInactive] = useState(true);
const tableMetadata = objectMetadataItem.isCustom
? GET_SETTINGS_OBJECT_DETAIL_TABLE_METADATA_CUSTOM
diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectTable.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectTable.tsx
index 7320a2ef0e..3a842afeb9 100644
--- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectTable.tsx
+++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectTable.tsx
@@ -68,7 +68,7 @@ export const SettingsObjectTable = ({
const isAdvancedModeEnabled = useRecoilValue(isAdvancedModeEnabledState);
const [searchTerm, setSearchTerm] = useState('');
- const [showDeactivated, setShowDeactivated] = useState(false);
+ const [showDeactivated, setShowDeactivated] = useState(true);
const [showSystemObjects, setShowSystemObjects] = useState(false);
const { deleteOneObjectMetadataItem } = useDeleteOneObjectMetadataItem();
diff --git a/packages/twenty-front/src/pages/settings/data-model/__stories__/SettingsObjectDetail.stories.tsx b/packages/twenty-front/src/pages/settings/data-model/__stories__/SettingsObjectDetail.stories.tsx
index 483a6cc69d..6d409a7c92 100644
--- a/packages/twenty-front/src/pages/settings/data-model/__stories__/SettingsObjectDetail.stories.tsx
+++ b/packages/twenty-front/src/pages/settings/data-model/__stories__/SettingsObjectDetail.stories.tsx
@@ -1,5 +1,5 @@
-import { expect, userEvent, within } from '@storybook/test';
import { type Meta, type StoryObj } from '@storybook/react';
+import { expect, within } from '@storybook/test';
import {
PageDecorator,
@@ -50,17 +50,3 @@ export const ObjectTabs: Story = {
await expect(settingsTab).toBeVisible();
},
};
-
-export const FieldDropdownMenu: Story = {
- play: async () => {
- const canvas = within(document.body);
- const [fieldVerticalDotsIconButton] = await canvas.findAllByRole('button', {
- name: 'Active Field Options',
- });
-
- await userEvent.click(fieldVerticalDotsIconButton);
-
- await canvas.findByText('View');
- await canvas.findByText('Deactivate');
- },
-};
diff --git a/packages/twenty-front/src/pages/settings/data-model/utils/__tests__/computeMetadataNameFromLabel.test.ts b/packages/twenty-front/src/pages/settings/data-model/utils/__tests__/computeMetadataNameFromLabel.test.ts
index 55673d3225..a655d6a16e 100644
--- a/packages/twenty-front/src/pages/settings/data-model/utils/__tests__/computeMetadataNameFromLabel.test.ts
+++ b/packages/twenty-front/src/pages/settings/data-model/utils/__tests__/computeMetadataNameFromLabel.test.ts
@@ -18,4 +18,20 @@ describe('computeMetadataNameFromLabel', () => {
expect(computeMetadataNameFromLabel(label)).toEqual('');
});
+
+ it('adds "Custom" suffix to reserved keywords', () => {
+ expect(computeMetadataNameFromLabel('Plan')).toEqual('planCustom');
+ expect(computeMetadataNameFromLabel('Event')).toEqual('eventCustom');
+ expect(computeMetadataNameFromLabel('User')).toEqual('userCustom');
+ });
+
+ it('adds "Custom" suffix to plural reserved keywords', () => {
+ expect(computeMetadataNameFromLabel('Plans')).toEqual('plansCustom');
+ expect(computeMetadataNameFromLabel('Events')).toEqual('eventsCustom');
+ });
+
+ it('does not modify non-reserved keywords', () => {
+ expect(computeMetadataNameFromLabel('Customer')).toEqual('customer');
+ expect(computeMetadataNameFromLabel('Order')).toEqual('order');
+ });
});
diff --git a/packages/twenty-front/src/pages/settings/data-model/utils/computeMetadataNameFromLabel.ts b/packages/twenty-front/src/pages/settings/data-model/utils/computeMetadataNameFromLabel.ts
index ec3f397a00..aaad22006b 100644
--- a/packages/twenty-front/src/pages/settings/data-model/utils/computeMetadataNameFromLabel.ts
+++ b/packages/twenty-front/src/pages/settings/data-model/utils/computeMetadataNameFromLabel.ts
@@ -1,8 +1,10 @@
-import { computeMetadataNameFromLabelOrThrow } from '~/pages/settings/data-model/utils/computeMetadataNameFromLabelOrThrow';
+import { computeMetadataNameFromLabel as computeMetadataNameFromLabelCore } from 'twenty-shared/metadata';
+// Frontend-specific wrapper that returns empty string on error instead of throwing
+// This is needed for form validation and UI components that prefer graceful degradation
export const computeMetadataNameFromLabel = (label: string): string => {
try {
- return computeMetadataNameFromLabelOrThrow(label);
+ return computeMetadataNameFromLabelCore(label);
} catch {
return '';
}
diff --git a/packages/twenty-front/src/pages/settings/data-model/utils/computeMetadataNameFromLabelOrThrow.ts b/packages/twenty-front/src/pages/settings/data-model/utils/computeMetadataNameFromLabelOrThrow.ts
deleted file mode 100644
index 766ffad4a7..0000000000
--- a/packages/twenty-front/src/pages/settings/data-model/utils/computeMetadataNameFromLabelOrThrow.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import camelCase from 'lodash.camelcase';
-import { slugify } from 'transliteration';
-
-export const computeMetadataNameFromLabelOrThrow = (label: string): string => {
- const prefixedLabel = /^\d/.test(label) ? `n${label}` : label;
-
- if (prefixedLabel === '') {
- return '';
- }
-
- const formattedString = slugify(prefixedLabel, {
- trim: true,
- separator: '_',
- allowedChars: 'a-zA-Z0-9',
- });
-
- if (formattedString === '') {
- throw new Error('Invalid label');
- }
-
- return camelCase(formattedString);
-};
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/agent.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/agent.service.ts
index 1921b1c4e7..24d06c6b47 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/agent.service.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/agent.service.ts
@@ -3,12 +3,12 @@ import { InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { In, Repository } from 'typeorm';
+import { computeMetadataNameFromLabel } from 'twenty-shared/metadata';
import { AiAgentRoleService } from 'src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.service';
import { type CreateAgentInput } from 'src/engine/metadata-modules/ai/ai-agent/dtos/create-agent.input';
import { type UpdateAgentInput } from 'src/engine/metadata-modules/ai/ai-agent/dtos/update-agent.input';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
-import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/compute-metadata-name-from-label.util';
import { AgentException, AgentExceptionCode } from './agent.exception';
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-morph-relation.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-morph-relation.service.ts
index e4a49683cc..34e2a585ed 100644
--- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-morph-relation.service.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-morph-relation.service.ts
@@ -5,6 +5,7 @@ import { FieldMetadataType } from 'twenty-shared/types';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
import { type Repository } from 'typeorm';
import { v4 } from 'uuid';
+import { computeMetadataNameFromLabel } from 'twenty-shared/metadata';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
@@ -20,7 +21,6 @@ import { prepareCustomFieldMetadataForCreation } from 'src/engine/metadata-modul
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
-import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
@Injectable()
export class FieldMetadataMorphRelationService {
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service.ts
index 3520b76bc2..eb7a9d196e 100644
--- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service.ts
@@ -10,6 +10,7 @@ import {
import { isDefined } from 'twenty-shared/utils';
import { type Repository } from 'typeorm';
import { v4 } from 'uuid';
+import { computeMetadataNameFromLabel } from 'twenty-shared/metadata';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
@@ -28,7 +29,6 @@ import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-m
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { validateFieldNameAvailabilityOrThrow } from 'src/engine/metadata-modules/utils/validate-field-name-availability.utils';
import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-or-throw.utils';
-import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
export class RelationCreationPayloadValidation {
diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/generate-morph-or-relation-flat-field-metadata-pair.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/generate-morph-or-relation-flat-field-metadata-pair.util.ts
index f32e91d90d..7b9b085811 100644
--- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/generate-morph-or-relation-flat-field-metadata-pair.util.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/generate-morph-or-relation-flat-field-metadata-pair.util.ts
@@ -4,6 +4,7 @@ import {
RelationType,
} from 'twenty-shared/types';
import { v4 } from 'uuid';
+import { computeMetadataNameFromLabel } from 'twenty-shared/metadata';
import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { type MorphOrRelationFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/morph-or-relation-field-metadata-type.type';
@@ -13,7 +14,6 @@ import { generateIndexForFlatFieldMetadata } from 'src/engine/metadata-modules/f
import { getDefaultFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util';
import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
-import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
type ComputeFieldMetadataRelationSettingsForRelationTypeArgs = {
relationType: RelationType;
diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-name-synced-with-label.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-name-synced-with-label.util.ts
index 5483c69015..884006091d 100644
--- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-name-synced-with-label.util.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-name-synced-with-label.util.ts
@@ -1,5 +1,6 @@
+import { computeMetadataNameFromLabel } from 'twenty-shared/metadata';
+
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
-import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
export const isFlatFieldMetadataNameSyncedWithLabel = (
flatFieldMetadata: Pick<
diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/are-flat-object-metadata-names-synced-with-labels.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/are-flat-object-metadata-names-synced-with-labels.util.ts
index 7252bd011a..fd62de4f88 100644
--- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/are-flat-object-metadata-names-synced-with-labels.util.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/are-flat-object-metadata-names-synced-with-labels.util.ts
@@ -1,5 +1,6 @@
+import { computeMetadataNameFromLabel } from 'twenty-shared/metadata';
+
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
-import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
export const areFlatObjectMetadataNamesSyncedWithLabels = (
flatObjectdMetadata: Pick<
diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/__tests__/compute-metadata-name-from-label.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/utils/__tests__/compute-metadata-name-from-label.util.spec.ts
index 6c0d5bc8a8..b932571c8d 100644
--- a/packages/twenty-server/src/engine/metadata-modules/utils/__tests__/compute-metadata-name-from-label.util.spec.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/utils/__tests__/compute-metadata-name-from-label.util.spec.ts
@@ -1,6 +1,6 @@
import { type EachTestingContext } from 'twenty-shared/testing';
-import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/compute-metadata-name-from-label.util';
+import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
import {
InvalidMetadataException,
InvalidMetadataExceptionCode,
@@ -65,6 +65,34 @@ describe('computeMetadataNameFromLabel', () => {
expected: 'mixedCase',
},
},
+ {
+ title: 'should add "Custom" suffix to reserved keywords',
+ context: {
+ input: 'Plan',
+ expected: 'planCustom',
+ },
+ },
+ {
+ title: 'should add "Custom" suffix to plural reserved keywords',
+ context: {
+ input: 'Events',
+ expected: 'eventsCustom',
+ },
+ },
+ {
+ title: 'should add "Custom" suffix to core object names',
+ context: {
+ input: 'User',
+ expected: 'userCustom',
+ },
+ },
+ {
+ title: 'should not modify non-reserved keywords',
+ context: {
+ input: 'Customer',
+ expected: 'customer',
+ },
+ },
];
const failingTestCases: ComputeMetadataNameFromLabelTestCase[] = [
diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/compute-metadata-name-from-label.util.ts b/packages/twenty-server/src/engine/metadata-modules/utils/compute-metadata-name-from-label.util.ts
deleted file mode 100644
index 27c695e2b0..0000000000
--- a/packages/twenty-server/src/engine/metadata-modules/utils/compute-metadata-name-from-label.util.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { msg } from '@lingui/core/macro';
-import camelCase from 'lodash.camelcase';
-import { slugify } from 'transliteration';
-import { isDefined } from 'twenty-shared/utils';
-
-import {
- InvalidMetadataException,
- InvalidMetadataExceptionCode,
-} from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
-
-export const computeMetadataNameFromLabel = (label: string): string => {
- if (!isDefined(label)) {
- throw new InvalidMetadataException(
- 'Label is required',
- InvalidMetadataExceptionCode.LABEL_REQUIRED,
- {
- userFriendlyMessage: msg`Label is required`,
- },
- );
- }
-
- const prefixedLabel = /^\d/.test(label) ? `n${label}` : label;
-
- if (prefixedLabel === '') {
- return '';
- }
-
- const formattedString = slugify(prefixedLabel, {
- trim: true,
- separator: '_',
- allowedChars: 'a-zA-Z0-9',
- });
-
- if (formattedString === '') {
- throw new InvalidMetadataException(
- `Invalid label: "${label}"`,
- InvalidMetadataExceptionCode.INVALID_LABEL,
- {
- userFriendlyMessage: msg`Invalid label: "${label}"`,
- },
- );
- }
-
- return camelCase(formattedString);
-};
diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/constants/metadata-name-flat-metadata-validators.constants.ts b/packages/twenty-server/src/engine/metadata-modules/utils/constants/metadata-name-flat-metadata-validators.constants.ts
index 0e020b591b..9351c4e426 100644
--- a/packages/twenty-server/src/engine/metadata-modules/utils/constants/metadata-name-flat-metadata-validators.constants.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/utils/constants/metadata-name-flat-metadata-validators.constants.ts
@@ -1,12 +1,12 @@
import { msg } from '@lingui/core/macro';
import camelCase from 'lodash.camelcase';
+import { RESERVED_METADATA_NAME_KEYWORDS } from 'twenty-shared/metadata';
import { type FlatMetadataValidator } from 'src/engine/metadata-modules/types/flat-metadata-validator.type';
import {
beneathDatabaseIdentifierMinimumLength,
exceedsDatabaseIdentifierMaximumLength,
} from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils';
-import { RESERVED_METADATA_NAME_KEYWORDS } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-reserved-keyword';
import { STARTS_WITH_LOWER_CASE_AND_CONTAINS_ONLY_CAPS_AND_LOWER_LETTERS_AND_NUMBER_STRING_REGEX } from 'src/engine/metadata-modules/utils/validate-metadata-name-start-with-lowercase-letter-and-contain-digits-nor-letters.utils';
export const METADATA_NAME_VALIDATORS: FlatMetadataValidator[] = [
@@ -30,7 +30,10 @@ export const METADATA_NAME_VALIDATORS: FlatMetadataValidator[] = [
),
},
{
- message: msg`The name is not available`,
+ // Safety net: Catch any reserved keywords that bypass frontend sanitization
+ // (e.g., programmatic API access, old clients)
+ // Frontend auto-adds "Custom" suffix, so properly formed requests will pass
+ message: msg`This name is reserved. Use a different name or the system will add "Custom" suffix.`,
validator: (name) => RESERVED_METADATA_NAME_KEYWORDS.includes(name),
},
];
diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-is-not-reserved-keyword.ts b/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-is-not-reserved-keyword.ts
index 4e2fda4ae8..c7e00df9b3 100644
--- a/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-is-not-reserved-keyword.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-is-not-reserved-keyword.ts
@@ -1,80 +1,11 @@
import { msg } from '@lingui/core/macro';
+import { RESERVED_METADATA_NAME_KEYWORDS } from 'twenty-shared/metadata';
import {
InvalidMetadataException,
InvalidMetadataExceptionCode,
} from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
-const coreObjectNames = [
- 'approvedAccessDomain',
- 'approvedAccessDomains',
- 'appToken',
- 'appTokens',
- 'billingCustomer',
- 'billingCustomers',
- 'billingEntitlement',
- 'billingEntitlements',
- 'billingMeter',
- 'billingMeters',
- 'billingProduct',
- 'billingProducts',
- 'billingSubscription',
- 'billingSubscriptions',
- 'billingSubscriptionItem',
- 'billingSubscriptionItems',
- 'featureFlag',
- 'featureFlags',
- 'job',
- 'jobs',
- 'keyValuePair',
- 'keyValuePairs',
- 'pageLayout',
- 'pageLayouts',
- 'pageLayoutTab',
- 'pageLayoutTabs',
- 'pageLayoutWidget',
- 'pageLayoutWidgets',
- 'postgresCredential',
- 'postgresCredentials',
- 'twoFactorMethod',
- 'twoFactorMethods',
- 'user',
- 'users',
- 'userWorkspace',
- 'userWorkspaces',
- 'workspace',
- 'workspaces',
- 'role',
- 'roles',
- 'userWorkspaceRole',
- 'userWorkspaceRoles',
-];
-
-export const RESERVED_METADATA_NAME_KEYWORDS = [
- ...coreObjectNames,
- 'plan',
- 'plans',
- 'event',
- 'events',
- 'field',
- 'fields',
- 'link',
- 'links',
- 'currency',
- 'currencies',
- 'fullNames',
- 'address',
- 'addresses',
- 'type',
- 'types',
- 'object',
- 'objects',
- 'index',
- 'relation',
- 'relations',
- 'aggregate',
-];
-
export const validateMetadataNameIsNotReservedKeywordOrThrow = (
name: string,
) => {
diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util.ts b/packages/twenty-server/src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util.ts
index 6877adb576..601ee48583 100644
--- a/packages/twenty-server/src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util.ts
@@ -1,5 +1,4 @@
-import camelCase from 'lodash.camelcase';
-import { slugify } from 'transliteration';
+import { computeMetadataNameFromLabel as computeMetadataNameFromLabelCore } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import {
@@ -7,6 +6,29 @@ import {
InvalidMetadataExceptionCode,
} from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
+// Server-specific wrapper that converts generic errors to InvalidMetadataException
+// This provides consistent error handling with proper exception codes for the server
+export const computeMetadataNameFromLabel = (label: string): string => {
+ if (!isDefined(label)) {
+ throw new InvalidMetadataException(
+ 'Label is required',
+ InvalidMetadataExceptionCode.LABEL_REQUIRED,
+ );
+ }
+
+ try {
+ return computeMetadataNameFromLabelCore(label);
+ } catch (error) {
+ if (error instanceof Error) {
+ throw new InvalidMetadataException(
+ error.message,
+ InvalidMetadataExceptionCode.INVALID_LABEL,
+ );
+ }
+ throw error;
+ }
+};
+
export const validateNameAndLabelAreSyncOrThrow = ({
label,
name,
@@ -23,33 +45,3 @@ export const validateNameAndLabelAreSyncOrThrow = ({
);
}
};
-
-export const computeMetadataNameFromLabel = (label: string): string => {
- if (!isDefined(label)) {
- throw new InvalidMetadataException(
- 'Label is required',
- InvalidMetadataExceptionCode.LABEL_REQUIRED,
- );
- }
-
- const prefixedLabel = /^\d/.test(label) ? `n${label}` : label;
-
- if (prefixedLabel === '') {
- return '';
- }
-
- const formattedString = slugify(prefixedLabel, {
- trim: true,
- separator: '_',
- allowedChars: 'a-zA-Z0-9',
- });
-
- if (formattedString === '') {
- throw new InvalidMetadataException(
- `Invalid label: "${label}"`,
- InvalidMetadataExceptionCode.INVALID_LABEL,
- );
- }
-
- return camelCase(formattedString);
-};
diff --git a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-field.decorator.ts b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-field.decorator.ts
index fa39d948e0..410832b64d 100644
--- a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-field.decorator.ts
+++ b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-field.decorator.ts
@@ -1,14 +1,14 @@
import { type MessageDescriptor } from '@lingui/core';
+import { computeMetadataNameFromLabel } from 'twenty-shared/metadata';
import {
- type FieldMetadataType,
- type FieldMetadataSettings,
type FieldMetadataOptions,
+ type FieldMetadataSettings,
+ type FieldMetadataType,
} from 'twenty-shared/types';
import { type FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { generateDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/generate-default-value';
-import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage';
import { TypedReflect } from 'src/utils/typed-reflect';
diff --git a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-relation.decorator.ts b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-relation.decorator.ts
index 977dc96a57..f58e20abe7 100644
--- a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-relation.decorator.ts
+++ b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-relation.decorator.ts
@@ -3,11 +3,11 @@ import { isDefined, isUUID } from 'class-validator';
import { CustomError } from 'twenty-shared/utils';
import { type ObjectType } from 'typeorm';
import { type RelationOnDeleteAction } from 'twenty-shared/types';
+import { computeMetadataNameFromLabel } from 'twenty-shared/metadata';
import { type RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
-import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage';
import { TypedReflect } from 'src/utils/typed-reflect';
diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-field-metadata-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-field-metadata-validator.service.ts
index 35abe0e89d..6e3d45f382 100644
--- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-field-metadata-validator.service.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-field-metadata-validator.service.ts
@@ -247,18 +247,6 @@ export class FlatFieldMetadataValidatorService {
});
}
- if (
- flatFieldMetadataToDelete.isActive &&
- !relationTargetObjectMetadataHasBeenDeleted &&
- !parentObjectMetadataHasBeenDeleted
- ) {
- validationResult.errors.push({
- code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
- message: "Active fields can't be deleted",
- userFriendlyMessage: msg`Active fields cannot be deleted`,
- });
- }
-
return validationResult;
}
diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-object-metadata-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-object-metadata-validator.service.ts
index 35353ba8aa..1a49d705e6 100644
--- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-object-metadata-validator.service.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-object-metadata-validator.service.ts
@@ -143,14 +143,6 @@ export class FlatObjectMetadataValidatorService {
userFriendlyMessage: msg`Standard objects cannot be deleted`,
});
}
-
- if (!buildOptions.isSystemBuild && flatObjectMetadataToDelete.isActive) {
- validationResult.errors.push({
- code: ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
- message: t`Active objects cannot be deleted`,
- userFriendlyMessage: msg`Active objects cannot be deleted`,
- });
- }
}
return validationResult;
diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-create-one-object-metadata-v2.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-create-one-object-metadata-v2.integration-spec.ts.snap
index 907cd5b4c0..e69f384979 100644
--- a/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-create-one-object-metadata-v2.integration-spec.ts.snap
+++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-create-one-object-metadata-v2.integration-spec.ts.snap
@@ -5314,7 +5314,7 @@ exports[`Object metadata creation should fail v2 when namePlural is a reserved k
"errors": [
{
"code": "INVALID_OBJECT_INPUT",
- "message": "The name is not available",
+ "message": "This name is reserved. Use a different name or the system will add \"Custom\" suffix.",
"value": "users",
},
],
@@ -8471,8 +8471,8 @@ exports[`Object metadata creation should fail v2 when nameSingular is a reserved
"errors": [
{
"code": "INVALID_FIELD_INPUT",
- "message": "The name is not available",
- "userFriendlyMessage": "The name is not available",
+ "message": "This name is reserved. Use a different name or the system will add \"Custom\" suffix.",
+ "userFriendlyMessage": "This name is reserved. Use a different name or the system will add \"Custom\" suffix.",
"value": "user",
},
{
@@ -8493,8 +8493,8 @@ exports[`Object metadata creation should fail v2 when nameSingular is a reserved
"errors": [
{
"code": "INVALID_FIELD_INPUT",
- "message": "The name is not available",
- "userFriendlyMessage": "The name is not available",
+ "message": "This name is reserved. Use a different name or the system will add \"Custom\" suffix.",
+ "userFriendlyMessage": "This name is reserved. Use a different name or the system will add \"Custom\" suffix.",
"value": "user",
},
{
@@ -8515,8 +8515,8 @@ exports[`Object metadata creation should fail v2 when nameSingular is a reserved
"errors": [
{
"code": "INVALID_FIELD_INPUT",
- "message": "The name is not available",
- "userFriendlyMessage": "The name is not available",
+ "message": "This name is reserved. Use a different name or the system will add \"Custom\" suffix.",
+ "userFriendlyMessage": "This name is reserved. Use a different name or the system will add \"Custom\" suffix.",
"value": "user",
},
{
@@ -8537,8 +8537,8 @@ exports[`Object metadata creation should fail v2 when nameSingular is a reserved
"errors": [
{
"code": "INVALID_FIELD_INPUT",
- "message": "The name is not available",
- "userFriendlyMessage": "The name is not available",
+ "message": "This name is reserved. Use a different name or the system will add \"Custom\" suffix.",
+ "userFriendlyMessage": "This name is reserved. Use a different name or the system will add \"Custom\" suffix.",
"value": "user",
},
{
@@ -8559,8 +8559,8 @@ exports[`Object metadata creation should fail v2 when nameSingular is a reserved
"errors": [
{
"code": "INVALID_FIELD_INPUT",
- "message": "The name is not available",
- "userFriendlyMessage": "The name is not available",
+ "message": "This name is reserved. Use a different name or the system will add \"Custom\" suffix.",
+ "userFriendlyMessage": "This name is reserved. Use a different name or the system will add \"Custom\" suffix.",
"value": "user",
},
{
@@ -8605,7 +8605,7 @@ exports[`Object metadata creation should fail v2 when nameSingular is a reserved
"errors": [
{
"code": "INVALID_OBJECT_INPUT",
- "message": "The name is not available",
+ "message": "This name is reserved. Use a different name or the system will add \"Custom\" suffix.",
"value": "user",
},
],
diff --git a/packages/twenty-shared/package.json b/packages/twenty-shared/package.json
index 76dc29112d..1a8685c28c 100644
--- a/packages/twenty-shared/package.json
+++ b/packages/twenty-shared/package.json
@@ -30,8 +30,10 @@
"class-validator": "^0.14.0",
"handlebars": "^4.7.8",
"libphonenumber-js": "^1.10.26",
+ "lodash.camelcase": "^4.3.0",
"qs": "^6.11.2",
"react-router-dom": "^6.4.4",
+ "transliteration": "^2.3.5",
"zod": "^4.1.11"
},
"exports": {
diff --git a/packages/twenty-shared/src/metadata/compute-metadata-name-from-label.util.ts b/packages/twenty-shared/src/metadata/compute-metadata-name-from-label.util.ts
new file mode 100644
index 0000000000..ba612410c2
--- /dev/null
+++ b/packages/twenty-shared/src/metadata/compute-metadata-name-from-label.util.ts
@@ -0,0 +1,26 @@
+import camelCase from 'lodash.camelcase';
+import { slugify } from 'transliteration';
+
+import { sanitizeReservedKeyword } from './sanitize-reserved-keyword.util';
+
+export const computeMetadataNameFromLabel = (label: string): string => {
+ if (!label) return '';
+
+ const prefixedLabel = /^\d/.test(label) ? `n${label}` : label;
+
+ if (prefixedLabel === '') return '';
+
+ const formattedString = slugify(prefixedLabel, {
+ trim: true,
+ separator: '_',
+ allowedChars: 'a-zA-Z0-9',
+ });
+
+ if (formattedString === '') {
+ throw new Error(`Invalid label: "${label}"`);
+ }
+
+ const computedName = camelCase(formattedString);
+
+ return sanitizeReservedKeyword(computedName);
+};
diff --git a/packages/twenty-shared/src/metadata/index.ts b/packages/twenty-shared/src/metadata/index.ts
index 99b41fe196..9310bde475 100644
--- a/packages/twenty-shared/src/metadata/index.ts
+++ b/packages/twenty-shared/src/metadata/index.ts
@@ -9,10 +9,16 @@
export { ALL_METADATA_NAME } from './all-metadata-name.constant';
export type { AllMetadataName } from './all-metadata-name.type';
+export { computeMetadataNameFromLabel } from './compute-metadata-name-from-label.util';
export type {
FailedMetadataValidationError,
FailedMetadataValidation,
MetadataValidationErrorResponse,
} from './MetadataValidationError';
export { WorkspaceMigrationV2ExceptionCode } from './MetadataValidationError';
+export {
+ CORE_OBJECT_NAMES,
+ RESERVED_METADATA_NAME_KEYWORDS,
+} from './reserved-metadata-name-keywords.constant';
+export { sanitizeReservedKeyword } from './sanitize-reserved-keyword.util';
export { STANDARD_OBJECT_IDS } from './standard-object-ids';
diff --git a/packages/twenty-shared/src/metadata/reserved-metadata-name-keywords.constant.ts b/packages/twenty-shared/src/metadata/reserved-metadata-name-keywords.constant.ts
new file mode 100644
index 0000000000..bebc340ebc
--- /dev/null
+++ b/packages/twenty-shared/src/metadata/reserved-metadata-name-keywords.constant.ts
@@ -0,0 +1,69 @@
+export const CORE_OBJECT_NAMES = [
+ 'approvedAccessDomain',
+ 'approvedAccessDomains',
+ 'appToken',
+ 'appTokens',
+ 'billingCustomer',
+ 'billingCustomers',
+ 'billingEntitlement',
+ 'billingEntitlements',
+ 'billingMeter',
+ 'billingMeters',
+ 'billingProduct',
+ 'billingProducts',
+ 'billingSubscription',
+ 'billingSubscriptions',
+ 'billingSubscriptionItem',
+ 'billingSubscriptionItems',
+ 'featureFlag',
+ 'featureFlags',
+ 'job',
+ 'jobs',
+ 'keyValuePair',
+ 'keyValuePairs',
+ 'pageLayout',
+ 'pageLayouts',
+ 'pageLayoutTab',
+ 'pageLayoutTabs',
+ 'pageLayoutWidget',
+ 'pageLayoutWidgets',
+ 'postgresCredential',
+ 'postgresCredentials',
+ 'twoFactorMethod',
+ 'twoFactorMethods',
+ 'user',
+ 'users',
+ 'userWorkspace',
+ 'userWorkspaces',
+ 'workspace',
+ 'workspaces',
+ 'role',
+ 'roles',
+ 'userWorkspaceRole',
+ 'userWorkspaceRoles',
+];
+
+export const RESERVED_METADATA_NAME_KEYWORDS = [
+ ...CORE_OBJECT_NAMES,
+ 'plan',
+ 'plans',
+ 'event',
+ 'events',
+ 'field',
+ 'fields',
+ 'link',
+ 'links',
+ 'currency',
+ 'currencies',
+ 'fullNames',
+ 'address',
+ 'addresses',
+ 'type',
+ 'types',
+ 'object',
+ 'objects',
+ 'index',
+ 'relation',
+ 'relations',
+ 'aggregate',
+];
diff --git a/packages/twenty-shared/src/metadata/sanitize-reserved-keyword.util.ts b/packages/twenty-shared/src/metadata/sanitize-reserved-keyword.util.ts
new file mode 100644
index 0000000000..60b10a0083
--- /dev/null
+++ b/packages/twenty-shared/src/metadata/sanitize-reserved-keyword.util.ts
@@ -0,0 +1,10 @@
+import { capitalize } from '../utils';
+import { RESERVED_METADATA_NAME_KEYWORDS } from './reserved-metadata-name-keywords.constant';
+
+export const sanitizeReservedKeyword = (name: string): string => {
+ if (!name) return name;
+
+ return RESERVED_METADATA_NAME_KEYWORDS.includes(name)
+ ? `${name}${capitalize('custom')}`
+ : name;
+};