feat: add Object Edit Settings section with Object preview (#4216)

* feat: add Object Edit Settings section with Object preview

Closes #3834

* fix: fix preview card stories

* test: improve getFieldDefaultPreviewValue tests

* test: add getFieldPreviewValueFromRecord tests

* test: add useFieldPreview tests

* refactor: rename and move components

* fix: restore RecordStoreDecorator
This commit is contained in:
Thaïs
2024-02-29 11:23:56 -03:00
committed by GitHub
parent 6ad3880696
commit a892d0f653
43 changed files with 1665 additions and 937 deletions

View File

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

View File

@ -14,7 +14,7 @@ import {
export const fieldMetadataId = 'fieldMetadataId';
const mockedPersonObjectMetadataItem = {
export const mockedPersonObjectMetadataItem = {
...mockedPeopleMetadata.node,
fields: mockedPeopleMetadata.node.fields.edges.map(({ node }) => node),
};

View File

@ -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 (
<StyledCard className={className}>
<StyledPreviewContainer divider={!!form}>
<StyledTitle>Preview</StyledTitle>
<StyledPreviewContent>{preview}</StyledPreviewContent>
</StyledPreviewContainer>
{!!form && <StyledFormContainer>{form}</StyledFormContainer>}
</StyledCard>
);
};
}: SettingsDataModelPreviewFormCardProps) => (
<Card className={className} fullWidth>
<StyledPreviewContainer divider={!!form}>
<StyledTitle>Preview</StyledTitle>
{preview}
</StyledPreviewContainer>
{!!form && <StyledFormContainer>{form}</StyledFormContainer>}
</Card>
);

View File

@ -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<Field, 'icon' | 'label' | 'type'> & { 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 (
<StyledCard className={className}>
<StyledCardContent>
<StyledObjectSummary>
<StyledObjectName>
{!!ObjectIcon && (
<ObjectIcon
size={theme.icon.size.sm}
stroke={theme.icon.stroke.md}
/>
)}
{objectMetadataItem?.labelPlural}
</StyledObjectName>
{objectMetadataItem?.isCustom ? (
<Tag color="orange" text="Custom" weight="medium" />
) : (
<Tag color="blue" text="Standard" weight="medium" />
)}
</StyledObjectSummary>
<SettingsObjectFieldPreviewValueEffect
entityId={entityId}
fieldName={fieldName}
value={value}
/>
<StyledFieldPreview shrink={shrink}>
<StyledFieldLabel>
{!!FieldIcon && (
<FieldIcon
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
)}
{fieldMetadata.label}:
</StyledFieldLabel>
<FieldContext.Provider
value={{
entityId,
isLabelIdentifier: false,
fieldDefinition: {
type: parseFieldType(fieldMetadata.type),
iconName: 'FieldIcon',
fieldMetadataId: fieldMetadata.id || '',
label: fieldMetadata.label,
metadata: {
fieldName,
relationObjectMetadataNameSingular:
relationObjectMetadataItem?.nameSingular,
options: selectOptions,
},
},
hotkeyScope: 'field-preview',
}}
>
{fieldMetadata.type === FieldMetadataType.Boolean ? (
<BooleanFieldInput readonly />
) : fieldMetadata.type === FieldMetadataType.Rating ? (
<RatingFieldInput readonly />
) : (
<FieldDisplay />
)}
</FieldContext.Provider>
</StyledFieldPreview>
</StyledCardContent>
</StyledCard>
);
};

View File

