diff --git a/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx b/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx
index 29beb1225..0be4c731a 100644
--- a/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx
+++ b/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx
@@ -38,6 +38,10 @@ const StyledYear = styled.span`
color: ${({ theme }) => theme.font.color.light};
`;
+const StyledTitleContainer = styled.div`
+ margin-bottom: ${({ theme }) => theme.spacing(4)};
+`;
+
export const Calendar = ({
targetableObject,
}: {
@@ -131,14 +135,16 @@ export const Calendar = ({
return (
-
- {monthLabel}
- {isLastMonthOfYear && {year}}
- >
- }
- />
+
+
+ {monthLabel}
+ {isLastMonthOfYear && {year}}
+ >
+ }
+ />
+
);
diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx
index 4b48c3c1b..b758acdc1 100644
--- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx
+++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx
@@ -143,12 +143,6 @@ const SettingsDevelopers = lazy(() =>
})),
);
-const SettingsObjectEdit = lazy(() =>
- import('~/pages/settings/data-model/SettingsObjectEdit').then((module) => ({
- default: module.SettingsObjectEdit,
- })),
-);
-
const SettingsIntegrations = lazy(() =>
import('~/pages/settings/integrations/SettingsIntegrations').then(
(module) => ({
@@ -292,7 +286,6 @@ export const SettingsRoutes = ({
path={SettingsPath.ObjectDetail}
element={}
/>
- } />
} />
} />
{isCRMMigrationEnabled && (
diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useUpdateOneObjectMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useUpdateOneObjectMetadataItem.ts
index aa02b1e73..a70ff41c2 100644
--- a/packages/twenty-front/src/modules/object-metadata/hooks/useUpdateOneObjectMetadataItem.ts
+++ b/packages/twenty-front/src/modules/object-metadata/hooks/useUpdateOneObjectMetadataItem.ts
@@ -16,7 +16,7 @@ import { useApolloMetadataClient } from './useApolloMetadataClient';
export const useUpdateOneObjectMetadataItem = () => {
const apolloClientMetadata = useApolloMetadataClient();
- const [mutate] = useMutation<
+ const [mutate, { loading }] = useMutation<
UpdateOneObjectMetadataItemMutation,
UpdateOneObjectMetadataItemMutationVariables
>(UPDATE_ONE_OBJECT_METADATA_ITEM, {
@@ -42,5 +42,6 @@ export const useUpdateOneObjectMetadataItem = () => {
return {
updateOneObjectMetadataItem,
+ loading,
};
};
diff --git a/packages/twenty-front/src/modules/settings/components/SettingsPageContainer.tsx b/packages/twenty-front/src/modules/settings/components/SettingsPageContainer.tsx
index 879e95b82..1c4f7de6b 100644
--- a/packages/twenty-front/src/modules/settings/components/SettingsPageContainer.tsx
+++ b/packages/twenty-front/src/modules/settings/components/SettingsPageContainer.tsx
@@ -5,7 +5,9 @@ import styled from '@emotion/styled';
import { ReactNode } from 'react';
import { isDefined } from '~/utils/isDefined';
-const StyledSettingsPageContainer = styled.div<{ width?: number }>`
+const StyledSettingsPageContainer = styled.div<{
+ width?: number;
+}>`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(8)};
diff --git a/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectFields.tsx b/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectFields.tsx
new file mode 100644
index 000000000..4721ad21e
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectFields.tsx
@@ -0,0 +1,44 @@
+import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
+import { SettingsObjectFieldTable } from '~/pages/settings/data-model/SettingsObjectFieldTable';
+
+import styled from '@emotion/styled';
+import { Button, H2Title, IconPlus, Section, UndecoratedLink } from 'twenty-ui';
+
+const StyledDiv = styled.div`
+ display: flex;
+ justify-content: flex-end;
+ padding-top: ${({ theme }) => theme.spacing(2)};
+`;
+
+type ObjectFieldsProps = {
+ objectMetadataItem: ObjectMetadataItem;
+};
+
+export const ObjectFields = ({ objectMetadataItem }: ObjectFieldsProps) => {
+ const shouldDisplayAddFieldButton = !objectMetadataItem.isRemote;
+
+ return (
+
+
+
+ {shouldDisplayAddFieldButton && (
+
+
+
+
+
+ )}
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectIndexes.tsx b/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectIndexes.tsx
new file mode 100644
index 000000000..50c9a4067
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectIndexes.tsx
@@ -0,0 +1,20 @@
+import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
+
+import { H2Title, Section } from 'twenty-ui';
+import { SettingsObjectIndexTable } from '~/pages/settings/data-model/SettingsObjectIndexTable';
+
+type ObjectIndexesProps = {
+ objectMetadataItem: ObjectMetadataItem;
+};
+
+export const ObjectIndexes = ({ objectMetadataItem }: ObjectIndexesProps) => {
+ return (
+
+ );
+};
diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectEdit.tsx b/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectSettings.tsx
similarity index 59%
rename from packages/twenty-front/src/pages/settings/data-model/SettingsObjectEdit.tsx
rename to packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectSettings.tsx
index 4747d88f1..105443082 100644
--- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectEdit.tsx
+++ b/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectSettings.tsx
@@ -1,20 +1,16 @@
/* eslint-disable react/jsx-props-no-spreading */
+import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
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 { useNavigate } from 'react-router-dom';
import { Button, H2Title, IconArchive, Section } from 'twenty-ui';
-import { z } from 'zod';
+import { z, ZodError } from 'zod';
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';
import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
-import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
-import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import {
IS_LABEL_SYNCED_WITH_NAME_LABEL,
SettingsDataModelObjectAboutForm,
@@ -24,13 +20,15 @@ import { settingsDataModelObjectIdentifiersFormSchema } from '@/settings/data-mo
import { SettingsDataModelObjectSettingsFormCard } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectSettingsFormCard';
import { settingsUpdateObjectInputSchema } from '@/settings/data-model/validation-schemas/settingsUpdateObjectInputSchema';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
-import { AppPath } from '@/types/AppPath';
import { SettingsPath } from '@/types/SettingsPath';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
-import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
+import styled from '@emotion/styled';
+import isEmpty from 'lodash.isempty';
+import pick from 'lodash.pick';
import { useSetRecoilState } from 'recoil';
+import { updatedObjectSlugState } from '~/pages/settings/data-model/states/updatedObjectSlugState';
import { computeMetadataNameFromLabelOrThrow } from '~/pages/settings/data-model/utils/compute-metadata-name-from-label.utils';
const objectEditFormSchema = z
@@ -42,21 +40,30 @@ type SettingsDataModelObjectEditFormValues = z.infer<
typeof objectEditFormSchema
>;
-export const SettingsObjectEdit = () => {
+type ObjectSettingsProps = {
+ objectMetadataItem: ObjectMetadataItem;
+};
+
+const StyledContentContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${({ theme }) => theme.spacing(8)};
+`;
+
+const StyledFormSection = styled(Section)`
+ padding-left: 0 !important;
+`;
+
+export const ObjectSettings = ({ objectMetadataItem }: ObjectSettingsProps) => {
const navigate = useNavigate();
const { enqueueSnackBar } = useSnackBar();
+ const setUpdatedObjectSlugState = useSetRecoilState(updatedObjectSlugState);
- const { objectSlug = '' } = useParams();
- const { findActiveObjectMetadataItemBySlug } =
- useFilteredObjectMetadataItems();
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
const { lastVisitedObjectMetadataItemId } =
useLastVisitedObjectMetadataItem();
const { getLastVisitedViewIdFromObjectMetadataItemId } = useLastVisitedView();
- const activeObjectMetadataItem =
- findActiveObjectMetadataItemBySlug(objectSlug);
-
const settingsObjectsPagePath = getSettingsPagePath(SettingsPath.Objects);
const formConfig = useForm({
@@ -68,15 +75,6 @@ export const SettingsObjectEdit = () => {
navigationMemorizedUrlState,
);
- useEffect(() => {
- if (!activeObjectMetadataItem) navigate(AppPath.NotFound);
- }, [activeObjectMetadataItem, navigate]);
-
- if (!activeObjectMetadataItem) return null;
-
- const { isDirty, isValid, isSubmitting } = formConfig.formState;
- const canSave = isDirty && isValid && !isSubmitting;
-
const getUpdatePayload = (
formValues: SettingsDataModelObjectEditFormValues,
) => {
@@ -88,19 +86,19 @@ export const SettingsObjectEdit = () => {
IS_LABEL_SYNCED_WITH_NAME_LABEL,
)
? (formValues.isLabelSyncedWithName as boolean)
- : activeObjectMetadataItem.isLabelSyncedWithName;
+ : objectMetadataItem.isLabelSyncedWithName;
if (shouldComputeNamesFromLabels) {
values = {
...values,
- ...(values.labelSingular
+ ...(values.labelSingular && dirtyFieldKeys.includes('labelSingular')
? {
nameSingular: computeMetadataNameFromLabelOrThrow(
formValues.labelSingular,
),
}
: {}),
- ...(values.labelPlural
+ ...(values.labelPlural && dirtyFieldKeys.includes('labelPlural')
? {
namePlural: computeMetadataNameFromLabelOrThrow(
formValues.labelPlural,
@@ -113,8 +111,14 @@ export const SettingsObjectEdit = () => {
return settingsUpdateObjectInputSchema.parse(
pick(values, [
...dirtyFieldKeys,
- ...(values.namePlural ? ['namePlural'] : []),
- ...(values.nameSingular ? ['nameSingular'] : []),
+ ...(shouldComputeNamesFromLabels &&
+ dirtyFieldKeys.includes('labelPlural')
+ ? ['namePlural']
+ : []),
+ ...(shouldComputeNamesFromLabels &&
+ dirtyFieldKeys.includes('labelSingular')
+ ? ['nameSingular']
+ : []),
]),
);
};
@@ -122,41 +126,53 @@ export const SettingsObjectEdit = () => {
const handleSave = async (
formValues: SettingsDataModelObjectEditFormValues,
) => {
+ if (isEmpty(formConfig.formState.dirtyFields) === true) {
+ return;
+ }
try {
const updatePayload = getUpdatePayload(formValues);
+ const objectNamePluralForRedirection =
+ updatePayload.namePlural ?? objectMetadataItem.namePlural;
+ const objectSlug = getObjectSlug({
+ ...updatePayload,
+ namePlural: objectNamePluralForRedirection,
+ });
+
+ setUpdatedObjectSlugState(objectSlug);
+
await updateOneObjectMetadataItem({
- idToUpdate: activeObjectMetadataItem.id,
+ idToUpdate: objectMetadataItem.id,
updatePayload,
});
- const objectNamePluralForRedirection =
- updatePayload.namePlural ?? activeObjectMetadataItem.namePlural;
+ formConfig.reset(undefined, { keepValues: true });
- if (lastVisitedObjectMetadataItemId === activeObjectMetadataItem.id) {
+ if (lastVisitedObjectMetadataItemId === objectMetadataItem.id) {
const lastVisitedView = getLastVisitedViewIdFromObjectMetadataItemId(
- activeObjectMetadataItem.id,
+ objectMetadataItem.id,
);
setNavigationMemorizedUrl(
`/objects/${objectNamePluralForRedirection}?view=${lastVisitedView}`,
);
}
- navigate(
- `${settingsObjectsPagePath}/${getObjectSlug({
- ...updatePayload,
- namePlural: objectNamePluralForRedirection,
- })}`,
- );
+ navigate(`${settingsObjectsPagePath}/${objectSlug}`);
} catch (error) {
- enqueueSnackBar((error as Error).message, {
- variant: SnackBarVariant.Error,
- });
+ if (error instanceof ZodError) {
+ enqueueSnackBar(error.issues[0].message, {
+ variant: SnackBarVariant.Error,
+ });
+ } else {
+ enqueueSnackBar((error as Error).message, {
+ variant: SnackBarVariant.Error,
+ });
+ }
}
};
const handleDisable = async () => {
await updateOneObjectMetadataItem({
- idToUpdate: activeObjectMetadataItem.id,
+ idToUpdate: objectMetadataItem.id,
updatePayload: { isActive: false },
});
navigate(settingsObjectsPagePath);
@@ -165,57 +181,33 @@ export const SettingsObjectEdit = () => {
return (
-
- navigate(`${settingsObjectsPagePath}/${objectSlug}`)
- }
- onSave={formConfig.handleSubmit(handleSave)}
- />
- )
- }
- >
-
+
+
+
+ {
+ formConfig.handleSubmit(handleSave)();
+ }}
+ />
+
+
-
+
+
-
-
+
+
);
diff --git a/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm.tsx b/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm.tsx
index 9e59ff4a4..1e7752d9a 100644
--- a/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm.tsx
+++ b/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm.tsx
@@ -49,6 +49,7 @@ type SettingsDataModelObjectAboutFormProps = {
disabled?: boolean;
disableNameEdit?: boolean;
objectMetadataItem?: ObjectMetadataItem;
+ onBlur?: () => void;
};
const StyledInputsContainer = styled.div`
@@ -68,12 +69,16 @@ const StyledAdvancedSettingsSectionInputWrapper = styled.div`
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
width: 100%;
+ flex: 1;
+`;
+
+const StyledAdvancedSettingsOuterContainer = styled.div`
+ padding-top: ${({ theme }) => theme.spacing(4)};
`;
const StyledAdvancedSettingsContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
- padding-top: ${({ theme }) => theme.spacing(4)};
position: relative;
width: 100%;
`;
@@ -81,7 +86,7 @@ const StyledAdvancedSettingsContainer = styled.div`
const StyledIconToolContainer = styled.div`
border-right: 1px solid ${MAIN_COLORS.yellow};
display: flex;
- left: ${({ theme }) => theme.spacing(-5)};
+ left: ${({ theme }) => theme.spacing(-6)};
position: absolute;
height: 100%;
`;
@@ -105,6 +110,7 @@ export const SettingsDataModelObjectAboutForm = ({
disabled,
disableNameEdit,
objectMetadataItem,
+ onBlur,
}: SettingsDataModelObjectAboutFormProps) => {
const { control, watch, setValue } =
useFormContext();
@@ -117,6 +123,9 @@ export const SettingsDataModelObjectAboutForm = ({
const isLabelSyncedWithName = watch(IS_LABEL_SYNCED_WITH_NAME_LABEL);
const labelSingular = watch('labelSingular');
const labelPlural = watch('labelPlural');
+ watch('nameSingular');
+ watch('namePlural');
+ watch('description');
const apiNameTooltipText = isLabelSyncedWithName
? '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';
@@ -138,14 +147,14 @@ export const SettingsDataModelObjectAboutForm = ({
setValue(
'nameSingular',
computeMetadataNameFromLabelOrThrow(labelSingular),
- { shouldDirty: false },
+ { shouldDirty: true },
);
};
const fillNamePluralFromLabelPlural = (labelPlural: string) => {
isDefined(labelPlural) &&
setValue('namePlural', computeMetadataNameFromLabelOrThrow(labelPlural), {
- shouldDirty: false,
+ shouldDirty: true,
});
};
@@ -184,6 +193,7 @@ export const SettingsDataModelObjectAboutForm = ({
fillNameSingularFromLabelSingular(value);
}
}}
+ onBlur={onBlur}
disabled={disabled || disableNameEdit}
fullWidth
maxLength={OBJECT_NAME_MAXIMUM_LENGTH}
@@ -236,105 +246,110 @@ export const SettingsDataModelObjectAboutForm = ({
exit="exit"
variants={motionAnimationVariants}
>
-
-
-
-
-
- {[
- {
- label: 'API Name (Singular)',
- fieldName: 'nameSingular' as const,
- placeholder: 'listing',
- defaultValue: objectMetadataItem?.nameSingular,
- disabled:
- disabled || disableNameEdit || isLabelSyncedWithName,
- tooltip: apiNameTooltipText,
- },
- {
- label: 'API Name (Plural)',
- fieldName: 'namePlural' as const,
- placeholder: 'listings',
- defaultValue: objectMetadataItem?.namePlural,
- disabled:
- disabled || disableNameEdit || isLabelSyncedWithName,
- tooltip: apiNameTooltipText,
- },
- ].map(
- ({
- defaultValue,
- fieldName,
- label,
- placeholder,
- disabled,
- tooltip,
- }) => (
-
- (
- <>
-
- tooltip && (
- <>
-
-
-
- >
- )
- }
- />
- >
- )}
- />
-
- ),
- )}
- (
- {
- onChange(value);
- if (value === true) {
- fillNamePluralFromLabelPlural(labelPlural);
- fillNameSingularFromLabelSingular(labelSingular);
- }
- }}
- />
+
+
+
+
+
+
+ {[
+ {
+ label: 'API Name (Singular)',
+ fieldName: 'nameSingular' as const,
+ placeholder: 'listing',
+ defaultValue: objectMetadataItem?.nameSingular,
+ disabled:
+ disabled || disableNameEdit || isLabelSyncedWithName,
+ tooltip: apiNameTooltipText,
+ },
+ {
+ label: 'API Name (Plural)',
+ fieldName: 'namePlural' as const,
+ placeholder: 'listings',
+ defaultValue: objectMetadataItem?.namePlural,
+ disabled:
+ disabled || disableNameEdit || isLabelSyncedWithName,
+ tooltip: apiNameTooltipText,
+ },
+ ].map(
+ ({
+ defaultValue,
+ fieldName,
+ label,
+ placeholder,
+ disabled,
+ tooltip,
+ }) => (
+
+ (
+ <>
+
+ tooltip && (
+ <>
+
+
+ >
+ )
+ }
+ />
+ >
+ )}
+ />
+
+ ),
)}
- />
-
-
+ (
+ {
+ onChange(value);
+ if (value === true) {
+ fillNamePluralFromLabelPlural(labelPlural);
+ fillNameSingularFromLabelSingular(labelSingular);
+ }
+ onBlur?.();
+ }}
+ />
+ )}
+ />
+
+
+
)}
diff --git a/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SyncObjectLabelAndNameToggle.tsx b/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SyncObjectLabelAndNameToggle.tsx
index d220b33f0..c3252c82c 100644
--- a/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SyncObjectLabelAndNameToggle.tsx
+++ b/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SyncObjectLabelAndNameToggle.tsx
@@ -5,10 +5,11 @@ 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};
+ border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
justify-content: space-between;
padding: ${({ theme }) => theme.spacing(4)};
+ background: ${({ theme }) => theme.background.secondary};
`;
const StyledIconRefreshContainer = styled.div`
@@ -40,17 +41,19 @@ const StyledDescription = styled.h3`
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.regular};
margin: 0;
- margin-top: ${({ theme }) => theme.spacing(2)};
+ margin-top: ${({ theme }) => theme.spacing(1)};
`;
type SyncObjectLabelAndNameToggleProps = {
value: boolean;
onChange: (value: boolean) => void;
+ disabled?: boolean;
};
export const SyncObjectLabelAndNameToggle = ({
value,
onChange,
+ disabled,
}: SyncObjectLabelAndNameToggleProps) => {
const theme = useTheme();
return (
@@ -66,7 +69,12 @@ export const SyncObjectLabelAndNameToggle = ({
-
+
);
};
diff --git a/packages/twenty-front/src/modules/types/SettingsPath.ts b/packages/twenty-front/src/modules/types/SettingsPath.ts
index 2d7b9ebdc..dc8f3c1cb 100644
--- a/packages/twenty-front/src/modules/types/SettingsPath.ts
+++ b/packages/twenty-front/src/modules/types/SettingsPath.ts
@@ -11,7 +11,6 @@ export enum SettingsPath {
Objects = 'objects',
ObjectOverview = 'objects/overview',
ObjectDetail = 'objects/:objectSlug',
- ObjectEdit = 'objects/:objectSlug/edit',
ObjectNewFieldSelect = 'objects/:objectSlug/new-field/select',
ObjectNewFieldConfigure = 'objects/:objectSlug/new-field/configure',
ObjectFieldEdit = 'objects/:objectSlug/:fieldSlug',
diff --git a/packages/twenty-front/src/modules/ui/input/components/TextArea.tsx b/packages/twenty-front/src/modules/ui/input/components/TextArea.tsx
index b6bb9d545..c8feb6c42 100644
--- a/packages/twenty-front/src/modules/ui/input/components/TextArea.tsx
+++ b/packages/twenty-front/src/modules/ui/input/components/TextArea.tsx
@@ -17,6 +17,7 @@ export type TextAreaProps = {
placeholder?: string;
value?: string;
className?: string;
+ onBlur?: () => void;
};
const StyledContainer = styled.div`
@@ -70,6 +71,7 @@ export const TextArea = ({
value = '',
className,
onChange,
+ onBlur,
}: TextAreaProps) => {
const computedMinRows = Math.min(minRows, MAX_ROWS);
@@ -86,6 +88,7 @@ export const TextArea = ({
const handleBlur: FocusEventHandler = () => {
goBackToPreviousHotkeyScope();
+ onBlur?.();
};
return (
diff --git a/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx b/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx
index db306bd1d..557e67ced 100644
--- a/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx
+++ b/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx
@@ -99,7 +99,7 @@ const StyledTrailingIconContainer = styled.div<
align-items: center;
display: flex;
justify-content: center;
- padding-right: ${({ theme }) => theme.spacing(1)};
+ padding-right: ${({ theme }) => theme.spacing(2)};
position: absolute;
top: 0;
bottom: 0;
diff --git a/packages/twenty-front/src/modules/ui/layout/page/components/SubMenuTopBarContainer.tsx b/packages/twenty-front/src/modules/ui/layout/page/components/SubMenuTopBarContainer.tsx
index 8772bced5..91a1ad09e 100644
--- a/packages/twenty-front/src/modules/ui/layout/page/components/SubMenuTopBarContainer.tsx
+++ b/packages/twenty-front/src/modules/ui/layout/page/components/SubMenuTopBarContainer.tsx
@@ -10,7 +10,7 @@ import { PageHeader } from './PageHeader';
type SubMenuTopBarContainerProps = {
children: JSX.Element | JSX.Element[];
- title?: string;
+ title?: string | JSX.Element;
actionButton?: ReactNode;
className?: string;
links: BreadcrumbProps['links'];
diff --git a/packages/twenty-front/src/modules/ui/layout/tab/components/Tab.tsx b/packages/twenty-front/src/modules/ui/layout/tab/components/Tab.tsx
index e723f0ae8..93a96eb46 100644
--- a/packages/twenty-front/src/modules/ui/layout/tab/components/Tab.tsx
+++ b/packages/twenty-front/src/modules/ui/layout/tab/components/Tab.tsx
@@ -1,5 +1,6 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
+import { ReactElement } from 'react';
import { IconComponent, Pill } from 'twenty-ui';
type TabProps = {
@@ -10,7 +11,7 @@ type TabProps = {
className?: string;
onClick?: () => void;
disabled?: boolean;
- pill?: string;
+ pill?: string | ReactElement;
};
const StyledTab = styled.div<{ active?: boolean; disabled?: boolean }>`
@@ -73,7 +74,7 @@ export const Tab = ({
{Icon && }
{title}
- {pill && }
+ {pill && typeof pill === 'string' ? : pill}
);
diff --git a/packages/twenty-front/src/modules/ui/layout/tab/components/TabList.tsx b/packages/twenty-front/src/modules/ui/layout/tab/components/TabList.tsx
index eac1de426..d0936c28f 100644
--- a/packages/twenty-front/src/modules/ui/layout/tab/components/TabList.tsx
+++ b/packages/twenty-front/src/modules/ui/layout/tab/components/TabList.tsx
@@ -15,7 +15,7 @@ export type SingleTabProps = {
id: string;
hide?: boolean;
disabled?: boolean;
- pill?: string;
+ pill?: string | React.ReactElement;
};
type TabListProps = {
diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectDetailPage.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectDetailPage.tsx
index 899c36909..52d22747a 100644
--- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectDetailPage.tsx
+++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectDetailPage.tsx
@@ -2,9 +2,71 @@ import { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
+import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
+import { ObjectFields } from '@/settings/data-model/object-details/components/tabs/ObjectFields';
+import { ObjectIndexes } from '@/settings/data-model/object-details/components/tabs/ObjectIndexes';
+import { ObjectSettings } from '@/settings/data-model/object-details/components/tabs/ObjectSettings';
+import { SettingsDataModelObjectTypeTag } from '@/settings/data-model/objects/components/SettingsDataModelObjectTypeTag';
+import { getObjectTypeLabel } from '@/settings/data-model/utils/getObjectTypeLabel';
+import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { AppPath } from '@/types/AppPath';
-import { isDefined } from 'twenty-ui';
-import { SettingsObjectDetailPageContent } from '~/pages/settings/data-model/SettingsObjectDetailPageContent';
+import { SettingsPath } from '@/types/SettingsPath';
+import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
+import { TabList } from '@/ui/layout/tab/components/TabList';
+import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
+import { isAdvancedModeEnabledState } from '@/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState';
+import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
+import styled from '@emotion/styled';
+import { useRecoilState, useRecoilValue } from 'recoil';
+import {
+ Button,
+ H3Title,
+ IconCodeCircle,
+ IconListDetails,
+ IconPlus,
+ IconSettings,
+ IconTool,
+ isDefined,
+ MAIN_COLORS,
+ UndecoratedLink,
+} from 'twenty-ui';
+import { updatedObjectSlugState } from '~/pages/settings/data-model/states/updatedObjectSlugState';
+
+const StyledTabListContainer = styled.div`
+ align-items: center;
+ border-bottom: ${({ theme }) => `1px solid ${theme.border.color.light}`};
+ box-sizing: border-box;
+ display: flex;
+ gap: ${({ theme }) => theme.spacing(2)};
+ height: ${({ theme }) => theme.spacing(10)};
+ .tab-list {
+ padding-left: 0px;
+ }
+ .tab-list > div {
+ padding: ${({ theme }) => theme.spacing(3) + ' 0'};
+ }
+`;
+
+const StyledContentContainer = styled.div`
+ flex: 1;
+ width: 100%;
+ padding-left: 0;
+`;
+
+const StyledObjectTypeTag = styled(SettingsDataModelObjectTypeTag)`
+ box-sizing: border-box;
+ height: ${({ theme }) => theme.spacing(5)};
+ margin-left: ${({ theme }) => theme.spacing(2)};
+`;
+
+const StyledTitleContainer = styled.div`
+ display: flex;
+`;
+
+const TAB_LIST_COMPONENT_ID = 'object-details-tab-list';
+const FIELDS_TAB_ID = 'fields';
+const SETTINGS_TAB_ID = 'settings';
+const INDEXES_TAB_ID = 'indexes';
export const SettingsObjectDetailPage = () => {
const navigate = useNavigate();
@@ -13,18 +75,115 @@ export const SettingsObjectDetailPage = () => {
const { findActiveObjectMetadataItemBySlug } =
useFilteredObjectMetadataItems();
- const activeObjectMetadataItem =
- findActiveObjectMetadataItemBySlug(objectSlug);
+ const [updatedObjectSlug, setUpdatedObjectSlug] = useRecoilState(
+ updatedObjectSlugState,
+ );
+ const objectMetadataItem =
+ findActiveObjectMetadataItemBySlug(objectSlug) ??
+ findActiveObjectMetadataItemBySlug(updatedObjectSlug);
+
+ const { activeTabIdState } = useTabList(TAB_LIST_COMPONENT_ID);
+ const activeTabId = useRecoilValue(activeTabIdState);
+
+ const isAdvancedModeEnabled = useRecoilValue(isAdvancedModeEnabledState);
+ const isUniqueIndexesEnabled = useIsFeatureEnabled(
+ 'IS_UNIQUE_INDEXES_ENABLED',
+ );
useEffect(() => {
- if (!activeObjectMetadataItem) navigate(AppPath.NotFound);
- }, [activeObjectMetadataItem, navigate]);
+ if (objectSlug === updatedObjectSlug) setUpdatedObjectSlug('');
+ if (!isDefined(objectMetadataItem)) navigate(AppPath.NotFound);
+ }, [
+ objectMetadataItem,
+ navigate,
+ objectSlug,
+ updatedObjectSlug,
+ setUpdatedObjectSlug,
+ ]);
- if (!isDefined(activeObjectMetadataItem)) return <>>;
+ if (!isDefined(objectMetadataItem)) return <>>;
+
+ const tabs = [
+ {
+ id: FIELDS_TAB_ID,
+ title: 'Fields',
+ Icon: IconListDetails,
+ hide: false,
+ },
+ {
+ id: SETTINGS_TAB_ID,
+ title: 'Settings',
+ Icon: IconSettings,
+ hide: false,
+ },
+ {
+ id: INDEXES_TAB_ID,
+ title: 'Indexes',
+ Icon: IconCodeCircle,
+ hide: !isAdvancedModeEnabled || !isUniqueIndexesEnabled,
+ pill: ,
+ },
+ ];
+
+ const renderActiveTabContent = () => {
+ switch (activeTabId) {
+ case FIELDS_TAB_ID:
+ return ;
+ case SETTINGS_TAB_ID:
+ return ;
+ case INDEXES_TAB_ID:
+ return ;
+ default:
+ return <>>;
+ }
+ };
+
+ const objectTypeLabel = getObjectTypeLabel(objectMetadataItem);
return (
-
+
+
+
+
+ }
+ links={[
+ {
+ children: 'Workspace',
+ href: getSettingsPagePath(SettingsPath.Workspace),
+ },
+ { children: 'Objects', href: '/settings/objects' },
+ {
+ children: objectMetadataItem.labelPlural,
+ },
+ ]}
+ actionButton={
+ activeTabId === FIELDS_TAB_ID && (
+
+
+
+ )
+ }
+ >
+
+
+
+
+
+ {renderActiveTabContent()}
+
+
+
);
};
diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectDetailPageContent.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectDetailPageContent.tsx
deleted file mode 100644
index 749f76d5f..000000000
--- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectDetailPageContent.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
-import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
-import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
-import { SettingsObjectSummaryCard } from '@/settings/data-model/object-details/components/SettingsObjectSummaryCard';
-import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
-import { SettingsPath } from '@/types/SettingsPath';
-import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
-import { isAdvancedModeEnabledState } from '@/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState';
-import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
-import styled from '@emotion/styled';
-import { useNavigate } from 'react-router-dom';
-import { useRecoilValue } from 'recoil';
-import { Button, H2Title, IconPlus, Section, UndecoratedLink } from 'twenty-ui';
-import { SettingsObjectFieldTable } from '~/pages/settings/data-model/SettingsObjectFieldTable';
-import { SettingsObjectIndexTable } from '~/pages/settings/data-model/SettingsObjectIndexTable';
-
-const StyledDiv = styled.div`
- display: flex;
- justify-content: flex-end;
- padding-top: ${({ theme }) => theme.spacing(2)};
-`;
-
-export type SettingsObjectDetailPageContentProps = {
- objectMetadataItem: ObjectMetadataItem;
-};
-
-export const SettingsObjectDetailPageContent = ({
- objectMetadataItem,
-}: SettingsObjectDetailPageContentProps) => {
- const navigate = useNavigate();
-
- const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
-
- const handleDisableObject = async () => {
- await updateOneObjectMetadataItem({
- idToUpdate: objectMetadataItem.id,
- updatePayload: { isActive: false },
- });
- navigate(getSettingsPagePath(SettingsPath.Objects));
- };
-
- const shouldDisplayAddFieldButton = !objectMetadataItem.isRemote;
-
- const isAdvancedModeEnabled = useRecoilValue(isAdvancedModeEnabledState);
-
- const isUniqueIndexesEnabled = useIsFeatureEnabled(
- 'IS_UNIQUE_INDEXES_ENABLED',
- );
-
- return (
-
-
-
-
- navigate('./edit')}
- />
-
-
-
-
- {shouldDisplayAddFieldButton && (
-
-
-
-
-
- )}
-
- {isAdvancedModeEnabled && isUniqueIndexesEnabled && (
-
- )}
-
-
- );
-};
diff --git a/packages/twenty-front/src/pages/settings/data-model/__stories__/SettingsObjectDetail.stories.tsx b/packages/twenty-front/src/pages/settings/data-model/__stories__/SettingsObjectDetail.stories.tsx
index 977480343..c13811db3 100644
--- a/packages/twenty-front/src/pages/settings/data-model/__stories__/SettingsObjectDetail.stories.tsx
+++ b/packages/twenty-front/src/pages/settings/data-model/__stories__/SettingsObjectDetail.stories.tsx
@@ -1,3 +1,4 @@
+import { expect } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/test';
@@ -39,20 +40,15 @@ export const CustomObject: Story = {
},
};
-export const ObjectDropdownMenu: Story = {
+export const ObjectTabs: Story = {
play: async () => {
const canvas = within(document.body);
- const objectSummaryVerticalDotsIconButton = await canvas.findByRole(
- 'button',
- {
- name: 'Object Options',
- },
- );
- await userEvent.click(objectSummaryVerticalDotsIconButton);
+ const fieldsTab = await canvas.findByTestId('tab-fields');
+ const settingsTab = await canvas.findByTestId('tab-settings');
- await canvas.findByText('Edit');
- await canvas.findByText('Deactivate');
+ await expect(fieldsTab).toBeVisible();
+ await expect(settingsTab).toBeVisible();
},
};
diff --git a/packages/twenty-front/src/pages/settings/data-model/__stories__/SettingsObjectEdit.stories.tsx b/packages/twenty-front/src/pages/settings/data-model/__stories__/SettingsObjectEdit.stories.tsx
deleted file mode 100644
index f1521a96d..000000000
--- a/packages/twenty-front/src/pages/settings/data-model/__stories__/SettingsObjectEdit.stories.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { Meta, StoryObj } from '@storybook/react';
-
-import {
- PageDecorator,
- PageDecoratorArgs,
-} from '~/testing/decorators/PageDecorator';
-import { graphqlMocks } from '~/testing/graphqlMocks';
-import { sleep } from '~/utils/sleep';
-
-import { SettingsObjectEdit } from '../SettingsObjectEdit';
-
-const meta: Meta = {
- title: 'Pages/Settings/DataModel/SettingsObjectEdit',
- component: SettingsObjectEdit,
- decorators: [PageDecorator],
- args: {
- routePath: '/settings/objects/:objectSlug/edit',
- routeParams: { ':objectSlug': 'companies' },
- },
- parameters: {
- msw: graphqlMocks,
- },
-};
-
-export default meta;
-
-export type Story = StoryObj;
-
-export const StandardObject: Story = {
- play: async () => {
- await sleep(100);
- },
-};
-
-export const CustomObject: Story = {
- args: {
- routeParams: { ':objectSlug': 'my-custom-objects' },
- },
-};
diff --git a/packages/twenty-front/src/pages/settings/data-model/states/updatedObjectSlugState.ts b/packages/twenty-front/src/pages/settings/data-model/states/updatedObjectSlugState.ts
new file mode 100644
index 000000000..be64d0b50
--- /dev/null
+++ b/packages/twenty-front/src/pages/settings/data-model/states/updatedObjectSlugState.ts
@@ -0,0 +1,6 @@
+import { createState } from 'twenty-ui';
+
+export const updatedObjectSlugState = createState({
+ key: 'updatedObjectSlugState',
+ defaultValue: '',
+});
diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts
index a1e823a2b..9682e2f75 100644
--- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts
@@ -428,11 +428,13 @@ export class ObjectMetadataService extends TypeOrmQueryService {
const relatedObject = await this.objectMetadataRepository.findOneBy({
id: fieldWihStandardRelation.objectMetadataId,
- workspaceId: updatedObjectMetadata.workspaceId,
+ workspaceId: workspaceId,
});
if (relatedObject) {
@@ -175,7 +178,7 @@ export class ObjectMetadataMigrationService {
generateMigrationName(
`rename-${existingObjectMetadata.nameSingular}-to-${updatedObjectMetadata.nameSingular}-in-${relatedObject.nameSingular}`,
),
- updatedObjectMetadata.workspaceId,
+ workspaceId,
[
{
name: relationTableName,
diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts
index ffe53e314..531928f65 100644
--- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts
+++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts
@@ -50,6 +50,7 @@ export {
IconClockHour8,
IconClockShare,
IconCode,
+ IconCodeCircle,
IconCoins,
IconColorSwatch,
IconMessageCircle as IconComment,
@@ -159,6 +160,7 @@ export {
IconLinkOff,
IconList,
IconListCheck,
+ IconListDetails,
IconListNumbers,
IconLock,
IconLockOpen,
diff --git a/packages/twenty-ui/src/display/typography/components/H2Title.tsx b/packages/twenty-ui/src/display/typography/components/H2Title.tsx
index 04da8b8b7..8b99023df 100644
--- a/packages/twenty-ui/src/display/typography/components/H2Title.tsx
+++ b/packages/twenty-ui/src/display/typography/components/H2Title.tsx
@@ -31,7 +31,7 @@ const StyledDescription = styled.h3`
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.regular};
margin: 0;
- margin-top: ${({ theme }) => theme.spacing(3)};
+ margin-top: ${({ theme }) => theme.spacing(2)};
`;
export const H2Title = ({
diff --git a/packages/twenty-ui/src/display/typography/components/H3Title.tsx b/packages/twenty-ui/src/display/typography/components/H3Title.tsx
index 4e8f8dcaa..bb0bc4397 100644
--- a/packages/twenty-ui/src/display/typography/components/H3Title.tsx
+++ b/packages/twenty-ui/src/display/typography/components/H3Title.tsx
@@ -1,5 +1,5 @@
-import { ReactNode } from 'react';
import styled from '@emotion/styled';
+import { ReactNode } from 'react';
type H3TitleProps = {
title: ReactNode;
@@ -11,7 +11,6 @@ const StyledH3Title = styled.h3`
font-size: ${({ theme }) => theme.font.size.lg};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin: 0;
- margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
export const H3Title = ({ title, className }: H3TitleProps) => {