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

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

@ -0,0 +1,29 @@
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
type SettingsDataModelSetFieldValueEffectProps = {
entityId: string;
fieldName: string;
value: unknown;
};
export const SettingsDataModelSetFieldValueEffect = ({
entityId,
fieldName,
value,
}: SettingsDataModelSetFieldValueEffectProps) => {
const setFieldValue = useSetRecoilState(
recordStoreFamilySelector({
recordId: entityId,
fieldName,
}),
);
useEffect(() => {
setFieldValue(value);
}, [value, setFieldValue]);
return null;
};

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