feat: create custom object field (#2225)

Closes #2171
This commit is contained in:
Thaïs
2023-10-26 11:34:26 +02:00
committed by GitHub
parent fc4075b372
commit 00dd046798
15 changed files with 91 additions and 193 deletions

View File

@ -1,12 +1,28 @@
import { ObjectFieldDataType } from '@/settings/data-model/types/ObjectFieldDataType';
import { Field } from '~/generated/graphql'; import { Field } from '~/generated/graphql';
import { formatMetadataFieldInput } from '../utils/formatMetadataFieldInput';
import { useCreateOneMetadataField } from './useCreateOneMetadataField';
import { useDeleteOneMetadataField } from './useDeleteOneMetadataField'; import { useDeleteOneMetadataField } from './useDeleteOneMetadataField';
import { useUpdateOneMetadataField } from './useUpdateOneMetadataField'; import { useUpdateOneMetadataField } from './useUpdateOneMetadataField';
export const useFieldMetadata = () => { export const useFieldMetadata = () => {
const { createOneMetadataField } = useCreateOneMetadataField();
const { updateOneMetadataField } = useUpdateOneMetadataField(); const { updateOneMetadataField } = useUpdateOneMetadataField();
const { deleteOneMetadataField } = useDeleteOneMetadataField(); const { deleteOneMetadataField } = useDeleteOneMetadataField();
const createField = (
input: Pick<Field, 'label' | 'icon' | 'description'> & {
objectId: string;
type: ObjectFieldDataType;
},
) =>
createOneMetadataField({
...formatMetadataFieldInput(input),
objectId: input.objectId,
});
const activateField = (metadataField: Field) => const activateField = (metadataField: Field) =>
updateOneMetadataField({ updateOneMetadataField({
fieldIdToUpdate: metadataField.id, fieldIdToUpdate: metadataField.id,
@ -24,6 +40,7 @@ export const useFieldMetadata = () => {
return { return {
activateField, activateField,
createField,
disableField, disableField,
eraseField, eraseField,
}; };

View File

@ -0,0 +1,17 @@
import toCamelCase from 'lodash.camelcase';
import upperFirst from 'lodash.upperfirst';
import { ObjectFieldDataType } from '@/settings/data-model/types/ObjectFieldDataType';
import { Field } from '~/generated-metadata/graphql';
export const formatMetadataFieldInput = (
input: Pick<Field, 'label' | 'icon' | 'description'> & {
type: ObjectFieldDataType;
},
) => ({
description: input.description?.trim() ?? null,
icon: input.icon,
label: input.label.trim(),
name: upperFirst(toCamelCase(input.label.trim())),
type: input.type,
});

View File

@ -0,0 +1,4 @@
const metadataLabelValidationPattern = /^[a-zA-Z][a-zA-Z0-9 ]*$/;
export const validateMetadataLabel = (value: string) =>
!!value.match(metadataLabelValidationPattern);

View File

@ -1,4 +0,0 @@
const metadataObjectLabelValidationPattern = /^[a-zA-Z][a-zA-Z0-9 ]*$/;
export const validateMetadataObjectLabel = (value: string) =>
!!value.match(metadataObjectLabelValidationPattern);

View File

@ -1,5 +1,6 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { validateMetadataLabel } from '@/metadata/utils/validateMetadataLabel';
import { H2Title } from '@/ui/display/typography/components/H2Title'; import { H2Title } from '@/ui/display/typography/components/H2Title';
import { IconPicker } from '@/ui/input/components/IconPicker'; import { IconPicker } from '@/ui/input/components/IconPicker';
import { TextArea } from '@/ui/input/components/TextArea'; import { TextArea } from '@/ui/input/components/TextArea';
@ -13,8 +14,8 @@ type SettingsObjectFieldFormSectionProps = {
iconKey?: string; iconKey?: string;
onChange?: ( onChange?: (
formValues: Partial<{ formValues: Partial<{
iconKey: string; icon: string;
name: string; label: string;
description: string; description: string;
}>, }>,
) => void; ) => void;
@ -42,13 +43,17 @@ export const SettingsObjectFieldFormSection = ({
<StyledInputsContainer> <StyledInputsContainer>
<IconPicker <IconPicker
selectedIconKey={iconKey} selectedIconKey={iconKey}
onChange={(value) => onChange?.({ iconKey: value.iconKey })} onChange={(value) => onChange?.({ icon: value.iconKey })}
variant="primary" variant="primary"
/> />
<TextInput <TextInput
placeholder="Employees" placeholder="Employees"
value={name} value={name}
onChange={(value) => onChange?.({ name: value })} onChange={(value) => {
if (!value || validateMetadataLabel(value)) {
onChange?.({ label: value });
}
}}
disabled={disabled} disabled={disabled}
fullWidth fullWidth
/> />

View File

@ -10,6 +10,9 @@ type SettingsObjectFieldTypeSelectSectionProps = {
onChange: (value: ObjectFieldDataType) => void; onChange: (value: ObjectFieldDataType) => void;
}; };
// TODO: remove "relation" type for now, add it back when the backend is ready.
const { relation: _, ...dataTypesWithoutRelation } = dataTypes;
export const SettingsObjectFieldTypeSelectSection = ({ export const SettingsObjectFieldTypeSelectSection = ({
type, type,
onChange, onChange,
@ -23,10 +26,12 @@ export const SettingsObjectFieldTypeSelectSection = ({
dropdownScopeId="object-field-type-select" dropdownScopeId="object-field-type-select"
value={type} value={type}
onChange={onChange} onChange={onChange}
options={Object.entries(dataTypes).map(([key, dataType]) => ({ options={Object.entries(dataTypesWithoutRelation).map(
value: key as ObjectFieldDataType, ([key, dataType]) => ({
...dataType, value: key as ObjectFieldDataType,
}))} ...dataType,
}),
)}
/> />
</Section> </Section>
); );

View File

@ -1,6 +1,6 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { validateMetadataObjectLabel } from '@/metadata/utils/validateMetadataObjectLabel'; import { validateMetadataLabel } from '@/metadata/utils/validateMetadataLabel';
import { H2Title } from '@/ui/display/typography/components/H2Title'; import { H2Title } from '@/ui/display/typography/components/H2Title';
import { TextArea } from '@/ui/input/components/TextArea'; import { TextArea } from '@/ui/input/components/TextArea';
import { TextInput } from '@/ui/input/components/TextInput'; import { TextInput } from '@/ui/input/components/TextInput';
@ -45,7 +45,7 @@ export const SettingsObjectFormSection = ({
placeholder="Investor" placeholder="Investor"
value={singularName} value={singularName}
onChange={(value) => { onChange={(value) => {
if (!value || validateMetadataObjectLabel(value)) { if (!value || validateMetadataLabel(value)) {
onChange?.({ labelSingular: value }); onChange?.({ labelSingular: value });
} }
}} }}
@ -57,7 +57,7 @@ export const SettingsObjectFormSection = ({
placeholder="Investors" placeholder="Investors"
value={pluralName} value={pluralName}
onChange={(value) => { onChange={(value) => {
if (!value || validateMetadataObjectLabel(value)) { if (!value || validateMetadataLabel(value)) {
onChange?.({ labelPlural: value }); onChange?.({ labelPlural: value });
} }
}} }}

View File

@ -3,9 +3,7 @@ import {
IconLink, IconLink,
IconNumbers, IconNumbers,
IconPlug, IconPlug,
IconSocial,
IconTextSize, IconTextSize,
IconUserCircle,
} from '@/ui/display/icon'; } from '@/ui/display/icon';
import { IconComponent } from '@/ui/display/icon/types/IconComponent'; import { IconComponent } from '@/ui/display/icon/types/IconComponent';
@ -17,9 +15,7 @@ export const dataTypes: Record<
> = { > = {
number: { label: 'Number', Icon: IconNumbers }, number: { label: 'Number', Icon: IconNumbers },
text: { label: 'Text', Icon: IconTextSize }, text: { label: 'Text', Icon: IconTextSize },
link: { label: 'Link', Icon: IconLink }, url: { label: 'Link', Icon: IconLink },
teammate: { label: 'Team member', Icon: IconUserCircle },
boolean: { label: 'True/False', Icon: IconCheck }, boolean: { label: 'True/False', Icon: IconCheck },
relation: { label: 'Relation', Icon: IconPlug }, relation: { label: 'Relation', Icon: IconPlug },
social: { label: 'Social', Icon: IconSocial },
}; };

View File

@ -1,42 +1,4 @@
import { import { IconMouse2 } from '@/ui/display/icon';
IconBrandLinkedin,
IconBrandTwitter,
IconBuildingSkyscraper,
IconCurrencyDollar,
IconFreeRights,
IconGraph,
IconHeadphones,
IconLink,
IconLuggage,
IconMouse2,
IconPlane,
IconTarget,
IconUser,
IconUserCircle,
IconUsers,
} from '@/ui/display/icon';
import { ObjectFieldItem } from '../types/ObjectFieldItem';
export const activeObjectItems = [
{
name: 'Companies',
singularName: 'Company',
Icon: IconBuildingSkyscraper,
type: 'standard',
fields: 23,
instances: 165,
description: 'Lorem ipsum',
},
{
name: 'People',
singularName: 'Person',
Icon: IconUser,
type: 'standard',
fields: 16,
instances: 462,
},
];
export const standardObjects = [ export const standardObjects = [
{ {
@ -52,92 +14,3 @@ export const standardObjects = [
description: 'Individuals who interact with your website', description: 'Individuals who interact with your website',
}, },
]; ];
export const disabledObjectItems = [
{
name: 'Travels',
Icon: IconLuggage,
type: 'custom',
fields: 23,
instances: 165,
},
{
name: 'Flights',
Icon: IconPlane,
type: 'custom',
fields: 23,
instances: 165,
},
];
export const activeFieldItems: ObjectFieldItem[] = [
{
name: 'People',
Icon: IconUser,
type: 'standard',
dataType: 'relation',
},
{
name: 'URL',
Icon: IconLink,
type: 'standard',
dataType: 'text',
},
{
name: 'Linkedin',
Icon: IconBrandLinkedin,
type: 'standard',
dataType: 'social',
},
{
name: 'Account Owner',
Icon: IconUserCircle,
type: 'standard',
dataType: 'teammate',
},
{
name: 'Employees',
Icon: IconUsers,
type: 'custom',
dataType: 'number',
},
];
export const disabledFieldItems: ObjectFieldItem[] = [
{
name: 'ICP',
Icon: IconTarget,
type: 'standard',
dataType: 'boolean',
},
{
name: 'Twitter',
Icon: IconBrandTwitter,
type: 'standard',
dataType: 'social',
},
{
name: 'Annual revenue',
Icon: IconCurrencyDollar,
type: 'standard',
dataType: 'number',
},
{
name: 'Is public',
Icon: IconGraph,
type: 'standard',
dataType: 'boolean',
},
{
name: 'Free tier?',
Icon: IconFreeRights,
type: 'custom',
dataType: 'boolean',
},
{
name: 'Priority support',
Icon: IconHeadphones,
type: 'custom',
dataType: 'boolean',
},
];

View File

@ -1,8 +1,6 @@
export type ObjectFieldDataType = export type ObjectFieldDataType =
| 'boolean' | 'boolean'
| 'link'
| 'number' | 'number'
| 'relation' | 'relation'
| 'social' | 'text'
| 'teammate' | 'url';
| 'text';

View File

@ -1,10 +0,0 @@
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { ObjectFieldDataType } from './ObjectFieldDataType';
export type ObjectFieldItem = {
name: string;
Icon: IconComponent;
type: 'standard' | 'custom';
dataType: ObjectFieldDataType;
};

View File

@ -5,7 +5,6 @@ export {
IconAlertTriangle, IconAlertTriangle,
IconArchive, IconArchive,
IconArchiveOff, IconArchiveOff,
IconArrowBack,
IconArrowDown, IconArrowDown,
IconArrowLeft, IconArrowLeft,
IconArrowRight, IconArrowRight,
@ -17,7 +16,6 @@ export {
IconBrandGithub, IconBrandGithub,
IconBrandGoogle, IconBrandGoogle,
IconBrandLinkedin, IconBrandLinkedin,
IconBrandTwitter,
IconBrandX, IconBrandX,
IconBriefcase, IconBriefcase,
IconBuildingSkyscraper, IconBuildingSkyscraper,
@ -31,11 +29,9 @@ export {
IconChevronsRight, IconChevronsRight,
IconChevronUp, IconChevronUp,
IconCircleDot, IconCircleDot,
IconCirclePlus,
IconColorSwatch, IconColorSwatch,
IconMessageCircle as IconComment, IconMessageCircle as IconComment,
IconCopy, IconCopy,
IconCross,
IconCurrencyDollar, IconCurrencyDollar,
IconDatabase, IconDatabase,
IconDotsVertical, IconDotsVertical,
@ -45,15 +41,11 @@ export {
IconFileImport, IconFileImport,
IconFileUpload, IconFileUpload,
IconForbid, IconForbid,
IconFreeRights,
IconGraph,
IconGripVertical, IconGripVertical,
IconHeadphones,
IconHeart, IconHeart,
IconHeartOff, IconHeartOff,
IconHelpCircle, IconHelpCircle,
IconHierarchy2, IconHierarchy2,
IconInbox,
IconLayoutKanban, IconLayoutKanban,
IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftCollapse,
IconLayoutSidebarRightCollapse, IconLayoutSidebarRightCollapse,
@ -62,7 +54,6 @@ export {
IconLinkOff, IconLinkOff,
IconList, IconList,
IconLogout, IconLogout,
IconLuggage,
IconMail, IconMail,
IconMap, IconMap,
IconMinus, IconMinus,
@ -72,7 +63,6 @@ export {
IconNumbers, IconNumbers,
IconPencil, IconPencil,
IconPhone, IconPhone,
IconPlane,
IconPlug, IconPlug,
IconPlus, IconPlus,
IconProgressCheck, IconProgressCheck,
@ -80,7 +70,6 @@ export {
IconRobot, IconRobot,
IconSearch, IconSearch,
IconSettings, IconSettings,
IconSocial,
IconTag, IconTag,
IconTarget, IconTarget,
IconTargetArrow, IconTargetArrow,

View File

@ -26,10 +26,10 @@ export const SettingsNewObject = () => {
const [customFormValues, setCustomFormValues] = useState<{ const [customFormValues, setCustomFormValues] = useState<{
description?: string; description?: string;
icon?: string; icon: string;
labelPlural: string; labelPlural: string;
labelSingular: string; labelSingular: string;
}>({ labelPlural: '', labelSingular: '' }); }>({ icon: 'IconPigMoney', labelPlural: '', labelSingular: '' });
const canSave = const canSave =
selectedObjectType === 'Custom' && selectedObjectType === 'Custom' &&

View File

@ -75,7 +75,7 @@ export const SettingsObjectDetail = () => {
<Section> <Section>
<H2Title <H2Title
title="Fields" title="Fields"
description={`Customise the fields available in the ${activeObject?.nameSingular} views and their display order in the ${activeObject?.nameSingular} detail view and menus.`} description={`Customise the fields available in the ${activeObject?.labelSingular} views and their display order in the ${activeObject?.labelSingular} detail view and menus.`}
/> />
<Table> <Table>
<StyledObjectFieldTableRow> <StyledObjectFieldTableRow>

View File

@ -1,12 +1,13 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useFieldMetadata } from '@/metadata/hooks/useFieldMetadata';
import { useObjectMetadata } from '@/metadata/hooks/useObjectMetadata';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer'; import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsObjectFieldFormSection } from '@/settings/data-model/components/SettingsObjectFieldFormSection'; import { SettingsObjectFieldFormSection } from '@/settings/data-model/components/SettingsObjectFieldFormSection';
import { SettingsObjectFieldTypeSelectSection } from '@/settings/data-model/components/SettingsObjectFieldTypeSelectSection'; import { SettingsObjectFieldTypeSelectSection } from '@/settings/data-model/components/SettingsObjectFieldTypeSelectSection';
import { activeObjectItems } from '@/settings/data-model/constants/mockObjects';
import { ObjectFieldDataType } from '@/settings/data-model/types/ObjectFieldDataType'; import { ObjectFieldDataType } from '@/settings/data-model/types/ObjectFieldDataType';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
import { IconSettings } from '@/ui/display/icon'; import { IconSettings } from '@/ui/display/icon';
@ -16,23 +17,30 @@ import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
export const SettingsObjectNewFieldStep2 = () => { export const SettingsObjectNewFieldStep2 = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { objectSlug = '' } = useParams(); const { objectSlug = '' } = useParams();
const activeObject = activeObjectItems.find( const { findActiveObjectBySlug } = useObjectMetadata();
(activeObject) => activeObject.name.toLowerCase() === objectSlug, const activeObject = findActiveObjectBySlug(objectSlug);
); const { createField } = useFieldMetadata();
useEffect(() => { useEffect(() => {
if (!activeObject) navigate(AppPath.NotFound); if (!activeObject) navigate(AppPath.NotFound);
}, [activeObject, navigate]); }, [activeObject, navigate]);
const [formValues, setFormValues] = useState< const [formValues, setFormValues] = useState<{
Partial<{ description?: string;
iconKey: string; icon: string;
name: string; label: string;
description: string; type: ObjectFieldDataType;
}> & { type: ObjectFieldDataType } }>({ icon: 'IconUsers', label: '', type: 'number' });
>({ type: 'number' });
const canSave = !!formValues.name; const canSave = !!formValues.label;
const handleSave = async () => {
if (!activeObject) return;
await createField({ ...formValues, objectId: activeObject.id });
navigate(`/settings/objects/${objectSlug}`);
};
return ( return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer Icon={IconSettings} title="Settings">
@ -42,7 +50,7 @@ export const SettingsObjectNewFieldStep2 = () => {
links={[ links={[
{ children: 'Objects', href: '/settings/objects' }, { children: 'Objects', href: '/settings/objects' },
{ {
children: activeObject?.name ?? '', children: activeObject?.labelPlural ?? '',
href: `/settings/objects/${objectSlug}`, href: `/settings/objects/${objectSlug}`,
}, },
{ children: 'New Field' }, { children: 'New Field' },
@ -51,14 +59,14 @@ export const SettingsObjectNewFieldStep2 = () => {
<SaveAndCancelButtons <SaveAndCancelButtons
isSaveDisabled={!canSave} isSaveDisabled={!canSave}
onCancel={() => { onCancel={() => {
navigate('/settings/objects'); navigate(`/settings/objects/${objectSlug}`);
}} }}
onSave={() => undefined} onSave={handleSave}
/> />
</SettingsHeaderContainer> </SettingsHeaderContainer>
<SettingsObjectFieldFormSection <SettingsObjectFieldFormSection
iconKey={formValues.iconKey} iconKey={formValues.icon}
name={formValues.name} name={formValues.label}
description={formValues.description} description={formValues.description}
onChange={(values) => onChange={(values) =>
setFormValues((previousValues) => ({ setFormValues((previousValues) => ({