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

@ -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,29 +0,0 @@
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
type SettingsObjectFieldPreviewValueEffectProps = {
entityId: string;
fieldName: string;
value: unknown;
};
export const SettingsObjectFieldPreviewValueEffect = ({
entityId,
fieldName,
value,
}: SettingsObjectFieldPreviewValueEffectProps) => {
const setFieldValue = useSetRecoilState(
recordStoreFamilySelector({
recordId: entityId,
fieldName,
}),
);
useEffect(() => {
setFieldValue(value);
}, [value, setFieldValue]);
return null;
};

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