feat: add New Field Step 2 form (#2138)

Closes #2001

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Thaïs
2023-10-21 13:28:15 +02:00
committed by GitHub
parent c90cf1eb8f
commit 34bbbdff41
15 changed files with 367 additions and 34 deletions

View File

@ -0,0 +1,64 @@
import styled from '@emotion/styled';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { TextArea } from '@/ui/input/components/TextArea';
import { TextInput } from '@/ui/input/components/TextInput';
import { Section } from '@/ui/layout/section/components/Section';
type SettingsObjectFieldFormSectionProps = {
disabled?: boolean;
name?: string;
description?: string;
iconKey?: string;
onChange?: (
formValues: Partial<{
iconKey: string;
name: string;
description: string;
}>,
) => void;
};
const StyledInputsContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
margin-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
export const SettingsObjectFieldFormSection = ({
disabled,
name = '',
description = '',
iconKey = 'IconUsers',
onChange,
}: SettingsObjectFieldFormSectionProps) => (
<Section>
<H2Title
title="Name and description"
description="The name and description of this field"
/>
<StyledInputsContainer>
<IconPicker
selectedIconKey={iconKey}
onChange={(value) => onChange?.({ iconKey: value.iconKey })}
variant="primary"
/>
<TextInput
placeholder="Employees"
value={name}
onChange={(value) => onChange?.({ name: value })}
disabled={disabled}
fullWidth
/>
</StyledInputsContainer>
<TextArea
placeholder="Write a description"
minRows={4}
value={description}
onChange={(value) => onChange?.({ description: value })}
disabled={disabled}
/>
</Section>
);

View File

@ -0,0 +1,32 @@
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { Select } from '@/ui/input/components/Select';
import { Section } from '@/ui/layout/section/components/Section';
import { dataTypes } from '../constants/dataTypes';
import { ObjectFieldDataType } from '../types/ObjectFieldDataType';
type SettingsObjectFieldTypeSelectSectionProps = {
type: ObjectFieldDataType;
onChange: (value: ObjectFieldDataType) => void;
};
export const SettingsObjectFieldTypeSelectSection = ({
type,
onChange,
}: SettingsObjectFieldTypeSelectSectionProps) => (
<Section>
<H2Title
title="Type and values"
description="The field's type and values."
/>
<Select
dropdownScopeId="object-field-type-select"
value={type}
onChange={onChange}
options={Object.entries(dataTypes).map(([key, dataType]) => ({
value: key as ObjectFieldDataType,
...dataType,
}))}
/>
</Section>
);

View File

@ -26,10 +26,6 @@ const StyledInputsContainer = styled.div`
width: 100%;
`;
const StyledTextInput = styled(TextInput)`
flex: 1 0 auto;
`;
export const SettingsObjectFormSection = ({
disabled,
singularName = '',
@ -43,19 +39,21 @@ export const SettingsObjectFormSection = ({
description="Name in both singular (e.g., 'Invoice') and plural (e.g., 'Invoices') forms."
/>
<StyledInputsContainer>
<StyledTextInput
<TextInput
label="Singular"
placeholder="Investor"
value={singularName}
onChange={(value) => onChange?.({ singularName: value })}
disabled={disabled}
fullWidth
/>
<StyledTextInput
<TextInput
label="Plural"
placeholder="Investors"
value={pluralName}
onChange={(value) => onChange?.({ pluralName: value })}
disabled={disabled}
fullWidth
/>
</StyledInputsContainer>
<TextArea

View File

@ -0,0 +1,24 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { SettingsObjectFieldFormSection } from '../SettingsObjectFieldFormSection';
const meta: Meta<typeof SettingsObjectFieldFormSection> = {
title: 'Modules/Settings/DataModel/SettingsObjectFieldFormSection',
component: SettingsObjectFieldFormSection,
decorators: [ComponentDecorator],
};
export default meta;
type Story = StoryObj<typeof SettingsObjectFieldFormSection>;
export const Default: Story = {};
export const WithDefaultValues: Story = {
args: {
iconKey: 'IconLink',
name: 'URL',
description: 'Lorem ipsum',
},
};

View File

@ -0,0 +1,28 @@
import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { SettingsObjectFieldTypeSelectSection } from '../SettingsObjectFieldTypeSelectSection';
const meta: Meta<typeof SettingsObjectFieldTypeSelectSection> = {
title: 'Modules/Settings/DataModel/SettingsObjectFieldTypeSelectSection',
component: SettingsObjectFieldTypeSelectSection,
decorators: [ComponentDecorator],
args: { type: 'number' },
};
export default meta;
type Story = StoryObj<typeof SettingsObjectFieldTypeSelectSection>;
export const Default: Story = {};
export const WithOpenSelect: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const selectLabel = canvas.getByText('Number');
await userEvent.click(selectLabel);
},
};

View File

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

View File

@ -1,16 +1,7 @@
import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
IconCheck,
IconLink,
IconNumbers,
IconPlug,
IconSocial,
IconUserCircle,
} from '@/ui/display/icon';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { dataTypes } from '../../constants/dataTypes';
import { ObjectFieldDataType } from '../../types/ObjectFieldDataType';
const StyledDataType = styled.div<{ value: ObjectFieldDataType }>`
@ -32,18 +23,6 @@ const StyledDataType = styled.div<{ value: ObjectFieldDataType }>`
: ''}
`;
const dataTypes: Record<
ObjectFieldDataType,
{ label: string; Icon: IconComponent }
> = {
boolean: { label: 'True/False', Icon: IconCheck },
number: { label: 'Number', Icon: IconNumbers },
relation: { label: 'Relation', Icon: IconPlug },
social: { label: 'Social', Icon: IconSocial },
teammate: { label: 'Teammate', Icon: IconUserCircle },
text: { label: 'Text', Icon: IconLink },
};
type SettingsObjectFieldDataTypeProps = {
value: ObjectFieldDataType;
};

View File

@ -1,5 +1,6 @@
export type ObjectFieldDataType =
| 'boolean'
| 'link'
| 'number'
| 'relation'
| 'social'

View File

@ -80,6 +80,7 @@ export {
IconTag,
IconTarget,
IconTargetArrow,
IconTextSize,
IconTimelineEvent,
IconTrash,
IconUpload,

View File

@ -246,6 +246,7 @@ const StyledButton = styled.button<
return '0';
}
}};
box-sizing: content-box;
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: flex;
flex-direction: row;

View File

@ -10,7 +10,7 @@ import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownM
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { IconButton } from '../button/components/IconButton';
import { IconButton, IconButtonVariant } from '../button/components/IconButton';
import { LightIconButton } from '../button/components/LightIconButton';
import { IconApps } from '../constants/icons';
import { useLazyLoadIcons } from '../hooks/useLazyLoadIcons';
@ -24,6 +24,7 @@ type IconPickerProps = {
onClickOutside?: () => void;
onClose?: () => void;
onOpen?: () => void;
variant?: IconButtonVariant;
};
const StyledMenuIconItemsContainer = styled.div`
@ -47,6 +48,7 @@ export const IconPicker = ({
onClickOutside,
onClose,
onOpen,
variant = 'secondary',
}: IconPickerProps) => {
const [searchString, setSearchString] = useState('');
@ -79,7 +81,7 @@ export const IconPicker = ({
<IconButton
disabled={disabled}
Icon={selectedIconKey ? icons[selectedIconKey] : IconApps}
variant="secondary"
variant={variant}
/>
}
dropdownComponents={
@ -119,7 +121,7 @@ export const IconPicker = ({
setSearchString('');
}}
onOpen={onOpen}
></Dropdown>
/>
</DropdownScope>
);
};

View File

@ -0,0 +1,94 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconChevronDown } from '@/ui/display/icon';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { SelectHotkeyScope } from '../types/SelectHotkeyScope';
export type SelectProps<Value extends string> = {
dropdownScopeId: string;
onChange: (value: Value) => void;
options: { value: Value; label: string; Icon?: IconComponent }[];
value?: Value;
};
const StyledContainer = styled.div`
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.primary};
cursor: pointer;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ theme }) => theme.spacing(8)};
justify-content: space-between;
padding: 0 ${({ theme }) => theme.spacing(2)};
`;
const StyledLabel = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
export const Select = <Value extends string>({
dropdownScopeId,
onChange,
options,
value,
}: SelectProps<Value>) => {
const theme = useTheme();
const selectedOption =
options.find(({ value: key }) => key === value) || options[0];
const { closeDropdown } = useDropdown({ dropdownScopeId });
return (
<DropdownScope dropdownScopeId={dropdownScopeId}>
<Dropdown
dropdownMenuWidth={176}
dropdownPlacement="bottom-start"
clickableComponent={
<StyledContainer>
<StyledLabel>
{!!selectedOption.Icon && (
<selectedOption.Icon
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
)}
{selectedOption.label}
</StyledLabel>
<IconChevronDown
color={theme.font.color.tertiary}
size={theme.icon.size.md}
/>
</StyledContainer>
}
dropdownComponents={
<DropdownMenuItemsContainer>
{options.map((option) => (
<MenuItem
key={option.value}
LeftIcon={option.Icon}
text={option.label}
onClick={() => {
onChange(option.value);
closeDropdown();
}}
/>
))}
</DropdownMenuItemsContainer>
}
dropdownHotkeyScope={{ scope: SelectHotkeyScope.Select }}
/>
</DropdownScope>
);
};

View File

@ -0,0 +1,51 @@
import { useState } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { Select, SelectProps } from '../Select';
type RenderProps = SelectProps<string>;
const Render = (args: RenderProps) => {
const [value, setValue] = useState(args.value);
const handleChange = (value: string) => {
args.onChange?.(value);
setValue(value);
};
// eslint-disable-next-line react/jsx-props-no-spreading
return <Select {...args} value={value} onChange={handleChange} />;
};
const meta: Meta<typeof Select> = {
title: 'UI/Input/Select',
component: Select,
decorators: [ComponentDecorator],
args: {
dropdownScopeId: 'select',
value: 'a',
options: [
{ value: 'a', label: 'Option A' },
{ value: 'b', label: 'Option B' },
{ value: 'c', label: 'Option C' },
],
},
render: Render,
};
export default meta;
type Story = StoryObj<typeof Select>;
export const Default: Story = {};
export const Open: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const selectLabel = await canvas.getByText('Option A');
await userEvent.click(selectLabel);
},
};

