Added new view to select types for objects (#6700)

Issue #6496 
Hi team,

Is this the right approach for handling type selection with states and
conditional rendering, or should these be managed on separate pages
altogether? Please let me know Ill make changes accordingly :)

I’m also working on styling the buttons according to the Figma design
and will be moving constants like categoryDescriptions and categories to
the constants folder.

Thanks for your guidance!



https://github.com/user-attachments/assets/452bea9f-4d0a-4472-9941-421b54cda47f

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
nitin
2024-09-07 02:17:40 +05:30
committed by GitHub
parent 99f8f8fedb
commit 79aba75649
17 changed files with 597 additions and 264 deletions

View File

@ -1,104 +0,0 @@
import styled from '@emotion/styled';
import { Controller, useFormContext } from 'react-hook-form';
import { z } from 'zod';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { fieldMetadataItemSchema } from '@/object-metadata/validation-schemas/fieldMetadataItemSchema';
import { getErrorMessageFromError } from '@/settings/data-model/fields/forms/utils/errorMessages';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { TextArea } from '@/ui/input/components/TextArea';
import { TextInput } from '@/ui/input/components/TextInput';
export const settingsDataModelFieldAboutFormSchema = (
existingLabels?: string[],
) => {
return fieldMetadataItemSchema(existingLabels || []).pick({
description: true,
icon: true,
label: true,
});
};
// Correctly infer the type from the returned schema
type SettingsDataModelFieldAboutFormValues = z.infer<
ReturnType<typeof settingsDataModelFieldAboutFormSchema>
>;
type SettingsDataModelFieldAboutFormProps = {
disabled?: boolean;
fieldMetadataItem?: FieldMetadataItem;
maxLength?: number;
};
const StyledInputsContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
margin-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const LABEL = 'label';
export const SettingsDataModelFieldAboutForm = ({
disabled,
fieldMetadataItem,
maxLength,
}: SettingsDataModelFieldAboutFormProps) => {
const {
control,
trigger,
formState: { errors },
} = useFormContext<SettingsDataModelFieldAboutFormValues>();
return (
<>
<StyledInputsContainer>
<Controller
name="icon"
control={control}
defaultValue={fieldMetadataItem?.icon ?? 'IconUsers'}
render={({ field: { onChange, value } }) => (
<IconPicker
disabled={disabled}
selectedIconKey={value ?? ''}
onChange={({ iconKey }) => onChange(iconKey)}
variant="primary"
/>
)}
/>
<Controller
name={LABEL}
control={control}
defaultValue={fieldMetadataItem?.label}
render={({ field: { onChange, value } }) => (
<TextInput
placeholder="Employees"
value={value}
onChange={(e) => {
onChange(e);
trigger(LABEL);
}}
error={getErrorMessageFromError(errors.label?.message)}
disabled={disabled}
maxLength={maxLength}
fullWidth
/>
)}
/>
</StyledInputsContainer>
<Controller
name="description"
control={control}
defaultValue={fieldMetadataItem?.description}
render={({ field: { onChange, value } }) => (
<TextArea
placeholder="Write a description"
minRows={4}
value={value ?? undefined}
onChange={onChange}
disabled={disabled}
/>
)}
/>
</>
);
};

View File

@ -0,0 +1,47 @@
import { Controller, useFormContext } from 'react-hook-form';
import { z } from 'zod';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { fieldMetadataItemSchema } from '@/object-metadata/validation-schemas/fieldMetadataItemSchema';
import { TextArea } from '@/ui/input/components/TextArea';
export const settingsDataModelFieldDescriptionFormSchema = () => {
return fieldMetadataItemSchema([]).pick({
description: true,
});
};
type SettingsDataModelFieldDescriptionFormValues = z.infer<
ReturnType<typeof settingsDataModelFieldDescriptionFormSchema>
>;
type SettingsDataModelFieldDescriptionFormProps = {
disabled?: boolean;
fieldMetadataItem?: FieldMetadataItem;
};
export const SettingsDataModelFieldDescriptionForm = ({
disabled,
fieldMetadataItem,
}: SettingsDataModelFieldDescriptionFormProps) => {
const { control } =
useFormContext<SettingsDataModelFieldDescriptionFormValues>();
return (
<Controller
name="description"
control={control}
defaultValue={fieldMetadataItem?.description}
render={({ field: { onChange, value } }) => (
<TextArea
placeholder="Write a description"
minRows={4}
value={value ?? undefined}
onChange={onChange}
disabled={disabled}
/>
)}
/>
);
};

