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:
+9
-16
@@ -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,
|
||||
|
||||
-90
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+56
-97
@@ -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
|
||||
|
||||
+34
-4
@@ -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} />
|
||||
|
||||
+74
-8
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+68
-2
@@ -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)`,
|
||||
|
||||
+2
-1
@@ -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 },
|
||||
|
||||
+37
@@ -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
-1
@@ -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
-15
@@ -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');
|
||||
},
|
||||
};
|
||||
|
||||
+16
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+4
-2
@@ -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 '';
|
||||
}
|
||||
|
||||
-22
@@ -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';
|
||||
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+2
-1
@@ -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<
|
||||
|
||||
+2
-1
@@ -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<
|
||||
|
||||
+29
-1
@@ -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[] = [
|
||||
|
||||
-45
@@ -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);
|
||||
};
|
||||
+5
-2
@@ -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
-70
@@ -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,
|
||||
) => {
|
||||
|
||||
+24
-32
@@ -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);
|
||||
};
|
||||
|
||||
+3
-3
@@ -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';
|
||||
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
|
||||
-12
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
-8
@@ -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;
|
||||
|
||||
+12
-12
@@ -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",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user