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:
co-authored by
gitstart-twenty
gitstart-twenty
Marie Stoppa
Charles Bochet
Weiko
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'];
|
||||
};
|
||||
|
||||
|
||||
+4
-10
@@ -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 {
|
||||
|
||||
+1
@@ -30,6 +30,7 @@ describe('objectMetadataItemSchema', () => {
|
||||
namePlural: 'notCamelCase',
|
||||
nameSingular: 'notCamelCase',
|
||||
updatedAt: 'invalid date',
|
||||
shouldSyncLabelAndName: 'not a boolean',
|
||||
};
|
||||
|
||||
// When
|
||||
|
||||
+1
@@ -26,4 +26,5 @@ export const objectMetadataItemSchema = z.object({
|
||||
namePlural: camelCaseStringSchema,
|
||||
nameSingular: camelCaseStringSchema,
|
||||
updatedAt: z.string().datetime(),
|
||||
shouldSyncLabelAndName: z.boolean(),
|
||||
}) satisfies z.ZodType<ObjectMetadataItem>;
|
||||
|
||||
+1
@@ -25,6 +25,7 @@ const objectMetadataItem: ObjectMetadataItem = {
|
||||
isRemote: false,
|
||||
labelPlural: 'object1s',
|
||||
labelSingular: 'object1',
|
||||
shouldSyncLabelAndName: true,
|
||||
};
|
||||
|
||||
describe('turnSortsIntoOrderBy', () => {
|
||||
|
||||
+1
@@ -26,6 +26,7 @@ describe('useLimitPerMetadataItem', () => {
|
||||
namePlural: 'namePlural',
|
||||
nameSingular: 'nameSingular',
|
||||
updatedAt: 'updatedAt',
|
||||
shouldSyncLabelAndName: false,
|
||||
fields: [],
|
||||
indexMetadatas: [],
|
||||
},
|
||||
|
||||
+1
@@ -34,6 +34,7 @@ const objectData: ObjectMetadataItem[] = [
|
||||
labelSingular: 'labelSingular',
|
||||
namePlural: 'namePlural',
|
||||
nameSingular: 'nameSingular',
|
||||
shouldSyncLabelAndName: false,
|
||||
updatedAt: 'updatedAt',
|
||||
fields: [
|
||||
{
|
||||
|
||||
+285
-62
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+72
@@ -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>
|
||||
);
|
||||
};
|
||||
+6
-2
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+4
-2
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+14
-13
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
+11
-22
@@ -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>
|
||||
|
||||
+3
@@ -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",
|
||||
|
||||
+19
@@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -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;
|
||||
}
|
||||
|
||||
+3
@@ -79,4 +79,7 @@ export class ObjectMetadataDTO {
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
imageIdentifierFieldMetadataId?: string | null;
|
||||
|
||||
@Field()
|
||||
shouldSyncLabelAndName: boolean;
|
||||
}
|
||||
|
||||
+5
@@ -61,6 +61,11 @@ export class UpdateObjectPayload {
|
||||
@IsOptional()
|
||||
@Field({ nullable: true })
|
||||
imageIdentifierFieldMetadataId?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@Field({ nullable: true })
|
||||
shouldSyncLabelAndName?: boolean;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
|
||||
-42
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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;
|
||||
|
||||
|
||||
+292
-26
@@ -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,
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
+35
@@ -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;
|
||||
};
|
||||
|
||||
+19
@@ -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,
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user