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
29 changed files with 900 additions and 192 deletions

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>
);
};