Skip to content

Commit 6ba2970

Browse files
Implement object fields and settings new layout
1 parent d51a797 commit 6ba2970

File tree

15 files changed

+485
-132
lines changed

15 files changed

+485
-132
lines changed

packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx

-7
Original file line numberDiff line numberDiff line change
@@ -143,12 +143,6 @@ const SettingsDevelopers = lazy(() =>
143143
})),
144144
);
145145

146-
const SettingsObjectEdit = lazy(() =>
147-
import('~/pages/settings/data-model/SettingsObjectEdit').then((module) => ({
148-
default: module.SettingsObjectEdit,
149-
})),
150-
);
151-
152146
const SettingsIntegrations = lazy(() =>
153147
import('~/pages/settings/integrations/SettingsIntegrations').then(
154148
(module) => ({
@@ -292,7 +286,6 @@ export const SettingsRoutes = ({
292286
path={SettingsPath.ObjectDetail}
293287
element={<SettingsObjectDetailPage />}
294288
/>
295-
<Route path={SettingsPath.ObjectEdit} element={<SettingsObjectEdit />} />
296289
<Route path={SettingsPath.NewObject} element={<SettingsNewObject />} />
297290
<Route path={SettingsPath.Developers} element={<SettingsDevelopers />} />
298291
{isCRMMigrationEnabled && (

packages/twenty-front/src/modules/object-metadata/hooks/useUpdateOneObjectMetadataItem.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { useApolloMetadataClient } from './useApolloMetadataClient';
1616
export const useUpdateOneObjectMetadataItem = () => {
1717
const apolloClientMetadata = useApolloMetadataClient();
1818

19-
const [mutate] = useMutation<
19+
const [mutate, { loading }] = useMutation<
2020
UpdateOneObjectMetadataItemMutation,
2121
UpdateOneObjectMetadataItemMutationVariables
2222
>(UPDATE_ONE_OBJECT_METADATA_ITEM, {
@@ -42,5 +42,6 @@ export const useUpdateOneObjectMetadataItem = () => {
4242

4343
return {
4444
updateOneObjectMetadataItem,
45+
loading,
4546
};
4647
};

packages/twenty-front/src/modules/settings/components/SettingsPageContainer.tsx

+12-2
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,18 @@ import styled from '@emotion/styled';
55
import { ReactNode } from 'react';
66
import { isDefined } from '~/utils/isDefined';
77

8-
const StyledSettingsPageContainer = styled.div<{ width?: number }>`
8+
const StyledSettingsPageContainer = styled.div<{
9+
width?: number;
10+
removeLeftPadding?: boolean;
11+
}>`
912
display: flex;
1013
flex-direction: column;
1114
gap: ${({ theme }) => theme.spacing(8)};
1215
overflow: auto;
1316
padding: ${({ theme }) => theme.spacing(6, 8, 8)};
17+
& {
18+
${({ removeLeftPadding }) => removeLeftPadding && `padding-left: 0;`}
19+
}
1420
width: ${({ width }) => {
1521
if (isDefined(width)) {
1622
return width + 'px';
@@ -25,10 +31,14 @@ const StyledSettingsPageContainer = styled.div<{ width?: number }>`
2531

2632
export const SettingsPageContainer = ({
2733
children,
34+
removeLeftPadding = false,
2835
}: {
2936
children: ReactNode;
37+
removeLeftPadding?: boolean;
3038
}) => (
3139
<ScrollWrapper contextProviderName="settingsPageContainer">
32-
<StyledSettingsPageContainer>{children}</StyledSettingsPageContainer>
40+
<StyledSettingsPageContainer removeLeftPadding={removeLeftPadding}>
41+
{children}
42+
</StyledSettingsPageContainer>
3343
</ScrollWrapper>
3444
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
2+
import { SettingsObjectFieldTable } from '~/pages/settings/data-model/SettingsObjectFieldTable';
3+
4+
import { Section } from '@/ui/layout/section/components/Section';
5+
import styled from '@emotion/styled';
6+
import { Button, H2Title, IconPlus, UndecoratedLink } from 'twenty-ui';
7+
8+
const StyledContentContainer = styled.div`
9+
padding-left: ${({ theme }) => theme.spacing(8)};
10+
`;
11+
12+
const StyledDiv = styled.div`
13+
display: flex;
14+
justify-content: flex-end;
15+
padding-top: ${({ theme }) => theme.spacing(2)};
16+
`;
17+
18+
type ObjectFieldsProps = {
19+
objectMetadataItem: ObjectMetadataItem;
20+
};
21+
22+
export const ObjectFields = ({ objectMetadataItem }: ObjectFieldsProps) => {
23+
const shouldDisplayAddFieldButton = !objectMetadataItem.isRemote;
24+
25+
return (
26+
<StyledContentContainer>
27+
<Section>
28+
<H2Title
29+
title="Fields"
30+
description={`Customise the fields available in the ${objectMetadataItem.labelSingular} views and their display order in the ${objectMetadataItem.labelSingular} detail view and menus.`}
31+
/>
32+
<SettingsObjectFieldTable
33+
objectMetadataItem={objectMetadataItem}
34+
mode="view"
35+
/>
36+
{shouldDisplayAddFieldButton && (
37+
<StyledDiv>
38+
<UndecoratedLink to={'./new-field/select'}>
39+
<Button
40+
Icon={IconPlus}
41+
title="Add Field"
42+
size="small"
43+
variant="secondary"
44+
/>
45+
</UndecoratedLink>
46+
</StyledDiv>
47+
)}
48+
</Section>
49+
</StyledContentContainer>
50+
);
51+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
2+
3+
import { Section } from '@/ui/layout/section/components/Section';
4+
import styled from '@emotion/styled';
5+
import { H2Title } from 'twenty-ui';
6+
import { SettingsObjectIndexTable } from '~/pages/settings/data-model/SettingsObjectIndexTable';
7+
8+
type ObjectIndexesProps = {
9+
objectMetadataItem: ObjectMetadataItem;
10+
};
11+
12+
const StyledContentContainer = styled.div`
13+
padding-left: ${({ theme }) => theme.spacing(8)};
14+
`;
15+
16+
export const ObjectIndexes = ({ objectMetadataItem }: ObjectIndexesProps) => {
17+
return (
18+
<StyledContentContainer>
19+
<Section>
20+
<H2Title
21+
title="Indexes"
22+
description={`Advanced feature to improve the performance of queries and to enforce unicity constraints.`}
23+
/>
24+
<SettingsObjectIndexTable objectMetadataItem={objectMetadataItem} />
25+
</Section>
26+
</StyledContentContainer>
27+
);
28+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
/* eslint-disable react/jsx-props-no-spreading */
2+
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
3+
import { zodResolver } from '@hookform/resolvers/zod';
4+
import { FormProvider, useForm, useWatch } from 'react-hook-form';
5+
import { useNavigate } from 'react-router-dom';
6+
import { Button, H2Title, IconArchive } from 'twenty-ui';
7+
import { z, ZodError } from 'zod';
8+
9+
import { useLastVisitedObjectMetadataItem } from '@/navigation/hooks/useLastVisitedObjectMetadataItem';
10+
import { useLastVisitedView } from '@/navigation/hooks/useLastVisitedView';
11+
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
12+
import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug';
13+
import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
14+
import {
15+
SettingsDataModelObjectAboutForm,
16+
settingsDataModelObjectAboutFormSchema,
17+
} from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm';
18+
import { settingsDataModelObjectIdentifiersFormSchema } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectIdentifiersForm';
19+
import { SettingsDataModelObjectSettingsFormCard } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectSettingsFormCard';
20+
import { settingsUpdateObjectInputSchema } from '@/settings/data-model/validation-schemas/settingsUpdateObjectInputSchema';
21+
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
22+
import { SettingsPath } from '@/types/SettingsPath';
23+
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
24+
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
25+
import { Section } from '@/ui/layout/section/components/Section';
26+
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
27+
import styled from '@emotion/styled';
28+
import pick from 'lodash.pick';
29+
import { useEffect } from 'react';
30+
import { useSetRecoilState } from 'recoil';
31+
import { useDebouncedCallback } from 'use-debounce';
32+
import { updatedObjectSlugState } from '~/pages/settings/data-model/states/updatedObjectSlugState';
33+
import { computeMetadataNameFromLabelOrThrow } from '~/pages/settings/data-model/utils/compute-metadata-name-from-label.utils';
34+
35+
const objectEditFormSchema = z
36+
.object({})
37+
.merge(settingsDataModelObjectAboutFormSchema)
38+
.merge(settingsDataModelObjectIdentifiersFormSchema);
39+
40+
type SettingsDataModelObjectEditFormValues = z.infer<
41+
typeof objectEditFormSchema
42+
>;
43+
44+
type ObjectSettingsProps = {
45+
objectMetadataItem: ObjectMetadataItem;
46+
};
47+
48+
const StyledContentContainer = styled.div`
49+
display: flex;
50+
flex-direction: column;
51+
gap: ${({ theme }) => theme.spacing(8)};
52+
padding-left: ${({ theme }) => theme.spacing(8)};
53+
`;
54+
55+
const StyledFormSection = styled(Section)`
56+
padding-left: 0 !important;
57+
`;
58+
59+
export const ObjectSettings = ({ objectMetadataItem }: ObjectSettingsProps) => {
60+
const navigate = useNavigate();
61+
const { enqueueSnackBar } = useSnackBar();
62+
const setUpdatedObjectSlugState = useSetRecoilState(updatedObjectSlugState);
63+
64+
const { updateOneObjectMetadataItem, loading } =
65+
useUpdateOneObjectMetadataItem();
66+
const { lastVisitedObjectMetadataItemId } =
67+
useLastVisitedObjectMetadataItem();
68+
const { getLastVisitedViewIdFromObjectMetadataItemId } = useLastVisitedView();
69+
70+
const settingsObjectsPagePath = getSettingsPagePath(SettingsPath.Objects);
71+
72+
const formConfig = useForm<SettingsDataModelObjectEditFormValues>({
73+
mode: 'onTouched',
74+
resolver: zodResolver(objectEditFormSchema),
75+
});
76+
77+
const setNavigationMemorizedUrl = useSetRecoilState(
78+
navigationMemorizedUrlState,
79+
);
80+
81+
const getUpdatePayload = (
82+
formValues: SettingsDataModelObjectEditFormValues,
83+
) => {
84+
let values = formValues;
85+
if (
86+
formValues.shouldSyncLabelAndName ??
87+
objectMetadataItem.shouldSyncLabelAndName
88+
) {
89+
values = {
90+
...values,
91+
...(values.labelSingular
92+
? {
93+
nameSingular: computeMetadataNameFromLabelOrThrow(
94+
formValues.labelSingular,
95+
),
96+
}
97+
: {}),
98+
...(values.labelPlural
99+
? {
100+
namePlural: computeMetadataNameFromLabelOrThrow(
101+
formValues.labelPlural,
102+
),
103+
}
104+
: {}),
105+
};
106+
}
107+
108+
const dirtyFieldKeys = Object.keys(
109+
formConfig.formState.dirtyFields,
110+
) as (keyof SettingsDataModelObjectEditFormValues)[];
111+
112+
return settingsUpdateObjectInputSchema.parse(
113+
pick(values, [
114+
...dirtyFieldKeys,
115+
...(values.namePlural ? ['namePlural'] : []),
116+
...(values.nameSingular ? ['nameSingular'] : []),
117+
]),
118+
);
119+
};
120+
121+
const handleSave = async (
122+
formValues: SettingsDataModelObjectEditFormValues,
123+
) => {
124+
try {
125+
const updatePayload = getUpdatePayload(formValues);
126+
const objectNamePluralForRedirection =
127+
updatePayload.namePlural ?? objectMetadataItem.namePlural;
128+
const objectSlug = getObjectSlug({
129+
...updatePayload,
130+
namePlural: objectNamePluralForRedirection,
131+
});
132+
133+
setUpdatedObjectSlugState(objectSlug);
134+
135+
await updateOneObjectMetadataItem({
136+
idToUpdate: objectMetadataItem.id,
137+
updatePayload,
138+
});
139+
140+
if (lastVisitedObjectMetadataItemId === objectMetadataItem.id) {
141+
const lastVisitedView = getLastVisitedViewIdFromObjectMetadataItemId(
142+
objectMetadataItem.id,
143+
);
144+
setNavigationMemorizedUrl(
145+
`/objects/${objectNamePluralForRedirection}?view=${lastVisitedView}`,
146+
);
147+
}
148+
149+
navigate(`${settingsObjectsPagePath}/${objectSlug}`);
150+
} catch (error) {
151+
if (error instanceof ZodError) {
152+
enqueueSnackBar(error.issues[0].message, {
153+
variant: SnackBarVariant.Error,
154+
});
155+
} else {
156+
enqueueSnackBar((error as Error).message, {
157+
variant: SnackBarVariant.Error,
158+
});
159+
}
160+
}
161+
};
162+
163+
const handleDisable = async () => {
164+
await updateOneObjectMetadataItem({
165+
idToUpdate: objectMetadataItem.id,
166+
updatePayload: { isActive: false },
167+
});
168+
navigate(settingsObjectsPagePath);
169+
};
170+
171+
const formData = useWatch<SettingsDataModelObjectEditFormValues>({
172+
control: formConfig.control,
173+
});
174+
175+
const debouncedHandleSave = useDebouncedCallback((data) => {
176+
handleSave(data);
177+
}, 1000);
178+
179+
useEffect(() => {
180+
if (Object.keys(formData).length > 0) {
181+
debouncedHandleSave.cancel();
182+
debouncedHandleSave(formData);
183+
}
184+
}, [debouncedHandleSave, formData]);
185+
186+
return (
187+
<RecordFieldValueSelectorContextProvider>
188+
<FormProvider {...formConfig}>
189+
<StyledContentContainer>
190+
<StyledFormSection>
191+
<H2Title
192+
title="About"
193+
description="Name in both singular (e.g., 'Invoice') and plural (e.g., 'Invoices') forms."
194+
/>
195+
<SettingsDataModelObjectAboutForm
196+
disabled={!objectMetadataItem.isCustom || loading}
197+
disableNameEdit={!objectMetadataItem.isCustom}
198+
objectMetadataItem={objectMetadataItem}
199+
/>
200+
</StyledFormSection>
201+
<StyledFormSection>
202+
<Section>
203+
<H2Title
204+
title="Options"
205+
description="Choose the fields that will identify your records"
206+
/>
207+
<SettingsDataModelObjectSettingsFormCard
208+
objectMetadataItem={objectMetadataItem}
209+
/>
210+
</Section>
211+
</StyledFormSection>
212+
<StyledFormSection>
213+
<Section>
214+
<H2Title title="Danger zone" description="Deactivate object" />
215+
<Button
216+
Icon={IconArchive}
217+
title="Deactivate"
218+
size="small"
219+
onClick={handleDisable}
220+
/>
221+
</Section>
222+
</StyledFormSection>
223+
</StyledContentContainer>
224+
</FormProvider>
225+
</RecordFieldValueSelectorContextProvider>
226+
);
227+
};

packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ export const SettingsDataModelObjectAboutForm = ({
176176
defaultValue={objectMetadataItem?.labelSingular}
177177
render={({ field: { onChange, value } }) => (
178178
<TextInput
179-
label={'Singular'}
179+
label={'Label Singular'}
180180
placeholder={'Listing'}
181181
value={value}
182182
onChange={(value) => {
@@ -199,7 +199,7 @@ export const SettingsDataModelObjectAboutForm = ({
199199
defaultValue={objectMetadataItem?.labelPlural}
200200
render={({ field: { onChange, value } }) => (
201201
<TextInput
202-
label={'Plural'}
202+
label={'Label Plural'}
203203
placeholder={'Listings'}
204204
value={value}
205205
onChange={(value) => {

0 commit comments

Comments
 (0)