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 { formatMetadataFieldInput } from '../utils/formatMetadataFieldInput';
import { useCreateOneMetadataField } from './useCreateOneMetadataField';
import { useDeleteOneMetadataField } from './useDeleteOneMetadataField';
import { useUpdateOneMetadataField } from './useUpdateOneMetadataField';
export const useFieldMetadata = () => {
const { createOneMetadataField } = useCreateOneMetadataField();
const { updateOneMetadataField } = useUpdateOneMetadataField();
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) =>
updateOneMetadataField({
fieldIdToUpdate: metadataField.id,
@ -24,6 +40,7 @@ export const useFieldMetadata = () => {
return {
activateField,
createField,
disableField,
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 { validateMetadataLabel } from '@/metadata/utils/validateMetadataLabel';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { TextArea } from '@/ui/input/components/TextArea';
@ -13,8 +14,8 @@ type SettingsObjectFieldFormSectionProps = {
iconKey?: string;
onChange?: (
formValues: Partial<{
iconKey: string;
name: string;
icon: string;
label: string;
description: string;
}>,
) => void;
@ -42,13 +43,17 @@ export const SettingsObjectFieldFormSection = ({
<StyledInputsContainer>
<IconPicker
selectedIconKey={iconKey}
onChange={(value) => onChange?.({ iconKey: value.iconKey })}
onChange={(value) => onChange?.({ icon: value.iconKey })}
variant="primary"
/>
<TextInput
placeholder="Employees"
value={name}
onChange={(value) => onChange?.({ name: value })}
onChange={(value) => {
if (!value || validateMetadataLabel(value)) {
onChange?.({ label: value });
}
}}
disabled={disabled}
fullWidth
/>

View File

@ -10,6 +10,9 @@ type SettingsObjectFieldTypeSelectSectionProps = {
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 = ({
type,
onChange,
@ -23,10 +26,12 @@ export const SettingsObjectFieldTypeSelectSection = ({
dropdownScopeId="object-field-type-select"
value={type}
onChange={onChange}
options={Object.entries(dataTypes).map(([key, dataType]) => ({
value: key as ObjectFieldDataType,
...dataType,
}))}
options={Object.entries(dataTypesWithoutRelation).map(
([key, dataType]) => ({
value: key as ObjectFieldDataType,
...dataType,
}),
)}
/>
</Section>
);

View File

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

View File

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

View File

@ -1,42 +1,4 @@
import {
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,
},
];
import { IconMouse2 } from '@/ui/display/icon';
export const standardObjects = [
{
@ -52,92 +14,3 @@ export const standardObjects = [
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 =
| 'boolean'
| 'link'
| 'number'
| 'relation'
| 'social'
| 'teammate'
| 'text';
| 'text'
| 'url';

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

View File

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

View File

@ -75,7 +75,7 @@ export const SettingsObjectDetail = () => {
<Section>
<H2Title
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>
<StyledObjectFieldTableRow>

View File

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