feat: add New Field Step 2 form (#2138)
Closes #2001 Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
@ -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>
|
||||
);
|
||||
@ -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>
|
||||
);
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
},
|
||||
};
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
25
front/src/modules/settings/data-model/constants/dataTypes.ts
Normal file
25
front/src/modules/settings/data-model/constants/dataTypes.ts
Normal 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 },
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export type ObjectFieldDataType =
|
||||
| 'boolean'
|
||||
| 'link'
|
||||
| 'number'
|
||||
| 'relation'
|
||||
| 'social'
|
||||
|
||||
@ -80,6 +80,7 @@ export {
|
||||
IconTag,
|
||||
IconTarget,
|
||||
IconTargetArrow,
|
||||
IconTextSize,
|
||||
IconTimelineEvent,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
94
front/src/modules/ui/input/components/Select.tsx
Normal file
94
front/src/modules/ui/input/components/Select.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
3
front/src/modules/ui/input/types/SelectHotkeyScope.ts
Normal file
3
front/src/modules/ui/input/types/SelectHotkeyScope.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export enum SelectHotkeyScope {
|
||||
Select = 'select',
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user