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:
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user