View File

@ -0,0 +1,84 @@
import styled from '@emotion/styled';
import { Controller, useFormContext } from 'react-hook-form';
import { z } from 'zod';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { fieldMetadataItemSchema } from '@/object-metadata/validation-schemas/fieldMetadataItemSchema';
import { getErrorMessageFromError } from '@/settings/data-model/fields/forms/utils/errorMessages';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { TextInput } from '@/ui/input/components/TextInput';
export const settingsDataModelFieldIconLabelFormSchema = (
existingLabels?: string[],
) => {
return fieldMetadataItemSchema(existingLabels || []).pick({
icon: true,
label: true,
});
};
type SettingsDataModelFieldIconLabelFormValues = z.infer<
ReturnType<typeof settingsDataModelFieldIconLabelFormSchema>
>;
const StyledInputsContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
margin-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
type SettingsDataModelFieldIconLabelFormProps = {
disabled?: boolean;
fieldMetadataItem?: FieldMetadataItem;
maxLength?: number;
};
export const SettingsDataModelFieldIconLabelForm = ({
disabled,
fieldMetadataItem,
maxLength,
}: SettingsDataModelFieldIconLabelFormProps) => {
const {
control,
trigger,
formState: { errors },
} = useFormContext<SettingsDataModelFieldIconLabelFormValues>();
return (
<StyledInputsContainer>
<Controller
name="icon"
control={control}
defaultValue={fieldMetadataItem?.icon ?? 'IconUsers'}
render={({ field: { onChange, value } }) => (
<IconPicker
disabled={disabled}
selectedIconKey={value ?? ''}
onChange={({ iconKey }) => onChange(iconKey)}
variant="primary"
/>
)}
/>
<Controller
name="label"
control={control}
defaultValue={fieldMetadataItem?.label}
render={({ field: { onChange, value } }) => (
<TextInput
placeholder="Employees"
value={value}
onChange={(e) => {
onChange(e);
trigger('label');
}}
error={getErrorMessageFromError(errors.label?.message)}
disabled={disabled}
maxLength={maxLength}
fullWidth
/>
)}
/>
</StyledInputsContainer>
);
};

View File

