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:
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user