@ -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<Field, 'icon' | 'label'> & { id?: string };
onChange: (
values: Partial<SettingsObjectFieldTypeSelectSectionFormValues>,
) => void;
relationFieldMetadata?: Pick<Field, 'id' | 'isCustom'>;
values: SettingsObjectFieldTypeSelectSectionFormValues;
} & Pick<SettingsObjectFieldPreviewProps, 'objectMetadataId'>;
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 (
<Section>
<H2Title
title="Type and values"
description="The field's type and values."
/>
<Select
fullWidth
disabled={!!fieldMetadata?.id}
dropdownId="object-field-type-select"
value={values?.type}
onChange={(value) => onChange({ type: value })}
options={fieldTypeOptions}
/>
{!!values?.type &&
[
FieldMetadataType.Boolean,
FieldMetadataType.Currency,
FieldMetadataType.DateTime,
FieldMetadataType.Select,
FieldMetadataType.Link,
FieldMetadataType.Number,
FieldMetadataType.Rating,
FieldMetadataType.Relation,
FieldMetadataType.Text,
].includes(values.type) && (
<StyledSettingsObjectFieldTypeCard
preview={
<>
<StyledSettingsObjectFieldPreview
fieldMetadata={{
...fieldMetadata,
type: values.type,
}}
shrink={values.type === FieldMetadataType.Relation}
objectMetadataId={objectMetadataId}
relationObjectMetadataId={
relationFormConfig?.objectMetadataId
}
selectOptions={selectFormConfig}
/>
{values.type === FieldMetadataType.Relation &&
!!relationFormConfig?.type &&
!!relationFormConfig.objectMetadataId && (
<>
<StyledRelationImage
src={RELATION_TYPES[relationFormConfig.type].imageSrc}
flip={
RELATION_TYPES[relationFormConfig.type].isImageFlipped
}
alt={RELATION_TYPES[relationFormConfig.type].label}
/>
<StyledSettingsObjectFieldPreview
fieldMetadata={{
...relationFormConfig.field,
label:
relationFormConfig.field?.label || 'Field name',
type: FieldMetadataType.Relation,
id: relationFieldMetadata?.id,
}}
shrink
objectMetadataId={relationFormConfig.objectMetadataId}
relationObjectMetadataId={objectMetadataId}
/>
</>
)}
</>
}
form={
values.type === FieldMetadataType.Currency ? (
<SettingsObjectFieldCurrencyForm
disabled={disableCurrencyForm}
values={currencyFormConfig}
onChange={(nextValues) =>
onChange({
currency: { ...currencyFormConfig, ...nextValues },
})
}
/>
) : values.type === FieldMetadataType.Relation ? (
<SettingsObjectFieldRelationForm
disableFieldEdition={
relationFieldMetadata && !relationFieldMetadata.isCustom
}
disableRelationEdition={!!relationFieldMetadata}
values={relationFormConfig}
onChange={(nextValues) =>
onChange({
relation: { ...relationFormConfig, ...nextValues },
})
}
/>
) : values.type === FieldMetadataType.Select ? (
<SettingsObjectFieldSelectForm
values={selectFormConfig}
onChange={(nextValues) => onChange({ select: nextValues })}
/>
) : undefined
}
/>
)}
</Section>
);
};

View File

@ -1,128 +0,0 @@
import { Meta, StoryObj } from '@storybook/react';
import { Field, FieldMetadataType } from '~/generated-metadata/graphql';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { RecordStoreDecorator } from '~/testing/decorators/RecordStoreDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import {
mockedCompaniesMetadata,
mockedPeopleMetadata,
} from '~/testing/mock-data/metadata';
import { SettingsObjectFieldPreview } from '../SettingsObjectFieldPreview';
const meta: Meta<typeof SettingsObjectFieldPreview> = {
title: 'Modules/Settings/DataModel/SettingsObjectFieldPreview',
component: SettingsObjectFieldPreview,
decorators: [
RecordStoreDecorator,
ComponentDecorator,
ObjectMetadataItemsDecorator,
SnackBarDecorator,
],
args: {
fieldMetadata: mockedCompaniesMetadata.node.fields.edges.find(
({ node }) => node.type === FieldMetadataType.Text,
)?.node as Field,
objectMetadataId: mockedCompaniesMetadata.node.id,
},
parameters: {
records: [
{
id: `${mockedCompaniesMetadata.node.id}-field-form`,
domainName: 'Test',
idealCustomerProfile: true,
annualRecurringRevenue: {
amountMicros: 1000000,
currency: 'USD',
},
updatedAt: '2021-08-05T14:00:00.000Z',
linkedinLink: {
label: 'LinkedIn',
url: 'https://linkedin.com',
},
employees: 100,
},
],
msw: graphqlMocks,
},
};
export default meta;
type Story = StoryObj<typeof SettingsObjectFieldPreview>;
export const Text: Story = {};
export const Boolean: Story = {
args: {
fieldMetadata: mockedCompaniesMetadata.node.fields.edges.find(
({ node }) => node.type === FieldMetadataType.Boolean,
)?.node as Field,
},
};
export const Currency: Story = {
args: {
fieldMetadata: mockedCompaniesMetadata.node.fields.edges.find(
({ node }) => node.type === FieldMetadataType.Currency,
)?.node as Field,
},
};
export const Date: Story = {
args: {
fieldMetadata: mockedCompaniesMetadata.node.fields.edges.find(
({ node }) => node.type === FieldMetadataType.DateTime,
)?.node as Field,
},
};
export const Link: Story = {
decorators: [MemoryRouterDecorator],
args: {
fieldMetadata: mockedCompaniesMetadata.node.fields.edges.find(
({ node }) => node.type === FieldMetadataType.Link,
)?.node as Field,
},
};
export const Number: Story = {
args: {
fieldMetadata: mockedCompaniesMetadata.node.fields.edges.find(
({ node }) => node.type === FieldMetadataType.Number,
)?.node as Field,
},
};
export const Rating: Story = {
args: {
fieldMetadata: {
icon: 'IconHandClick',
label: 'Engagement',
type: FieldMetadataType.Rating,
},
},
};
export const Relation: Story = {
decorators: [MemoryRouterDecorator],
args: {
fieldMetadata: mockedPeopleMetadata.node.fields.edges.find(
({ node }) => node.type === FieldMetadataType.Relation,
)?.node as Field,
objectMetadataId: mockedPeopleMetadata.node.id,
relationObjectMetadataId: mockedCompaniesMetadata.node.id,
},
};
export const CustomObject: Story = {
args: {
fieldMetadata: mockedCompaniesMetadata.node.fields.edges.find(
({ node }) => node.isCustom,
)?.node as Field,
objectMetadataId: mockedCompaniesMetadata.node.id,
},
};

