Support custom object renaming (#7504)

This PR was created by [GitStart](https://gitstart.com/) to address the
requirements from this ticket:
[TWNTY-5491](https://clients.gitstart.com/twenty/5449/tickets/TWNTY-5491).
This ticket was imported from:
[TWNTY-5491](https://github.com/twentyhq/twenty/issues/5491)

 --- 

### Description

**How To Test:**\
1. Reset db using `npx nx database:reset twenty-server` on this PR

1. Run both backend and frontend
2. Navigate to `settings/data-model/objects/ `page
3. Select a `Custom `object from the list or create a new `Custom
`object
4. Navigate to custom object details page and click on edit button
5. Finally edit the object details.

**Issues and bugs**
The Typecheck is failing but we could not see this error locally
There is a bug after updating the label of a custom object. View title
is not updated till refreshing the page. We could not find a consistent
way to update this, should we reload the page after editing an object?


![](https://assets-service.gitstart.com/45430/03cd560f-a4f6-4ce2-9d78-6d3a9f56d197.png)###
Demo



<https://www.loom.com/share/64ecb57efad7498d99085cb11480b5dd?sid=28d0868c-e54f-454d-8432-3f789be9e2b7>

### Refs

#5491

---------

Co-authored-by: gitstart-twenty <gitstart-twenty@users.noreply.github.com>
Co-authored-by: gitstart-twenty <140154534+gitstart-twenty@users.noreply.github.com>
Co-authored-by: Marie Stoppa <marie.stoppa@essec.edu>
Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Weiko <corentin@twenty.com>
This commit is contained in:
gitstart-app[bot] 2024-10-24 11:52:30 +00:00 committed by GitHub
parent c6ef14acc4
commit 414f2ac498
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 900 additions and 192 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1160,6 +1160,7 @@ export type UpdateObjectPayload = {
labelSingular?: InputMaybe<Scalars['String']>;
namePlural?: InputMaybe<Scalars['String']>;
nameSingular?: InputMaybe<Scalars['String']>;
shouldSyncLabelAndName?: InputMaybe<Scalars['Boolean']>;
};
export type UpdateOneObjectInput = {
@ -1476,6 +1477,7 @@ export type Object = {
labelSingular: Scalars['String'];
namePlural: Scalars['String'];
nameSingular: Scalars['String'];
shouldSyncLabelAndName: Scalars['Boolean'];
updatedAt: Scalars['DateTime'];
};

View File

@ -35,9 +35,7 @@ export const EventRowDynamicComponent = ({
linkedObjectMetadataItem,
authorFullName,
}: EventRowDynamicComponentProps) => {
const [eventName] = event.name.split('.');
switch (eventName) {
switch (linkedObjectMetadataItem?.nameSingular) {
case 'calendarEvent':
return (
<EventRowCalendarEvent
@ -58,7 +56,7 @@ export const EventRowDynamicComponent = ({
authorFullName={authorFullName}
/>
);
case 'linked-task':
case 'task':
return (
<EventRowActivity
labelIdentifierValue={labelIdentifierValue}
@ -69,7 +67,7 @@ export const EventRowDynamicComponent = ({
objectNameSingular={CoreObjectNameSingular.Task}
/>
);
case 'linked-note':
case 'note':
return (
<EventRowActivity
labelIdentifierValue={labelIdentifierValue}
@ -80,7 +78,7 @@ export const EventRowDynamicComponent = ({
objectNameSingular={CoreObjectNameSingular.Note}
/>
);
case mainObjectMetadataItem?.nameSingular:
default:
return (
<EventRowMainObject
labelIdentifierValue={labelIdentifierValue}
@ -90,9 +88,5 @@ export const EventRowDynamicComponent = ({
authorFullName={authorFullName}
/>
);
default:
throw new Error(
`Cannot find event component for event name ${eventName}`,
);
}
};

View File

@ -124,6 +124,7 @@ describe('useCommandMenu', () => {
namePlural: 'tasks',
labelSingular: 'Task',
labelPlural: 'Tasks',
shouldSyncLabelAndName: true,
description: 'A task',
icon: 'IconCheckbox',
isCustom: false,

View File

@ -24,6 +24,7 @@ export const FIND_MANY_OBJECT_METADATA_ITEMS = gql`
updatedAt
labelIdentifierFieldMetadataId
imageIdentifierFieldMetadataId
shouldSyncLabelAndName
indexMetadatas(paging: { first: 100 }) {
edges {
node {

View File

@ -30,6 +30,7 @@ describe('objectMetadataItemSchema', () => {
namePlural: 'notCamelCase',
nameSingular: 'notCamelCase',
updatedAt: 'invalid date',
shouldSyncLabelAndName: 'not a boolean',
};
// When

View File

@ -26,4 +26,5 @@ export const objectMetadataItemSchema = z.object({
namePlural: camelCaseStringSchema,
nameSingular: camelCaseStringSchema,
updatedAt: z.string().datetime(),
shouldSyncLabelAndName: z.boolean(),
}) satisfies z.ZodType<ObjectMetadataItem>;

View File

@ -25,6 +25,7 @@ const objectMetadataItem: ObjectMetadataItem = {
isRemote: false,
labelPlural: 'object1s',
labelSingular: 'object1',
shouldSyncLabelAndName: true,
};
describe('turnSortsIntoOrderBy', () => {

View File

@ -26,6 +26,7 @@ describe('useLimitPerMetadataItem', () => {
namePlural: 'namePlural',
nameSingular: 'nameSingular',
updatedAt: 'updatedAt',
shouldSyncLabelAndName: false,
fields: [],
indexMetadatas: [],
},

View File

@ -34,6 +34,7 @@ const objectData: ObjectMetadataItem[] = [
labelSingular: 'labelSingular',
namePlural: 'namePlural',
nameSingular: 'nameSingular',
shouldSyncLabelAndName: false,
updatedAt: 'updatedAt',
fields: [
{

View File

@ -1,21 +1,45 @@
import styled from '@emotion/styled';
import { Controller, useFormContext } from 'react-hook-form';
import { z } from 'zod';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { objectMetadataItemSchema } from '@/object-metadata/validation-schemas/objectMetadataItemSchema';
import { OBJECT_NAME_MAXIMUM_LENGTH } from '@/settings/data-model/constants/ObjectNameMaximumLength';
import { SyncObjectLabelAndNameToggle } from '@/settings/data-model/objects/forms/components/SyncObjectLabelAndNameToggle';
import { useExpandedHeightAnimation } from '@/settings/hooks/useExpandedHeightAnimation';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { TextArea } from '@/ui/input/components/TextArea';
import { TextInput } from '@/ui/input/components/TextInput';
import { isAdvancedModeEnabledState } from '@/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { AnimatePresence, motion } from 'framer-motion';
import { plural } from 'pluralize';
import { Controller, useFormContext } from 'react-hook-form';
import { useRecoilValue } from 'recoil';
import {
AppTooltip,
IconInfoCircle,
IconTool,
MAIN_COLORS,
TooltipDelay,
} from 'twenty-ui';
import { z } from 'zod';
import { computeMetadataNameFromLabelOrThrow } from '~/pages/settings/data-model/utils/compute-metadata-name-from-label.utils';
import { isDefined } from '~/utils/isDefined';
export const settingsDataModelObjectAboutFormSchema =
objectMetadataItemSchema.pick({
export const settingsDataModelObjectAboutFormSchema = objectMetadataItemSchema
.pick({
description: true,
icon: true,
labelPlural: true,
labelSingular: true,
});
})
.merge(
objectMetadataItemSchema
.pick({
nameSingular: true,
namePlural: true,
shouldSyncLabelAndName: true,
})
.partial(),
);
type SettingsDataModelObjectAboutFormValues = z.infer<
typeof settingsDataModelObjectAboutFormSchema
@ -34,6 +58,41 @@ const StyledInputsContainer = styled.div`
width: 100%;
`;
const StyledInputContainer = styled.div`
display: flex;
flex-direction: column;
`;
const StyledSectionWrapper = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
const StyledAdvancedSettingsSectionInputWrapper = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
width: 100%;
`;
const StyledAdvancedSettingsContainer = styled.div`
display: flex;
width: 100%;
gap: ${({ theme }) => theme.spacing(2)};
position: relative;
`;
const StyledIconToolContainer = styled.div`
border-right: 1px solid ${MAIN_COLORS.yellow};
display: flex;
left: ${({ theme }) => theme.spacing(-5)};
position: absolute;
height: 100%;
`;
const StyledIconTool = styled(IconTool)`
margin-right: ${({ theme }) => theme.spacing(0.5)};
`;
const StyledLabel = styled.span`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.xs};
@ -41,83 +100,247 @@ const StyledLabel = styled.span`
margin-bottom: ${({ theme }) => theme.spacing(1)};
`;
const StyledInputContainer = styled.div`
display: flex;
flex-direction: column;
`;
const infoCircleElementId = 'info-circle-id';
export const SettingsDataModelObjectAboutForm = ({
disabled,
disableNameEdit,
objectMetadataItem,
}: SettingsDataModelObjectAboutFormProps) => {
const { control } = useFormContext<SettingsDataModelObjectAboutFormValues>();
const { control, watch, setValue } =
useFormContext<SettingsDataModelObjectAboutFormValues>();
const theme = useTheme();
const isAdvancedModeEnabled = useRecoilValue(isAdvancedModeEnabledState);
const { contentRef, motionAnimationVariants } = useExpandedHeightAnimation(
isAdvancedModeEnabled,
);
const shouldSyncLabelAndName = watch('shouldSyncLabelAndName');
const labelSingular = watch('labelSingular');
const labelPlural = watch('labelPlural');
const apiNameTooltipText = shouldSyncLabelAndName
? 'Deactivate "Synchronize Objects Labels and API Names" to set a custom API name'
: 'Input must be in camel case and cannot start with a number';
const fillLabelPlural = (labelSingular: string) => {
const newLabelPluralValue = isDefined(labelSingular)
? plural(labelSingular)
: '';
setValue('labelPlural', newLabelPluralValue, {
shouldDirty: isDefined(labelSingular) ? true : false,
});
if (shouldSyncLabelAndName === true) {
fillNamePluralFromLabelPlural(newLabelPluralValue);
}
};
const fillNameSingularFromLabelSingular = (labelSingular: string) => {
isDefined(labelSingular) &&
setValue(
'nameSingular',
computeMetadataNameFromLabelOrThrow(labelSingular),
{ shouldDirty: false },
);
};
const fillNamePluralFromLabelPlural = (labelPlural: string) => {
isDefined(labelPlural) &&
setValue('namePlural', computeMetadataNameFromLabelOrThrow(labelPlural), {
shouldDirty: false,
});
};
return (
<>
<StyledInputsContainer>
<StyledInputContainer>
<StyledLabel>Icon</StyledLabel>
<StyledSectionWrapper>
<StyledInputsContainer>
<StyledInputContainer>
<StyledLabel>Icon</StyledLabel>
<Controller
name="icon"
control={control}
defaultValue={objectMetadataItem?.icon ?? 'IconListNumbers'}
render={({ field: { onChange, value } }) => (
<IconPicker
disabled={disabled}
selectedIconKey={value}
onChange={({ iconKey }) => onChange(iconKey)}
/>
)}
/>
</StyledInputContainer>
<Controller
name="icon"
key={`object-labelSingular-text-input`}
name={'labelSingular'}
control={control}
defaultValue={objectMetadataItem?.icon ?? 'IconListNumbers'}
render={({ field: { onChange, value } }) => (
<IconPicker
disabled={disabled}
selectedIconKey={value}
onChange={({ iconKey }) => onChange(iconKey)}
/>
)}
/>
</StyledInputContainer>
{[
{
label: 'Singular',
fieldName: 'labelSingular' as const,
placeholder: 'Listing',
defaultValue: objectMetadataItem?.labelSingular,
},
{
label: 'Plural',
fieldName: 'labelPlural' as const,
placeholder: 'Listings',
defaultValue: objectMetadataItem?.labelPlural,
},
].map(({ defaultValue, fieldName, label, placeholder }) => (
<Controller
key={`object-${fieldName}-text-input`}
name={fieldName}
control={control}
defaultValue={defaultValue}
defaultValue={objectMetadataItem?.labelSingular}
render={({ field: { onChange, value } }) => (
<TextInput
label={label}
placeholder={placeholder}
label={'Singular'}
placeholder={'Listing'}
value={value}
onChange={onChange}
onChange={(value) => {
onChange(value);
fillLabelPlural(value);
if (shouldSyncLabelAndName === true) {
fillNameSingularFromLabelSingular(value);
}
}}
disabled={disabled || disableNameEdit}
fullWidth
maxLength={OBJECT_NAME_MAXIMUM_LENGTH}
/>
)}
/>
))}
</StyledInputsContainer>
<Controller
name="description"
control={control}
defaultValue={objectMetadataItem?.description ?? null}
render={({ field: { onChange, value } }) => (
<TextArea
placeholder="Write a description"
minRows={4}
value={value ?? undefined}
onChange={(nextValue) => onChange(nextValue ?? null)}
disabled={disabled}
<Controller
key={`object-labelPlural-text-input`}
name={'labelPlural'}
control={control}
defaultValue={objectMetadataItem?.labelPlural}
render={({ field: { onChange, value } }) => (
<TextInput
label={'Plural'}
placeholder={'Listings'}
value={value}
onChange={(value) => {
onChange(value);
if (shouldSyncLabelAndName === true) {
fillNamePluralFromLabelPlural(value);
}
}}
disabled={disabled || disableNameEdit}
fullWidth
maxLength={OBJECT_NAME_MAXIMUM_LENGTH}
/>
)}
/>
</StyledInputsContainer>
<Controller
name="description"
control={control}
defaultValue={objectMetadataItem?.description ?? null}
render={({ field: { onChange, value } }) => (
<TextArea
placeholder="Write a description"
minRows={4}
value={value ?? undefined}
onChange={(nextValue) => onChange(nextValue ?? null)}
disabled={disabled}
/>
)}
/>
</StyledSectionWrapper>
<AnimatePresence>
{isAdvancedModeEnabled && (
<motion.div
ref={contentRef}
initial="initial"
animate="animate"
exit="exit"
variants={motionAnimationVariants}
>
<StyledAdvancedSettingsContainer>
<StyledIconToolContainer>
<StyledIconTool size={12} color={MAIN_COLORS.yellow} />
</StyledIconToolContainer>
<StyledAdvancedSettingsSectionInputWrapper>
{[
{
label: 'API Name (Singular)',
fieldName: 'nameSingular' as const,
placeholder: 'listing',
defaultValue: objectMetadataItem?.nameSingular,
disabled:
disabled || disableNameEdit || shouldSyncLabelAndName,
tooltip: apiNameTooltipText,
},
{
label: 'API Name (Plural)',
fieldName: 'namePlural' as const,
placeholder: 'listings',
defaultValue: objectMetadataItem?.namePlural,
disabled:
disabled || disableNameEdit || shouldSyncLabelAndName,
tooltip: apiNameTooltipText,
},
].map(
({
defaultValue,
fieldName,
label,
placeholder,
disabled,
tooltip,
}) => (
<StyledInputContainer
key={`object-${fieldName}-text-input`}
>
<Controller
name={fieldName}
control={control}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => (
<>
<TextInput
label={label}
placeholder={placeholder}
value={value}
onChange={onChange}
disabled={disabled}
fullWidth
maxLength={OBJECT_NAME_MAXIMUM_LENGTH}
RightIcon={() =>
tooltip && (
<>
<IconInfoCircle
id={infoCircleElementId + fieldName}
size={theme.icon.size.md}
color={theme.font.color.tertiary}
/>
<AppTooltip
anchorSelect={`#${infoCircleElementId}${fieldName}`}
content={tooltip}
offset={5}
noArrow
place="bottom"
positionStrategy="absolute"
delay={TooltipDelay.shortDelay}
/>
</>
)
}
/>
</>
)}
/>
</StyledInputContainer>
),
)}
<Controller
name="shouldSyncLabelAndName"
control={control}
defaultValue={
objectMetadataItem?.shouldSyncLabelAndName ?? true
}
render={({ field: { onChange, value } }) => (
<SyncObjectLabelAndNameToggle
value={value ?? true}
onChange={(value) => {
onChange(value);
if (value === true) {
fillNamePluralFromLabelPlural(labelPlural);
fillNameSingularFromLabelSingular(labelSingular);
}
}}
/>
)}
/>
</StyledAdvancedSettingsSectionInputWrapper>
</StyledAdvancedSettingsContainer>
</motion.div>
)}
/>
</AnimatePresence>
</>
);
};

View File

@ -0,0 +1,72 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconRefresh, MAIN_COLORS, Toggle } from 'twenty-ui';
const StyledToggleContainer = styled.div`
align-items: center;
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
justify-content: space-between;
padding: ${({ theme }) => theme.spacing(4)};
`;
const StyledIconRefreshContainer = styled.div`
border: 2px solid ${({ theme }) => theme.border.color.medium};
border-radius: 3px;
margin-right: ${({ theme }) => theme.spacing(3)};
width: ${({ theme }) => theme.spacing(8)};
height: ${({ theme }) => theme.spacing(8)};
display: flex;
align-items: center;
justify-content: center;
`;
const StyledTitleContainer = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
`;
const StyledTitle = styled.h2`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin: 0;
`;
const StyledDescription = styled.h3`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.regular};
margin: 0;
margin-top: ${({ theme }) => theme.spacing(2)};
`;
type SyncObjectLabelAndNameToggleProps = {
value: boolean;
onChange: (value: boolean) => void;
};
export const SyncObjectLabelAndNameToggle = ({
value,
onChange,
}: SyncObjectLabelAndNameToggleProps) => {
const theme = useTheme();
return (
<StyledToggleContainer>
<StyledTitleContainer>
<StyledIconRefreshContainer>
<IconRefresh size={22.5} color={theme.font.color.tertiary} />
</StyledIconRefreshContainer>
<div>
<StyledTitle>Synchronize Objects Labels and API Names</StyledTitle>
<StyledDescription>
Should changing an object's label also change the API?
</StyledDescription>
</div>
</StyledTitleContainer>
<Toggle onChange={onChange} color={MAIN_COLORS.yellow} value={value} />
</StyledToggleContainer>
);
};

View File

@ -12,6 +12,9 @@ describe('settingsCreateObjectInputSchema', () => {
icon: 'IconPlus',
labelPlural: ' Labels ',
labelSingular: 'Label ',
namePlural: 'namePlural',
nameSingular: 'nameSingular',
shouldSyncLabelAndName: false,
};
// When
@ -24,8 +27,9 @@ describe('settingsCreateObjectInputSchema', () => {
icon: validInput.icon,
labelPlural: 'Labels',
labelSingular: 'Label',
namePlural: 'labels',
nameSingular: 'label',
namePlural: 'namePlural',
nameSingular: 'nameSingular',
shouldSyncLabelAndName: false,
});
});

View File

@ -12,6 +12,8 @@ describe('settingsUpdateObjectInputSchema', () => {
icon: 'IconName',
labelPlural: 'Labels Plural ',
labelSingular: ' Label Singular',
namePlural: 'namePlural',
nameSingular: 'nameSingular',
labelIdentifierFieldMetadataId: '3fa85f64-5717-4562-b3fc-2c963f66afa6',
};
@ -26,8 +28,8 @@ describe('settingsUpdateObjectInputSchema', () => {
labelIdentifierFieldMetadataId: validInput.labelIdentifierFieldMetadataId,
labelPlural: 'Labels Plural',
labelSingular: 'Label Singular',
namePlural: 'labelsPlural',
nameSingular: 'labelSingular',
namePlural: 'namePlural',
nameSingular: 'nameSingular',
});
});

View File

@ -1,16 +1,17 @@
import { objectMetadataItemSchema } from '@/object-metadata/validation-schemas/objectMetadataItemSchema';
import { settingsDataModelObjectAboutFormSchema } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm';
import { CreateObjectInput } from '~/generated-metadata/graphql';
import { computeMetadataNameFromLabelOrThrow } from '~/pages/settings/data-model/utils/compute-metadata-name-from-label.utils';
export const settingsCreateObjectInputSchema = objectMetadataItemSchema
.pick({
description: true,
icon: true,
labelPlural: true,
labelSingular: true,
})
.transform<CreateObjectInput>((value) => ({
...value,
nameSingular: computeMetadataNameFromLabelOrThrow(value.labelSingular),
namePlural: computeMetadataNameFromLabelOrThrow(value.labelPlural),
}));
export const settingsCreateObjectInputSchema =
settingsDataModelObjectAboutFormSchema.transform<CreateObjectInput>(
(values) => ({
...values,
nameSingular:
values.nameSingular ??
computeMetadataNameFromLabelOrThrow(values.labelSingular),
namePlural:
values.namePlural ??
computeMetadataNameFromLabelOrThrow(values.labelPlural),
shouldSyncLabelAndName: values.shouldSyncLabelAndName ?? true,
}),
);

View File

@ -1,24 +1,13 @@
import { objectMetadataItemSchema } from '@/object-metadata/validation-schemas/objectMetadataItemSchema';
import { UpdateObjectPayload } from '~/generated-metadata/graphql';
import { computeMetadataNameFromLabelOrThrow } from '~/pages/settings/data-model/utils/compute-metadata-name-from-label.utils';
import { settingsDataModelObjectAboutFormSchema } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm';
export const settingsUpdateObjectInputSchema = objectMetadataItemSchema
.pick({
description: true,
icon: true,
imageIdentifierFieldMetadataId: true,
isActive: true,
labelIdentifierFieldMetadataId: true,
labelPlural: true,
labelSingular: true,
})
.partial()
.transform<UpdateObjectPayload>((value) => ({
...value,
nameSingular: value.labelSingular
? computeMetadataNameFromLabelOrThrow(value.labelSingular)
: undefined,
namePlural: value.labelPlural
? computeMetadataNameFromLabelOrThrow(value.labelPlural)
: undefined,
}));
export const settingsUpdateObjectInputSchema =
settingsDataModelObjectAboutFormSchema
.merge(
objectMetadataItemSchema.pick({
imageIdentifierFieldMetadataId: true,
isActive: true,
labelIdentifierFieldMetadataId: true,
}),
)
.partial();

View File

@ -1,5 +1,6 @@
/* eslint-disable react/jsx-props-no-spreading */
import { useLastVisitedObjectMetadataItem } from '@/navigation/hooks/useLastVisitedObjectMetadataItem';
import { useLastVisitedView } from '@/navigation/hooks/useLastVisitedView';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug';
@ -20,13 +21,16 @@ import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/Snac
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
import { zodResolver } from '@hookform/resolvers/zod';
import pick from 'lodash.pick';
import { useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router-dom';
import { useSetRecoilState } from 'recoil';
import { Button, H2Title, IconArchive } from 'twenty-ui';
import { z } from 'zod';
import { computeMetadataNameFromLabelOrThrow } from '~/pages/settings/data-model/utils/compute-metadata-name-from-label.utils';
const objectEditFormSchema = z
.object({})
@ -45,6 +49,9 @@ export const SettingsObjectEdit = () => {
const { findActiveObjectMetadataItemBySlug } =
useFilteredObjectMetadataItems();
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
const { lastVisitedObjectMetadataItemId } =
useLastVisitedObjectMetadataItem();
const { getLastVisitedViewIdFromObjectMetadataItemId } = useLastVisitedView();
const activeObjectMetadataItem =
findActiveObjectMetadataItemBySlug(objectSlug);
@ -56,6 +63,10 @@ export const SettingsObjectEdit = () => {
resolver: zodResolver(objectEditFormSchema),
});
const setNavigationMemorizedUrl = useSetRecoilState(
navigationMemorizedUrlState,
);
useEffect(() => {
if (!activeObjectMetadataItem) navigate(AppPath.NotFound);
}, [activeObjectMetadataItem, navigate]);
@ -65,25 +76,72 @@ export const SettingsObjectEdit = () => {
const { isDirty, isValid, isSubmitting } = formConfig.formState;
const canSave = isDirty && isValid && !isSubmitting;
const handleSave = async (
const getUpdatePayload = (
formValues: SettingsDataModelObjectEditFormValues,
) => {
let values = formValues;
if (
formValues.shouldSyncLabelAndName ??
activeObjectMetadataItem.shouldSyncLabelAndName
) {
values = {
...values,
...(values.labelSingular
? {
nameSingular: computeMetadataNameFromLabelOrThrow(
formValues.labelSingular,
),
}
: {}),
...(values.labelPlural
? {
namePlural: computeMetadataNameFromLabelOrThrow(
formValues.labelPlural,
),
}
: {}),
};
}
const dirtyFieldKeys = Object.keys(
formConfig.formState.dirtyFields,
) as (keyof SettingsDataModelObjectEditFormValues)[];
return settingsUpdateObjectInputSchema.parse(
pick(values, [
...dirtyFieldKeys,
...(values.namePlural ? ['namePlural'] : []),
...(values.nameSingular ? ['nameSingular'] : []),
]),
);
};
const handleSave = async (
formValues: SettingsDataModelObjectEditFormValues,
) => {
try {
const updatePayload = getUpdatePayload(formValues);
await updateOneObjectMetadataItem({
idToUpdate: activeObjectMetadataItem.id,
updatePayload: settingsUpdateObjectInputSchema.parse(
pick(formValues, dirtyFieldKeys),
),
updatePayload,
});
const objectNamePluralForRedirection =
updatePayload.namePlural ?? activeObjectMetadataItem.namePlural;
if (lastVisitedObjectMetadataItemId === activeObjectMetadataItem.id) {
const lastVisitedView = getLastVisitedViewIdFromObjectMetadataItemId(
activeObjectMetadataItem.id,
);
setNavigationMemorizedUrl(
`/objects/${objectNamePluralForRedirection}?view=${lastVisitedView}`,
);
}
navigate(
`${settingsObjectsPagePath}/${getObjectSlug({
...formValues,
namePlural: formValues.labelPlural,
...updatePayload,
namePlural: objectNamePluralForRedirection,
})}`,
);
} catch (error) {
@ -142,7 +200,7 @@ export const SettingsObjectEdit = () => {
/>
<SettingsDataModelObjectAboutForm
disabled={!activeObjectMetadataItem.isCustom}
disableNameEdit
disableNameEdit={!activeObjectMetadataItem.isCustom}
objectMetadataItem={activeObjectMetadataItem}
/>
</Section>

View File

@ -2,5 +2,8 @@ import { METADATA_NAME_VALID_PATTERN } from '~/pages/settings/data-model/constan
import { transliterateAndFormatOrThrow } from '~/pages/settings/data-model/utils/transliterate-and-format.utils';
export const computeMetadataNameFromLabelOrThrow = (label: string): string => {
if (label === '') {
return '';
}
return transliterateAndFormatOrThrow(label, METADATA_NAME_VALID_PATTERN);
};

View File

@ -20,6 +20,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "fd99213f-1b50-4d72-8708-75ba80097736",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "noteTarget",
@ -645,6 +646,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "f98ea433-1b70-46d3-aefa-43eb369925d2",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "messageThread",
@ -830,6 +832,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "f2414140-86ea-4fa3-bc63-ca5dab9f9044",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "workspaceMember",
@ -1864,6 +1867,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "f04a7171-564a-44ec-a061-63938e29f0c5",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "apiKey",
@ -2069,6 +2073,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "edfd2da3-26e4-4e84-b490-c0790848dc23",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "taskTarget",
@ -2706,6 +2711,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "eda936a5-97b9-4b9f-986a-d8e19e8ea882",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "comment",
@ -3079,6 +3085,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "e5915d30-4425-4c4c-a9c4-1b4bff20c469",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "workflowVersion",
@ -3521,6 +3528,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "d828bda6-68e2-47f0-b0aa-b810b1f33981",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "connectedAccount",
@ -4052,6 +4060,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "d00ff1e9-774a-4b08-87fb-03d37c24f174",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "attachment",
@ -5082,6 +5091,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "cb8c8d67-16c0-4a38-a919-b375845abf42",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "favorite",
@ -6157,6 +6167,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "c55193eb-042d-42d5-a6a7-8263fd1433a2",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "viewSort",
@ -6492,6 +6503,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "c46916fc-0528-4331-9766-6ac2247a70fb",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "view",
@ -7016,6 +7028,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "be13cda6-aff5-4003-8fe9-e936011b3325",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "opportunity",
@ -7908,6 +7921,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "b74e80b0-7132-469f-bbd9-6e6fc12f04f8",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "person",
@ -9058,6 +9072,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "b6e22795-68e7-4d18-a242-545afea5a8a9",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "timelineActivity",
@ -10068,6 +10083,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "af56ee43-5666-482f-a980-434fefac00c7",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "calendarEventParticipant",
@ -10640,6 +10656,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "981fd8a9-37a2-4742-98c1-08509d995bd3",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "calendarEvent",
@ -11154,6 +11171,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "92b529f1-b82b-4352-a0d5-18f32f8e47ab",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "messageChannel",
@ -11901,6 +11919,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "8cceadc4-de6b-4ecf-8324-82c6b4eec077",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "calendarChannel",
@ -12575,6 +12594,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "8ae98b12-2ef6-4c20-adc6-240857dd7343",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "blocklist",
@ -12836,6 +12856,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "88f29168-a15b-4330-89a1-680581a2e86b",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "viewFilter",
@ -13166,6 +13187,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "823e8b9d-1947-48f9-9f43-116a2cbceba3",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "webhook",
@ -13371,6 +13393,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "7cab9c82-929f-4ea3-98e1-5c221a12263d",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "workflow",
@ -13814,6 +13837,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "6edf5dd8-ee31-42ec-80f9-728b01c50ff4",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "messageParticipant",
@ -14340,6 +14364,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "6a09bc08-33ae-4321-868a-30064279097f",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "note",
@ -14767,6 +14792,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "681f89d7-0581-42b0-b97d-870e3b2a8359",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "task",
@ -15375,6 +15401,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "673b8cb8-44c1-4c20-9834-7c35d44fd180",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "message",
@ -15803,6 +15830,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "65cce76e-0f4c-4de1-a68a-6cadce4d000e",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "workflowEventListener",
@ -16064,6 +16092,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "631882fd-28e8-4a87-8ceb-f8217006a620",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "messageChannelMessageAssociation",
@ -16509,6 +16538,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "5a1aa92b-1ee9-4a7e-ab08-ca8c1e462d16",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "activity",
@ -17144,6 +17174,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "50f61b05-868d-425b-ab3f-c085e1652d82",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "viewField",
@ -17502,6 +17533,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "45b7e1cf-792c-45fa-8d6a-0d5e67e1fa42",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "workflowRun",
@ -18034,6 +18066,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "43fe0e45-b323-4b6e-ab98-1d9fe30c9af9",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "activityTarget",
@ -18671,6 +18704,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "39d5f2b7-03ce-41e7-afe9-7710aeb766a2",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "company",
@ -19757,6 +19791,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "2590029a-05d7-4908-8b7a-a253967068a1",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "auditLog",
@ -20133,6 +20168,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "1e5ee6b2-67e5-4549-bebc-8d35bc6bc649",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "rocket",
@ -20682,6 +20718,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "objectEdge",
"node": {
"__typename": "object",
"shouldSyncLabelAndName": true,
"id": "149f1a0d-f528-48a3-a3f8-0203926d07f5",
"dataSourceId": "d8a38ce6-6ac9-4c10-b55f-408386f86290",
"nameSingular": "calendarChannelEventAssociation",

View File

@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddShouldSyncLabelAndName1728579416430
implements MigrationInterface
{
name = 'AddShouldSyncLabelAndName1728579416430';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."objectMetadata" ADD "shouldSyncLabelAndName" boolean NOT NULL DEFAULT false`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."objectMetadata" DROP COLUMN "shouldSyncLabelAndName"`,
);
}
}

View File

@ -13,8 +13,8 @@ import GraphQLJSON from 'graphql-type-json';
import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { IsValidMetadataName } from 'src/engine/decorators/metadata/is-valid-metadata-name.decorator';
import { BeforeCreateOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-create-one-object.hook';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { BeforeCreateOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-create-one-object.hook';
@InputType()
@BeforeCreateOne(BeforeCreateOneObject)
@ -81,4 +81,9 @@ export class CreateObjectInput {
primaryKeyFieldMetadataSettings?: FieldMetadataSettings<
FieldMetadataType | 'default'
>;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
shouldSyncLabelAndName?: boolean;
}

View File

@ -79,4 +79,7 @@ export class ObjectMetadataDTO {
@Field(() => String, { nullable: true })
imageIdentifierFieldMetadataId?: string | null;
@Field()
shouldSyncLabelAndName: boolean;
}

View File

@ -61,6 +61,11 @@ export class UpdateObjectPayload {
@IsOptional()
@Field({ nullable: true })
imageIdentifierFieldMetadataId?: string;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
shouldSyncLabelAndName?: boolean;
}
@InputType()

View File

@ -14,7 +14,6 @@ import { Equal, In, Repository } from 'typeorm';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@Injectable()
export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
@ -99,47 +98,6 @@ export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
}
}
this.checkIfFieldIsEditable(instance.update, objectMetadata);
return instance;
}
// This is temporary until we properly use the MigrationRunner to update column names
private checkIfFieldIsEditable(
update: UpdateObjectPayload,
objectMetadata: ObjectMetadataEntity,
) {
if (
update.nameSingular &&
update.nameSingular !== objectMetadata.nameSingular
) {
throw new BadRequestException(
"Object's nameSingular can't be updated. Please create a new object instead",
);
}
if (
update.labelSingular &&
update.labelSingular !== objectMetadata.labelSingular
) {
throw new BadRequestException(
"Object's labelSingular can't be updated. Please create a new object instead",
);
}
if (update.namePlural && update.namePlural !== objectMetadata.namePlural) {
throw new BadRequestException(
"Object's namePlural can't be updated. Please create a new object instead",
);
}
if (
update.labelPlural &&
update.labelPlural !== objectMetadata.labelPlural
) {
throw new BadRequestException(
"Object's labelPlural can't be updated. Please create a new object instead",
);
}
}
}

View File

@ -75,6 +75,9 @@ export class ObjectMetadataEntity implements ObjectMetadataInterface {
@Column({ nullable: true, type: 'uuid' })
imageIdentifierFieldMetadataId?: string | null;
@Column({ default: true })
shouldSyncLabelAndName: boolean;
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;

View File

@ -5,7 +5,8 @@ import console from 'console';
import { Query, QueryOptions } from '@ptc-org/nestjs-query-core';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { FindManyOptions, FindOneOptions, In, Repository } from 'typeorm';
import { isDefined } from 'class-validator';
import { FindManyOptions, FindOneOptions, In, Not, Repository } from 'typeorm';
import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
@ -25,6 +26,7 @@ import {
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { buildMigrationsForCustomObjectRelations } from 'src/engine/metadata-modules/object-metadata/utils/build-migrations-for-custom-object-relations.util';
import { validateObjectMetadataInputOrThrow } from 'src/engine/metadata-modules/object-metadata/utils/validate-object-metadata-input.util';
import { validateNameAndLabelAreSyncOrThrow } from 'src/engine/metadata-modules/object-metadata/utils/validate-object-metadata-sync-label-name.util';
import {
RelationMetadataEntity,
RelationMetadataType,
@ -35,6 +37,7 @@ import { RemoteTableRelationsService } from 'src/engine/metadata-modules/remote-
import { mapUdtNameToFieldType } from 'src/engine/metadata-modules/remote-server/remote-table/utils/udt-name-mapper.util';
import { SearchService } from 'src/engine/metadata-modules/search/search.service';
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
import { fieldMetadataTypeToColumnType } from 'src/engine/metadata-modules/workspace-migration/utils/field-metadata-type-to-column-type.util';
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
import {
WorkspaceMigrationColumnActionType,
@ -201,34 +204,23 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
);
}
const objectAlreadyExists = await this.objectMetadataRepository.findOne({
where: [
{
nameSingular: objectMetadataInput.nameSingular,
workspaceId: objectMetadataInput.workspaceId,
},
{
nameSingular: objectMetadataInput.namePlural,
workspaceId: objectMetadataInput.workspaceId,
},
{
namePlural: objectMetadataInput.nameSingular,
workspaceId: objectMetadataInput.workspaceId,
},
{
namePlural: objectMetadataInput.namePlural,
workspaceId: objectMetadataInput.workspaceId,
},
],
});
if (objectAlreadyExists) {
throw new ObjectMetadataException(
'Object already exists',
ObjectMetadataExceptionCode.OBJECT_ALREADY_EXISTS,
if (objectMetadataInput.shouldSyncLabelAndName === true) {
validateNameAndLabelAreSyncOrThrow(
objectMetadataInput.labelSingular,
objectMetadataInput.nameSingular,
);
validateNameAndLabelAreSyncOrThrow(
objectMetadataInput.labelPlural,
objectMetadataInput.namePlural,
);
}
this.validatesNoOtherObjectWithSameNameExistsOrThrows({
objectMetadataNamePlural: objectMetadataInput.namePlural,
objectMetadataNameSingular: objectMetadataInput.nameSingular,
workspaceId: objectMetadataInput.workspaceId,
});
const isCustom = !objectMetadataInput.isRemote;
const createdObjectMetadata = await super.createOne({
@ -421,12 +413,55 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
): Promise<ObjectMetadataEntity> {
validateObjectMetadataInputOrThrow(input.update);
const existingObjectMetadata = await this.objectMetadataRepository.findOne({
where: { id: input.id, workspaceId: workspaceId },
});
if (!existingObjectMetadata) {
throw new ObjectMetadataException(
'Object does not exist',
ObjectMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
);
}
const fullObjectMetadataAfterUpdate = {
...existingObjectMetadata,
...input.update,
};
await this.validatesNoOtherObjectWithSameNameExistsOrThrows({
objectMetadataNameSingular: fullObjectMetadataAfterUpdate.nameSingular,
objectMetadataNamePlural: fullObjectMetadataAfterUpdate.namePlural,
workspaceId: workspaceId,
existingObjectMetadataId: fullObjectMetadataAfterUpdate.id,
});
if (fullObjectMetadataAfterUpdate.shouldSyncLabelAndName) {
validateNameAndLabelAreSyncOrThrow(
fullObjectMetadataAfterUpdate.labelSingular,
fullObjectMetadataAfterUpdate.nameSingular,
);
validateNameAndLabelAreSyncOrThrow(
fullObjectMetadataAfterUpdate.labelPlural,
fullObjectMetadataAfterUpdate.namePlural,
);
}
const updatedObject = await super.updateOne(input.id, input.update);
await this.handleObjectNameAndLabelUpdates(
existingObjectMetadata,
fullObjectMetadataAfterUpdate,
input,
);
if (input.update.isActive !== undefined) {
await this.updateObjectRelationships(input.id, input.update.isActive);
}
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
workspaceId,
);
if (input.update.labelIdentifierFieldMetadataId) {
const labelIdentifierFieldMetadata =
await this.fieldMetadataRepository.findOneByOrFail({
@ -1375,4 +1410,235 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
}),
);
}
private async handleObjectNameAndLabelUpdates(
existingObjectMetadata: ObjectMetadataEntity,
objectMetadataForUpdate: ObjectMetadataEntity,
input: UpdateOneObjectInput,
) {
if (
isDefined(input.update.nameSingular) ||
isDefined(input.update.namePlural)
) {
if (
objectMetadataForUpdate.nameSingular ===
objectMetadataForUpdate.namePlural
) {
throw new ObjectMetadataException(
'The singular and plural name cannot be the same for an object',
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
);
}
}
const newTargetTableName = computeObjectTargetTable(
objectMetadataForUpdate,
);
const existingTargetTableName = computeObjectTargetTable(
existingObjectMetadata,
);
if (!(newTargetTableName === existingTargetTableName)) {
await this.createRenameTableMigration(
existingObjectMetadata,
objectMetadataForUpdate,
);
await this.createRelationsUpdatesMigrations(
existingObjectMetadata,
objectMetadataForUpdate,
);
}
if (input.update.labelPlural || input.update.icon) {
if (
!(input.update.labelPlural === existingObjectMetadata.labelPlural) ||
!(input.update.icon === existingObjectMetadata.icon)
) {
await this.updateObjectView(
objectMetadataForUpdate,
objectMetadataForUpdate.workspaceId,
);
}
}
}
private async createRenameTableMigration(
existingObjectMetadata: ObjectMetadataEntity,
objectMetadataForUpdate: ObjectMetadataEntity,
) {
const newTargetTableName = computeObjectTargetTable(
objectMetadataForUpdate,
);
const existingTargetTableName = computeObjectTargetTable(
existingObjectMetadata,
);
this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`rename-${existingObjectMetadata.nameSingular}`),
objectMetadataForUpdate.workspaceId,
[
{
name: existingTargetTableName,
newName: newTargetTableName,
action: WorkspaceMigrationTableActionType.ALTER,
},
],
);
}
private async createRelationsUpdatesMigrations(
existingObjectMetadata: ObjectMetadataEntity,
updatedObjectMetadata: ObjectMetadataEntity,
) {
const existingTableName = computeObjectTargetTable(existingObjectMetadata);
const newTableName = computeObjectTargetTable(updatedObjectMetadata);
if (existingTableName !== newTableName) {
const searchCriteria = {
isCustom: false,
settings: {
isForeignKey: true,
},
name: `${existingObjectMetadata.nameSingular}Id`,
};
const fieldsWihStandardRelation = await this.fieldMetadataRepository.find(
{
where: {
isCustom: false,
settings: {
isForeignKey: true,
},
name: `${existingObjectMetadata.nameSingular}Id`,
},
},
);
await this.fieldMetadataRepository.update(searchCriteria, {
name: `${updatedObjectMetadata.nameSingular}Id`,
});
await Promise.all(
fieldsWihStandardRelation.map(async (fieldWihStandardRelation) => {
const relatedObject = await this.objectMetadataRepository.findOneBy({
id: fieldWihStandardRelation.objectMetadataId,
workspaceId: updatedObjectMetadata.workspaceId,
});
if (relatedObject) {
await this.fieldMetadataRepository.update(
{
name: existingObjectMetadata.nameSingular,
label: existingObjectMetadata.labelSingular,
},
{
name: updatedObjectMetadata.nameSingular,
label: updatedObjectMetadata.labelSingular,
},
);
const relationTableName = computeObjectTargetTable(relatedObject);
const columnName = `${existingObjectMetadata.nameSingular}Id`;
const columnType = fieldMetadataTypeToColumnType(
fieldWihStandardRelation.type,
);
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(
`rename-${existingObjectMetadata.nameSingular}-to-${updatedObjectMetadata.nameSingular}-in-${relatedObject.nameSingular}`,
),
updatedObjectMetadata.workspaceId,
[
{
name: relationTableName,
action: WorkspaceMigrationTableActionType.ALTER,
columns: [
{
action: WorkspaceMigrationColumnActionType.ALTER,
currentColumnDefinition: {
columnName,
columnType,
isNullable: true,
defaultValue: null,
},
alteredColumnDefinition: {
columnName: `${updatedObjectMetadata.nameSingular}Id`,
columnType,
isNullable: true,
defaultValue: null,
},
},
],
},
],
);
}
}),
);
}
}
private async updateObjectView(
updatedObjectMetadata: ObjectMetadataEntity,
workspaceId: string,
) {
const dataSourceMetadata =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
workspaceId,
);
const workspaceDataSource =
await this.typeORMService.connectToDataSource(dataSourceMetadata);
await workspaceDataSource?.query(
`UPDATE ${dataSourceMetadata.schema}."view"
SET "name"=$1, "icon"=$2 WHERE "objectMetadataId"=$3 AND "key"=$4`,
[
`All ${updatedObjectMetadata.labelPlural}`,
updatedObjectMetadata.icon,
updatedObjectMetadata.id,
'INDEX',
],
);
}
private validatesNoOtherObjectWithSameNameExistsOrThrows = async ({
objectMetadataNameSingular,
objectMetadataNamePlural,
workspaceId,
existingObjectMetadataId,
}: {
objectMetadataNameSingular: string;
objectMetadataNamePlural: string;
workspaceId: string;
existingObjectMetadataId?: string;
}): Promise<void> => {
const baseWhereConditions = [
{ nameSingular: objectMetadataNameSingular, workspaceId },
{ nameSingular: objectMetadataNamePlural, workspaceId },
{ namePlural: objectMetadataNameSingular, workspaceId },
{ namePlural: objectMetadataNamePlural, workspaceId },
];
const whereConditions = baseWhereConditions.map((condition) => {
return {
...condition,
...(isDefined(existingObjectMetadataId)
? { id: Not(In([existingObjectMetadataId])) }
: {}),
};
});
const objectAlreadyExists = await this.objectMetadataRepository.findOne({
where: whereConditions,
});
if (objectAlreadyExists) {
throw new ObjectMetadataException(
'Object already exists',
ObjectMetadataExceptionCode.OBJECT_ALREADY_EXISTS,
);
}
};
}

View File

@ -1,3 +1,6 @@
import toCamelCase from 'lodash.camelcase';
import { slugify, transliterate } from 'transliteration';
import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
import { UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import {
@ -40,6 +43,8 @@ const reservedKeywords = [
'addresses',
];
const METADATA_NAME_VALID_PATTERN = /^[a-zA-Z][a-zA-Z0-9]*$/;
export const validateObjectMetadataInputOrThrow = <
T extends UpdateObjectPayload | CreateObjectInput,
>(
@ -58,6 +63,30 @@ export const validateObjectMetadataInputOrThrow = <
validateNameIsNotTooLongThrow(objectMetadataInput.namePlural);
};
export const transliterateAndFormatOrThrow = (string?: string): string => {
if (!string) {
throw new ObjectMetadataException(
'Name is required',
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
);
}
let formattedString = string;
if (formattedString.match(METADATA_NAME_VALID_PATTERN) !== null) {
return toCamelCase(formattedString);
}
formattedString = toCamelCase(
slugify(transliterate(formattedString, { trim: true })),
);
if (!formattedString.match(METADATA_NAME_VALID_PATTERN)) {
throw new Error(`"${string}" is not a valid name`);
}
return formattedString;
};
const validateNameIsNotReservedKeywordOrThrow = (name?: string) => {
if (name) {
if (reservedKeywords.includes(name)) {
@ -107,3 +136,9 @@ const validateNameCharactersOrThrow = (name?: string) => {
}
}
};
export const computeMetadataNameFromLabelOrThrow = (label: string): string => {
const formattedString = transliterateAndFormatOrThrow(label);
return formattedString;
};

View File

@ -0,0 +1,19 @@
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { computeMetadataNameFromLabelOrThrow } from 'src/engine/metadata-modules/object-metadata/utils/validate-object-metadata-input.util';
export const validateNameAndLabelAreSyncOrThrow = (
label: string,
name: string,
) => {
const computedName = computeMetadataNameFromLabelOrThrow(label);
if (name !== computedName) {
throw new ObjectMetadataException(
`Name is not synced with label. Expected name: "${computedName}", got ${name}`,
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
);
}
};