fix: allow identical singular and plural labels for objects (#18678)
## Summary Closes #18673 Some languages (e.g., German "Unternehmen") and even English words (sheep, deer, aircraft, series) have identical singular and plural forms. Twenty previously blocked saving when labels matched, making it impossible to correctly name objects in these cases. - **Labels** are purely display strings — removed the equality validation from both the frontend Zod schema and backend validator - **API names** (nameSingular/namePlural) must stay different since they generate distinct GraphQL resolvers (`findOne` vs `findMany`, `createOne` vs `createMany`, etc.) and REST endpoints — this validation is preserved - Added a shared `computeMetadataNamesFromLabels` util in `twenty-shared` that auto-appends `'s'` to the plural API name when both labels produce the same camelCase name (e.g., "Unternehmen" → `unternehmen` / `unternehmens`) - Both the frontend form and backend sync-check use the same shared util — single source of truth, no duplicated logic **No retroactive impact**: since the old code prevented identical labels from ever being saved, no existing workspace has `labelSingular === labelPlural`. ## Test plan - [x] New unit tests for `computeMetadataNamesFromLabels` (7 tests: standard labels, Sheep, Unternehmen, Aircraft, empty labels, different labels, applyCustomSuffix) - [x] Updated frontend schema validation tests (identical labels with different names now passes; identical names still fails) - [x] Updated backend integration test cases (removed identical-label failing cases) - [ ] Manual: create a new object with identical singular/plural labels (e.g. "Sheep" / "Sheep") — should save successfully with API names `sheep` / `sheeps` - [ ] Manual: verify existing objects with different labels still work unchanged Made with [Cursor](https://cursor.com)
This commit is contained in:
-1
@@ -1 +0,0 @@
|
||||
export const DATABASE_IDENTIFIER_MAXIMUM_LENGTH = 63;
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
import { DATABASE_IDENTIFIER_MAXIMUM_LENGTH } from '@/settings/data-model/constants/DatabaseIdentifierMaximumLength';
|
||||
import { IDENTIFIER_MAX_CHAR_LENGTH } from 'twenty-shared/metadata';
|
||||
|
||||
export const FIELD_NAME_MAXIMUM_LENGTH = DATABASE_IDENTIFIER_MAXIMUM_LENGTH;
|
||||
export const FIELD_NAME_MAXIMUM_LENGTH = IDENTIFIER_MAX_CHAR_LENGTH;
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
import { DATABASE_IDENTIFIER_MAXIMUM_LENGTH } from '@/settings/data-model/constants/DatabaseIdentifierMaximumLength';
|
||||
import { IDENTIFIER_MAX_CHAR_LENGTH } from 'twenty-shared/metadata';
|
||||
|
||||
export const OBJECT_NAME_MAXIMUM_LENGTH = DATABASE_IDENTIFIER_MAXIMUM_LENGTH;
|
||||
export const OBJECT_NAME_MAXIMUM_LENGTH = IDENTIFIER_MAX_CHAR_LENGTH;
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
import { DATABASE_IDENTIFIER_MAXIMUM_LENGTH } from '@/settings/data-model/constants/DatabaseIdentifierMaximumLength';
|
||||
import { IDENTIFIER_MAX_CHAR_LENGTH } from 'twenty-shared/metadata';
|
||||
|
||||
export const OPTION_VALUE_MAXIMUM_LENGTH = DATABASE_IDENTIFIER_MAXIMUM_LENGTH;
|
||||
export const OPTION_VALUE_MAXIMUM_LENGTH = IDENTIFIER_MAX_CHAR_LENGTH;
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@ import { fieldMetadataItemSchema } from '@/object-metadata/validation-schemas/fi
|
||||
import { AdvancedSettingsContentWrapperWithDot } from '@/settings/components/AdvancedSettingsContentWrapperWithDot';
|
||||
import { AdvancedSettingsWrapper } from '@/settings/components/AdvancedSettingsWrapper';
|
||||
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
|
||||
import { DATABASE_IDENTIFIER_MAXIMUM_LENGTH } from '@/settings/data-model/constants/DatabaseIdentifierMaximumLength';
|
||||
import { IDENTIFIER_MAX_CHAR_LENGTH } from 'twenty-shared/metadata';
|
||||
import { getErrorMessageFromError } from '@/settings/data-model/fields/forms/utils/errorMessages';
|
||||
import { IconPicker } from '@/ui/input/components/IconPicker';
|
||||
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
|
||||
@@ -202,7 +202,7 @@ export const SettingsDataModelFieldIconLabelForm = ({
|
||||
readOnly={readonly}
|
||||
disabled={!isNameEditEnabled}
|
||||
fullWidth
|
||||
maxLength={DATABASE_IDENTIFIER_MAXIMUM_LENGTH}
|
||||
maxLength={IDENTIFIER_MAX_CHAR_LENGTH}
|
||||
RightIcon={() =>
|
||||
apiNameTooltipText && (
|
||||
<>
|
||||
|
||||
+13
-18
@@ -25,7 +25,7 @@ import { Card } from 'twenty-ui/layout';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { type StringKeyOf } from 'type-fest';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
import { computeMetadataNameFromLabel } from '~/pages/settings/data-model/utils/computeMetadataNameFromLabel';
|
||||
import { computeMetadataNamesFromLabels } from '~/pages/settings/data-model/utils/computeMetadataNamesFromLabels';
|
||||
|
||||
type SettingsDataModelObjectAboutFormProps = {
|
||||
disableEdition?: boolean;
|
||||
@@ -146,25 +146,24 @@ export const SettingsDataModelObjectAboutForm = ({
|
||||
shouldValidate: true,
|
||||
});
|
||||
if (isLabelSyncedWithName) {
|
||||
fillNamePluralFromLabelPlural(labelPluralFromSingularLabel);
|
||||
fillNamesFromLabels(labelSingular, labelPluralFromSingularLabel);
|
||||
}
|
||||
};
|
||||
|
||||
const fillNameSingularFromLabelSingular = (
|
||||
labelSingular: string | undefined,
|
||||
const fillNamesFromLabels = (
|
||||
currentLabelSingular: string,
|
||||
currentLabelPlural: string,
|
||||
) => {
|
||||
if (!isDefined(labelSingular)) return;
|
||||
const { nameSingular, namePlural } = computeMetadataNamesFromLabels(
|
||||
currentLabelSingular,
|
||||
currentLabelPlural,
|
||||
);
|
||||
|
||||
setValue('nameSingular', computeMetadataNameFromLabel(labelSingular), {
|
||||
setValue('nameSingular', nameSingular, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
};
|
||||
|
||||
const fillNamePluralFromLabelPlural = (labelPlural: string | undefined) => {
|
||||
if (!isDefined(labelPlural)) return;
|
||||
|
||||
setValue('namePlural', computeMetadataNameFromLabel(labelPlural), {
|
||||
setValue('namePlural', namePlural, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
@@ -215,9 +214,6 @@ export const SettingsDataModelObjectAboutForm = ({
|
||||
onChange={(value) => {
|
||||
onChange(capitalize(value));
|
||||
fillLabelPlural(capitalize(value));
|
||||
if (isLabelSyncedWithName === true) {
|
||||
fillNameSingularFromLabelSingular(value);
|
||||
}
|
||||
}}
|
||||
onBlur={() => onNewDirtyField?.()}
|
||||
disabled={disableEdition}
|
||||
@@ -243,7 +239,7 @@ export const SettingsDataModelObjectAboutForm = ({
|
||||
onChange={(value) => {
|
||||
onChange(capitalize(value));
|
||||
if (isLabelSyncedWithName === true) {
|
||||
fillNamePluralFromLabelPlural(value);
|
||||
fillNamesFromLabels(labelSingular, capitalize(value));
|
||||
}
|
||||
}}
|
||||
onBlur={() => onNewDirtyField?.()}
|
||||
@@ -411,8 +407,7 @@ export const SettingsDataModelObjectAboutForm = ({
|
||||
value === true &&
|
||||
(isCustomObject || isbeingCreatedObject)
|
||||
) {
|
||||
fillNamePluralFromLabelPlural(labelPlural);
|
||||
fillNameSingularFromLabelSingular(labelSingular);
|
||||
fillNamesFromLabels(labelSingular, labelPlural);
|
||||
}
|
||||
onNewDirtyField?.();
|
||||
}}
|
||||
|
||||
+5
-19
@@ -34,20 +34,6 @@ exports[`settingsDataModelObjectAboutFormSchema fails when labels are empty stri
|
||||
"labelPlural"
|
||||
],
|
||||
"message": "Too small: expected string to have >=1 characters"
|
||||
},
|
||||
{
|
||||
"code": "custom",
|
||||
"message": "Singular and plural labels must be different",
|
||||
"path": [
|
||||
"labelPlural"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "custom",
|
||||
"message": "Singular and plural labels must be different",
|
||||
"path": [
|
||||
"labelSingular"
|
||||
]
|
||||
}
|
||||
]]
|
||||
`;
|
||||
@@ -92,20 +78,20 @@ exports[`settingsDataModelObjectAboutFormSchema fails when required fields are m
|
||||
]]
|
||||
`;
|
||||
|
||||
exports[`settingsDataModelObjectAboutFormSchema fails when singular and plural labels are the same 1`] = `
|
||||
exports[`settingsDataModelObjectAboutFormSchema fails when singular and plural labels are the same and names are the same 1`] = `
|
||||
[ZodError: [
|
||||
{
|
||||
"code": "custom",
|
||||
"message": "Singular and plural labels must be different",
|
||||
"message": "Singular and plural names must be different",
|
||||
"path": [
|
||||
"labelPlural"
|
||||
"nameSingular"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "custom",
|
||||
"message": "Singular and plural labels must be different",
|
||||
"message": "Singular and plural names must be different",
|
||||
"path": [
|
||||
"labelSingular"
|
||||
"namePlural"
|
||||
]
|
||||
}
|
||||
]]
|
||||
|
||||
+17
-1
@@ -50,6 +50,19 @@ describe('settingsDataModelObjectAboutFormSchema', () => {
|
||||
expectedSuccess: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'validates input with identical labels but different names',
|
||||
context: {
|
||||
input: {
|
||||
...validInput,
|
||||
labelSingular: 'Sheep',
|
||||
labelPlural: 'Sheep',
|
||||
nameSingular: 'sheep',
|
||||
namePlural: 'sheeps',
|
||||
},
|
||||
expectedSuccess: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const failsValidationTestsUseCase: EachTestingContext<{
|
||||
@@ -90,12 +103,15 @@ describe('settingsDataModelObjectAboutFormSchema', () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'fails when singular and plural labels are the same',
|
||||
title:
|
||||
'fails when singular and plural labels are the same and names are the same',
|
||||
context: {
|
||||
input: {
|
||||
...validInput,
|
||||
labelPlural: 'Same Label',
|
||||
labelSingular: 'Same Label',
|
||||
namePlural: 'sameName',
|
||||
nameSingular: 'sameName',
|
||||
},
|
||||
expectedSuccess: false,
|
||||
},
|
||||
|
||||
+1
-17
@@ -30,23 +30,7 @@ const settingsDataModelFormFieldsSchema = z.object({
|
||||
|
||||
export const settingsDataModelObjectAboutFormSchema =
|
||||
settingsDataModelFormFieldsSchema.superRefine(
|
||||
({ labelPlural, labelSingular, namePlural, nameSingular }, ctx) => {
|
||||
const labelsAreDifferent =
|
||||
labelPlural.trim().toLowerCase() !== labelSingular.trim().toLowerCase();
|
||||
if (!labelsAreDifferent) {
|
||||
const labelFields: ReadonlyKeysArray<ObjectMetadataItem> = [
|
||||
'labelPlural',
|
||||
'labelSingular',
|
||||
];
|
||||
labelFields.forEach((field) =>
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: t`Singular and plural labels must be different`,
|
||||
path: [field],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
({ namePlural, nameSingular }, ctx) => {
|
||||
const nameAreDifferent =
|
||||
nameSingular.toLowerCase() !== namePlural.toLowerCase();
|
||||
if (!nameAreDifferent) {
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { computeMetadataNamesFromLabelsOrThrow } from 'twenty-shared/metadata';
|
||||
|
||||
export const computeMetadataNamesFromLabels = (
|
||||
labelSingular: string,
|
||||
labelPlural: string,
|
||||
): { nameSingular: string; namePlural: string } => {
|
||||
try {
|
||||
return computeMetadataNamesFromLabelsOrThrow({
|
||||
labelSingular,
|
||||
labelPlural,
|
||||
});
|
||||
} catch {
|
||||
return { nameSingular: '', namePlural: '' };
|
||||
}
|
||||
};
|
||||
+1
-1
@@ -17,7 +17,7 @@ import {
|
||||
import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
|
||||
import { type FlatFieldMetadataTypeValidationArgs } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-type-validator.type';
|
||||
import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type';
|
||||
import { IDENTIFIER_MAX_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-max-char-length.constants';
|
||||
import { IDENTIFIER_MAX_CHAR_LENGTH } from 'twenty-shared/metadata';
|
||||
import { IDENTIFIER_MIN_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-min-char-length.constants';
|
||||
import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type';
|
||||
import { isSnakeCaseString } from 'src/utils/is-snake-case-string';
|
||||
|
||||
+4
-2
@@ -1,9 +1,11 @@
|
||||
import { msg, t } from '@lingui/core/macro';
|
||||
import { RESERVED_METADATA_NAME_KEYWORDS } from 'twenty-shared/metadata';
|
||||
import {
|
||||
IDENTIFIER_MAX_CHAR_LENGTH,
|
||||
RESERVED_METADATA_NAME_KEYWORDS,
|
||||
} from 'twenty-shared/metadata';
|
||||
|
||||
import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
|
||||
import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type';
|
||||
import { IDENTIFIER_MAX_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-max-char-length.constants';
|
||||
import { IDENTIFIER_MIN_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-min-char-length.constants';
|
||||
import { isCallerTwentyStandardApp } from 'src/engine/metadata-modules/utils/is-caller-twenty-standard-app.util';
|
||||
import { type WorkspaceMigrationBuilderOptions } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-builder-options.type';
|
||||
|
||||
+6
-9
@@ -1,4 +1,4 @@
|
||||
import { computeMetadataNameFromLabel } from 'twenty-shared/metadata';
|
||||
import { computeMetadataNamesFromLabelsOrThrow } from 'twenty-shared/metadata';
|
||||
|
||||
import { isCallerTwentyStandardApp } from 'src/engine/metadata-modules/utils/is-caller-twenty-standard-app.util';
|
||||
import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type';
|
||||
@@ -14,15 +14,12 @@ export const areFlatObjectMetadataNamesSyncedWithLabels = ({
|
||||
'namePlural' | 'nameSingular' | 'labelPlural' | 'labelSingular'
|
||||
>;
|
||||
}) => {
|
||||
const [computedSingularName, computedPluralName] = [
|
||||
flatObjectMetadata.labelSingular,
|
||||
flatObjectMetadata.labelPlural,
|
||||
].map((label) =>
|
||||
computeMetadataNameFromLabel({
|
||||
label,
|
||||
const { nameSingular: computedSingularName, namePlural: computedPluralName } =
|
||||
computeMetadataNamesFromLabelsOrThrow({
|
||||
labelSingular: flatObjectMetadata.labelSingular,
|
||||
labelPlural: flatObjectMetadata.labelPlural,
|
||||
applyCustomSuffix: !isCallerTwentyStandardApp(buildOptions),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
flatObjectMetadata.nameSingular === computedSingularName &&
|
||||
|
||||
+1
-14
@@ -3,7 +3,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import { type FlatObjectMetadataValidationError } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata-validation-error.type';
|
||||
import { type ObjectMetadataMinimalInformation } from 'src/engine/metadata-modules/flat-object-metadata/types/object-metadata-minimal-information.type';
|
||||
import { ObjectMetadataExceptionCode } from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
|
||||
import { IDENTIFIER_MAX_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-max-char-length.constants';
|
||||
import { IDENTIFIER_MAX_CHAR_LENGTH } from 'twenty-shared/metadata';
|
||||
import { IDENTIFIER_MIN_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-min-char-length.constants';
|
||||
import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type';
|
||||
|
||||
@@ -37,18 +37,5 @@ export const validateFlatObjectMetadataLabel = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Check if labels are identical
|
||||
const labelsAreIdentical =
|
||||
labelSingular.trim().toLowerCase() === labelPlural.trim().toLowerCase();
|
||||
|
||||
if (labelsAreIdentical) {
|
||||
errors.push({
|
||||
code: ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
|
||||
message: `The singular and plural labels cannot be the same for an object`,
|
||||
userFriendlyMessage: msg`The singular and plural labels cannot be the same for an object`,
|
||||
value: labelSingular,
|
||||
});
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
+4
-2
@@ -1,10 +1,12 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { RESERVED_METADATA_NAME_KEYWORDS } from 'twenty-shared/metadata';
|
||||
import {
|
||||
IDENTIFIER_MAX_CHAR_LENGTH,
|
||||
RESERVED_METADATA_NAME_KEYWORDS,
|
||||
} from 'twenty-shared/metadata';
|
||||
|
||||
import { type FlatObjectMetadataValidationError } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata-validation-error.type';
|
||||
import { type ObjectMetadataMinimalInformation } from 'src/engine/metadata-modules/flat-object-metadata/types/object-metadata-minimal-information.type';
|
||||
import { ObjectMetadataExceptionCode } from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
|
||||
import { IDENTIFIER_MAX_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-max-char-length.constants';
|
||||
import { IDENTIFIER_MIN_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-min-char-length.constants';
|
||||
import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type';
|
||||
|
||||
|
||||
-1
@@ -1 +0,0 @@
|
||||
export const IDENTIFIER_MAX_CHAR_LENGTH = 63;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { IDENTIFIER_MAX_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-max-char-length.constants';
|
||||
import { IDENTIFIER_MAX_CHAR_LENGTH } from 'twenty-shared/metadata';
|
||||
import { IDENTIFIER_MIN_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-min-char-length.constants';
|
||||
|
||||
export const exceedsDatabaseIdentifierMaximumLength = (string: string) =>
|
||||
|
||||
-1464
File diff suppressed because it is too large
Load Diff
-14
@@ -29,18 +29,4 @@ export const OBJECT_METADATA_LABEL_FAILING_TEST_CASES: EachTestingContext<
|
||||
title: 'when labelPlural contains only whitespace',
|
||||
context: { labelPlural: ' ' },
|
||||
},
|
||||
{
|
||||
title: 'when labels are identical',
|
||||
context: {
|
||||
labelPlural: 'fooBar',
|
||||
labelSingular: 'fooBar',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'when labels with whitespaces result to be identical',
|
||||
context: {
|
||||
labelPlural: ' fooBar ',
|
||||
labelSingular: 'fooBar',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import { computeMetadataNamesFromLabelsOrThrow } from '@/metadata/utils/compute-metadata-names-from-labels-or-throw.util';
|
||||
|
||||
describe('computeMetadataNamesFromLabelsOrThrow', () => {
|
||||
it('should compute different names from different labels', () => {
|
||||
expect(
|
||||
computeMetadataNamesFromLabelsOrThrow({
|
||||
labelSingular: 'Company',
|
||||
labelPlural: 'Companies',
|
||||
}),
|
||||
).toEqual({
|
||||
nameSingular: 'company',
|
||||
namePlural: 'companies',
|
||||
});
|
||||
});
|
||||
|
||||
it('should append "s" to plural name when labels produce identical names', () => {
|
||||
expect(
|
||||
computeMetadataNamesFromLabelsOrThrow({
|
||||
labelSingular: 'Sheep',
|
||||
labelPlural: 'Sheep',
|
||||
}),
|
||||
).toEqual({
|
||||
nameSingular: 'sheep',
|
||||
namePlural: 'sheeps',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle German words with identical singular and plural', () => {
|
||||
expect(
|
||||
computeMetadataNamesFromLabelsOrThrow({
|
||||
labelSingular: 'Unternehmen',
|
||||
labelPlural: 'Unternehmen',
|
||||
}),
|
||||
).toEqual({
|
||||
nameSingular: 'unternehmen',
|
||||
namePlural: 'unternehmens',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not append "s" when labels produce different names', () => {
|
||||
expect(
|
||||
computeMetadataNamesFromLabelsOrThrow({
|
||||
labelSingular: 'Person',
|
||||
labelPlural: 'People',
|
||||
}),
|
||||
).toEqual({
|
||||
nameSingular: 'person',
|
||||
namePlural: 'people',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty labels gracefully', () => {
|
||||
expect(
|
||||
computeMetadataNamesFromLabelsOrThrow({
|
||||
labelSingular: '',
|
||||
labelPlural: '',
|
||||
}),
|
||||
).toEqual({
|
||||
nameSingular: '',
|
||||
namePlural: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply custom suffix for reserved words by default', () => {
|
||||
const result = computeMetadataNamesFromLabelsOrThrow({
|
||||
labelSingular: 'Job',
|
||||
labelPlural: 'Jobs',
|
||||
});
|
||||
|
||||
expect(result.nameSingular).toBe('jobCustom');
|
||||
expect(result.namePlural).toBe('jobsCustom');
|
||||
});
|
||||
|
||||
it('should skip custom suffix when applyCustomSuffix is false', () => {
|
||||
const result = computeMetadataNamesFromLabelsOrThrow({
|
||||
labelSingular: 'Job',
|
||||
labelPlural: 'Jobs',
|
||||
applyCustomSuffix: false,
|
||||
});
|
||||
|
||||
expect(result.nameSingular).toBe('job');
|
||||
expect(result.namePlural).toBe('jobs');
|
||||
});
|
||||
|
||||
it('should handle case-insensitive collision after camelCase conversion', () => {
|
||||
expect(
|
||||
computeMetadataNamesFromLabelsOrThrow({
|
||||
labelSingular: 'Aircraft',
|
||||
labelPlural: 'Aircraft',
|
||||
}),
|
||||
).toEqual({
|
||||
nameSingular: 'aircraft',
|
||||
namePlural: 'aircrafts',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
// PostgreSQL max identifier length (NAMEDATALEN - 1)
|
||||
export const IDENTIFIER_MAX_CHAR_LENGTH = 63;
|
||||
@@ -14,6 +14,7 @@ export {
|
||||
} from './check-if-field-is-label-identifier.util';
|
||||
export { ALL_METADATA_NAME } from './constants/all-metadata-name.constant';
|
||||
export { DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS } from './constants/default-relations-object-standard-ids.constant';
|
||||
export { IDENTIFIER_MAX_CHAR_LENGTH } from './constants/identifier-max-char-length.constant';
|
||||
export { RESERVED_METADATA_NAME_KEYWORDS } from './constants/reserved-metadata-name-keywords.constant';
|
||||
export { STANDARD_OBJECTS } from './constants/standard-object.constant';
|
||||
export type { AllMetadataName } from './types/all-metadata-name.type';
|
||||
@@ -25,3 +26,4 @@ export type {
|
||||
export { WorkspaceMigrationV2ExceptionCode } from './types/MetadataValidationError';
|
||||
export { addCustomSuffixIfIsReserved } from './utils/add-custom-suffix-if-reserved.util';
|
||||
export { computeMetadataNameFromLabel } from './utils/compute-metadata-name-from-label.util';
|
||||
export { computeMetadataNamesFromLabelsOrThrow } from './utils/compute-metadata-names-from-labels-or-throw.util';
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { IDENTIFIER_MAX_CHAR_LENGTH } from '@/metadata/constants/identifier-max-char-length.constant';
|
||||
import { computeMetadataNameFromLabel } from '@/metadata/utils/compute-metadata-name-from-label.util';
|
||||
|
||||
export const computeMetadataNamesFromLabelsOrThrow = ({
|
||||
labelSingular,
|
||||
labelPlural,
|
||||
applyCustomSuffix = true,
|
||||
}: {
|
||||
labelSingular: string;
|
||||
labelPlural: string;
|
||||
applyCustomSuffix?: boolean;
|
||||
}): { nameSingular: string; namePlural: string } => {
|
||||
const nameSingular = computeMetadataNameFromLabel({
|
||||
label: labelSingular,
|
||||
applyCustomSuffix,
|
||||
});
|
||||
|
||||
let namePlural = computeMetadataNameFromLabel({
|
||||
label: labelPlural,
|
||||
applyCustomSuffix,
|
||||
});
|
||||
|
||||
if (namePlural !== '' && namePlural === nameSingular) {
|
||||
namePlural = (namePlural + 's').slice(0, IDENTIFIER_MAX_CHAR_LENGTH);
|
||||
}
|
||||
|
||||
return { nameSingular, namePlural };
|
||||
};
|
||||
Reference in New Issue
Block a user