View File

@ -0,0 +1,3 @@
export enum SelectHotkeyScope {
Select = 'select',
}

View File

@ -1,10 +1,13 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
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';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
@ -21,6 +24,16 @@ export const SettingsObjectNewFieldStep2 = () => {
if (!activeObject) navigate(AppPath.NotFound);
}, [activeObject, navigate]);
const [formValues, setFormValues] = useState<
Partial<{
iconKey: string;
name: string;
description: string;
}> & { type: ObjectFieldDataType }
>({ type: 'number' });
const canSave = !!formValues.name;
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
@ -36,13 +49,30 @@ export const SettingsObjectNewFieldStep2 = () => {
]}
/>
<SaveAndCancelButtons
isSaveDisabled
isSaveDisabled={!canSave}
onCancel={() => {
navigate('/settings/objects');
}}
onSave={() => {}}
onSave={() => undefined}
/>
</SettingsHeaderContainer>
<SettingsObjectFieldFormSection
iconKey={formValues.iconKey}
name={formValues.name}
description={formValues.description}
onChange={(values) =>
setFormValues((previousValues) => ({
...previousValues,
...values,
}))
}
/>
<SettingsObjectFieldTypeSelectSection
type={formValues.type}
onChange={(type) =>
setFormValues((previousValues) => ({ ...previousValues, type }))
}
/>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);