mirror of
				https://github.com/lingble/twenty.git
				synced 2025-10-29 20:02:29 +00:00 
			
		
		
		
	Implement object fields and settings new layout (#7979)
### Description - This PR has as the base branch the TWNTY-5491 branch, but we also had to include updates from the main branch, and currently, there are conflicts in the TWNTY-5491, that cause errors on typescript in this PR, so, we can update once the conflicts are resolved on the base branch, but the functionality can be reviewed anyway - We Implemented a new layout of object details settings and new, the data is auto-saved in `Settings `tab of object detail - There is no indication to the user that data are saved automatically in the design, currently we are disabling the form ### Demo\ <https://www.loom.com/share/4198c0aa54b5450780a570ceee574838?sid=b4ef0a42-2d41-435f-9f5f-1b16816939f7> ### Refs #TWNTY-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: Weiko <corentin@twenty.com>
This commit is contained in:
		![57568882+gitstart-app[bot]@users.noreply.github.com](/assets/img/avatar_default.png) gitstart-app[bot]
					gitstart-app[bot]
				
			
				
					committed by
					
						 GitHub
						GitHub
					
				
			
			
				
	
			
			
			 GitHub
						GitHub
					
				
			
						parent
						
							3be30651b7
						
					
				
				
					commit
					7bab65b569
				
			| @@ -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 ( | ||||
|             <Section key={monthTime}> | ||||
|               <H3Title | ||||
|                 title={ | ||||
|                   <> | ||||
|                     {monthLabel} | ||||
|                     {isLastMonthOfYear && <StyledYear> {year}</StyledYear>} | ||||
|                   </> | ||||
|                 } | ||||
|               /> | ||||
|               <StyledTitleContainer> | ||||
|                 <H3Title | ||||
|                   title={ | ||||
|                     <> | ||||
|                       {monthLabel} | ||||
|                       {isLastMonthOfYear && <StyledYear> {year}</StyledYear>} | ||||
|                     </> | ||||
|                   } | ||||
|                 /> | ||||
|               </StyledTitleContainer> | ||||
|               <CalendarMonthCard dayTimes={monthDayTimes} /> | ||||
|             </Section> | ||||
|           ); | ||||
|   | ||||
| @@ -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={<SettingsObjectDetailPage />} | ||||
|       /> | ||||
|       <Route path={SettingsPath.ObjectEdit} element={<SettingsObjectEdit />} /> | ||||
|       <Route path={SettingsPath.NewObject} element={<SettingsNewObject />} /> | ||||
|       <Route path={SettingsPath.Developers} element={<SettingsDevelopers />} /> | ||||
|       {isCRMMigrationEnabled && ( | ||||
|   | ||||
| @@ -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, | ||||
|   }; | ||||
| }; | ||||
|   | ||||
| @@ -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)}; | ||||
|   | ||||
| @@ -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 ( | ||||
|     <Section> | ||||
|       <H2Title | ||||
|         title="Fields" | ||||
|         description={`Customise the fields available in the ${objectMetadataItem.labelSingular} views and their display order in the ${objectMetadataItem.labelSingular} detail view and menus.`} | ||||
|       /> | ||||
|       <SettingsObjectFieldTable | ||||
|         objectMetadataItem={objectMetadataItem} | ||||
|         mode="view" | ||||
|       /> | ||||
|       {shouldDisplayAddFieldButton && ( | ||||
|         <StyledDiv> | ||||
|           <UndecoratedLink to={'./new-field/select'}> | ||||
|             <Button | ||||
|               Icon={IconPlus} | ||||
|               title="Add Field" | ||||
|               size="small" | ||||
|               variant="secondary" | ||||
|             /> | ||||
|           </UndecoratedLink> | ||||
|         </StyledDiv> | ||||
|       )} | ||||
|     </Section> | ||||
|   ); | ||||
| }; | ||||
| @@ -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 ( | ||||
|     <Section> | ||||
|       <H2Title | ||||
|         title="Indexes" | ||||
|         description={`Advanced feature to improve the performance of queries and to enforce unicity constraints.`} | ||||
|       /> | ||||
|       <SettingsObjectIndexTable objectMetadataItem={objectMetadataItem} /> | ||||
|     </Section> | ||||
|   ); | ||||
| }; | ||||
| @@ -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<SettingsDataModelObjectEditFormValues>({ | ||||
| @@ -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 ( | ||||
|     <RecordFieldValueSelectorContextProvider> | ||||
|       <FormProvider {...formConfig}> | ||||
|         <SubMenuTopBarContainer | ||||
|           title="Edit" | ||||
|           links={[ | ||||
|             { | ||||
|               children: 'Workspace', | ||||
|               href: getSettingsPagePath(SettingsPath.Workspace), | ||||
|             }, | ||||
|             { | ||||
|               children: 'Objects', | ||||
|               href: settingsObjectsPagePath, | ||||
|             }, | ||||
|             { | ||||
|               children: activeObjectMetadataItem.labelPlural, | ||||
|               href: `${settingsObjectsPagePath}/${objectSlug}`, | ||||
|             }, | ||||
|             { children: 'Edit Object' }, | ||||
|           ]} | ||||
|           actionButton={ | ||||
|             activeObjectMetadataItem.isCustom && ( | ||||
|               <SaveAndCancelButtons | ||||
|                 isSaveDisabled={!canSave} | ||||
|                 isCancelDisabled={isSubmitting} | ||||
|                 onCancel={() => | ||||
|                   navigate(`${settingsObjectsPagePath}/${objectSlug}`) | ||||
|                 } | ||||
|                 onSave={formConfig.handleSubmit(handleSave)} | ||||
|               /> | ||||
|             ) | ||||
|           } | ||||
|         > | ||||
|           <SettingsPageContainer> | ||||
|         <StyledContentContainer> | ||||
|           <StyledFormSection> | ||||
|             <H2Title | ||||
|               title="About" | ||||
|               description="Name in both singular (e.g., 'Invoice') and plural (e.g., 'Invoices') forms." | ||||
|             /> | ||||
|             <SettingsDataModelObjectAboutForm | ||||
|               disabled={!objectMetadataItem.isCustom} | ||||
|               disableNameEdit={!objectMetadataItem.isCustom} | ||||
|               objectMetadataItem={objectMetadataItem} | ||||
|               onBlur={() => { | ||||
|                 formConfig.handleSubmit(handleSave)(); | ||||
|               }} | ||||
|             /> | ||||
|           </StyledFormSection> | ||||
|           <StyledFormSection> | ||||
|             <Section> | ||||
|               <H2Title | ||||
|                 title="About" | ||||
|                 description="Name in both singular (e.g., 'Invoice') and plural (e.g., 'Invoices') forms." | ||||
|               /> | ||||
|               <SettingsDataModelObjectAboutForm | ||||
|                 disabled={!activeObjectMetadataItem.isCustom} | ||||
|                 disableNameEdit={!activeObjectMetadataItem.isCustom} | ||||
|                 objectMetadataItem={activeObjectMetadataItem} | ||||
|               /> | ||||
|             </Section> | ||||
|             <Section> | ||||
|               <H2Title | ||||
|                 title="Settings" | ||||
|                 title="Options" | ||||
|                 description="Choose the fields that will identify your records" | ||||
|               /> | ||||
|               <SettingsDataModelObjectSettingsFormCard | ||||
|                 objectMetadataItem={activeObjectMetadataItem} | ||||
|                 objectMetadataItem={objectMetadataItem} | ||||
|               /> | ||||
|             </Section> | ||||
|           </StyledFormSection> | ||||
|           <StyledFormSection> | ||||
|             <Section> | ||||
|               <H2Title title="Danger zone" description="Deactivate object" /> | ||||
|               <Button | ||||
| @@ -225,8 +217,8 @@ export const SettingsObjectEdit = () => { | ||||
|                 onClick={handleDisable} | ||||
|               /> | ||||
|             </Section> | ||||
|           </SettingsPageContainer> | ||||
|         </SubMenuTopBarContainer> | ||||
|           </StyledFormSection> | ||||
|         </StyledContentContainer> | ||||
|       </FormProvider> | ||||
|     </RecordFieldValueSelectorContextProvider> | ||||
|   ); | ||||
| @@ -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<SettingsDataModelObjectAboutFormValues>(); | ||||
| @@ -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} | ||||
|           > | ||||
|             <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 || 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, | ||||
|                   }) => ( | ||||
|                     <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={IS_LABEL_SYNCED_WITH_NAME_LABEL} | ||||
|                   control={control} | ||||
|                   defaultValue={ | ||||
|                     objectMetadataItem?.isLabelSyncedWithName ?? true | ||||
|                   } | ||||
|                   render={({ field: { onChange, value } }) => ( | ||||
|                     <SyncObjectLabelAndNameToggle | ||||
|                       value={value ?? true} | ||||
|                       onChange={(value) => { | ||||
|                         onChange(value); | ||||
|                         if (value === true) { | ||||
|                           fillNamePluralFromLabelPlural(labelPlural); | ||||
|                           fillNameSingularFromLabelSingular(labelSingular); | ||||
|                         } | ||||
|                       }} | ||||
|                     /> | ||||
|             <StyledAdvancedSettingsOuterContainer> | ||||
|               <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 || 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, | ||||
|                     }) => ( | ||||
|                       <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} | ||||
|                                 onBlur={onBlur} | ||||
|                                 RightIcon={() => | ||||
|                                   tooltip && ( | ||||
|                                     <> | ||||
|                                       <IconInfoCircle | ||||
|                                         id={infoCircleElementId + fieldName} | ||||
|                                         size={theme.icon.size.md} | ||||
|                                         color={theme.font.color.tertiary} | ||||
|                                         style={{ outline: 'none' }} | ||||
|                                       /> | ||||
|                                       <AppTooltip | ||||
|                                         anchorSelect={`#${infoCircleElementId}${fieldName}`} | ||||
|                                         content={tooltip} | ||||
|                                         offset={5} | ||||
|                                         noArrow | ||||
|                                         place="bottom" | ||||
|                                         positionStrategy="fixed" | ||||
|                                         delay={TooltipDelay.shortDelay} | ||||
|                                       /> | ||||
|                                     </> | ||||
|                                   ) | ||||
|                                 } | ||||
|                               /> | ||||
|                             </> | ||||
|                           )} | ||||
|                         /> | ||||
|                       </StyledInputContainer> | ||||
|                     ), | ||||
|                   )} | ||||
|                 /> | ||||
|               </StyledAdvancedSettingsSectionInputWrapper> | ||||
|             </StyledAdvancedSettingsContainer> | ||||
|                   <Controller | ||||
|                     name={IS_LABEL_SYNCED_WITH_NAME_LABEL} | ||||
|                     control={control} | ||||
|                     defaultValue={ | ||||
|                       objectMetadataItem?.isLabelSyncedWithName ?? true | ||||
|                     } | ||||
|                     render={({ field: { onChange, value } }) => ( | ||||
|                       <SyncObjectLabelAndNameToggle | ||||
|                         value={value ?? true} | ||||
|                         disabled={!objectMetadataItem?.isCustom} | ||||
|                         onChange={(value) => { | ||||
|                           onChange(value); | ||||
|                           if (value === true) { | ||||
|                             fillNamePluralFromLabelPlural(labelPlural); | ||||
|                             fillNameSingularFromLabelSingular(labelSingular); | ||||
|                           } | ||||
|                           onBlur?.(); | ||||
|                         }} | ||||
|                       /> | ||||
|                     )} | ||||
|                   /> | ||||
|                 </StyledAdvancedSettingsSectionInputWrapper> | ||||
|               </StyledAdvancedSettingsContainer> | ||||
|             </StyledAdvancedSettingsOuterContainer> | ||||
|           </motion.div> | ||||
|         )} | ||||
|       </AnimatePresence> | ||||
|   | ||||
| @@ -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 = ({ | ||||
|           </StyledDescription> | ||||
|         </div> | ||||
|       </StyledTitleContainer> | ||||
|       <Toggle onChange={onChange} color={MAIN_COLORS.yellow} value={value} /> | ||||
|       <Toggle | ||||
|         onChange={onChange} | ||||
|         color={MAIN_COLORS.yellow} | ||||
|         value={value} | ||||
|         disabled={disabled} | ||||
|       /> | ||||
|     </StyledToggleContainer> | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -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', | ||||
|   | ||||
| @@ -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<HTMLTextAreaElement> = () => { | ||||
|     goBackToPreviousHotkeyScope(); | ||||
|     onBlur?.(); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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']; | ||||
|   | ||||
| @@ -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 = ({ | ||||
|       <StyledHover> | ||||
|         {Icon && <Icon size={theme.icon.size.md} />} | ||||
|         {title} | ||||
|         {pill && <Pill label={pill} />} | ||||
|         {pill && typeof pill === 'string' ? <Pill label={pill} /> : pill} | ||||
|       </StyledHover> | ||||
|     </StyledTab> | ||||
|   ); | ||||
|   | ||||
| @@ -15,7 +15,7 @@ export type SingleTabProps = { | ||||
|   id: string; | ||||
|   hide?: boolean; | ||||
|   disabled?: boolean; | ||||
|   pill?: string; | ||||
|   pill?: string | React.ReactElement; | ||||
| }; | ||||
|  | ||||
| type TabListProps = { | ||||
|   | ||||
| @@ -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: <IconTool size={12} color={MAIN_COLORS.yellow} />, | ||||
|     }, | ||||
|   ]; | ||||
|  | ||||
|   const renderActiveTabContent = () => { | ||||
|     switch (activeTabId) { | ||||
|       case FIELDS_TAB_ID: | ||||
|         return <ObjectFields objectMetadataItem={objectMetadataItem} />; | ||||
|       case SETTINGS_TAB_ID: | ||||
|         return <ObjectSettings objectMetadataItem={objectMetadataItem} />; | ||||
|       case INDEXES_TAB_ID: | ||||
|         return <ObjectIndexes objectMetadataItem={objectMetadataItem} />; | ||||
|       default: | ||||
|         return <></>; | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const objectTypeLabel = getObjectTypeLabel(objectMetadataItem); | ||||
|  | ||||
|   return ( | ||||
|     <SettingsObjectDetailPageContent | ||||
|       objectMetadataItem={activeObjectMetadataItem} | ||||
|     /> | ||||
|     <SubMenuTopBarContainer | ||||
|       title={ | ||||
|         <StyledTitleContainer> | ||||
|           <H3Title title={objectMetadataItem.labelPlural} /> | ||||
|           <StyledObjectTypeTag objectTypeLabel={objectTypeLabel} /> | ||||
|         </StyledTitleContainer> | ||||
|       } | ||||
|       links={[ | ||||
|         { | ||||
|           children: 'Workspace', | ||||
|           href: getSettingsPagePath(SettingsPath.Workspace), | ||||
|         }, | ||||
|         { children: 'Objects', href: '/settings/objects' }, | ||||
|         { | ||||
|           children: objectMetadataItem.labelPlural, | ||||
|         }, | ||||
|       ]} | ||||
|       actionButton={ | ||||
|         activeTabId === FIELDS_TAB_ID && ( | ||||
|           <UndecoratedLink to={'./new-field/select'}> | ||||
|             <Button | ||||
|               title="New Field" | ||||
|               variant="primary" | ||||
|               size="small" | ||||
|               accent="blue" | ||||
|               Icon={IconPlus} | ||||
|             /> | ||||
|           </UndecoratedLink> | ||||
|         ) | ||||
|       } | ||||
|     > | ||||
|       <SettingsPageContainer> | ||||
|         <StyledTabListContainer> | ||||
|           <TabList | ||||
|             tabListId={TAB_LIST_COMPONENT_ID} | ||||
|             tabs={tabs} | ||||
|             className="tab-list" | ||||
|           /> | ||||
|         </StyledTabListContainer> | ||||
|         <StyledContentContainer> | ||||
|           {renderActiveTabContent()} | ||||
|         </StyledContentContainer> | ||||
|       </SettingsPageContainer> | ||||
|     </SubMenuTopBarContainer> | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -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 ( | ||||
|     <SubMenuTopBarContainer | ||||
|       title={objectMetadataItem.labelPlural} | ||||
|       links={[ | ||||
|         { | ||||
|           children: 'Workspace', | ||||
|           href: getSettingsPagePath(SettingsPath.Workspace), | ||||
|         }, | ||||
|         { children: 'Objects', href: '/settings/objects' }, | ||||
|         { children: objectMetadataItem.labelPlural }, | ||||
|       ]} | ||||
|     > | ||||
|       <SettingsPageContainer> | ||||
|         <Section> | ||||
|           <H2Title title="About" description="Manage your object" /> | ||||
|           <SettingsObjectSummaryCard | ||||
|             iconKey={objectMetadataItem.icon ?? undefined} | ||||
|             name={objectMetadataItem.labelPlural || ''} | ||||
|             objectMetadataItem={objectMetadataItem} | ||||
|             onDeactivate={handleDisableObject} | ||||
|             onEdit={() => navigate('./edit')} | ||||
|           /> | ||||
|         </Section> | ||||
|         <Section> | ||||
|           <H2Title | ||||
|             title="Fields" | ||||
|             description={`Customise the fields available in the ${objectMetadataItem.labelSingular} views and their display order in the ${objectMetadataItem.labelSingular} detail view and menus.`} | ||||
|           /> | ||||
|           <SettingsObjectFieldTable | ||||
|             objectMetadataItem={objectMetadataItem} | ||||
|             mode="view" | ||||
|           /> | ||||
|           {shouldDisplayAddFieldButton && ( | ||||
|             <StyledDiv> | ||||
|               <UndecoratedLink to={'./new-field/select'}> | ||||
|                 <Button | ||||
|                   Icon={IconPlus} | ||||
|                   title="Add Field" | ||||
|                   size="small" | ||||
|                   variant="secondary" | ||||
|                 /> | ||||
|               </UndecoratedLink> | ||||
|             </StyledDiv> | ||||
|           )} | ||||
|         </Section> | ||||
|         {isAdvancedModeEnabled && isUniqueIndexesEnabled && ( | ||||
|           <Section> | ||||
|             <H2Title | ||||
|               title="Indexes" | ||||
|               description={`Advanced feature to improve the performance of queries and to enforce unicity constraints.`} | ||||
|             /> | ||||
|             <SettingsObjectIndexTable objectMetadataItem={objectMetadataItem} /> | ||||
|           </Section> | ||||
|         )} | ||||
|       </SettingsPageContainer> | ||||
|     </SubMenuTopBarContainer> | ||||
|   ); | ||||
| }; | ||||
| @@ -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(); | ||||
|   }, | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -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<PageDecoratorArgs> = { | ||||
|   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<typeof SettingsObjectEdit>; | ||||
|  | ||||
| export const StandardObject: Story = { | ||||
|   play: async () => { | ||||
|     await sleep(100); | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| export const CustomObject: Story = { | ||||
|   args: { | ||||
|     routeParams: { ':objectSlug': 'my-custom-objects' }, | ||||
|   }, | ||||
| }; | ||||
| @@ -0,0 +1,6 @@ | ||||
| import { createState } from 'twenty-ui'; | ||||
|  | ||||
| export const updatedObjectSlugState = createState<string>({ | ||||
|   key: 'updatedObjectSlugState', | ||||
|   defaultValue: '', | ||||
| }); | ||||
| @@ -428,11 +428,13 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt | ||||
|       await this.objectMetadataMigrationService.createRenameTableMigration( | ||||
|         existingObjectMetadata, | ||||
|         objectMetadataForUpdate, | ||||
|         objectMetadataForUpdate.workspaceId, | ||||
|       ); | ||||
|  | ||||
|       await this.objectMetadataMigrationService.createRelationsUpdatesMigrations( | ||||
|         existingObjectMetadata, | ||||
|         objectMetadataForUpdate, | ||||
|         objectMetadataForUpdate.workspaceId, | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -93,6 +93,7 @@ export class ObjectMetadataMigrationService { | ||||
|   public async createRenameTableMigration( | ||||
|     existingObjectMetadata: ObjectMetadataEntity, | ||||
|     objectMetadataForUpdate: ObjectMetadataEntity, | ||||
|     workspaceId: string, | ||||
|   ) { | ||||
|     const newTargetTableName = computeObjectTargetTable( | ||||
|       objectMetadataForUpdate, | ||||
| @@ -103,7 +104,7 @@ export class ObjectMetadataMigrationService { | ||||
|  | ||||
|     this.workspaceMigrationService.createCustomMigration( | ||||
|       generateMigrationName(`rename-${existingObjectMetadata.nameSingular}`), | ||||
|       objectMetadataForUpdate.workspaceId, | ||||
|       workspaceId, | ||||
|       [ | ||||
|         { | ||||
|           name: existingTargetTableName, | ||||
| @@ -117,6 +118,7 @@ export class ObjectMetadataMigrationService { | ||||
|   public async createRelationsUpdatesMigrations( | ||||
|     existingObjectMetadata: ObjectMetadataEntity, | ||||
|     updatedObjectMetadata: ObjectMetadataEntity, | ||||
|     workspaceId: string, | ||||
|   ) { | ||||
|     const existingTableName = computeObjectTargetTable(existingObjectMetadata); | ||||
|     const newTableName = computeObjectTargetTable(updatedObjectMetadata); | ||||
| @@ -128,6 +130,7 @@ export class ObjectMetadataMigrationService { | ||||
|           isForeignKey: true, | ||||
|         }, | ||||
|         name: `${existingObjectMetadata.nameSingular}Id`, | ||||
|         workspaceId: workspaceId, | ||||
|       }; | ||||
|  | ||||
|       const fieldsWihStandardRelation = await this.fieldMetadataRepository.find( | ||||
| @@ -150,7 +153,7 @@ export class ObjectMetadataMigrationService { | ||||
|         fieldsWihStandardRelation.map(async (fieldWihStandardRelation) => { | ||||
|           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, | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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 = ({ | ||||
|   | ||||
| @@ -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) => { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user