View File

@ -1,132 +0,0 @@
import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/test';
import { fn } from '@storybook/test';
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 {
mockedCompaniesMetadata,
mockedPeopleMetadata,
} from '~/testing/mock-data/metadata';
import { fieldMetadataFormDefaultValues } from '../../hooks/useFieldMetadataForm';
import {
SettingsObjectFieldTypeSelectSection,
SettingsObjectFieldTypeSelectSectionFormValues,
} from '../SettingsObjectFieldTypeSelectSection';
const fieldMetadata = mockedCompaniesMetadata.node.fields.edges.find(
({ node }) => node.type === FieldMetadataType.Text,
)!.node;
const { id: _id, ...fieldMetadataWithoutId } = fieldMetadata;
const meta: Meta<typeof SettingsObjectFieldTypeSelectSection> = {
title: 'Modules/Settings/DataModel/SettingsObjectFieldTypeSelectSection',
component: SettingsObjectFieldTypeSelectSection,
decorators: [
ComponentDecorator,
ObjectMetadataItemsDecorator,
SnackBarDecorator,
],
args: {
fieldMetadata: fieldMetadataWithoutId,
objectMetadataId: mockedCompaniesMetadata.node.id,
onChange: fn(),
values: fieldMetadataFormDefaultValues,
},
parameters: {
container: { width: 512 },
msw: graphqlMocks,
},
};
export default meta;
type Story = StoryObj<typeof SettingsObjectFieldTypeSelectSection>;
export const Default: Story = {};
export const Disabled: Story = {
args: {
fieldMetadata,
},
};
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);
},
};
const relationFieldMetadata = mockedPeopleMetadata.node.fields.edges.find(
({ node }) => node.type === FieldMetadataType.Relation,
)!.node;
export const WithRelationForm: Story = {
decorators: [MemoryRouterDecorator],
args: {
fieldMetadata: mockedCompaniesMetadata.node.fields.edges.find(
({ node }) => node.type === FieldMetadataType.Relation,
)?.node,
relationFieldMetadata,
values: {
...fieldMetadataFormDefaultValues,
type: FieldMetadataType.Relation,
relation: {
field: relationFieldMetadata,
objectMetadataId: mockedPeopleMetadata.node.id,
type: RelationMetadataType.OneToMany,
},
} as unknown as SettingsObjectFieldTypeSelectSectionFormValues,
},
};
export const WithSelectForm: Story = {
args: {
fieldMetadata: { label: 'Industry', icon: 'IconBuildingFactory2' },
values: {
...fieldMetadataFormDefaultValues,
type: FieldMetadataType.Select,
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' },
],
},
},
};

View File

@ -91,5 +91,9 @@ export const SETTINGS_FIELD_METADATA_TYPES: Partial<
Icon: IconTwentyStar,
defaultValue: '3',
},
[FieldMetadataType.FullName]: { label: 'Full Name', Icon: IconUser },
[FieldMetadataType.FullName]: {
label: 'Full Name',
Icon: IconUser,
defaultValue: { firstName: 'John', lastName: 'Doe' },
},
};

View File

