Improve deactivated objects & fields behaviors. (#16090)

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.


<!-- CURSOR_SUMMARY -->
---

> [!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.
> 
> <sup>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).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Abdullah.
2025-12-01 20:04:36 +05:00
committed by GitHub
parent 1fcb8b464c
commit ee08060798
39 changed files with 644 additions and 481 deletions
@@ -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,
@@ -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 (
<Dropdown
dropdownId={dropdownId}
clickableComponent={
<LightIconButton
aria-label="Active Field Options"
Icon={IconDotsVertical}
accent="tertiary"
/>
}
dropdownComponents={
<DropdownContent widthInPixels={GenericDropdownContentWidth.Narrow}>
<DropdownMenuItemsContainer>
<MenuItem
text={isCustomField && !readonly ? 'Edit' : 'View'}
LeftIcon={isCustomField ? IconPencil : IconEye}
onClick={handleEdit}
/>
{isDefined(onSetAsLabelIdentifier) && !readonly && (
<MenuItem
text="Set as record text"
LeftIcon={IconTextSize}
onClick={handleSetAsLabelIdentifier}
/>
)}
{isDefined(onDeactivate) && !readonly && (
<MenuItem
text="Deactivate"
LeftIcon={IconArchive}
onClick={handleDeactivate}
/>
)}
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
);
};
@@ -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}
/>
)}
<StyledNameLabel title={fieldMetadataItem.label}>
{fieldMetadataItem.label}
</StyledNameLabel>
<StyledNameContainer>
<StyledNameLabel title={fieldMetadataItem.label}>
{fieldMetadataItem.label}
</StyledNameLabel>
{!fieldMetadataItem.isActive && (
<StyledInactiveLabel>{t`Deactivated`}</StyledInactiveLabel>
)}
</StyledNameContainer>
</StyledNameTableCell>
</UndecoratedLink>
@@ -281,27 +250,17 @@ export const SettingsObjectFieldItemTableRow = ({
<StyledIconTableCell>
{status === 'active' ? (
mode === 'view' ? (
<SettingsObjectFieldActiveActionDropdown
isCustomField={fieldMetadataItem.isCustom === true}
readonly={readonly}
fieldMetadataItemId={fieldMetadataItem.id}
onEdit={() =>
navigate(SettingsPath.ObjectFieldEdit, {
objectNamePlural: objectMetadataItem.namePlural,
fieldName: fieldMetadataItem.name,
})
}
onSetAsLabelIdentifier={
canBeSetAsLabelIdentifier
? () => handleSetLabelIdentifierField(fieldMetadataItem)
: undefined
}
onDeactivate={
isLabelIdentifier
? undefined
: () => handleDisableField(fieldMetadataItem)
}
/>
<UndecoratedLink
to={getSettingsPath(SettingsPath.ObjectFieldEdit, {
objectNamePlural: objectMetadataItem.namePlural,
fieldName: fieldMetadataItem.name,
})}
>
<StyledIconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
</UndecoratedLink>
) : (
canToggleField && (
<LightIconButton
@@ -1,12 +1,13 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { type ReactNode } from 'react';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { SettingsItemTypeTag } from '@/settings/components/SettingsItemTypeTag';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useIcons } from 'twenty-ui/display';
import { SettingsItemTypeTag } from '@/settings/components/SettingsItemTypeTag';
export type SettingsObjectMetadataItemTableRowProps = {
action: ReactNode;
@@ -24,12 +25,35 @@ 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 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}
/>
)}
<StyledNameLabel title={objectMetadataItem.labelPlural}>
{objectMetadataItem.labelPlural}
</StyledNameLabel>
<StyledNameContainer>
<StyledNameLabel title={objectMetadataItem.labelPlural}>
{objectMetadataItem.labelPlural}
</StyledNameLabel>
{!objectMetadataItem.isActive && (
<StyledInactiveLabel>{t`Deactivated`}</StyledInactiveLabel>
)}
</StyledNameContainer>
</StyledNameTableCell>
<TableCell>
<SettingsItemTypeTag item={objectMetadataItem} />
@@ -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 (
<StyledContentContainer>
<StyledFormSection>
@@ -79,15 +122,38 @@ export const ObjectSettings = ({ objectMetadataItem }: ObjectSettingsProps) => {
title={t`Danger zone`}
description={t`Deactivate object`}
/>
<Button
Icon={IconArchive}
title={t`Deactivate`}
size="small"
onClick={handleDisable}
/>
<StyledDangerButtonsContainer>
<Button
Icon={IconArchive}
title={t`Deactivate`}
size="small"
onClick={handleDisable}
/>
{objectMetadataItem.isCustom && (
<Button
Icon={IconTrash}
title={t`Delete`}
size="small"
accent="danger"
variant="secondary"
onClick={handleDelete}
/>
)}
</StyledDangerButtonsContainer>
</Section>
</StyledFormSection>
)}
<ConfirmationModal
modalId={DELETE_OBJECT_MODAL_ID}
title={t`Delete ${objectLabel} object?`}
subtitle={t`This will permanently delete the object and all its records. Type "yes" to confirm.`}
confirmButtonText={t`Delete`}
onConfirmClick={confirmDelete}
onClose={() => closeModal(DELETE_OBJECT_MODAL_ID)}
confirmationValue="yes"
confirmationPlaceholder="yes"
loading={isDeleting}
/>
</StyledContentContainer>
);
};
@@ -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<SettingsDataModelObjectAboutFormValues>();
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 = ({
<StyledAdvancedSettingsOuterContainer>
<StyledAdvancedSettingsContainer>
<StyledAdvancedSettingsSectionInputWrapper>
{isDefined(conflictingObjectMetadataItem) && (
<StyledConflictBanner>
<StyledBannerContent>
<IconInfoCircle
color={theme.color.blue}
size={theme.icon.size.md}
/>
<StyledBannerText>
{t`An object with this name already exists`}
</StyledBannerText>
</StyledBannerContent>
<StyledConflictButton
size="small"
variant="secondary"
accent="blue"
title={t`Open`}
onClick={() =>
navigateSettings(SettingsPath.ObjectDetail, {
objectNamePlural:
conflictingObjectMetadataItem.namePlural,
})
}
/>
</StyledConflictBanner>
)}
{[
{
label: t`API Name (Singular)`,
@@ -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<typeof SettingsDataModelObjectAboutForm> = {
FormProviderDecorator,
IconsProviderDecorator,
ComponentDecorator,
MemoryRouterDecorator,
],
parameters: {
container: { width: 520 },
@@ -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;
});
};
@@ -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<typeof SettingsApiKeysFieldItemTableRow> = {
title: 'Modules/Settings/Developers/ApiKeys/SettingsApiKeysFieldItemTableRow',
component: SettingsApiKeysFieldItemTableRow,
decorators: [ComponentDecorator, RouterDecorator],
decorators: [ComponentDecorator, RouterDecorator, I18nFrontDecorator],
args: {
apiKey: {
id: '3f4a42e8-b81f-4f8c-9c20-1602e6b34791',
@@ -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<SettingsDataModelObjectAboutFormValues>({
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 = () => {
/>
<SettingsDataModelObjectAboutForm
onNewDirtyField={() => formConfig.trigger()}
conflictingObjectMetadataItem={
!isLoading ? conflictingObjectMetadataItem : undefined
}
/>
</Section>
</SettingsPageContainer>
@@ -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 <ObjectFields objectMetadataItem={objectMetadataItem} />;
case SETTINGS_OBJECT_DETAIL_TABS.TABS_IDS.SETTINGS:
return <ObjectSettings objectMetadataItem={objectMetadataItem} />;
return (
<ObjectSettings
objectMetadataItem={objectMetadataItem}
isDeleting={isDeleting}
setIsDeleting={setIsDeleting}
/>
);
case SETTINGS_OBJECT_DETAIL_TABS.TABS_IDS.INDEXES:
return <ObjectIndexes objectMetadataItem={objectMetadataItem} />;
default:
@@ -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<string | null>(
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`}
/>
<Button
Icon={
fieldMetadataItem.isActive ? IconArchive : IconArchiveOff
}
variant="secondary"
title={
fieldMetadataItem.isActive ? t`Deactivate` : t`Activate`
}
size="small"
onClick={
fieldMetadataItem.isActive
? handleDeactivate
: handleActivate
}
/>
<StyledDangerButtons>
<Button
Icon={
fieldMetadataItem.isActive ? IconArchive : IconArchiveOff
}
variant="secondary"
title={
fieldMetadataItem.isActive ? t`Deactivate` : t`Activate`
}
size="small"
onClick={
fieldMetadataItem.isActive
? handleDeactivate
: handleActivate
}
/>
{fieldMetadataItem.isCustom && (
<Button
Icon={IconTrash}
variant="secondary"
accent="danger"
title={t`Delete`}
size="small"
onClick={handleDelete}
/>
)}
</StyledDangerButtons>
</Section>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
</FormProvider>
{fieldMetadataItem?.isCustom && (
<ConfirmationModal
modalId={DELETE_FIELD_MODAL_ID}
title={t`Delete ${fieldLabel} field?`}
subtitle={t`This will permanently delete the field and all its data from ${objectLabel}. Type "yes" to confirm.`}
confirmButtonText={t`Delete`}
confirmationValue="yes"
confirmationPlaceholder="yes"
onConfirmClick={confirmDelete}
onClose={() => closeModal(DELETE_FIELD_MODAL_ID)}
loading={isDeleting}
/>
)}
</>
);
};
@@ -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
@@ -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();
@@ -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');
},
};
@@ -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');
});
});
@@ -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 '';
}
@@ -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);
};
@@ -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';
@@ -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 {
@@ -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 {
@@ -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;
@@ -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<
@@ -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<
@@ -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[] = [
@@ -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);
};
@@ -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<string>[] = [
@@ -30,7 +30,10 @@ export const METADATA_NAME_VALIDATORS: FlatMetadataValidator<string>[] = [
),
},
{
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),
},
];
@@ -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,
) => {
@@ -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);
};
@@ -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';
@@ -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';
@@ -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;
}
@@ -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;
@@ -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",
},
],
+2
View File
@@ -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": {
@@ -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);
};
@@ -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';
@@ -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',
];
@@ -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;
};