@ -1,8 +1,10 @@
import omit from 'lodash.omit';
import styled from '@emotion/styled';
import { Controller, useFormContext } from 'react-hook-form';
import { z } from 'zod';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SETTINGS_FIELD_TYPE_CATEGORIES } from '@/settings/data-model/constants/SettingsFieldTypeCategories';
import { SETTINGS_FIELD_TYPE_CATEGORY_DESCRIPTIONS } from '@/settings/data-model/constants/SettingsFieldTypeCategoryDescriptions';
import {
SETTINGS_FIELD_TYPE_CONFIGS,
SettingsFieldTypeConfig,
@ -11,7 +13,12 @@ import { useBooleanSettingsFormInitialValues } from '@/settings/data-model/field
import { useCurrencySettingsFormInitialValues } from '@/settings/data-model/fields/forms/currency/hooks/useCurrencySettingsFormInitialValues';
import { useSelectSettingsFormInitialValues } from '@/settings/data-model/fields/forms/select/hooks/useSelectSettingsFormInitialValues';
import { SettingsSupportedFieldType } from '@/settings/data-model/types/SettingsSupportedFieldType';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { Button } from '@/ui/input/button/components/Button';
import { TextInput } from '@/ui/input/components/TextInput';
import { useTheme } from '@emotion/react';
import { Section } from '@react-email/components';
import { useState } from 'react';
import { H2Title, IconChevronRight, IconSearch } from 'twenty-ui';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const settingsDataModelFieldTypeFormSchema = z.object({
@ -23,39 +30,76 @@ export const settingsDataModelFieldTypeFormSchema = z.object({
),
});
type SettingsDataModelFieldTypeFormValues = z.infer<
export type SettingsDataModelFieldTypeFormValues = z.infer<
typeof settingsDataModelFieldTypeFormSchema
>;
type SettingsDataModelFieldTypeSelectProps = {
className?: string;
disabled?: boolean;
excludedFieldTypes?: SettingsSupportedFieldType[];
fieldMetadataItem?: Pick<
FieldMetadataItem,
'defaultValue' | 'options' | 'type'
>;
onFieldTypeSelect: () => void;
};
const StyledTypeSelectContainer = styled.div`
display: flex;
flex-direction: column;
gap: inherit;
width: 100%;
`;
const StyledButton = styled(Button)<{ isActive: boolean }>`
background: ${({ theme, isActive }) =>
isActive ? theme.background.quaternary : theme.background.secondary};
height: 40px;
width: 100%;
border-radius: ${({ theme }) => theme.border.radius.md};
`;
const StyledContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
justify-content: flex-start;
flex-wrap: wrap;
width: 100%;
`;
const StyledButtonContainer = styled.div`
display: flex;
position: relative;
width: calc(50% - ${({ theme }) => theme.spacing(1)});
`;
const StyledRightChevron = styled(IconChevronRight)`
color: ${({ theme }) => theme.font.color.secondary};
position: absolute;
right: ${({ theme }) => theme.spacing(2)};
top: 50%;
transform: translateY(-50%);
`;
const StyledSearchInput = styled(TextInput)`
width: 100%;
`;
export const SettingsDataModelFieldTypeSelect = ({
className,
disabled,
excludedFieldTypes = [],
fieldMetadataItem,
onFieldTypeSelect,
}: SettingsDataModelFieldTypeSelectProps) => {
const { control } = useFormContext<SettingsDataModelFieldTypeFormValues>();
const fieldTypeConfigs: Partial<
Record<SettingsSupportedFieldType, SettingsFieldTypeConfig>
> = omit(SETTINGS_FIELD_TYPE_CONFIGS, excludedFieldTypes);
const fieldTypeOptions = Object.entries<SettingsFieldTypeConfig>(
fieldTypeConfigs,
).map<SelectOption<SettingsSupportedFieldType>>(([key, dataTypeConfig]) => ({
Icon: dataTypeConfig.Icon,
label: dataTypeConfig.label,
value: key as SettingsSupportedFieldType,
}));
const [searchQuery, setSearchQuery] = useState('');
const theme = useTheme();
const fieldTypeConfigs = Object.entries<SettingsFieldTypeConfig>(
SETTINGS_FIELD_TYPE_CONFIGS,
).filter(
([key, config]) =>
!excludedFieldTypes.includes(key as SettingsSupportedFieldType) &&
config.label.toLowerCase().includes(searchQuery.toLowerCase()),
);
const { resetDefaultValueField: resetBooleanDefaultValueField } =
useBooleanSettingsFormInitialValues({ fieldMetadataItem });
@ -66,8 +110,6 @@ export const SettingsDataModelFieldTypeSelect = ({
const { resetDefaultValueField: resetSelectDefaultValueField } =
useSelectSettingsFormInitialValues({ fieldMetadataItem });
// Reset defaultValue on type change with a valid value for the selected type
// so the form does not become invalid.
const resetDefaultValueField = (nextValue: SettingsSupportedFieldType) => {
switch (nextValue) {
case FieldMetadataType.Boolean:
@ -95,18 +137,49 @@ export const SettingsDataModelFieldTypeSelect = ({
: FieldMetadataType.Text
}
render={({ field: { onChange, value } }) => (
<Select
className={className}
fullWidth
disabled={disabled}
dropdownId="object-field-type-select"
value={value}
onChange={(nextValue) => {
onChange(nextValue);
resetDefaultValueField(nextValue);
}}
options={fieldTypeOptions}
/>
<StyledTypeSelectContainer className={className}>
<Section>
<StyledSearchInput
LeftIcon={IconSearch}
placeholder="Search a type"
value={searchQuery}
onChange={setSearchQuery}
/>
</Section>
{SETTINGS_FIELD_TYPE_CATEGORIES.map((category) => (
<Section key={category}>
<H2Title
title={category}
description={
SETTINGS_FIELD_TYPE_CATEGORY_DESCRIPTIONS[category]
}
/>
<StyledContainer>
{fieldTypeConfigs
.filter(([, config]) => config.category === category)
.map(([key, config]) => (
<StyledButtonContainer>
<StyledButton
key={key}
onClick={() => {
onChange(key as SettingsSupportedFieldType);
resetDefaultValueField(
key as SettingsSupportedFieldType,
);
onFieldTypeSelect();
}}
title={config.label}
Icon={config.Icon}
size="small"
isActive={value === key}
/>
<StyledRightChevron size={theme.icon.size.md} />
</StyledButtonContainer>
))}
</StyledContainer>
</Section>
))}
</StyledTypeSelectContainer>
)}
/>
);

View File

@ -0,0 +1,40 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { FormProviderDecorator } from '~/testing/decorators/FormProviderDecorator';
import { mockedPersonObjectMetadataItem } from '~/testing/mock-data/metadata';
import { SettingsDataModelFieldDescriptionForm } from '../SettingsDataModelFieldDescriptionForm';
const meta: Meta<typeof SettingsDataModelFieldDescriptionForm> = {
title: 'Modules/Settings/DataModel/SettingsDataModelFieldDescriptionForm',
component: SettingsDataModelFieldDescriptionForm,
decorators: [
(Story) => (
<div style={{ flex: 1 }}>
<Story />
</div>
),
FormProviderDecorator,
ComponentDecorator,
],
};
export default meta;
type Story = StoryObj<typeof SettingsDataModelFieldDescriptionForm>;
export const Default: Story = {};
export const WithFieldMetadataItem: Story = {
args: {
fieldMetadataItem: mockedPersonObjectMetadataItem.fields.find(
({ description }) => description === 'description',
)!,
},
};
export const Disabled: Story = {
args: {
disabled: true,
},
};

View File

@ -4,17 +4,17 @@ import { ComponentDecorator } from 'twenty-ui';
import { FormProviderDecorator } from '~/testing/decorators/FormProviderDecorator';
import { IconsProviderDecorator } from '~/testing/decorators/IconsProviderDecorator';
import { mockedPersonObjectMetadataItem } from '~/testing/mock-data/metadata';
import { SettingsDataModelFieldAboutForm } from '../SettingsDataModelFieldAboutForm';
import { mockedPersonObjectMetadataItem } from '~/testing/mock-data/metadata';
import { SettingsDataModelFieldIconLabelForm } from '../SettingsDataModelFieldIconLabelForm';
const StyledContainer = styled.div`
flex: 1;
`;
const meta: Meta<typeof SettingsDataModelFieldAboutForm> = {
title: 'Modules/Settings/DataModel/SettingsDataModelFieldAboutForm',
component: SettingsDataModelFieldAboutForm,
const meta: Meta<typeof SettingsDataModelFieldIconLabelForm> = {
title: 'Modules/Settings/DataModel/SettingsDataModelFieldIconLabelForm',
component: SettingsDataModelFieldIconLabelForm,
decorators: [
(Story) => (
<StyledContainer>
@ -28,11 +28,11 @@ const meta: Meta<typeof SettingsDataModelFieldAboutForm> = {
};
export default meta;
type Story = StoryObj<typeof SettingsDataModelFieldAboutForm>;
type Story = StoryObj<typeof SettingsDataModelFieldIconLabelForm>;
export const Default: Story = {};
export const WithDefaultValues: Story = {
export const WithFieldMetadataItem: Story = {
args: {
fieldMetadataItem: mockedPersonObjectMetadataItem.fields.find(
({ name }) => name === 'name',
@ -45,3 +45,9 @@ export const Disabled: Story = {
disabled: true,
},
};
export const WithMaxLength: Story = {
args: {
maxLength: 50,
},
};

View File

@ -24,12 +24,6 @@ type Story = StoryObj<typeof SettingsDataModelFieldTypeSelect>;
export const Default: Story = {};
export const Disabled: Story = {
args: {
disabled: true,
},
};
export const WithOpenSelect: Story = {
play: async () => {
const canvas = within(document.body);

View File

@ -1,13 +1,15 @@
import { z } from 'zod';
import { settingsDataModelFieldAboutFormSchema } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldAboutForm';
import { settingsDataModelFieldDescriptionFormSchema } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldDescriptionForm';
import { settingsDataModelFieldIconLabelFormSchema } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldIconLabelForm';
import { settingsDataModelFieldSettingsFormSchema } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard';
import { settingsDataModelFieldTypeFormSchema } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldTypeSelect';
export const settingsFieldFormSchema = (existingLabels?: string[]) => {
return z
.object({})
.merge(settingsDataModelFieldAboutFormSchema(existingLabels))
.merge(settingsDataModelFieldIconLabelFormSchema(existingLabels))
.merge(settingsDataModelFieldDescriptionFormSchema())
.merge(settingsDataModelFieldTypeFormSchema)
.and(settingsDataModelFieldSettingsFormSchema);
};