@ -0,0 +1,158 @@
import styled from '@emotion/styled';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import {
SettingsObjectFieldCurrencyForm,
SettingsObjectFieldCurrencyFormValues,
} from '@/settings/data-model/components/SettingsObjectFieldCurrencyForm';
import {
SettingsObjectFieldRelationForm,
SettingsObjectFieldRelationFormValues,
} from '@/settings/data-model/components/SettingsObjectFieldRelationForm';
import {
SettingsObjectFieldSelectForm,
SettingsObjectFieldSelectFormValues,
} from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
import { RELATION_TYPES } from '@/settings/data-model/constants/RelationTypes';
import {
SettingsDataModelFieldPreviewCard,
SettingsDataModelFieldPreviewCardProps,
} from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export type SettingsDataModelFieldSettingsFormValues = {
currency: SettingsObjectFieldCurrencyFormValues;
relation: SettingsObjectFieldRelationFormValues;
select: SettingsObjectFieldSelectFormValues;
};
type SettingsDataModelFieldSettingsFormCardProps = {
disableCurrencyForm?: boolean;
onChange: (values: Partial<SettingsDataModelFieldSettingsFormValues>) => void;
relationFieldMetadataItem?: Pick<
FieldMetadataItem,
'id' | 'isCustom' | 'name'
>;
values: SettingsDataModelFieldSettingsFormValues;
} & Pick<
SettingsDataModelFieldPreviewCardProps,
'fieldMetadataItem' | 'objectMetadataItem'
>;
const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
display: grid;
flex: 1 1 100%;
`;
const StyledPreviewContent = styled.div`
display: flex;
gap: 6px;
`;
const StyledRelationImage = styled.img<{ flip?: boolean }>`
transform: ${({ flip }) => (flip ? 'scaleX(-1)' : 'none')};
width: 54px;
`;
const previewableTypes = [
FieldMetadataType.Boolean,
FieldMetadataType.Currency,
FieldMetadataType.DateTime,
FieldMetadataType.Select,
FieldMetadataType.Link,
FieldMetadataType.Number,
FieldMetadataType.Rating,
FieldMetadataType.Relation,
FieldMetadataType.Text,
];
export const SettingsDataModelFieldSettingsFormCard = ({
disableCurrencyForm,
fieldMetadataItem,
objectMetadataItem,
onChange,
relationFieldMetadataItem,
values,
}: SettingsDataModelFieldSettingsFormCardProps) => {
const { findObjectMetadataItemById } = useObjectMetadataItemForSettings();
if (!previewableTypes.includes(fieldMetadataItem.type)) return null;
const relationObjectMetadataItem = findObjectMetadataItemById(
values.relation.objectMetadataId,
);
const relationTypeConfig = RELATION_TYPES[values.relation.type];
return (
<SettingsDataModelPreviewFormCard
preview={
<StyledPreviewContent>
<StyledFieldPreviewCard
fieldMetadataItem={fieldMetadataItem}
shrink={fieldMetadataItem.type === FieldMetadataType.Relation}
objectMetadataItem={objectMetadataItem}
relationObjectMetadataItem={relationObjectMetadataItem}
selectOptions={values.select}
/>
{fieldMetadataItem.type === FieldMetadataType.Relation &&
!!relationObjectMetadataItem && (
<>
<StyledRelationImage
src={relationTypeConfig.imageSrc}
flip={relationTypeConfig.isImageFlipped}
alt={relationTypeConfig.label}
/>
<StyledFieldPreviewCard
fieldMetadataItem={{
icon: values.relation.field.icon,
label: values.relation.field.label || 'Field name',
type: FieldMetadataType.Relation,
name: relationFieldMetadataItem?.name,
id: relationFieldMetadataItem?.id,
}}
shrink
objectMetadataItem={relationObjectMetadataItem}
relationObjectMetadataItem={objectMetadataItem}
/>
</>
)}
</StyledPreviewContent>
}
form={
fieldMetadataItem.type === FieldMetadataType.Currency ? (
<SettingsObjectFieldCurrencyForm
disabled={disableCurrencyForm}
values={values.currency}
onChange={(nextCurrencyValues) =>
onChange({
currency: { ...values.currency, ...nextCurrencyValues },
})
}
/>
) : fieldMetadataItem.type === FieldMetadataType.Relation ? (
<SettingsObjectFieldRelationForm
disableFieldEdition={
relationFieldMetadataItem && !relationFieldMetadataItem.isCustom
}
disableRelationEdition={!!relationFieldMetadataItem}
values={values.relation}
onChange={(nextRelationValues) =>
onChange({
relation: { ...values.relation, ...nextRelationValues },
})
}
/>
) : fieldMetadataItem.type === FieldMetadataType.Select ? (
<SettingsObjectFieldSelectForm
values={values.select}
onChange={(nextSelectValues) =>
onChange({ select: nextSelectValues })
}
/>
) : undefined
}
/>
);
};

View File

@ -0,0 +1,39 @@
import { SETTINGS_FIELD_METADATA_TYPES } from '@/settings/data-model/constants/SettingsFieldMetadataTypes';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { FieldMetadataType } from '~/generated-metadata/graphql';
type SettingsDataModelFieldTypeSelectProps = {
className?: string;
disabled?: boolean;
excludedFieldTypes?: FieldMetadataType[];
onChange?: ({ type }: { type: FieldMetadataType }) => void;
value?: FieldMetadataType;
};
export const SettingsDataModelFieldTypeSelect = ({
className,
disabled,
excludedFieldTypes,
onChange,
value,
}: SettingsDataModelFieldTypeSelectProps) => {
const fieldTypeOptions = Object.entries(SETTINGS_FIELD_METADATA_TYPES)
.filter(([key]) => !excludedFieldTypes?.includes(key as FieldMetadataType))
.map<SelectOption<FieldMetadataType>>(([key, dataTypeConfig]) => ({
Icon: dataTypeConfig.Icon,
label: dataTypeConfig.label,
value: key as FieldMetadataType,
}));
return (
<Select
className={className}
fullWidth
disabled={disabled}
dropdownId="object-field-type-select"
value={value}
onChange={(value) => onChange?.({ type: value })}
options={fieldTypeOptions}
/>
);
};

View File

@ -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<typeof SettingsDataModelFieldSettingsFormCard> = {
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<typeof SettingsDataModelFieldSettingsFormCard>;
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' },
],
},
},
};

View File

@ -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<typeof SettingsDataModelFieldTypeSelect> = {
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<typeof SettingsDataModelFieldTypeSelect>;
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();
},
};

View File

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

View File

@ -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<FieldMetadataItem, 'icon' | 'label' | 'type'> & {
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 ? (
<SettingsDataModelSetRecordEffect record={record} />
) : (
<SettingsDataModelSetFieldValueEffect
entityId={entityId}
fieldName={fieldName}
value={fieldPreviewValue}
/>
)}
<StyledFieldPreview shrink={shrink}>
{!!withFieldLabel && (
<StyledFieldLabel>
<FieldIcon
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
{fieldMetadataItem.label}:
</StyledFieldLabel>
)}
<FieldContext.Provider
value={{
entityId,
isLabelIdentifier,
fieldDefinition: {
type: parseFieldType(fieldMetadataItem.type),
iconName: 'FieldIcon',
fieldMetadataId: fieldMetadataItem.id || '',
label: fieldMetadataItem.label,
metadata: {
fieldName,
objectMetadataNameSingular: objectMetadataItem.nameSingular,
relationObjectMetadataNameSingular:
relationObjectMetadataItem?.nameSingular,
options: selectOptions,
},
},
hotkeyScope: 'field-preview',
}}
>
{fieldMetadataItem.type === FieldMetadataType.Boolean ? (
<BooleanFieldInput readonly />
) : fieldMetadataItem.type === FieldMetadataType.Rating ? (
<RatingFieldInput readonly />
) : (
<FieldDisplay />
)}
</FieldContext.Provider>
</StyledFieldPreview>
</>
);
};

View File

@ -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) => (
<StyledCard className={className} fullWidth>
<StyledCardContent>
<SettingsDataModelObjectSummary objectMetadataItem={objectMetadataItem} />
<SettingsDataModelFieldPreview
objectMetadataItem={objectMetadataItem}
fieldMetadataItem={fieldMetadataItem}
relationObjectMetadataItem={relationObjectMetadataItem}
selectOptions={selectOptions}
shrink={shrink}
withFieldLabel={withFieldLabel}
/>
</StyledCardContent>
</StyledCard>
);

View File

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

View File

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

View File

@ -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<typeof SettingsDataModelFieldPreviewCard> = {
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<typeof SettingsDataModelFieldPreviewCard>;
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,
},
};

View File

@ -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 }) => (
<RecoilRoot>
<MockedProvider addTypename={false}>
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
{children}
</SnackBarProviderScope>
</MockedProvider>
</RecoilRoot>
);
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,
});
});
});

View File

@ -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<FieldMetadataItem, 'icon' | 'type'> & {
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,
};
};

View File

@ -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 }) => (
<RecoilRoot>
<MockedProvider addTypename={false}>
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
{children}
</SnackBarProviderScope>
</MockedProvider>
</RecoilRoot>
);
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();
});
});

View File

@ -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<Field, 'icon' | 'label' | 'type'> & { 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,
};
};

View File

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

View File

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

View File

@ -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 && <Icon size={theme.icon.size.md} />}
{name}
</StyledName>
{isCustom ? (
<StyledTag color="orange" text="Custom" weight="medium" />
) : (
<StyledTag color="blue" text="Standard" weight="medium" />
)}
<StyledIsCustomTag isCustom={isCustom} />
<Dropdown
dropdownId={dropdownId}
clickableComponent={

View File

@ -4,8 +4,8 @@ import styled from '@emotion/styled';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { SettingsDataModelIsCustomTag } from '@/settings/data-model/objects/SettingsDataModelIsCustomTag';
import { useIcons } from '@/ui/display/icon/hooks/useIcons';
import { Tag } from '@/ui/display/tag/components/Tag';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
@ -52,11 +52,7 @@ export const SettingsObjectItemTableRow = ({
{objectItem.labelPlural}
</StyledNameTableCell>
<TableCell>
{objectItem.isCustom ? (
<Tag color="orange" text="Custom" weight="regular" />
) : (
<Tag color="blue" text="Standard" weight="regular" />
)}
<SettingsDataModelIsCustomTag isCustom={objectItem.isCustom} />
</TableCell>
<TableCell align="right">
{objectItem.fields.filter((field) => !field.isSystem).length}

View File

@ -0,0 +1,18 @@
import { Tag } from '@/ui/display/tag/components/Tag';
type SettingsDataModelIsCustomTagProps = {
className?: string;
isCustom?: boolean;
};
export const SettingsDataModelIsCustomTag = ({
className,
isCustom,
}: SettingsDataModelIsCustomTagProps) => (
<Tag
className={className}
color={isCustom ? 'orange' : 'blue'}
text={isCustom ? 'Custom' : 'Standard'}
weight="medium"
/>
);

View File

@ -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 (
<StyledObjectSummary className={className}>
<StyledObjectName>
<ObjectIcon size={theme.icon.size.sm} stroke={theme.icon.stroke.md} />
{objectMetadataItem.labelPlural}
</StyledObjectName>
<SettingsDataModelIsCustomTag isCustom={objectMetadataItem.isCustom} />
</StyledObjectSummary>
);
};

View File

@ -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 (
<SettingsDataModelPreviewFormCard
preview={
labelIdentifierFieldMetadataItem ? (
<StyledFieldPreviewCard
objectMetadataItem={objectMetadataItem}
fieldMetadataItem={labelIdentifierFieldMetadataItem}
withFieldLabel={false}
/>
) : (
<StyledObjectSummaryCard>
<StyledObjectSummaryCardContent>
<SettingsDataModelObjectSummary
objectMetadataItem={objectMetadataItem}
/>
</StyledObjectSummaryCardContent>
</StyledObjectSummaryCard>
)
}
/>
);
};

View File

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

View File

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

View File

@ -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<FieldMetadataItem, 'type'> & {
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;
};

View File

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

View File

@ -32,10 +32,11 @@ export type SelectProps<Value extends string | number | null> = {
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 = <Value extends string | number | null>({
const { closeDropdown } = useDropdown(dropdownId);
const selectControl = (
<StyledControlContainer disabled={disabled} fullWidth={fullWidth}>
<StyledControlContainer disabled={disabled}>
<StyledControlLabel>
{!!selectedOption?.Icon && (
<selectedOption.Icon
@ -115,50 +116,49 @@ export const Select = <Value extends string | number | null>({
</StyledControlContainer>
);
return disabled ? (
<div>
return (
<StyledContainer className={className} fullWidth={fullWidth}>
{!!label && <StyledLabel>{label}</StyledLabel>}
{selectControl}
</div>
) : (
<div className={className}>
{!!label && <StyledLabel>{label}</StyledLabel>}
<Dropdown
dropdownId={dropdownId}
dropdownMenuWidth={dropdownWidth}
dropdownPlacement="bottom-start"
clickableComponent={selectControl}
dropdownComponents={
<>
{!!withSearchInput && (
<DropdownMenuSearchInput
autoFocus
value={searchInputValue}
onChange={(event) => setSearchInputValue(event.target.value)}
/>
)}
{!!withSearchInput && !!filteredOptions.length && (
<DropdownMenuSeparator />
)}
{!!filteredOptions.length && (
<DropdownMenuItemsContainer hasMaxHeight>
{filteredOptions.map((option) => (
<MenuItem
key={option.value}
LeftIcon={option.Icon}
text={option.label}
onClick={() => {
onChange?.(option.value);
closeDropdown();
}}
/>
))}
</DropdownMenuItemsContainer>
)}
</>
}
dropdownHotkeyScope={{ scope: SelectHotkeyScope.Select }}
/>
</div>
{disabled ? (
selectControl
) : (
<Dropdown
dropdownId={dropdownId}
dropdownMenuWidth={dropdownWidth}
dropdownPlacement="bottom-start"
clickableComponent={selectControl}
dropdownComponents={
<>
{!!withSearchInput && (
<DropdownMenuSearchInput
autoFocus
value={searchInputValue}
onChange={(event) => setSearchInputValue(event.target.value)}
/>
)}
{!!withSearchInput && !!filteredOptions.length && (
<DropdownMenuSeparator />
)}
{!!filteredOptions.length && (
<DropdownMenuItemsContainer hasMaxHeight>
{filteredOptions.map((option) => (
<MenuItem
key={option.value}
LeftIcon={option.Icon}
text={option.label}
onClick={() => {
onChange?.(option.value);
closeDropdown();
}}
/>
))}
</DropdownMenuItemsContainer>
)}
</>
}
dropdownHotkeyScope={{ scope: SelectHotkeyScope.Select }}
/>
)}
</StyledContainer>
);
};

View File

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

View File

@ -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 = () => {
}))
}
/>
<Section>
<H2Title
title="Settings"
description="Choose the fields that will identify your records"
/>
<SettingsDataModelObjectSettingsFormCard
objectMetadataItem={activeObjectMetadataItem}
/>
</Section>
<Section>
<H2Title title="Danger zone" description="Disable object" />
<Button

View File

@ -1,5 +1,6 @@
import { useEffect, useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import styled from '@emotion/styled';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
@ -12,8 +13,9 @@ import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderCon
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsObjectFieldCurrencyFormValues } from '@/settings/data-model/components/SettingsObjectFieldCurrencyForm';
import { SettingsObjectFieldFormSection } from '@/settings/data-model/components/SettingsObjectFieldFormSection';
import { SettingsObjectFieldTypeSelectSection } from '@/settings/data-model/components/SettingsObjectFieldTypeSelectSection';
import { useFieldMetadataForm } from '@/settings/data-model/hooks/useFieldMetadataForm';
import { SettingsDataModelFieldSettingsFormCard } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard';
import { SettingsDataModelFieldTypeSelect } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldTypeSelect';
import { useFieldMetadataForm } from '@/settings/data-model/fields/forms/hooks/useFieldMetadataForm';
import { AppPath } from '@/types/AppPath';
import { IconArchive, IconSettings } from '@/ui/display/icon';
import { H2Title } from '@/ui/display/typography/components/H2Title';
@ -27,6 +29,12 @@ import {
RelationMetadataType,
} from '~/generated-metadata/graphql';
const StyledSettingsObjectFieldTypeSelect = styled(
SettingsDataModelFieldTypeSelect,
)`
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
const canPersistFieldMetadataItemUpdate = (
fieldMetadataItem: FieldMetadataItem,
) => {
@ -214,23 +222,35 @@ export const SettingsObjectFieldEdit = () => {
iconKey={formValues.icon}
onChange={handleFormChange}
/>
<SettingsObjectFieldTypeSelectSection
disableCurrencyForm
fieldMetadata={{
icon: formValues.icon,
label: formValues.label || 'Employees',
id: activeMetadataField.id,
}}
objectMetadataId={activeObjectMetadataItem.id}
onChange={handleFormChange}
relationFieldMetadata={relationFieldMetadataItem}
values={{
type: formValues.type,
currency: formValues.currency,
relation: formValues.relation,
select: formValues.select,
}}
/>
<Section>
<H2Title
title="Type and values"
description="The field's type and values."
/>
<StyledSettingsObjectFieldTypeSelect
disabled
onChange={handleFormChange}
value={formValues.type}
/>
<SettingsDataModelFieldSettingsFormCard
disableCurrencyForm
fieldMetadataItem={{
icon: formValues.icon,
id: activeMetadataField.id,
label: formValues.label,
name: activeMetadataField.name,
type: formValues.type,
}}
objectMetadataItem={activeObjectMetadataItem}
onChange={handleFormChange}
relationFieldMetadataItem={relationFieldMetadataItem}
values={{
currency: formValues.currency,
relation: formValues.relation,
select: formValues.select,
}}
/>
</Section>
{!isLabelIdentifier && (
<Section>
<H2Title title="Danger zone" description="Disable this field" />

View File

@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Reference } from '@apollo/client';
import styled from '@emotion/styled';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { useCreateOneRelationMetadataItem } from '@/object-metadata/hooks/useCreateOneRelationMetadataItem';
@ -14,17 +15,26 @@ import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsObjectFieldFormSection } from '@/settings/data-model/components/SettingsObjectFieldFormSection';
import { SettingsObjectFieldTypeSelectSection } from '@/settings/data-model/components/SettingsObjectFieldTypeSelectSection';
import { useFieldMetadataForm } from '@/settings/data-model/hooks/useFieldMetadataForm';
import { SettingsDataModelFieldSettingsFormCard } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard';
import { SettingsDataModelFieldTypeSelect } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldTypeSelect';
import { useFieldMetadataForm } from '@/settings/data-model/fields/forms/hooks/useFieldMetadataForm';
import { AppPath } from '@/types/AppPath';
import { IconSettings } from '@/ui/display/icon';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { View } from '@/views/types/View';
import { ViewType } from '@/views/types/ViewType';
import { FieldMetadataType } from '~/generated-metadata/graphql';
const StyledSettingsObjectFieldTypeSelect = styled(
SettingsDataModelFieldTypeSelect,
)`
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
export const SettingsObjectNewFieldStep2 = () => {
const navigate = useNavigate();
const { objectSlug = '' } = useParams();
@ -286,21 +296,31 @@ export const SettingsObjectNewFieldStep2 = () => {
description={formValues.description}
onChange={handleFormChange}
/>
<SettingsObjectFieldTypeSelectSection
excludedFieldTypes={excludedFieldTypes}
fieldMetadata={{
icon: formValues.icon,
label: formValues.label || 'Employees',
}}
objectMetadataId={activeObjectMetadataItem.id}
onChange={handleFormChange}
values={{
type: formValues.type,
currency: formValues.currency,
relation: formValues.relation,
select: formValues.select,
}}
/>
<Section>
<H2Title
title="Type and values"
description="The field's type and values."
/>
<StyledSettingsObjectFieldTypeSelect
excludedFieldTypes={excludedFieldTypes}
onChange={handleFormChange}
value={formValues.type}
/>
<SettingsDataModelFieldSettingsFormCard
fieldMetadataItem={{
icon: formValues.icon,
label: formValues.label || 'Employees',
type: formValues.type,
}}
objectMetadataItem={activeObjectMetadataItem}
onChange={handleFormChange}
values={{
currency: formValues.currency,
relation: formValues.relation,
select: formValues.select,
}}
/>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);

View File

@ -1,11 +1,22 @@
import { useEffect } from 'react';
import { Decorator } from '@storybook/react';
import { useRecoilValue } from 'recoil';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { ObjectMetadataItemsLoadEffect } from '@/object-metadata/components/ObjectMetadataItemsLoadEffect';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { mockWorkspaceMembers } from '~/testing/mock-data/workspace-members';
export const ObjectMetadataItemsDecorator: Decorator = (Story) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const setCurrentWorkspaceMember = useSetRecoilState(
currentWorkspaceMemberState,
);
useEffect(
() => setCurrentWorkspaceMember(mockWorkspaceMembers[0]),
[setCurrentWorkspaceMember],
);
return (
<>

View File

@ -103,11 +103,15 @@ export const graphqlMocks = {
},
});
}),
graphql.query('FindManyCompanies', () => {
graphql.query('FindManyCompanies', ({ variables }) => {
const mockedData = variables.limit
? mockedCompaniesData.slice(0, variables.limit)
: mockedCompaniesData;
return HttpResponse.json({
data: {
companies: {
edges: mockedCompaniesData.map((company) => ({
edges: mockedData.map((company) => ({
node: {
...company,
favorites: {

View File

@ -1109,7 +1109,7 @@ export const mockedCompaniesMetadata = {
node: {
__typename: 'field',
id: '726f398f-7007-49f9-8b03-f71813c9fcf2',
type: FieldMetadataType.Text,
type: FieldMetadataType.Select,
name: 'industry',
label: 'Industry',
description: 'The industry the company is involved in',
@ -1122,8 +1122,51 @@ export const mockedCompaniesMetadata = {
updatedAt: '2023-12-20T12:25:25.057Z',
fromRelationMetadata: null,
toRelationMetadata: null,
options: [],
defaultValue: null,
options: [
{
id: '9ecaa155-cb8a-4840-8c28-a308d4fdc35d',
color: 'pink',
label: '💊 Health',
position: 0,
value: 'HEALTH',
},
{
id: '75287708-8bb6-4c69-8951-a622b2935ac7',
color: 'purple',
label: '🏭 Industry',
position: 1,
value: 'INDUSTRY',
},
{
id: 'f8694cf8-c673-49ab-b758-b369de8584ec',
color: 'sky',
label: '🤖 SaaS',
position: 2,
value: 'SAAS',
},
{
id: '3fd92580-1549-4cc0-b0b3-3a03bcffa7d0',
color: 'turquoise',
label: '🌿 Green tech',
position: 3,
value: 'GREEN_TECH',
},
{
id: '9b4a1271-c515-4440-b450-78eb7ca05975',
color: 'yellow',
label: '🚲 Mobility',
position: 4,
value: 'MOBILITY',
},
{
id: 'c80f4670-e8a5-4488-88b3-97a1b46f5940',
color: 'green',
label: '🌏 NGO',
position: 5,
value: 'NGO',
},
],
defaultValue: 'HEALTH',
},
},
],

View File

@ -10,7 +10,7 @@ export const mockWorkspaceMembers = [
createdAt: '2023-12-18T09:51:19.645Z',
updatedAt: '2023-12-18T09:51:19.645Z',
userId: '20202020-7169-42cf-bc47-1cfef15264b8',
colorScheme: 'Light',
colorScheme: 'Light' as const,
},
{
id: '20202020-77d5-4cb6-b60a-f4a835a85d61',
@ -23,6 +23,6 @@ export const mockWorkspaceMembers = [
createdAt: '2023-12-18T09:51:19.645Z',
updatedAt: '2023-12-18T09:51:19.645Z',
userId: '20202020-3957-4908-9c36-2929a23f8357',
colorScheme: 'Light',
colorScheme: 'Dark' as const,
},
];