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;
+};