mirror of
https://github.com/twentyhq/twenty.git
synced 2024-11-22 21:50:43 +03:00
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:
parent
c6ef14acc4
commit
414f2ac498
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -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'];
|
||||
};
|
||||
|
||||
|
@ -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}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -124,6 +124,7 @@ describe('useCommandMenu', () => {
|
||||
namePlural: 'tasks',
|
||||
labelSingular: 'Task',
|
||||
labelPlural: 'Tasks',
|
||||
shouldSyncLabelAndName: true,
|
||||
description: 'A task',
|
||||
icon: 'IconCheckbox',
|
||||
isCustom: false,
|
||||
|
@ -24,6 +24,7 @@ export const FIND_MANY_OBJECT_METADATA_ITEMS = gql`
|
||||
updatedAt
|
||||
labelIdentifierFieldMetadataId
|
||||
imageIdentifierFieldMetadataId
|
||||
shouldSyncLabelAndName
|
||||
indexMetadatas(paging: { first: 100 }) {
|
||||
edges {
|
||||
node {
|
||||
|
@ -30,6 +30,7 @@ describe('objectMetadataItemSchema', () => {
|
||||
namePlural: 'notCamelCase',
|
||||
nameSingular: 'notCamelCase',
|
||||
updatedAt: 'invalid date',
|
||||
shouldSyncLabelAndName: 'not a boolean',
|
||||
};
|
||||
|
||||
// When
|
||||
|
@ -26,4 +26,5 @@ export const objectMetadataItemSchema = z.object({
|
||||
namePlural: camelCaseStringSchema,
|
||||
nameSingular: camelCaseStringSchema,
|
||||
updatedAt: z.string().datetime(),
|
||||
shouldSyncLabelAndName: z.boolean(),
|
||||
}) satisfies z.ZodType<ObjectMetadataItem>;
|
||||
|
@ -25,6 +25,7 @@ const objectMetadataItem: ObjectMetadataItem = {
|
||||
isRemote: false,
|
||||
labelPlural: 'object1s',
|
||||
labelSingular: 'object1',
|
||||
shouldSyncLabelAndName: true,
|
||||
};
|
||||
|
||||
describe('turnSortsIntoOrderBy', () => {
|
||||
|
@ -26,6 +26,7 @@ describe('useLimitPerMetadataItem', () => {
|
||||
namePlural: 'namePlural',
|
||||
nameSingular: 'nameSingular',
|
||||
updatedAt: 'updatedAt',
|
||||
shouldSyncLabelAndName: false,
|
||||
fields: [],
|
||||
indexMetadatas: [],
|
||||
},
|
||||
|
@ -34,6 +34,7 @@ const objectData: ObjectMetadataItem[] = [
|
||||
labelSingular: 'labelSingular',
|
||||
namePlural: 'namePlural',
|
||||
nameSingular: 'nameSingular',
|
||||
shouldSyncLabelAndName: false,
|
||||
updatedAt: 'updatedAt',
|
||||
fields: [
|
||||
{
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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,
|
||||
}),
|
||||
);
|
||||
|
@ -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();
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
};
|
||||
|
@ -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",
|
||||
|
@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -79,4 +79,7 @@ export class ObjectMetadataDTO {
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
imageIdentifierFieldMetadataId?: string | null;
|
||||
|
||||
@Field()
|
||||
shouldSyncLabelAndName: boolean;
|
||||
}
|
||||
|
@ -61,6 +61,11 @@ export class UpdateObjectPayload {
|
||||
@IsOptional()
|
||||
@Field({ nullable: true })
|
||||
imageIdentifierFieldMetadataId?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@Field({ nullable: true })
|
||||
shouldSyncLabelAndName?: boolean;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
|
@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
};
|
Loading…
Reference in New Issue
Block a user