diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts b/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts index 36c8b7be7..41d4cea56 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts @@ -62,9 +62,9 @@ export const getObjectRecordIdentifier = ({ // TODO: This is a temporary solution before we seed imageIdentifierFieldMetadataId in the database const avatarUrl = (objectMetadataItem.nameSingular === CoreObjectNameSingular.Company - ? getLogoUrlFromDomainName(record['domainName'] ?? '') + ? getLogoUrlFromDomainName(record.domainName ?? '') : objectMetadataItem.nameSingular === CoreObjectNameSingular.Person - ? record['avatarUrl'] ?? '' + ? record.avatarUrl ?? '' : imageIdentifierFieldValue) ?? ''; const basePathToShowPage = getBasePathToShowPage({ @@ -74,9 +74,10 @@ export const getObjectRecordIdentifier = ({ const isWorkspaceMemberObjectMetadata = objectMetadataItem.nameSingular === CoreObjectNameSingular.WorkspaceMember; - const linkToShowPage = isWorkspaceMemberObjectMetadata - ? '' - : `${basePathToShowPage}${record.id}`; + const linkToShowPage = + isWorkspaceMemberObjectMetadata || !record.id + ? '' + : `${basePathToShowPage}${record.id}`; return { id: record.id, diff --git a/packages/twenty-front/src/modules/object-record/record-field/__mocks__/fieldDefinitions.ts b/packages/twenty-front/src/modules/object-record/record-field/__mocks__/fieldDefinitions.ts index 9c3405f19..576289add 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/__mocks__/fieldDefinitions.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/__mocks__/fieldDefinitions.ts @@ -14,7 +14,7 @@ import { export const fieldMetadataId = 'fieldMetadataId'; -const mockedPersonObjectMetadataItem = { +export const mockedPersonObjectMetadataItem = { ...mockedPeopleMetadata.node, fields: mockedPeopleMetadata.node.fields.edges.map(({ node }) => node), }; diff --git a/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldTypeCard.tsx b/packages/twenty-front/src/modules/settings/data-model/components/SettingsDataModelPreviewFormCard.tsx similarity index 57% rename from packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldTypeCard.tsx rename to packages/twenty-front/src/modules/settings/data-model/components/SettingsDataModelPreviewFormCard.tsx index a7fceae81..34e514abe 100644 --- a/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldTypeCard.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/components/SettingsDataModelPreviewFormCard.tsx @@ -4,14 +4,12 @@ import styled from '@emotion/styled'; import { Card } from '@/ui/layout/card/components/Card'; import { CardContent } from '@/ui/layout/card/components/CardContent'; -type SettingsObjectFieldTypeCardProps = { +type SettingsDataModelPreviewFormCardProps = { className?: string; preview: ReactNode; form?: ReactNode; }; -const StyledCard = styled(Card)``; - const StyledPreviewContainer = styled(CardContent)` background-color: ${({ theme }) => theme.background.transparent.lighter}; `; @@ -24,27 +22,20 @@ const StyledTitle = styled.h3` margin-bottom: ${({ theme }) => theme.spacing(4)}; `; -const StyledPreviewContent = styled.div` - display: flex; - gap: 6px; -`; - const StyledFormContainer = styled(CardContent)` padding: 0; `; -export const SettingsObjectFieldTypeCard = ({ +export const SettingsDataModelPreviewFormCard = ({ className, preview, form, -}: SettingsObjectFieldTypeCardProps) => { - return ( - - - Preview - {preview} - - {!!form && {form}} - - ); -}; +}: SettingsDataModelPreviewFormCardProps) => ( + + + Preview + {preview} + + {!!form && {form}} + +); diff --git a/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldPreview.tsx b/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldPreview.tsx deleted file mode 100644 index 7c13d9841..000000000 --- a/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldPreview.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { useTheme } from '@emotion/react'; -import styled from '@emotion/styled'; - -import { parseFieldType } from '@/object-metadata/utils/parseFieldType'; -import { FieldDisplay } from '@/object-record/record-field/components/FieldDisplay'; -import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; -import { BooleanFieldInput } from '@/object-record/record-field/meta-types/input/components/BooleanFieldInput'; -import { RatingFieldInput } from '@/object-record/record-field/meta-types/input/components/RatingFieldInput'; -import { Tag } from '@/ui/display/tag/components/Tag'; -import { Card } from '@/ui/layout/card/components/Card'; -import { CardContent } from '@/ui/layout/card/components/CardContent'; -import { Field, FieldMetadataType } from '~/generated-metadata/graphql'; - -import { SettingsObjectFieldPreviewValueEffect } from '../components/SettingsObjectFieldPreviewValueEffect'; -import { useFieldPreview } from '../hooks/useFieldPreview'; - -import { SettingsObjectFieldSelectFormValues } from './SettingsObjectFieldSelectForm'; - -export type SettingsObjectFieldPreviewProps = { - className?: string; - fieldMetadata: Pick & { id?: string }; - objectMetadataId: string; - relationObjectMetadataId?: string; - selectOptions?: SettingsObjectFieldSelectFormValues; - shrink?: boolean; -}; - -const StyledCard = styled(Card)` - border-radius: ${({ theme }) => theme.border.radius.md}; - color: ${({ theme }) => theme.font.color.primary}; - max-width: 480px; -`; - -const StyledCardContent = styled(CardContent)` - display: grid; - padding: ${({ theme }) => theme.spacing(2)}; -`; - -const StyledObjectSummary = styled.div` - align-items: center; - display: flex; - gap: ${({ theme }) => theme.spacing(2)}; - justify-content: space-between; - margin-bottom: ${({ theme }) => theme.spacing(2)}; -`; - -const StyledObjectName = styled.div` - align-items: center; - display: flex; - font-weight: ${({ theme }) => theme.font.weight.medium}; - gap: ${({ theme }) => theme.spacing(1)}; -`; - -const StyledFieldPreview = styled.div<{ shrink?: boolean }>` - align-items: center; - background-color: ${({ theme }) => theme.background.primary}; - border: 1px solid ${({ theme }) => theme.border.color.medium}; - border-radius: ${({ theme }) => theme.border.radius.sm}; - display: flex; - gap: ${({ theme }) => theme.spacing(2)}; - height: ${({ theme }) => theme.spacing(8)}; - overflow: hidden; - padding: 0 - ${({ shrink, theme }) => (shrink ? theme.spacing(1) : theme.spacing(2))}; - white-space: nowrap; -`; - -const StyledFieldLabel = styled.div` - align-items: center; - color: ${({ theme }) => theme.font.color.tertiary}; - display: flex; - gap: ${({ theme }) => theme.spacing(1)}; -`; - -export const SettingsObjectFieldPreview = ({ - className, - fieldMetadata, - objectMetadataId, - relationObjectMetadataId, - selectOptions, - shrink, -}: SettingsObjectFieldPreviewProps) => { - const theme = useTheme(); - - const { - entityId, - FieldIcon, - fieldName, - ObjectIcon, - objectMetadataItem, - relationObjectMetadataItem, - value, - } = useFieldPreview({ - fieldMetadata, - objectMetadataId, - relationObjectMetadataId, - selectOptions, - }); - - return ( - - - - - {!!ObjectIcon && ( - - )} - {objectMetadataItem?.labelPlural} - - {objectMetadataItem?.isCustom ? ( - - ) : ( - - )} - - - - - {!!FieldIcon && ( - - )} - {fieldMetadata.label}: - - - {fieldMetadata.type === FieldMetadataType.Boolean ? ( - - ) : fieldMetadata.type === FieldMetadataType.Rating ? ( - - ) : ( - - )} - - - - - ); -}; diff --git a/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldTypeSelectSection.tsx b/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldTypeSelectSection.tsx deleted file mode 100644 index 6fa6a6fc8..000000000 --- a/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldTypeSelectSection.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import styled from '@emotion/styled'; - -import { RELATION_TYPES } from '@/settings/data-model/constants/RelationTypes'; -import { SETTINGS_FIELD_METADATA_TYPES } from '@/settings/data-model/constants/SettingsFieldMetadataTypes'; -import { H2Title } from '@/ui/display/typography/components/H2Title'; -import { Select } from '@/ui/input/components/Select'; -import { Section } from '@/ui/layout/section/components/Section'; -import { Field, FieldMetadataType } from '~/generated-metadata/graphql'; - -import { - SettingsObjectFieldCurrencyForm, - SettingsObjectFieldCurrencyFormValues, -} from './SettingsObjectFieldCurrencyForm'; -import { - SettingsObjectFieldPreview, - SettingsObjectFieldPreviewProps, -} from './SettingsObjectFieldPreview'; -import { - SettingsObjectFieldRelationForm, - SettingsObjectFieldRelationFormValues, -} from './SettingsObjectFieldRelationForm'; -import { - SettingsObjectFieldSelectForm, - SettingsObjectFieldSelectFormValues, -} from './SettingsObjectFieldSelectForm'; -import { SettingsObjectFieldTypeCard } from './SettingsObjectFieldTypeCard'; - -export type SettingsObjectFieldTypeSelectSectionFormValues = { - type: FieldMetadataType; - currency: SettingsObjectFieldCurrencyFormValues; - relation: SettingsObjectFieldRelationFormValues; - select: SettingsObjectFieldSelectFormValues; -}; - -type SettingsObjectFieldTypeSelectSectionProps = { - disableCurrencyForm?: boolean; - excludedFieldTypes?: FieldMetadataType[]; - fieldMetadata: Pick & { id?: string }; - onChange: ( - values: Partial, - ) => void; - relationFieldMetadata?: Pick; - values: SettingsObjectFieldTypeSelectSectionFormValues; -} & Pick; - -const StyledSettingsObjectFieldTypeCard = styled(SettingsObjectFieldTypeCard)` - margin-top: ${({ theme }) => theme.spacing(4)}; -`; - -const StyledSettingsObjectFieldPreview = styled(SettingsObjectFieldPreview)` - display: grid; - flex: 1 1 100%; -`; - -const StyledRelationImage = styled.img<{ flip?: boolean }>` - transform: ${({ flip }) => (flip ? 'scaleX(-1)' : 'none')}; - width: 54px; -`; - -export const SettingsObjectFieldTypeSelectSection = ({ - disableCurrencyForm, - excludedFieldTypes, - fieldMetadata, - objectMetadataId, - onChange, - relationFieldMetadata, - values, -}: SettingsObjectFieldTypeSelectSectionProps) => { - const currencyFormConfig = values.currency; - const relationFormConfig = values.relation; - const selectFormConfig = values.select; - - const fieldTypeOptions = Object.entries(SETTINGS_FIELD_METADATA_TYPES) - .filter(([key]) => !excludedFieldTypes?.includes(key as FieldMetadataType)) - .map(([key, dataTypeConfig]) => ({ - value: key as FieldMetadataType, - ...dataTypeConfig, - })); - - return ( -
- - onChange?.({ type: value })} + options={fieldTypeOptions} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/__stories__/SettingsDataModelFieldSettingsFormCard.stories.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/__stories__/SettingsDataModelFieldSettingsFormCard.stories.tsx new file mode 100644 index 000000000..c6e6e520e --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/__stories__/SettingsDataModelFieldSettingsFormCard.stories.tsx @@ -0,0 +1,115 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; + +import { + mockedCompanyObjectMetadataItem, + mockedPersonObjectMetadataItem, +} from '@/object-record/record-field/__mocks__/fieldDefinitions'; +import { fieldMetadataFormDefaultValues } from '@/settings/data-model/fields/forms/hooks/useFieldMetadataForm'; +import { + FieldMetadataType, + RelationMetadataType, +} from '~/generated-metadata/graphql'; +import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator'; +import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator'; +import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; +import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; +import { graphqlMocks } from '~/testing/graphqlMocks'; + +import { SettingsDataModelFieldSettingsFormCard } from '../SettingsDataModelFieldSettingsFormCard'; + +const fieldMetadataItem = mockedCompanyObjectMetadataItem.fields.find( + ({ type }) => type === FieldMetadataType.Text, +)!; + +const defaultValues = { + currency: fieldMetadataFormDefaultValues.currency, + relation: fieldMetadataFormDefaultValues.relation, + select: fieldMetadataFormDefaultValues.select, +}; + +const meta: Meta = { + title: + 'Modules/Settings/DataModel/Fields/Forms/SettingsDataModelFieldSettingsFormCard', + component: SettingsDataModelFieldSettingsFormCard, + decorators: [ + ComponentDecorator, + ObjectMetadataItemsDecorator, + SnackBarDecorator, + ], + args: { + fieldMetadataItem, + objectMetadataItem: mockedCompanyObjectMetadataItem, + onChange: fn(), + values: defaultValues, + }, + parameters: { + container: { width: 512 }, + msw: graphqlMocks, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +const relationFieldMetadataItem = mockedPersonObjectMetadataItem.fields.find( + ({ name }) => name === 'company', +)!; + +export const WithRelationForm: Story = { + decorators: [MemoryRouterDecorator], + args: { + fieldMetadataItem: mockedCompanyObjectMetadataItem.fields.find( + ({ name }) => name === 'people', + ), + relationFieldMetadataItem, + values: { + ...defaultValues, + relation: { + field: relationFieldMetadataItem, + objectMetadataId: mockedPersonObjectMetadataItem.id, + type: RelationMetadataType.OneToMany, + }, + }, + }, +}; + +export const WithSelectForm: Story = { + args: { + fieldMetadataItem: { + label: 'Industry', + icon: 'IconBuildingFactory2', + type: FieldMetadataType.Select, + }, + values: { + ...defaultValues, + select: [ + { + color: 'pink', + isDefault: true, + label: '💊 Health', + value: 'HEALTH', + }, + { + color: 'purple', + label: '🏭 Industry', + value: 'INDUSTRY', + }, + { color: 'sky', label: '🤖 SaaS', value: 'SAAS' }, + { + color: 'turquoise', + label: '🌿 Green tech', + value: 'GREEN_TECH', + }, + { + color: 'yellow', + label: '🚲 Mobility', + value: 'MOBILITY', + }, + { color: 'green', label: '🌏 NGO', value: 'NGO' }, + ], + }, + }, +}; diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/__stories__/SettingsDataModelFieldTypeSelect.stories.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/__stories__/SettingsDataModelFieldTypeSelect.stories.tsx new file mode 100644 index 000000000..35b6b4320 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/__stories__/SettingsDataModelFieldTypeSelect.stories.tsx @@ -0,0 +1,67 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { expect, fn, userEvent, within } from '@storybook/test'; + +import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator'; +import { graphqlMocks } from '~/testing/graphqlMocks'; + +import { SettingsDataModelFieldTypeSelect } from '../SettingsDataModelFieldTypeSelect'; + +const meta: Meta = { + title: + 'Modules/Settings/DataModel/Fields/Forms/SettingsDataModelFieldTypeSelect', + component: SettingsDataModelFieldTypeSelect, + decorators: [ComponentDecorator], + args: { + onChange: fn(), + value: FieldMetadataType.Text, + }, + parameters: { + container: { width: 512 }, + msw: graphqlMocks, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; + +export const WithOpenSelect: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const inputField = await canvas.findByText('Text'); + + await userEvent.click(inputField); + + const input = await canvas.findByText('Unique ID'); + await userEvent.click(input); + + await userEvent.click(inputField); + }, +}; + +export const WithExcludedFieldTypes: Story = { + args: { + excludedFieldTypes: [FieldMetadataType.Uuid, FieldMetadataType.Numeric], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const inputField = await canvas.findByText('Text'); + + await userEvent.click(inputField); + + await canvas.findByText('Number'); + + expect(canvas.queryByText('Unique ID')).toBeNull(); + expect(canvas.queryByText('Numeric')).toBeNull(); + }, +}; diff --git a/packages/twenty-front/src/modules/settings/data-model/hooks/__tests__/useFieldMetadataForm.test.ts b/packages/twenty-front/src/modules/settings/data-model/fields/forms/hooks/__tests__/useFieldMetadataForm.test.ts similarity index 100% rename from packages/twenty-front/src/modules/settings/data-model/hooks/__tests__/useFieldMetadataForm.test.ts rename to packages/twenty-front/src/modules/settings/data-model/fields/forms/hooks/__tests__/useFieldMetadataForm.test.ts diff --git a/packages/twenty-front/src/modules/settings/data-model/hooks/useFieldMetadataForm.ts b/packages/twenty-front/src/modules/settings/data-model/fields/forms/hooks/useFieldMetadataForm.ts similarity index 96% rename from packages/twenty-front/src/modules/settings/data-model/hooks/useFieldMetadataForm.ts rename to packages/twenty-front/src/modules/settings/data-model/fields/forms/hooks/useFieldMetadataForm.ts index 66d5cddd0..9353ae601 100644 --- a/packages/twenty-front/src/modules/settings/data-model/hooks/useFieldMetadataForm.ts +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/hooks/useFieldMetadataForm.ts @@ -11,13 +11,14 @@ import { } from '~/generated-metadata/graphql'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; -import { SettingsObjectFieldTypeSelectSectionFormValues } from '../components/SettingsObjectFieldTypeSelectSection'; +import { SettingsDataModelFieldSettingsFormValues } from '../components/SettingsDataModelFieldSettingsFormCard'; type FormValues = { description?: string; icon: string; label: string; -} & SettingsObjectFieldTypeSelectSectionFormValues; + type: FieldMetadataType; +} & SettingsDataModelFieldSettingsFormValues; export const fieldMetadataFormDefaultValues: FormValues = { icon: 'IconUsers', diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelFieldPreview.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelFieldPreview.tsx new file mode 100644 index 000000000..099ed694e --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelFieldPreview.tsx @@ -0,0 +1,125 @@ +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; + +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { parseFieldType } from '@/object-metadata/utils/parseFieldType'; +import { FieldDisplay } from '@/object-record/record-field/components/FieldDisplay'; +import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; +import { BooleanFieldInput } from '@/object-record/record-field/meta-types/input/components/BooleanFieldInput'; +import { RatingFieldInput } from '@/object-record/record-field/meta-types/input/components/RatingFieldInput'; +import { SettingsObjectFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm'; +import { SettingsDataModelSetFieldValueEffect } from '@/settings/data-model/fields/preview/components/SettingsDataModelSetFieldValueEffect'; +import { SettingsDataModelSetRecordEffect } from '@/settings/data-model/fields/preview/components/SettingsDataModelSetRecordEffect'; +import { useFieldPreview } from '@/settings/data-model/fields/preview/hooks/useFieldPreview'; +import { useIcons } from '@/ui/display/icon/hooks/useIcons'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +export type SettingsDataModelFieldPreviewProps = { + fieldMetadataItem: Pick & { + id?: string; + name?: string; + }; + objectMetadataItem: ObjectMetadataItem; + relationObjectMetadataItem?: ObjectMetadataItem; + selectOptions?: SettingsObjectFieldSelectFormValues; + shrink?: boolean; + withFieldLabel?: boolean; +}; + +const StyledFieldPreview = styled.div<{ shrink?: boolean }>` + align-items: center; + background-color: ${({ theme }) => theme.background.primary}; + border: 1px solid ${({ theme }) => theme.border.color.medium}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + display: flex; + gap: ${({ theme }) => theme.spacing(2)}; + height: ${({ theme }) => theme.spacing(8)}; + overflow: hidden; + padding: 0 + ${({ shrink, theme }) => (shrink ? theme.spacing(1) : theme.spacing(2))}; + white-space: nowrap; + margin-top: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledFieldLabel = styled.div` + align-items: center; + color: ${({ theme }) => theme.font.color.tertiary}; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; +`; + +export const SettingsDataModelFieldPreview = ({ + fieldMetadataItem, + objectMetadataItem, + relationObjectMetadataItem, + selectOptions, + shrink, + withFieldLabel = true, +}: SettingsDataModelFieldPreviewProps) => { + const theme = useTheme(); + + const { getIcon } = useIcons(); + const FieldIcon = getIcon(fieldMetadataItem.icon); + + const { entityId, fieldName, fieldPreviewValue, isLabelIdentifier, record } = + useFieldPreview({ + fieldMetadataItem, + objectMetadataItem, + relationObjectMetadataItem, + selectOptions, + }); + + return ( + <> + {record ? ( + + ) : ( + + )} + + {!!withFieldLabel && ( + + + {fieldMetadataItem.label}: + + )} + + {fieldMetadataItem.type === FieldMetadataType.Boolean ? ( + + ) : fieldMetadataItem.type === FieldMetadataType.Rating ? ( + + ) : ( + + )} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard.tsx new file mode 100644 index 000000000..ef26a404e --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard.tsx @@ -0,0 +1,48 @@ +import styled from '@emotion/styled'; + +import { + SettingsDataModelFieldPreview, + SettingsDataModelFieldPreviewProps, +} from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreview'; +import { SettingsDataModelObjectSummary } from '@/settings/data-model/objects/SettingsDataModelObjectSummary'; +import { Card } from '@/ui/layout/card/components/Card'; +import { CardContent } from '@/ui/layout/card/components/CardContent'; + +export type SettingsDataModelFieldPreviewCardProps = + SettingsDataModelFieldPreviewProps & { + className?: string; + }; + +const StyledCard = styled(Card)` + border-radius: ${({ theme }) => theme.border.radius.md}; + color: ${({ theme }) => theme.font.color.primary}; +`; + +const StyledCardContent = styled(CardContent)` + display: grid; + padding: ${({ theme }) => theme.spacing(2)}; +`; + +export const SettingsDataModelFieldPreviewCard = ({ + className, + fieldMetadataItem, + objectMetadataItem, + relationObjectMetadataItem, + selectOptions, + shrink, + withFieldLabel = true, +}: SettingsDataModelFieldPreviewCardProps) => ( + + + + + + +); diff --git a/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldPreviewValueEffect.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelSetFieldValueEffect.tsx similarity index 76% rename from packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldPreviewValueEffect.tsx rename to packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelSetFieldValueEffect.tsx index e946f484d..d6d23bf77 100644 --- a/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldPreviewValueEffect.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelSetFieldValueEffect.tsx @@ -3,17 +3,17 @@ import { useSetRecoilState } from 'recoil'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; -type SettingsObjectFieldPreviewValueEffectProps = { +type SettingsDataModelSetFieldValueEffectProps = { entityId: string; fieldName: string; value: unknown; }; -export const SettingsObjectFieldPreviewValueEffect = ({ +export const SettingsDataModelSetFieldValueEffect = ({ entityId, fieldName, value, -}: SettingsObjectFieldPreviewValueEffectProps) => { +}: SettingsDataModelSetFieldValueEffectProps) => { const setFieldValue = useSetRecoilState( recordStoreFamilySelector({ recordId: entityId, diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelSetRecordEffect.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelSetRecordEffect.tsx new file mode 100644 index 000000000..c34a07f77 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelSetRecordEffect.tsx @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; + +import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; + +type SettingsDataModelSetRecordEffectProps = { + record: ObjectRecord; +}; + +export const SettingsDataModelSetRecordEffect = ({ + record, +}: SettingsDataModelSetRecordEffectProps) => { + const { setRecords: setRecordsInStore } = useSetRecordInStore(); + + useEffect(() => { + setRecordsInStore([record]); + }, [record, setRecordsInStore]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/__stories__/SettingsDataModelFieldPreviewCard.stories.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/__stories__/SettingsDataModelFieldPreviewCard.stories.tsx new file mode 100644 index 000000000..f81b2072c --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/__stories__/SettingsDataModelFieldPreviewCard.stories.tsx @@ -0,0 +1,111 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { + mockedCompanyObjectMetadataItem, + mockedPersonObjectMetadataItem, +} from '@/object-record/record-field/__mocks__/fieldDefinitions'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator'; +import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator'; +import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; +import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; +import { graphqlMocks } from '~/testing/graphqlMocks'; + +import { SettingsDataModelFieldPreviewCard } from '../SettingsDataModelFieldPreviewCard'; + +const meta: Meta = { + title: + 'Modules/Settings/DataModel/Fields/Preview/SettingsDataModelFieldPreviewCard', + component: SettingsDataModelFieldPreviewCard, + decorators: [ + ComponentDecorator, + ObjectMetadataItemsDecorator, + SnackBarDecorator, + ], + args: { + fieldMetadataItem: mockedCompanyObjectMetadataItem.fields.find( + ({ type }) => type === FieldMetadataType.Text, + ), + objectMetadataItem: mockedCompanyObjectMetadataItem, + }, + parameters: { + container: { width: 480 }, + msw: graphqlMocks, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Text: Story = {}; + +export const Boolean: Story = { + args: { + fieldMetadataItem: mockedCompanyObjectMetadataItem.fields.find( + ({ type }) => type === FieldMetadataType.Boolean, + ), + }, +}; + +export const Currency: Story = { + args: { + fieldMetadataItem: mockedCompanyObjectMetadataItem.fields.find( + ({ type }) => type === FieldMetadataType.Currency, + ), + }, +}; + +export const Date: Story = { + args: { + fieldMetadataItem: mockedCompanyObjectMetadataItem.fields.find( + ({ type }) => type === FieldMetadataType.DateTime, + ), + }, +}; + +export const Link: Story = { + decorators: [MemoryRouterDecorator], + args: { + fieldMetadataItem: mockedCompanyObjectMetadataItem.fields.find( + ({ type }) => type === FieldMetadataType.Link, + ), + }, +}; + +export const Number: Story = { + args: { + fieldMetadataItem: mockedCompanyObjectMetadataItem.fields.find( + ({ type }) => type === FieldMetadataType.Number, + ), + }, +}; + +export const Rating: Story = { + args: { + fieldMetadataItem: { + icon: 'IconHandClick', + label: 'Engagement', + type: FieldMetadataType.Rating, + }, + }, +}; + +export const Relation: Story = { + decorators: [MemoryRouterDecorator], + args: { + fieldMetadataItem: mockedPersonObjectMetadataItem.fields.find( + ({ name }) => name === 'company', + ), + objectMetadataItem: mockedPersonObjectMetadataItem, + relationObjectMetadataItem: mockedCompanyObjectMetadataItem, + }, +}; + +export const CustomObject: Story = { + args: { + fieldMetadataItem: mockedCompanyObjectMetadataItem.fields.find( + ({ isCustom }) => isCustom, + ), + objectMetadataItem: mockedCompanyObjectMetadataItem, + }, +}; diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/preview/hooks/__tests__/useFieldPreview.test.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/preview/hooks/__tests__/useFieldPreview.test.tsx new file mode 100644 index 000000000..8866c5f82 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/fields/preview/hooks/__tests__/useFieldPreview.test.tsx @@ -0,0 +1,67 @@ +import { ReactNode } from 'react'; +import { MockedProvider } from '@apollo/client/testing'; +import { renderHook } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; + +import { mockedCompanyObjectMetadataItem } from '@/object-record/record-field/__mocks__/fieldDefinitions'; +import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope'; + +import { useFieldPreview } from '../useFieldPreview'; + +const Wrapper = ({ children }: { children: ReactNode }) => ( + + + + {children} + + + +); + +describe('useFieldPreview', () => { + it('returns default preview data if no records are found', () => { + // Given + const objectMetadataItem = mockedCompanyObjectMetadataItem; + const fieldMetadataItem = mockedCompanyObjectMetadataItem.fields.find( + ({ name }) => name === 'linkedinLink', + )!; + + // When + const { result } = renderHook( + () => useFieldPreview({ fieldMetadataItem, objectMetadataItem }), + { wrapper: Wrapper }, + ); + + // Then + expect(result.current).toEqual({ + entityId: 'company-linkedinLink-preview-field-form', + fieldName: 'linkedinLink', + fieldPreviewValue: { label: '', url: 'www.twenty.com' }, + isLabelIdentifier: false, + record: null, + }); + }); + + it('returns default preview data for a label identifier field if no records are found', () => { + // Given + const objectMetadataItem = mockedCompanyObjectMetadataItem; + const fieldMetadataItem = mockedCompanyObjectMetadataItem.fields.find( + ({ name }) => name === 'name', + )!; + + // When + const { result } = renderHook( + () => useFieldPreview({ fieldMetadataItem, objectMetadataItem }), + { wrapper: Wrapper }, + ); + + // Then + expect(result.current).toEqual({ + entityId: 'company-name-preview-field-form', + fieldName: 'name', + fieldPreviewValue: 'Company', + isLabelIdentifier: true, + record: null, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/preview/hooks/useFieldPreview.ts b/packages/twenty-front/src/modules/settings/data-model/fields/preview/hooks/useFieldPreview.ts new file mode 100644 index 000000000..e42bc0061 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/fields/preview/hooks/useFieldPreview.ts @@ -0,0 +1,101 @@ +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField'; +import { parseFieldType } from '@/object-metadata/utils/parseFieldType'; +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty'; +import { SettingsObjectFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm'; +import { getFieldDefaultPreviewValue } from '@/settings/data-model/utils/getFieldDefaultPreviewValue'; +import { getFieldPreviewValueFromRecord } from '@/settings/data-model/utils/getFieldPreviewValueFromRecord'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +type UseFieldPreviewParams = { + fieldMetadataItem: Pick & { + id?: string; + name?: string; + }; + objectMetadataItem: ObjectMetadataItem; + relationObjectMetadataItem?: ObjectMetadataItem; + selectOptions?: SettingsObjectFieldSelectFormValues; +}; + +export const useFieldPreview = ({ + fieldMetadataItem, + objectMetadataItem, + relationObjectMetadataItem, + selectOptions, +}: UseFieldPreviewParams) => { + const isLabelIdentifier = + !!fieldMetadataItem.id && + !!fieldMetadataItem.name && + isLabelIdentifierField({ + fieldMetadataItem: { + id: fieldMetadataItem.id, + name: fieldMetadataItem.name, + }, + objectMetadataItem, + }); + + const { records } = useFindManyRecords({ + objectNameSingular: objectMetadataItem.nameSingular, + limit: 1, + skip: !fieldMetadataItem.name, + }); + const [firstRecord] = records; + + const fieldPreviewValueFromFirstRecord = + firstRecord && fieldMetadataItem.name + ? getFieldPreviewValueFromRecord({ + record: firstRecord, + fieldMetadataItem: { + name: fieldMetadataItem.name, + type: fieldMetadataItem.type, + }, + selectOptions, + }) + : null; + + const isValueFromFirstRecord = + firstRecord && + !isFieldValueEmpty({ + fieldDefinition: { type: parseFieldType(fieldMetadataItem.type) }, + fieldValue: fieldPreviewValueFromFirstRecord, + }); + + const { records: relationRecords } = useFindManyRecords({ + objectNameSingular: + relationObjectMetadataItem?.nameSingular || + CoreObjectNameSingular.Company, + limit: 1, + skip: + !relationObjectMetadataItem || + fieldMetadataItem.type !== FieldMetadataType.Relation || + isValueFromFirstRecord, + }); + const [firstRelationRecord] = relationRecords; + + const fieldPreviewValue = isValueFromFirstRecord + ? fieldPreviewValueFromFirstRecord + : firstRelationRecord ?? + getFieldDefaultPreviewValue({ + fieldMetadataItem, + objectMetadataItem, + relationObjectMetadataItem, + selectOptions, + }); + + const fieldName = + fieldMetadataItem.name || `${fieldMetadataItem.type}-new-field`; + const entityId = isValueFromFirstRecord + ? firstRecord.id + : `${objectMetadataItem.nameSingular}-${fieldMetadataItem.name}-preview-field-form`; + + return { + entityId, + fieldName, + fieldPreviewValue, + isLabelIdentifier, + record: isValueFromFirstRecord ? firstRecord : null, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/data-model/hooks/__tests__/useFieldPreview.test.tsx b/packages/twenty-front/src/modules/settings/data-model/hooks/__tests__/useFieldPreview.test.tsx deleted file mode 100644 index c42bb40fb..000000000 --- a/packages/twenty-front/src/modules/settings/data-model/hooks/__tests__/useFieldPreview.test.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { ReactNode } from 'react'; -import { MockedProvider } from '@apollo/client/testing'; -import { renderHook } from '@testing-library/react'; -import { RecoilRoot, useSetRecoilState } from 'recoil'; - -import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; -import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; -import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope'; - -import { useFieldPreview } from '../useFieldPreview'; - -const Wrapper = ({ children }: { children: ReactNode }) => ( - - - - {children} - - - -); - -const mockObjectMetadataItems = getObjectMetadataItemsMock(); - -describe('useFieldPreview', () => { - it('returns default values', () => { - const objectMetadataItem = mockObjectMetadataItems[1]; - const fieldMetadata = objectMetadataItem.fields[0]; - const { result } = renderHook( - () => { - const setMetadataItems = useSetRecoilState(objectMetadataItemsState); - setMetadataItems(mockObjectMetadataItems); - - return useFieldPreview({ - objectMetadataId: objectMetadataItem.id, - fieldMetadata, - }); - }, - { wrapper: Wrapper }, - ); - - expect(result.current.entityId).toBe(`${objectMetadataItem.id}-field-form`); - expect(result.current.FieldIcon).toBeDefined(); - expect(result.current.fieldName).toBe(fieldMetadata.name); - expect(result.current.ObjectIcon).toBeDefined(); - expect(result.current.fieldName).toBe(fieldMetadata.name); - expect(result.current.objectMetadataItem?.id).toBe(objectMetadataItem.id); - expect(result.current.relationObjectMetadataItem).toBeUndefined(); - expect(result.current.value).toBeDefined(); - }); -}); diff --git a/packages/twenty-front/src/modules/settings/data-model/hooks/useFieldPreview.ts b/packages/twenty-front/src/modules/settings/data-model/hooks/useFieldPreview.ts deleted file mode 100644 index cd9637714..000000000 --- a/packages/twenty-front/src/modules/settings/data-model/hooks/useFieldPreview.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings'; -import { SETTINGS_FIELD_METADATA_TYPES } from '@/settings/data-model/constants/SettingsFieldMetadataTypes'; -import { useIcons } from '@/ui/display/icon/hooks/useIcons'; -import { Field, FieldMetadataType } from '~/generated-metadata/graphql'; - -import { SettingsObjectFieldSelectFormOption } from '../types/SettingsObjectFieldSelectFormOption'; - -import { useFieldPreviewValue } from './useFieldPreviewValue'; -import { useRelationFieldPreviewValue } from './useRelationFieldPreviewValue'; - -export const useFieldPreview = ({ - fieldMetadata, - objectMetadataId, - relationObjectMetadataId, - selectOptions, -}: { - fieldMetadata: Pick & { id?: string }; - objectMetadataId: string; - relationObjectMetadataId?: string; - selectOptions?: SettingsObjectFieldSelectFormOption[]; -}) => { - const { findObjectMetadataItemById } = useObjectMetadataItemForSettings(); - const objectMetadataItem = findObjectMetadataItemById(objectMetadataId); - - const { getIcon } = useIcons(); - const ObjectIcon = getIcon(objectMetadataItem?.icon); - const FieldIcon = getIcon(fieldMetadata.icon); - - const fieldName = fieldMetadata.id - ? objectMetadataItem?.fields.find(({ id }) => id === fieldMetadata.id)?.name - : undefined; - - const { value: firstRecordFieldValue } = useFieldPreviewValue({ - fieldName: fieldName || '', - objectNamePlural: objectMetadataItem?.namePlural ?? '', - skip: - !fieldName || - !objectMetadataItem || - fieldMetadata.type === FieldMetadataType.Relation, - }); - - const { relationObjectMetadataItem, value: relationValue } = - useRelationFieldPreviewValue({ - relationObjectMetadataId, - skip: fieldMetadata.type !== FieldMetadataType.Relation, - }); - - const settingsFieldMetadataType = - SETTINGS_FIELD_METADATA_TYPES[fieldMetadata.type]; - - const defaultSelectValue = selectOptions?.[0]; - const selectValue = - fieldMetadata.type === FieldMetadataType.Select && - typeof firstRecordFieldValue === 'string' - ? selectOptions?.find( - (selectOption) => selectOption.value === firstRecordFieldValue, - ) - : undefined; - - return { - entityId: `${objectMetadataId}-field-form`, - FieldIcon, - fieldName: fieldName || `${fieldMetadata.type}-new-field`, - ObjectIcon, - objectMetadataItem, - relationObjectMetadataItem, - value: - fieldMetadata.type === FieldMetadataType.Relation - ? relationValue - : fieldMetadata.type === FieldMetadataType.Select - ? selectValue || defaultSelectValue - : firstRecordFieldValue || settingsFieldMetadataType?.defaultValue, - }; -}; diff --git a/packages/twenty-front/src/modules/settings/data-model/hooks/useFieldPreviewValue.ts b/packages/twenty-front/src/modules/settings/data-model/hooks/useFieldPreviewValue.ts deleted file mode 100644 index 55186d4e9..000000000 --- a/packages/twenty-front/src/modules/settings/data-model/hooks/useFieldPreviewValue.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural'; -import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; -import { assertNotNull } from '~/utils/assert'; - -export const useFieldPreviewValue = ({ - fieldName, - objectNamePlural, - skip, -}: { - fieldName: string; - objectNamePlural: string; - skip?: boolean; -}) => { - const { objectNameSingular } = useObjectNameSingularFromPlural({ - objectNamePlural, - }); - - const { records } = useFindManyRecords({ - objectNameSingular, - skip, - }); - - const firstRecordWithValue = records.find( - (record) => assertNotNull(record[fieldName]) && record[fieldName] !== '', - ); - - return { - value: firstRecordWithValue?.[fieldName], - }; -}; diff --git a/packages/twenty-front/src/modules/settings/data-model/hooks/useRelationFieldPreviewValue.ts b/packages/twenty-front/src/modules/settings/data-model/hooks/useRelationFieldPreviewValue.ts deleted file mode 100644 index e6caecb92..000000000 --- a/packages/twenty-front/src/modules/settings/data-model/hooks/useRelationFieldPreviewValue.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; - -export const useRelationFieldPreviewValue = ({ - relationObjectMetadataId, - skip, -}: { - relationObjectMetadataId?: string; - skip?: boolean; -}) => { - const { findObjectMetadataItemById } = useObjectMetadataItemForSettings(); - - // TODO: make this impossible to be undefined - const relationObjectMetadataItem = relationObjectMetadataId - ? findObjectMetadataItemById(relationObjectMetadataId) - : undefined; - - const { records: relationObjects } = useFindManyRecords({ - objectNameSingular: - relationObjectMetadataItem?.nameSingular ?? - CoreObjectNameSingular.Company, // TODO fix this hack - skip: skip || !relationObjectMetadataItem, - }); - - const label = relationObjectMetadataItem?.labelSingular ?? ''; - - return { - relationObjectMetadataItem, - value: relationObjects?.[0] ?? { - company: { name: label }, // Temporary mock for opportunities, this needs to be replaced once labelIdentifiers are implemented - name: label, - }, - }; -}; diff --git a/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectAboutSection.tsx b/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectAboutSection.tsx index cf60f1357..8abe35a90 100644 --- a/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectAboutSection.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectAboutSection.tsx @@ -1,9 +1,9 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; +import { SettingsDataModelIsCustomTag } from '@/settings/data-model/objects/SettingsDataModelIsCustomTag'; import { IconArchive, IconDotsVertical, IconPencil } from '@/ui/display/icon'; import { useIcons } from '@/ui/display/icon/hooks/useIcons'; -import { Tag } from '@/ui/display/tag/components/Tag'; import { H2Title } from '@/ui/display/typography/components/H2Title'; import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; import { Card } from '@/ui/layout/card/components/Card'; @@ -38,7 +38,7 @@ const StyledName = styled.div` margin-right: auto; `; -const StyledTag = styled(Tag)` +const StyledIsCustomTag = styled(SettingsDataModelIsCustomTag)` box-sizing: border-box; height: ${({ theme }) => theme.spacing(6)}; `; @@ -77,11 +77,7 @@ export const SettingsAboutSection = ({ {!!Icon && } {name} - {isCustom ? ( - - ) : ( - - )} + - {objectItem.isCustom ? ( - - ) : ( - - )} + {objectItem.fields.filter((field) => !field.isSystem).length} diff --git a/packages/twenty-front/src/modules/settings/data-model/objects/SettingsDataModelIsCustomTag.tsx b/packages/twenty-front/src/modules/settings/data-model/objects/SettingsDataModelIsCustomTag.tsx new file mode 100644 index 000000000..4e4394a69 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/objects/SettingsDataModelIsCustomTag.tsx @@ -0,0 +1,18 @@ +import { Tag } from '@/ui/display/tag/components/Tag'; + +type SettingsDataModelIsCustomTagProps = { + className?: string; + isCustom?: boolean; +}; + +export const SettingsDataModelIsCustomTag = ({ + className, + isCustom, +}: SettingsDataModelIsCustomTagProps) => ( + +); diff --git a/packages/twenty-front/src/modules/settings/data-model/objects/SettingsDataModelObjectSummary.tsx b/packages/twenty-front/src/modules/settings/data-model/objects/SettingsDataModelObjectSummary.tsx new file mode 100644 index 000000000..5b7b44740 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/objects/SettingsDataModelObjectSummary.tsx @@ -0,0 +1,48 @@ +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; + +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { SettingsDataModelIsCustomTag } from '@/settings/data-model/objects/SettingsDataModelIsCustomTag'; +import { useIcons } from '@/ui/display/icon/hooks/useIcons'; + +export type SettingsDataModelObjectSummaryProps = { + className?: string; + objectMetadataItem: Pick< + ObjectMetadataItem, + 'icon' | 'isCustom' | 'labelPlural' + >; +}; + +const StyledObjectSummary = styled.div` + align-items: center; + display: flex; + gap: ${({ theme }) => theme.spacing(2)}; + justify-content: space-between; +`; + +const StyledObjectName = styled.div` + align-items: center; + display: flex; + font-weight: ${({ theme }) => theme.font.weight.medium}; + gap: ${({ theme }) => theme.spacing(1)}; +`; + +export const SettingsDataModelObjectSummary = ({ + className, + objectMetadataItem, +}: SettingsDataModelObjectSummaryProps) => { + const theme = useTheme(); + + const { getIcon } = useIcons(); + const ObjectIcon = getIcon(objectMetadataItem.icon); + + return ( + + + + {objectMetadataItem.labelPlural} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectSettingsFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectSettingsFormCard.tsx new file mode 100644 index 000000000..31331818c --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectSettingsFormCard.tsx @@ -0,0 +1,56 @@ +import styled from '@emotion/styled'; + +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem'; +import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard'; +import { SettingsDataModelFieldPreviewCard } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard'; +import { SettingsDataModelObjectSummary } from '@/settings/data-model/objects/SettingsDataModelObjectSummary'; +import { Card } from '@/ui/layout/card/components/Card'; +import { CardContent } from '@/ui/layout/card/components/CardContent'; + +type SettingsDataModelObjectSettingsFormCardProps = { + objectMetadataItem: ObjectMetadataItem; +}; + +const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)` + width: 100%; +`; + +const StyledObjectSummaryCard = styled(Card)` + border-radius: ${({ theme }) => theme.border.radius.md}; + color: ${({ theme }) => theme.font.color.primary}; + max-width: 480px; +`; + +const StyledObjectSummaryCardContent = styled(CardContent)` + padding: ${({ theme }) => theme.spacing(2)}; +`; + +export const SettingsDataModelObjectSettingsFormCard = ({ + objectMetadataItem, +}: SettingsDataModelObjectSettingsFormCardProps) => { + const labelIdentifierFieldMetadataItem = + getLabelIdentifierFieldMetadataItem(objectMetadataItem); + + return ( + + ) : ( + + + + + + ) + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/settings/data-model/utils/__tests__/getFieldDefaultPreviewValue.test.ts b/packages/twenty-front/src/modules/settings/data-model/utils/__tests__/getFieldDefaultPreviewValue.test.ts new file mode 100644 index 000000000..18b9117d6 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/utils/__tests__/getFieldDefaultPreviewValue.test.ts @@ -0,0 +1,203 @@ +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { + mockedCompanyObjectMetadataItem, + mockedPersonObjectMetadataItem, +} from '@/object-record/record-field/__mocks__/fieldDefinitions'; +import { SettingsObjectFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm'; + +import { getFieldDefaultPreviewValue } from '../getFieldDefaultPreviewValue'; + +describe('getFieldDefaultPreviewValue', () => { + describe('SELECT field', () => { + it('returns the default select option', () => { + // Given + const objectMetadataItem = mockedCompanyObjectMetadataItem; + const fieldMetadataItem = mockedCompanyObjectMetadataItem.fields.find( + ({ name }) => name === 'industry', + )!; + const selectOptions: SettingsObjectFieldSelectFormValues = [ + { + color: 'purple', + label: '🏭 Industry', + value: 'INDUSTRY', + }, + { + color: 'pink', + isDefault: true, + label: '💊 Health', + value: 'HEALTH', + }, + ]; + + // When + const result = getFieldDefaultPreviewValue({ + objectMetadataItem, + fieldMetadataItem, + selectOptions, + }); + + // Then + expect(result).toEqual(selectOptions[1]); + }); + + it('returns the first select option if no default option was found', () => { + // Given + const objectMetadataItem = mockedCompanyObjectMetadataItem; + const fieldMetadataItem = mockedCompanyObjectMetadataItem.fields.find( + ({ name }) => name === 'industry', + )!; + const selectOptions = [ + { + color: 'purple' as const, + label: '🏭 Industry', + value: 'INDUSTRY', + }, + { + color: 'pink' as const, + label: '💊 Health', + value: 'HEALTH', + }, + ]; + + // When + const result = getFieldDefaultPreviewValue({ + objectMetadataItem, + fieldMetadataItem, + selectOptions, + }); + + // Then + expect(result).toEqual(selectOptions[0]); + }); + }); + + describe('RELATION field', () => { + it('returns a record with a default label identifier (if relation label identifier type !== TEXT)', () => { + // Given + const objectMetadataItem = mockedCompanyObjectMetadataItem; + const fieldMetadataItem = mockedCompanyObjectMetadataItem.fields.find( + ({ name }) => name === 'people', + )!; + const relationObjectMetadataItem = mockedPersonObjectMetadataItem; + + // When + const result = getFieldDefaultPreviewValue({ + objectMetadataItem, + fieldMetadataItem, + relationObjectMetadataItem, + }); + + // Then + expect(result).toEqual({ + name: { + firstName: 'John', + lastName: 'Doe', + }, + }); + }); + + it('returns a record with the relation object label singular as label identifier (if relation label identifier type === TEXT)', () => { + // Given + const objectMetadataItem = mockedPersonObjectMetadataItem; + const fieldMetadataItem = mockedPersonObjectMetadataItem.fields.find( + ({ name }) => name === 'company', + )!; + const relationObjectMetadataItem = mockedCompanyObjectMetadataItem; + + // When + const result = getFieldDefaultPreviewValue({ + objectMetadataItem, + fieldMetadataItem, + relationObjectMetadataItem, + }); + + // Then + expect(result).toEqual({ + name: 'Company', + }); + }); + + it('returns null if the relation object does not have a label identifier field', () => { + // Given + const objectMetadataItem = mockedPersonObjectMetadataItem; + const fieldMetadataItem = mockedPersonObjectMetadataItem.fields.find( + ({ name }) => name === 'company', + )!; + const relationObjectMetadataItem: ObjectMetadataItem = { + ...mockedCompanyObjectMetadataItem, + labelIdentifierFieldMetadataId: null, + fields: mockedCompanyObjectMetadataItem.fields.filter( + ({ name }) => name !== 'name', + ), + }; + + // When + const result = getFieldDefaultPreviewValue({ + objectMetadataItem, + fieldMetadataItem, + relationObjectMetadataItem, + }); + + // Then + expect(result).toBeNull(); + }); + }); + + describe('Other fields', () => { + it('returns the object singular name as default value for the label identifier field (type TEXT)', () => { + // Given + const objectMetadataItem = mockedCompanyObjectMetadataItem; + const fieldMetadataItem = mockedCompanyObjectMetadataItem.fields.find( + ({ name }) => name === 'name', + )!; + + // When + const result = getFieldDefaultPreviewValue({ + objectMetadataItem, + fieldMetadataItem, + }); + + // Then + expect(result).toBe('Company'); + }); + + it('returns a default value for the label identifier field (type FULL_NAME)', () => { + // Given + const objectMetadataItem = mockedPersonObjectMetadataItem; + const fieldMetadataItem = mockedPersonObjectMetadataItem.fields.find( + ({ name }) => name === 'name', + )!; + + // When + const result = getFieldDefaultPreviewValue({ + objectMetadataItem, + fieldMetadataItem, + }); + + // Then + expect(result).toEqual({ + firstName: 'John', + lastName: 'Doe', + }); + }); + + it('returns a default value for other field types', () => { + // Given + const objectMetadataItem = mockedCompanyObjectMetadataItem; + const fieldMetadataItem = mockedCompanyObjectMetadataItem.fields.find( + ({ name }) => name === 'domainName', + )!; + + // When + const result = getFieldDefaultPreviewValue({ + objectMetadataItem, + fieldMetadataItem, + }); + + // Then + expect(result).toBe( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum magna enim, dapibus non enim in, lacinia faucibus nunc. Sed interdum ante sed felis facilisis, eget ultricies neque molestie. Mauris auctor, justo eu volutpat cursus, libero erat tempus nulla, non sodales lorem lacus a est.', + ); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/data-model/utils/__tests__/getFieldPreviewValueFromRecord.test.ts b/packages/twenty-front/src/modules/settings/data-model/utils/__tests__/getFieldPreviewValueFromRecord.test.ts new file mode 100644 index 000000000..3737f2510 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/utils/__tests__/getFieldPreviewValueFromRecord.test.ts @@ -0,0 +1,149 @@ +import { + mockedCompanyObjectMetadataItem, + mockedPersonObjectMetadataItem, +} from '@/object-record/record-field/__mocks__/fieldDefinitions'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { SettingsObjectFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm'; + +import { getFieldPreviewValueFromRecord } from '../getFieldPreviewValueFromRecord'; + +describe('getFieldPreviewValueFromRecord', () => { + describe('SELECT field', () => { + it('returns the select option corresponding to the record field value', () => { + // Given + const record: ObjectRecord = { id: '', industry: 'GREEN_TECH' }; + const fieldMetadataItem = mockedCompanyObjectMetadataItem.fields.find( + ({ name }) => name === 'industry', + )!; + const selectOptions: SettingsObjectFieldSelectFormValues = [ + { + color: 'purple', + label: '🏭 Industry', + value: 'INDUSTRY', + }, + { + color: 'pink', + isDefault: true, + label: '💊 Health', + value: 'HEALTH', + }, + { + color: 'turquoise', + label: '🌿 Green tech', + value: 'GREEN_TECH', + }, + ]; + + // When + const result = getFieldPreviewValueFromRecord({ + record, + fieldMetadataItem, + selectOptions, + }); + + // Then + expect(result).toEqual(selectOptions[2]); + }); + + it('returns undefined if the select option was not found', () => { + // Given + const record: ObjectRecord = { id: '', industry: 'MOBILITY' }; + const fieldMetadataItem = mockedCompanyObjectMetadataItem.fields.find( + ({ name }) => name === 'industry', + )!; + const selectOptions: SettingsObjectFieldSelectFormValues = [ + { + color: 'purple', + label: '🏭 Industry', + value: 'INDUSTRY', + }, + { + color: 'pink', + isDefault: true, + label: '💊 Health', + value: 'HEALTH', + }, + { + color: 'turquoise', + label: '🌿 Green tech', + value: 'GREEN_TECH', + }, + ]; + + // When + const result = getFieldPreviewValueFromRecord({ + record, + fieldMetadataItem, + selectOptions, + }); + + // Then + expect(result).toBeUndefined(); + }); + }); + + describe('RELATION field', () => { + it('returns the first relation record from a list of edges ("to many" relation)', () => { + // Given + const firstRelationRecord = { + id: '1', + name: { firstName: 'Jane', lastName: 'Doe' }, + }; + const record: ObjectRecord = { + id: '', + people: { + edges: [{ node: firstRelationRecord }, { node: { id: '2' } }], + }, + }; + const fieldMetadataItem = mockedCompanyObjectMetadataItem.fields.find( + ({ name }) => name === 'people', + )!; + + // When + const result = getFieldPreviewValueFromRecord({ + record, + fieldMetadataItem, + }); + + // Then + expect(result).toEqual(firstRelationRecord); + }); + + it('returns the record field value ("to one" relation)', () => { + // Given + const relationRecord = { id: '20', name: 'Twenty' }; + const record = { id: '', company: relationRecord }; + const fieldMetadataItem = mockedPersonObjectMetadataItem.fields.find( + ({ name }) => name === 'company', + )!; + + // When + const result = getFieldPreviewValueFromRecord({ + record, + fieldMetadataItem, + }); + + // Then + expect(result).toEqual(relationRecord); + }); + }); + + describe('Other fields', () => { + it('returns the record field value', () => { + // Given + const record = { id: '', name: 'Twenty' }; + const fieldMetadataItem = mockedCompanyObjectMetadataItem.fields.find( + ({ name }) => name === 'name', + )!; + + // When + const result = getFieldPreviewValueFromRecord({ + record, + fieldMetadataItem, + }); + + // Then + expect(result).toEqual(record.name); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/data-model/utils/getFieldDefaultPreviewValue.ts b/packages/twenty-front/src/modules/settings/data-model/utils/getFieldDefaultPreviewValue.ts new file mode 100644 index 000000000..efb648395 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/utils/getFieldDefaultPreviewValue.ts @@ -0,0 +1,68 @@ +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem'; +import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField'; +import { SettingsObjectFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm'; +import { SETTINGS_FIELD_METADATA_TYPES } from '@/settings/data-model/constants/SettingsFieldMetadataTypes'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +export const getFieldDefaultPreviewValue = ({ + fieldMetadataItem, + objectMetadataItem, + relationObjectMetadataItem, + selectOptions, +}: { + fieldMetadataItem: Pick & { + id?: string; + name?: string; + }; + objectMetadataItem: ObjectMetadataItem; + relationObjectMetadataItem?: ObjectMetadataItem; + selectOptions?: SettingsObjectFieldSelectFormValues; +}) => { + // Select field + if (fieldMetadataItem.type === FieldMetadataType.Select && selectOptions) { + return selectOptions.find(({ isDefault }) => isDefault) || selectOptions[0]; + } + + // Relation field + if ( + fieldMetadataItem.type === FieldMetadataType.Relation && + relationObjectMetadataItem + ) { + const relationLabelIdentifierFieldMetadataItem = + getLabelIdentifierFieldMetadataItem(relationObjectMetadataItem); + + if (!relationLabelIdentifierFieldMetadataItem) return null; + + const defaultRelationLabelIdentifierFieldValue = + relationLabelIdentifierFieldMetadataItem.type === FieldMetadataType.Text + ? relationObjectMetadataItem.labelSingular + : SETTINGS_FIELD_METADATA_TYPES[ + relationLabelIdentifierFieldMetadataItem.type + ]?.defaultValue; + + const defaultRelationRecord = { + [relationLabelIdentifierFieldMetadataItem.name]: + defaultRelationLabelIdentifierFieldValue, + }; + + return defaultRelationRecord; + } + + const isLabelIdentifier = + !!fieldMetadataItem.id && + !!fieldMetadataItem.name && + isLabelIdentifierField({ + fieldMetadataItem: { + id: fieldMetadataItem.id, + name: fieldMetadataItem.name, + }, + objectMetadataItem, + }); + + // Other fields + return isLabelIdentifier && fieldMetadataItem.type === FieldMetadataType.Text + ? objectMetadataItem.labelSingular + : SETTINGS_FIELD_METADATA_TYPES[fieldMetadataItem.type]?.defaultValue; +}; diff --git a/packages/twenty-front/src/modules/settings/data-model/utils/getFieldPreviewValueFromRecord.ts b/packages/twenty-front/src/modules/settings/data-model/utils/getFieldPreviewValueFromRecord.ts new file mode 100644 index 000000000..79e0a952c --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/utils/getFieldPreviewValueFromRecord.ts @@ -0,0 +1,34 @@ +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { SettingsObjectFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +export const getFieldPreviewValueFromRecord = ({ + record, + fieldMetadataItem, + selectOptions, +}: { + record: ObjectRecord; + fieldMetadataItem: Pick; + selectOptions?: SettingsObjectFieldSelectFormValues; +}) => { + const recordFieldValue = record[fieldMetadataItem.name]; + + // Select field + if (fieldMetadataItem.type === FieldMetadataType.Select) { + return selectOptions?.find( + (selectOption) => selectOption.value === recordFieldValue, + ); + } + + // Relation fields (to many) + if ( + fieldMetadataItem.type === FieldMetadataType.Relation && + Array.isArray(recordFieldValue?.edges) + ) { + return recordFieldValue.edges[0]?.node; + } + + // Other fields + return recordFieldValue; +}; diff --git a/packages/twenty-front/src/modules/ui/input/components/Select.tsx b/packages/twenty-front/src/modules/ui/input/components/Select.tsx index 531f00f86..ab5174b86 100644 --- a/packages/twenty-front/src/modules/ui/input/components/Select.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/Select.tsx @@ -32,10 +32,11 @@ export type SelectProps = { withSearchInput?: boolean; }; -const StyledControlContainer = styled.div<{ - disabled?: boolean; - fullWidth?: boolean; -}>` +const StyledContainer = styled.div<{ fullWidth?: boolean }>` + width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')}; +`; + +const StyledControlContainer = styled.div<{ disabled?: boolean }>` align-items: center; background-color: ${({ theme }) => theme.background.transparent.lighter}; border: 1px solid ${({ theme }) => theme.border.color.medium}; @@ -43,7 +44,7 @@ const StyledControlContainer = styled.div<{ color: ${({ disabled, theme }) => disabled ? theme.font.color.tertiary : theme.font.color.primary}; cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')}; - display: ${({ fullWidth }) => (fullWidth ? 'flex' : 'inline-flex')}; + display: flex; gap: ${({ theme }) => theme.spacing(1)}; height: ${({ theme }) => theme.spacing(8)}; justify-content: space-between; @@ -100,7 +101,7 @@ export const Select = ({ const { closeDropdown } = useDropdown(dropdownId); const selectControl = ( - + {!!selectedOption?.Icon && ( ({ ); - return disabled ? ( -
+ return ( + {!!label && {label}} - {selectControl} -
- ) : ( -
- {!!label && {label}} - - {!!withSearchInput && ( - setSearchInputValue(event.target.value)} - /> - )} - {!!withSearchInput && !!filteredOptions.length && ( - - )} - {!!filteredOptions.length && ( - - {filteredOptions.map((option) => ( - { - onChange?.(option.value); - closeDropdown(); - }} - /> - ))} - - )} - - } - dropdownHotkeyScope={{ scope: SelectHotkeyScope.Select }} - /> -
+ {disabled ? ( + selectControl + ) : ( + + {!!withSearchInput && ( + setSearchInputValue(event.target.value)} + /> + )} + {!!withSearchInput && !!filteredOptions.length && ( + + )} + {!!filteredOptions.length && ( + + {filteredOptions.map((option) => ( + { + onChange?.(option.value); + closeDropdown(); + }} + /> + ))} + + )} + + } + dropdownHotkeyScope={{ scope: SelectHotkeyScope.Select }} + /> + )} + ); }; diff --git a/packages/twenty-front/src/modules/ui/layout/card/components/Card.tsx b/packages/twenty-front/src/modules/ui/layout/card/components/Card.tsx index 1cf4e4abd..de5d724dd 100644 --- a/packages/twenty-front/src/modules/ui/layout/card/components/Card.tsx +++ b/packages/twenty-front/src/modules/ui/layout/card/components/Card.tsx @@ -1,10 +1,11 @@ import styled from '@emotion/styled'; -const StyledCard = styled.div` +const StyledCard = styled.div<{ fullWidth?: boolean }>` border: 1px solid ${({ theme }) => theme.border.color.medium}; border-radius: ${({ theme }) => theme.border.radius.sm}; color: ${({ theme }) => theme.font.color.secondary}; overflow: hidden; + width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')}; `; export { StyledCard as Card }; diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectEdit.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectEdit.tsx index 77e920d13..3963c5774 100644 --- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectEdit.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectEdit.tsx @@ -7,6 +7,7 @@ import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsObjectFormSection } from '@/settings/data-model/components/SettingsObjectFormSection'; +import { SettingsDataModelObjectSettingsFormCard } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectSettingsFormCard'; import { AppPath } from '@/types/AppPath'; import { IconArchive, IconSettings } from '@/ui/display/icon'; import { H2Title } from '@/ui/display/typography/components/H2Title'; @@ -125,6 +126,15 @@ export const SettingsObjectEdit = () => { })) } /> +
+ + +