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:
gitstart-app[bot] 2024-11-07 14:50:53 +01:00 committed by GitHub
parent 3be30651b7
commit 7bab65b569
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 496 additions and 391 deletions

View File

@ -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,6 +135,7 @@ export const Calendar = ({
return (
<Section key={monthTime}>
<StyledTitleContainer>
<H3Title
title={
<>
@ -139,6 +144,7 @@ export const Calendar = ({
</>
}
/>
</StyledTitleContainer>
<CalendarMonthCard dayTimes={monthDayTimes} />
</Section>
);

View File

@ -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 && (

View File

@ -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,
};
};

View File

@ -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)};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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) {
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>
<Section>
<StyledContentContainer>
<StyledFormSection>
<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}
disabled={!objectMetadataItem.isCustom}
disableNameEdit={!objectMetadataItem.isCustom}
objectMetadataItem={objectMetadataItem}
onBlur={() => {
formConfig.handleSubmit(handleSave)();
}}
/>
</Section>
</StyledFormSection>
<StyledFormSection>
<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>
);

View File

@ -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,6 +246,7 @@ export const SettingsDataModelObjectAboutForm = ({
exit="exit"
variants={motionAnimationVariants}
>
<StyledAdvancedSettingsOuterContainer>
<StyledAdvancedSettingsContainer>
<StyledIconToolContainer>
<StyledIconTool size={12} color={MAIN_COLORS.yellow} />
@ -286,6 +297,7 @@ export const SettingsDataModelObjectAboutForm = ({
disabled={disabled}
fullWidth
maxLength={OBJECT_NAME_MAXIMUM_LENGTH}
onBlur={onBlur}
RightIcon={() =>
tooltip && (
<>
@ -293,15 +305,15 @@ export const SettingsDataModelObjectAboutForm = ({
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="absolute"
positionStrategy="fixed"
delay={TooltipDelay.shortDelay}
/>
</>
@ -323,18 +335,21 @@ export const SettingsDataModelObjectAboutForm = ({
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>

View File

@ -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>
);
};

View File

@ -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',

View File

@ -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 (

View File

@ -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;

View File

@ -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'];

View File

@ -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>
);

View File

@ -15,7 +15,7 @@ export type SingleTabProps = {
id: string;
hide?: boolean;
disabled?: boolean;
pill?: string;
pill?: string | React.ReactElement;
};
type TabListProps = {

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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();
},
};

View File

@ -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' },
},
};

View File

@ -0,0 +1,6 @@
import { createState } from 'twenty-ui';
export const updatedObjectSlugState = createState<string>({
key: 'updatedObjectSlugState',
defaultValue: '',
});

View File

@ -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,
);
}

View File

@ -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,

View File

@ -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,

View File

@ -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 = ({

View File

@ -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) => {