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? ### 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:
committed by
GitHub
parent
c6ef14acc4
commit
414f2ac498
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user