Migrate to a monorepo structure (#2909)

This commit is contained in:
Charles Bochet
2023-12-10 18:10:54 +01:00
committed by GitHub
parent a70a9281eb
commit 5bdca9de6c
2304 changed files with 37152 additions and 25869 deletions

View File

@ -0,0 +1,3 @@
<svg width="34" height="16" viewBox="0 0 34 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 7C0.447715 7 0 7.44772 0 8C0 8.55228 0.447715 9 1 9V7ZM33.7071 8.70711C34.0976 8.31658 34.0976 7.68342 33.7071 7.29289L27.3431 0.928932C26.9526 0.538408 26.3195 0.538408 25.9289 0.928932C25.5384 1.31946 25.5384 1.95262 25.9289 2.34315L31.5858 8L25.9289 13.6569C25.5384 14.0474 25.5384 14.6805 25.9289 15.0711C26.3195 15.4616 26.9526 15.4616 27.3431 15.0711L33.7071 8.70711ZM1 9H33V7H1V9Z" fill="#EBEBEB"/>
</svg>

After

Width:  |  Height:  |  Size: 520 B

View File

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 58 46" width="58" height="46" fill="none" preserveAspectRatio="xMidYMid meet">
<rect width="9" height="9" x=".5" y="18.5" stroke="#EBEBEB" rx="2.5" />
<rect width="4" height="4" x="3" y="21" fill="#D6D6D6" rx="1" />
<rect width="9" height="9" x="48.5" y=".5" stroke="#EBEBEB" rx="2.5" />
<rect width="4" height="4" x="51" y="3" fill="#D6D6D6" rx="1" />
<rect width="9" height="9" x="48.5" y="18.5" stroke="#EBEBEB" rx="2.5" />
<rect width="4" height="4" x="51" y="21" fill="#D6D6D6" rx="1" />
<rect width="9" height="9" x="48.5" y="36.5" stroke="#EBEBEB" rx="2.5" />
<rect width="4" height="4" x="51" y="39" fill="#D6D6D6" rx="1" />
<path fill="#D6D6D6" d="M5.113 22.5h48v1h-48v-1Z" />
<path stroke="#D6D6D6" d="M52.884 41H45.06a7.544 7.544 0 0 1-7.56-7.561V12.56A7.544 7.544 0 0 1 45.06 5h7.793" />
</svg>

After

Width:  |  Height:  |  Size: 870 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="58" height="46" fill="none" viewBox="0 0 58 46">
<rect width="9" height="9" x=".5" y="18.5" stroke="#EBEBEB" rx="2.5"/>
<rect width="4" height="4" x="3" y="21" fill="#D6D6D6" rx="1"/>
<rect width="9" height="9" x="48.5" y="18.5" stroke="#EBEBEB" rx="2.5"/>
<rect width="4" height="4" x="51" y="21" fill="#D6D6D6" rx="1"/>
<path fill="#D6D6D6" d="M5.113 22.5h48v1h-48v-1Z"/>
</svg>

After

Width:  |  Height:  |  Size: 437 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

View File

@ -0,0 +1,72 @@
import styled from '@emotion/styled';
import { validateMetadataLabel } from '@/object-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';
import { TextInput } from '@/ui/input/components/TextInput';
import { Section } from '@/ui/layout/section/components/Section';
type SettingsObjectFieldFormSectionProps = {
disabled?: boolean;
disableNameEdition?: boolean;
name?: string;
description?: string;
iconKey?: string;
onChange?: (
formValues: Partial<{
icon: string;
label: 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,
disableNameEdition,
name = '',
description = '',
iconKey = 'IconUsers',
onChange,
}: SettingsObjectFieldFormSectionProps) => (
<Section>
<H2Title
title="Name and description"
description="The name and description of this field"
/>
<StyledInputsContainer>
<IconPicker
disabled={disabled}
selectedIconKey={iconKey}
onChange={(value) => onChange?.({ icon: value.iconKey })}
variant="primary"
/>
<TextInput
placeholder="Employees"
value={name}
onChange={(value) => {
if (!value || validateMetadataLabel(value)) {
onChange?.({ label: value });
}
}}
disabled={disabled || disableNameEdition}
fullWidth
/>
</StyledInputsContainer>
<TextArea
placeholder="Write a description"
minRows={4}
value={description}
onChange={(value) => onChange?.({ description: value })}
disabled={disabled}
/>
</Section>
);

View File

@ -0,0 +1,159 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { parseFieldType } from '@/object-metadata/utils/parseFieldType';
import { FieldDisplay } from '@/object-record/field/components/FieldDisplay';
import { FieldContext } from '@/object-record/field/contexts/FieldContext';
import { BooleanFieldInput } from '@/object-record/field/meta-types/input/components/BooleanFieldInput';
import { RatingFieldInput } from '@/object-record/field/meta-types/input/components/RatingFieldInput';
import { Tag } from '@/ui/display/tag/components/Tag';
import { Field, FieldMetadataType } from '~/generated-metadata/graphql';
import { SettingsObjectFieldPreviewValueEffect } from '../components/SettingsObjectFieldPreviewValueEffect';
import { useFieldPreview } from '../hooks/useFieldPreview';
import { SettingsObjectFieldSelectFormValues } from './SettingsObjectFieldSelectForm';
export type SettingsObjectFieldPreviewProps = {
className?: string;
fieldMetadata: Pick<Field, 'icon' | 'label' | 'type'> & { id?: string };
objectMetadataId: string;
relationObjectMetadataId?: string;
selectOptions?: SettingsObjectFieldSelectFormValues;
shrink?: boolean;
};
const StyledContainer = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
box-sizing: border-box;
color: ${({ theme }) => theme.font.color.primary};
max-width: 480px;
padding: ${({ theme }) => theme.spacing(2)};
`;
const StyledObjectSummary = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
justify-content: space-between;
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledObjectName = styled.div`
align-items: center;
display: flex;
font-weight: ${({ theme }) => theme.font.weight.medium};
gap: ${({ theme }) => theme.spacing(1)};
`;
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;
`;
const StyledFieldLabel = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
export const SettingsObjectFieldPreview = ({
className,
fieldMetadata,
objectMetadataId,
relationObjectMetadataId,
selectOptions,
shrink,
}: SettingsObjectFieldPreviewProps) => {
const theme = useTheme();
const {
entityId,
FieldIcon,
fieldName,
ObjectIcon,
objectMetadataItem,
relationObjectMetadataItem,
value,
} = useFieldPreview({
fieldMetadata,
objectMetadataId,
relationObjectMetadataId,
selectOptions,
});
return (
<StyledContainer className={className}>
<StyledObjectSummary>
<StyledObjectName>
{!!ObjectIcon && (
<ObjectIcon
size={theme.icon.size.sm}
stroke={theme.icon.stroke.sm}
/>
)}
{objectMetadataItem?.labelPlural}
</StyledObjectName>
{objectMetadataItem?.isCustom ? (
<Tag color="orange" text="Custom" />
) : (
<Tag color="blue" text="Standard" />
)}
</StyledObjectSummary>
<SettingsObjectFieldPreviewValueEffect
entityId={entityId}
fieldName={fieldName}
value={value}
/>
<StyledFieldPreview shrink={shrink}>
<StyledFieldLabel>
{!!FieldIcon && (
<FieldIcon
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
)}
{fieldMetadata.label}:
</StyledFieldLabel>
<FieldContext.Provider
value={{
entityId,
isLabelIdentifier: false,
fieldDefinition: {
type: parseFieldType(fieldMetadata.type),
iconName: 'FieldIcon',
fieldMetadataId: fieldMetadata.id || '',
label: fieldMetadata.label,
metadata: {
fieldName,
relationObjectMetadataNameSingular:
relationObjectMetadataItem?.nameSingular,
},
},
hotkeyScope: 'field-preview',
}}
>
{fieldMetadata.type === FieldMetadataType.Boolean ? (
<BooleanFieldInput readonly />
) : fieldMetadata.type === FieldMetadataType.Rating ? (
<RatingFieldInput readonly />
) : (
<FieldDisplay />
)}
</FieldContext.Provider>
</StyledFieldPreview>
</StyledContainer>
);
};

View File

@ -0,0 +1,29 @@
import { useEffect } from 'react';
import { useRecoilState } from 'recoil';
import { entityFieldsFamilySelector } from '@/object-record/field/states/selectors/entityFieldsFamilySelector';
type SettingsObjectFieldPreviewValueEffectProps = {
entityId: string;
fieldName: string;
value: unknown;
};
export const SettingsObjectFieldPreviewValueEffect = ({
entityId,
fieldName,
value,
}: SettingsObjectFieldPreviewValueEffectProps) => {
const [, setFieldValue] = useRecoilState(
entityFieldsFamilySelector({
entityId,
fieldName,
}),
);
useEffect(() => {
setFieldValue(value);
}, [value, setFieldValue]);
return null;
};

View File

@ -0,0 +1,131 @@
import styled from '@emotion/styled';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { validateMetadataLabel } from '@/object-metadata/utils/validateMetadataLabel';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { Select } from '@/ui/input/components/Select';
import { TextInput } from '@/ui/input/components/TextInput';
import { useLazyLoadIcons } from '@/ui/input/hooks/useLazyLoadIcons';
import { Field } from '~/generated-metadata/graphql';
import { relationTypes } from '../constants/relationTypes';
import { RelationType } from '../types/RelationType';
export type SettingsObjectFieldRelationFormValues = {
field: Pick<Field, 'icon' | 'label'>;
objectMetadataId: string;
type: RelationType;
};
type SettingsObjectFieldRelationFormProps = {
disableFieldEdition?: boolean;
disableRelationEdition?: boolean;
onChange: (values: Partial<SettingsObjectFieldRelationFormValues>) => void;
values: SettingsObjectFieldRelationFormValues;
};
const StyledContainer = styled.div`
padding: ${({ theme }) => theme.spacing(4)};
`;
const StyledSelectsContainer = styled.div`
display: grid;
gap: ${({ theme }) => theme.spacing(4)};
grid-template-columns: 1fr 1fr;
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
const StyledInputsLabel = styled.span`
color: ${({ theme }) => theme.font.color.light};
display: block;
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(1)};
text-transform: uppercase;
`;
const StyledInputsContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
export const SettingsObjectFieldRelationForm = ({
disableFieldEdition,
disableRelationEdition,
onChange,
values,
}: SettingsObjectFieldRelationFormProps) => {
const { icons } = useLazyLoadIcons();
const { objectMetadataItems, findObjectMetadataItemById } =
useObjectMetadataItemForSettings();
const selectedObjectMetadataItem =
(values.objectMetadataId
? findObjectMetadataItemById(values.objectMetadataId)
: undefined) || objectMetadataItems[0];
return (
<StyledContainer>
<StyledSelectsContainer>
<Select
label="Relation type"
dropdownScopeId="relation-type-select"
disabled={disableRelationEdition}
value={values.type}
options={Object.entries(relationTypes).map(
([value, { label, Icon }]) => ({
label,
value: value as RelationType,
Icon,
}),
)}
onChange={(value) => onChange({ type: value })}
/>
<Select
label="Object destination"
dropdownScopeId="object-destination-select"
disabled={disableRelationEdition}
value={values.objectMetadataId}
options={objectMetadataItems.map((objectMetadataItem) => ({
label: objectMetadataItem.labelPlural,
value: objectMetadataItem.id,
Icon: objectMetadataItem.icon
? icons[objectMetadataItem.icon]
: undefined,
}))}
onChange={(value) => onChange({ objectMetadataId: value })}
/>
</StyledSelectsContainer>
<StyledInputsLabel>
Field on {selectedObjectMetadataItem?.labelPlural}
</StyledInputsLabel>
<StyledInputsContainer>
<IconPicker
disabled={disableFieldEdition}
dropdownScopeId="field-destination-icon-picker"
selectedIconKey={values.field.icon || undefined}
onChange={(value) =>
onChange({
field: { ...values.field, icon: value.iconKey },
})
}
variant="primary"
/>
<TextInput
disabled={disableFieldEdition}
placeholder="Field name"
value={values.field.label}
onChange={(value) => {
if (!value || validateMetadataLabel(value)) {
onChange({
field: { ...values.field, label: value },
});
}
}}
fullWidth
/>
</StyledInputsContainer>
</StyledContainer>
);
};

View File

@ -0,0 +1,137 @@
import styled from '@emotion/styled';
import { DropResult } from '@hello-pangea/dnd';
import { v4 } from 'uuid';
import { IconPlus } from '@/ui/display/icon';
import { Button } from '@/ui/input/button/components/Button';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList';
import { mainColorNames, ThemeColor } from '@/ui/theme/constants/colors';
import { SettingsObjectFieldSelectFormOption } from '../types/SettingsObjectFieldSelectFormOption';
import { SettingsObjectFieldSelectFormOptionRow } from './SettingsObjectFieldSelectFormOptionRow';
export type SettingsObjectFieldSelectFormValues =
SettingsObjectFieldSelectFormOption[];
type SettingsObjectFieldSelectFormProps = {
onChange: (values: SettingsObjectFieldSelectFormValues) => void;
values: SettingsObjectFieldSelectFormValues;
};
const StyledContainer = styled.div`
padding: ${({ theme }) => theme.spacing(4)};
padding-bottom: ${({ theme }) => theme.spacing(3.5)};
`;
const StyledLabel = styled.span`
color: ${({ theme }) => theme.font.color.light};
display: block;
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: 6px;
margin-top: ${({ theme }) => theme.spacing(1)};
text-transform: uppercase;
`;
const StyledButton = styled(Button)`
border-bottom: 0;
border-left: 0;
border-radius: 0;
border-right: 0;
justify-content: center;
text-align: center;
`;
const getNextColor = (currentColor: ThemeColor) => {
const currentColorIndex = mainColorNames.findIndex(
(color) => color === currentColor,
);
const nextColorIndex = (currentColorIndex + 1) % mainColorNames.length;
return mainColorNames[nextColorIndex];
};
export const SettingsObjectFieldSelectForm = ({
onChange,
values,
}: SettingsObjectFieldSelectFormProps) => {
const handleDragEnd = (result: DropResult) => {
if (!result.destination) return;
const nextOptions = [...values];
const [movedOption] = nextOptions.splice(result.source.index, 1);
nextOptions.splice(result.destination.index, 0, movedOption);
onChange(nextOptions);
};
return (
<>
<StyledContainer>
<StyledLabel>Options</StyledLabel>
<DraggableList
onDragEnd={handleDragEnd}
draggableItems={
<>
{values.map((option, index) => (
<DraggableItem
key={option.value}
draggableId={option.value}
index={index}
isDragDisabled={values.length === 1}
itemComponent={
<SettingsObjectFieldSelectFormOptionRow
key={option.value}
isDefault={option.isDefault}
onChange={(nextOption) => {
const hasDefaultOptionChanged =
!option.isDefault && nextOption.isDefault;
const nextOptions = hasDefaultOptionChanged
? values.map((value) => ({
...value,
isDefault: false,
}))
: [...values];
nextOptions.splice(index, 1, nextOption);
onChange(nextOptions);
}}
onRemove={
values.length > 1
? () => {
const nextOptions = [...values];
nextOptions.splice(index, 1);
onChange(nextOptions);
}
: undefined
}
option={option}
/>
}
/>
))}
</>
}
/>
</StyledContainer>
<StyledButton
title="Add option"
fullWidth
Icon={IconPlus}
onClick={() =>
onChange([
...values,
{
color: getNextColor(values[values.length - 1].color),
label: `Option ${values.length + 1}`,
value: v4(),
},
])
}
/>
</>
);
};

View File

@ -0,0 +1,163 @@
import { useMemo } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { v4 } from 'uuid';
import { ColorSample } from '@/ui/display/color/components/ColorSample';
import {
IconCheck,
IconDotsVertical,
IconGripVertical,
IconTrash,
IconX,
} from '@/ui/display/icon';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { TextInput } from '@/ui/input/components/TextInput';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
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 { MenuItemSelectColor } from '@/ui/navigation/menu-item/components/MenuItemSelectColor';
import { mainColorNames } from '@/ui/theme/constants/colors';
import { SettingsObjectFieldSelectFormOption } from '../types/SettingsObjectFieldSelectFormOption';
type SettingsObjectFieldSelectFormOptionRowProps = {
className?: string;
isDefault?: boolean;
onChange: (value: SettingsObjectFieldSelectFormOption) => void;
onRemove?: () => void;
option: SettingsObjectFieldSelectFormOption;
};
const StyledRow = styled.div`
align-items: center;
display: flex;
height: ${({ theme }) => theme.spacing(6)};
padding: ${({ theme }) => theme.spacing(1.5)} 0;
`;
const StyledColorSample = styled(ColorSample)`
cursor: pointer;
margin-left: 9px;
margin-right: 14px;
`;
const StyledOptionInput = styled(TextInput)`
flex: 1 0 auto;
margin-right: ${({ theme }) => theme.spacing(2)};
& input {
height: ${({ theme }) => theme.spacing(2)};
}
`;
export const SettingsObjectFieldSelectFormOptionRow = ({
className,
isDefault,
onChange,
onRemove,
option,
}: SettingsObjectFieldSelectFormOptionRowProps) => {
const theme = useTheme();
const dropdownScopeIds = useMemo(() => {
const baseScopeId = `select-field-option-row-${v4()}`;
return { color: `${baseScopeId}-color`, actions: `${baseScopeId}-actions` };
}, []);
const { closeDropdown: closeColorDropdown } = useDropdown({
dropdownScopeId: dropdownScopeIds.color,
});
const { closeDropdown: closeActionsDropdown } = useDropdown({
dropdownScopeId: dropdownScopeIds.actions,
});
return (
<StyledRow className={className}>
<IconGripVertical
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={theme.font.color.extraLight}
/>
<DropdownScope dropdownScopeId={dropdownScopeIds.color}>
<Dropdown
dropdownPlacement="bottom-start"
dropdownHotkeyScope={{
scope: dropdownScopeIds.color,
}}
clickableComponent={<StyledColorSample colorName={option.color} />}
dropdownComponents={
<DropdownMenu>
<DropdownMenuItemsContainer>
{mainColorNames.map((colorName) => (
<MenuItemSelectColor
key={colorName}
onClick={() => {
onChange({ ...option, color: colorName });
closeColorDropdown();
}}
color={colorName}
selected={colorName === option.color}
/>
))}
</DropdownMenuItemsContainer>
</DropdownMenu>
}
/>
</DropdownScope>
<StyledOptionInput
value={option.label}
onChange={(label) => onChange({ ...option, label })}
RightIcon={isDefault ? IconCheck : undefined}
/>
<DropdownScope dropdownScopeId={dropdownScopeIds.actions}>
<Dropdown
dropdownPlacement="right-start"
dropdownHotkeyScope={{
scope: dropdownScopeIds.actions,
}}
clickableComponent={<LightIconButton Icon={IconDotsVertical} />}
dropdownComponents={
<DropdownMenu>
<DropdownMenuItemsContainer>
{isDefault ? (
<MenuItem
LeftIcon={IconX}
text="Remove as default"
onClick={() => {
onChange({ ...option, isDefault: false });
closeActionsDropdown();
}}
/>
) : (
<MenuItem
LeftIcon={IconCheck}
text="Set as default"
onClick={() => {
onChange({ ...option, isDefault: true });
closeActionsDropdown();
}}
/>
)}
{!!onRemove && (
<MenuItem
accent="danger"
LeftIcon={IconTrash}
text="Remove option"
onClick={() => {
onRemove();
closeActionsDropdown();
}}
/>
)}
</DropdownMenuItemsContainer>
</DropdownMenu>
}
/>
</DropdownScope>
</StyledRow>
);
};

View File

@ -0,0 +1,57 @@
import { ReactNode } from 'react';
import styled from '@emotion/styled';
import { Card } from '@/ui/layout/card/components/Card';
type SettingsObjectFieldTypeCardProps = {
className?: string;
preview: ReactNode;
form?: ReactNode;
};
const StyledPreviewContainer = styled(Card)`
background-color: ${({ theme }) => theme.background.transparent.lighter};
padding: ${({ theme }) => theme.spacing(4)};
&:not(:last-child) {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
`;
const StyledTitle = styled.h3`
color: ${({ theme }) => theme.font.color.extraLight};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
margin: 0;
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
const StyledPreviewContent = styled.div`
display: flex;
gap: 6px;
`;
const StyledFormContainer = styled(Card)`
border-top: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
overflow: hidden;
padding: 0;
`;
export const SettingsObjectFieldTypeCard = ({
className,
preview,
form,
}: SettingsObjectFieldTypeCardProps) => {
return (
<div className={className}>
<StyledPreviewContainer>
<StyledTitle>Preview</StyledTitle>
<StyledPreviewContent>{preview}</StyledPreviewContent>
</StyledPreviewContainer>
{!!form && <StyledFormContainer>{form}</StyledFormContainer>}
</div>
);
};

View File

@ -0,0 +1,165 @@
import styled from '@emotion/styled';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { Select } from '@/ui/input/components/Select';
import { Section } from '@/ui/layout/section/components/Section';
import { Field, FieldMetadataType } from '~/generated-metadata/graphql';
import { relationTypes } from '../constants/relationTypes';
import { settingsFieldMetadataTypes } from '../constants/settingsFieldMetadataTypes';
import {
SettingsObjectFieldPreview,
SettingsObjectFieldPreviewProps,
} from './SettingsObjectFieldPreview';
import {
SettingsObjectFieldRelationForm,
SettingsObjectFieldRelationFormValues,
} from './SettingsObjectFieldRelationForm';
import {
SettingsObjectFieldSelectForm,
SettingsObjectFieldSelectFormValues,
} from './SettingsObjectFieldSelectForm';
import { SettingsObjectFieldTypeCard } from './SettingsObjectFieldTypeCard';
export type SettingsObjectFieldTypeSelectSectionFormValues = {
type: FieldMetadataType;
relation: SettingsObjectFieldRelationFormValues;
select: SettingsObjectFieldSelectFormValues;
};
type SettingsObjectFieldTypeSelectSectionProps = {
excludedFieldTypes?: FieldMetadataType[];
fieldMetadata: Pick<Field, 'icon' | 'label'> & { id?: string };
onChange: (
values: Partial<SettingsObjectFieldTypeSelectSectionFormValues>,
) => void;
relationFieldMetadata?: Pick<Field, 'id' | 'isCustom'>;
values: SettingsObjectFieldTypeSelectSectionFormValues;
} & Pick<SettingsObjectFieldPreviewProps, 'objectMetadataId'>;
const StyledSettingsObjectFieldTypeCard = styled(SettingsObjectFieldTypeCard)`
margin-top: ${({ theme }) => theme.spacing(4)};
`;
const StyledSettingsObjectFieldPreview = styled(SettingsObjectFieldPreview)`
display: grid;
flex: 1 1 100%;
`;
const StyledRelationImage = styled.img<{ flip?: boolean }>`
transform: ${({ flip }) => (flip ? 'scaleX(-1)' : 'none')};
width: 54px;
`;
export const SettingsObjectFieldTypeSelectSection = ({
excludedFieldTypes,
fieldMetadata,
objectMetadataId,
onChange,
relationFieldMetadata,
values,
}: SettingsObjectFieldTypeSelectSectionProps) => {
const relationFormConfig = values.relation;
const selectFormConfig = values.select;
const fieldTypeOptions = Object.entries(settingsFieldMetadataTypes)
.filter(([key]) => !excludedFieldTypes?.includes(key as FieldMetadataType))
.map(([key, dataTypeConfig]) => ({
value: key as FieldMetadataType,
...dataTypeConfig,
}));
return (
<Section>
<H2Title
title="Type and values"
description="The field's type and values."
/>
<Select
disabled={!!fieldMetadata?.id}
dropdownScopeId="object-field-type-select"
value={values?.type}
onChange={(value) => onChange({ type: value })}
options={fieldTypeOptions}
/>
{!!values?.type &&
[
FieldMetadataType.Boolean,
FieldMetadataType.Currency,
FieldMetadataType.DateTime,
FieldMetadataType.Select,
FieldMetadataType.Link,
FieldMetadataType.Number,
FieldMetadataType.Rating,
FieldMetadataType.Relation,
FieldMetadataType.Text,
].includes(values.type) && (
<StyledSettingsObjectFieldTypeCard
preview={
<>
<StyledSettingsObjectFieldPreview
fieldMetadata={{
...fieldMetadata,
type: values.type,
}}
shrink={values.type === FieldMetadataType.Relation}
objectMetadataId={objectMetadataId}
relationObjectMetadataId={
relationFormConfig?.objectMetadataId
}
selectOptions={selectFormConfig}
/>
{values.type === FieldMetadataType.Relation &&
!!relationFormConfig?.type &&
!!relationFormConfig.objectMetadataId && (
<>
<StyledRelationImage
src={relationTypes[relationFormConfig.type].imageSrc}
flip={
relationTypes[relationFormConfig.type].isImageFlipped
}
alt={relationTypes[relationFormConfig.type].label}
/>
<StyledSettingsObjectFieldPreview
fieldMetadata={{
...relationFormConfig.field,
label:
relationFormConfig.field?.label || 'Field name',
type: FieldMetadataType.Relation,
id: relationFieldMetadata?.id,
}}
shrink
objectMetadataId={relationFormConfig.objectMetadataId}
relationObjectMetadataId={objectMetadataId}
/>
</>
)}
</>
}
form={
values.type === FieldMetadataType.Relation ? (
<SettingsObjectFieldRelationForm
disableFieldEdition={
relationFieldMetadata && !relationFieldMetadata.isCustom
}
disableRelationEdition={!!relationFieldMetadata}
values={relationFormConfig}
onChange={(nextValues) =>
onChange({
relation: { ...relationFormConfig, ...nextValues },
})
}
/>
) : values.type === FieldMetadataType.Select ? (
<SettingsObjectFieldSelectForm
values={selectFormConfig}
onChange={(nextValues) => onChange({ select: nextValues })}
/>
) : undefined
}
/>
)}
</Section>
);
};

View File

@ -0,0 +1,76 @@
import styled from '@emotion/styled';
import { validateMetadataLabel } from '@/object-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';
import { Section } from '@/ui/layout/section/components/Section';
type SettingsObjectFormSectionProps = {
disabled?: boolean;
singularName?: string;
pluralName?: string;
description?: string;
onChange?: (
formValues: Partial<{
labelSingular: string;
labelPlural: 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 SettingsObjectFormSection = ({
disabled,
singularName = '',
pluralName = '',
description = '',
onChange,
}: SettingsObjectFormSectionProps) => (
<Section>
<H2Title
title="Name and description"
description="Name in both singular (e.g., 'Invoice') and plural (e.g., 'Invoices') forms."
/>
<StyledInputsContainer>
<TextInput
label="Singular"
placeholder="Investor"
value={singularName}
onChange={(value) => {
if (!value || validateMetadataLabel(value)) {
onChange?.({ labelSingular: value });
}
}}
disabled={disabled}
fullWidth
/>
<TextInput
label="Plural"
placeholder="Investors"
value={pluralName}
onChange={(value) => {
if (!value || validateMetadataLabel(value)) {
onChange?.({ labelPlural: 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,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,127 @@
import { MemoryRouter } from 'react-router-dom';
import { Meta, StoryObj } from '@storybook/react';
import { ObjectMetadataItemsProvider } from '@/object-metadata/components/ObjectMetadataItemsProvider';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { Field, FieldMetadataType } from '~/generated-metadata/graphql';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import {
mockedCompaniesMetadata,
mockedPeopleMetadata,
mockedWorkspacesMetadata,
} from '~/testing/mock-data/metadata';
import { SettingsObjectFieldPreview } from '../SettingsObjectFieldPreview';
const meta: Meta<typeof SettingsObjectFieldPreview> = {
title: 'Modules/Settings/DataModel/SettingsObjectFieldPreview',
component: SettingsObjectFieldPreview,
decorators: [
ComponentDecorator,
(Story) => (
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
<ObjectMetadataItemsProvider>
<Story />
</ObjectMetadataItemsProvider>
</SnackBarProviderScope>
),
],
args: {
fieldMetadata: mockedCompaniesMetadata.node.fields.edges.find(
({ node }) => node.type === FieldMetadataType.Text,
)?.node,
objectMetadataId: mockedCompaniesMetadata.node.id,
},
parameters: {
msw: graphqlMocks,
},
};
export default meta;
type Story = StoryObj<typeof SettingsObjectFieldPreview>;
export const Text: Story = {};
export const Boolean: Story = {
args: {
fieldMetadata: mockedCompaniesMetadata.node.fields.edges.find(
({ node }) => node.type === FieldMetadataType.Boolean,
)?.node as Field,
},
};
export const Currency: Story = {
args: {
fieldMetadata: mockedCompaniesMetadata.node.fields.edges.find(
({ node }) => node.type === FieldMetadataType.Currency,
)?.node as Field,
},
};
export const Date: Story = {
args: {
fieldMetadata: mockedCompaniesMetadata.node.fields.edges.find(
({ node }) => node.type === FieldMetadataType.DateTime,
)?.node as Field,
},
};
export const Link: Story = {
decorators: [
(Story) => (
<MemoryRouter>
<Story />
</MemoryRouter>
),
],
args: {
fieldMetadata: mockedCompaniesMetadata.node.fields.edges.find(
({ node }) => node.type === FieldMetadataType.Link,
)?.node as Field,
},
};
export const Number: Story = {
args: {
fieldMetadata: mockedCompaniesMetadata.node.fields.edges.find(
({ node }) => node.type === FieldMetadataType.Number,
)?.node as Field,
},
};
export const Rating: Story = {
args: {
fieldMetadata: {
icon: 'IconHandClick',
label: 'Engagement',
type: FieldMetadataType.Rating,
},
},
};
export const Relation: Story = {
decorators: [
(Story) => (
<MemoryRouter>
<Story />
</MemoryRouter>
),
],
args: {
fieldMetadata: mockedPeopleMetadata.node.fields.edges.find(
({ node }) => node.type === FieldMetadataType.Relation,
)?.node as Field,
objectMetadataId: mockedPeopleMetadata.node.id,
relationObjectMetadataId: mockedCompaniesMetadata.node.id,
},
};
export const CustomObject: Story = {
args: {
fieldMetadata: mockedWorkspacesMetadata.node.fields.edges.find(
({ node }) => node.type === FieldMetadataType.Text,
)?.node as Field,
objectMetadataId: mockedWorkspacesMetadata.node.id,
},
};

View File

@ -0,0 +1,139 @@
import { MemoryRouter } from 'react-router-dom';
import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/test';
import { ObjectMetadataItemsProvider } from '@/object-metadata/components/ObjectMetadataItemsProvider';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import {
FieldMetadataType,
RelationMetadataType,
} from '~/generated-metadata/graphql';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import {
mockedCompaniesMetadata,
mockedPeopleMetadata,
} from '~/testing/mock-data/metadata';
import { fieldMetadataFormDefaultValues } from '../../hooks/useFieldMetadataForm';
import {
SettingsObjectFieldTypeSelectSection,
SettingsObjectFieldTypeSelectSectionFormValues,
} from '../SettingsObjectFieldTypeSelectSection';
const fieldMetadata = mockedCompaniesMetadata.node.fields.edges.find(
({ node }) => node.type === FieldMetadataType.Text,
)!.node;
const { id: _id, ...fieldMetadataWithoutId } = fieldMetadata;
const meta: Meta<typeof SettingsObjectFieldTypeSelectSection> = {
title: 'Modules/Settings/DataModel/SettingsObjectFieldTypeSelectSection',
component: SettingsObjectFieldTypeSelectSection,
decorators: [
ComponentDecorator,
(Story) => (
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
<ObjectMetadataItemsProvider>
<Story />
</ObjectMetadataItemsProvider>
</SnackBarProviderScope>
),
],
args: {
fieldMetadata: fieldMetadataWithoutId,
objectMetadataId: mockedCompaniesMetadata.node.id,
values: fieldMetadataFormDefaultValues,
},
parameters: {
container: { width: 512 },
msw: graphqlMocks,
},
};
export default meta;
type Story = StoryObj<typeof SettingsObjectFieldTypeSelectSection>;
export const Default: Story = {};
export const Disabled: Story = {
args: {
fieldMetadata,
},
};
export const WithOpenSelect: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = await canvas.findByText('Unique ID');
await userEvent.click(input);
const selectLabel = canvas.getByText('Number');
await userEvent.click(selectLabel);
},
};
const relationFieldMetadata = mockedPeopleMetadata.node.fields.edges.find(
({ node }) => node.type === FieldMetadataType.Relation,
)!.node;
export const WithRelationForm: Story = {
decorators: [
(Story) => (
<MemoryRouter>
<Story />
</MemoryRouter>
),
],
args: {
fieldMetadata: mockedCompaniesMetadata.node.fields.edges.find(
({ node }) => node.type === FieldMetadataType.Relation,
)?.node,
relationFieldMetadata,
values: {
...fieldMetadataFormDefaultValues,
type: FieldMetadataType.Relation,
relation: {
field: relationFieldMetadata,
objectMetadataId: mockedPeopleMetadata.node.id,
type: RelationMetadataType.OneToMany,
},
} as unknown as SettingsObjectFieldTypeSelectSectionFormValues,
},
};
export const WithSelectForm: Story = {
args: {
fieldMetadata: { label: 'Industry', icon: 'IconBuildingFactory2' },
values: {
...fieldMetadataFormDefaultValues,
type: FieldMetadataType.Select,
select: [
{
color: 'pink',
isDefault: true,
label: '💊 Health',
value: 'HEALTH',
},
{
color: 'purple',
label: '🏭 Industry',
value: 'INDUSTRY',
},
{ color: 'sky', label: '🤖 SaaS', value: 'SAAS' },
{
color: 'turquoise',
label: '🌿 Green tech',
value: 'GREEN_TECH',
},
{
color: 'yellow',
label: '🚲 Mobility',
value: 'MOBILITY',
},
{ color: 'green', label: '🌏 NGO', value: 'NGO' },
],
},
},
};

View File

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

View File

@ -0,0 +1,16 @@
import { IconMouse2 } from '@/ui/display/icon';
export const standardObjects = [
{
name: 'Users',
Icon: IconMouse2,
fields: 6,
description: 'Individuals who interact with your website',
},
{
name: 'Users',
Icon: IconMouse2,
fields: 8,
description: 'Individuals who interact with your website',
},
];

View File

@ -0,0 +1 @@
export const objectSettingsWidth = '512px';

View File

@ -0,0 +1,34 @@
import { IconRelationOneToMany, IconRelationOneToOne } from '@/ui/display/icon';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { RelationMetadataType } from '~/generated-metadata/graphql';
import OneToManySvg from '../assets/OneToMany.svg';
import OneToOneSvg from '../assets/OneToOne.svg';
import { RelationType } from '../types/RelationType';
export const relationTypes: Record<
RelationType,
{
label: string;
Icon: IconComponent;
imageSrc: string;
isImageFlipped?: boolean;
}
> = {
[RelationMetadataType.OneToMany]: {
label: 'Has many',
Icon: IconRelationOneToMany,
imageSrc: OneToManySvg,
},
[RelationMetadataType.OneToOne]: {
label: 'Has one',
Icon: IconRelationOneToOne,
imageSrc: OneToOneSvg,
},
MANY_TO_ONE: {
label: 'Belongs to one',
Icon: IconRelationOneToMany,
imageSrc: OneToManySvg,
isImageFlipped: true,
},
};

View File

@ -0,0 +1,94 @@
import {
IconCalendarEvent,
IconCheck,
IconCoins,
IconKey,
IconLink,
IconMail,
IconNumbers,
IconPhone,
IconRelationManyToMany,
IconTag,
IconTextSize,
IconUser,
} from '@/ui/display/icon';
import { IconTwentyStar } from '@/ui/display/icon/components/IconTwentyStar';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { FieldMetadataType } from '~/generated-metadata/graphql';
const defaultDateValue = new Date();
defaultDateValue.setFullYear(defaultDateValue.getFullYear() + 2);
export const settingsFieldMetadataTypes: Partial<
Record<
FieldMetadataType,
{ label: string; Icon: IconComponent; defaultValue?: unknown }
>
> = {
[FieldMetadataType.Uuid]: {
label: 'Unique ID',
Icon: IconKey,
defaultValue: '00000000-0000-0000-0000-000000000000',
},
[FieldMetadataType.Text]: {
label: 'Text',
Icon: IconTextSize,
defaultValue:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum magna enim, dapibus non enim in, lacinia faucibus nunc. Sed interdum ante sed felis facilisis, eget ultricies neque molestie. Mauris auctor, justo eu volutpat cursus, libero erat tempus nulla, non sodales lorem lacus a est.',
},
[FieldMetadataType.Numeric]: {
label: 'Numeric',
Icon: IconNumbers,
defaultValue: 2000,
},
[FieldMetadataType.Number]: {
label: 'Number',
Icon: IconNumbers,
defaultValue: 2000,
},
[FieldMetadataType.Link]: {
label: 'Link',
Icon: IconLink,
defaultValue: { url: 'www.twenty.com', label: '' },
},
[FieldMetadataType.Boolean]: {
label: 'True/False',
Icon: IconCheck,
defaultValue: true,
},
[FieldMetadataType.DateTime]: {
label: 'Date & Time',
Icon: IconCalendarEvent,
defaultValue: defaultDateValue.toISOString(),
},
[FieldMetadataType.Select]: {
label: 'Select',
Icon: IconTag,
},
[FieldMetadataType.MultiSelect]: {
label: 'MultiSelect',
Icon: IconTag,
},
[FieldMetadataType.Currency]: {
label: 'Currency',
Icon: IconCoins,
defaultValue: { amountMicros: 2000000000, currencyCode: 'USD' },
},
[FieldMetadataType.Relation]: {
label: 'Relation',
Icon: IconRelationManyToMany,
},
[FieldMetadataType.Email]: { label: 'Email', Icon: IconMail },
[FieldMetadataType.Phone]: { label: 'Phone', Icon: IconPhone },
[FieldMetadataType.Probability]: {
label: 'Rating',
Icon: IconTwentyStar,
defaultValue: '3',
},
[FieldMetadataType.Rating]: {
label: 'Rating',
Icon: IconTwentyStar,
defaultValue: '3',
},
[FieldMetadataType.FullName]: { label: 'Full Name', Icon: IconUser },
};

View File

@ -0,0 +1,191 @@
import { useState } from 'react';
import { DeepPartial } from 'react-hook-form';
import { v4 } from 'uuid';
import { z } from 'zod';
import { themeColorSchema } from '@/ui/theme/utils/themeColorSchema';
import {
FieldMetadataType,
RelationMetadataType,
} from '~/generated-metadata/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { SettingsObjectFieldTypeSelectSectionFormValues } from '../components/SettingsObjectFieldTypeSelectSection';
type FormValues = {
description?: string;
icon: string;
label: string;
type: FieldMetadataType;
relation: SettingsObjectFieldTypeSelectSectionFormValues['relation'];
select: SettingsObjectFieldTypeSelectSectionFormValues['select'];
};
export const fieldMetadataFormDefaultValues: FormValues = {
icon: 'IconUsers',
label: '',
type: FieldMetadataType.Text,
relation: {
type: RelationMetadataType.OneToMany,
objectMetadataId: '',
field: { label: '' },
},
select: [{ color: 'green', label: 'Option 1', value: v4() }],
};
const fieldSchema = z.object({
description: z.string().optional(),
icon: z.string().startsWith('Icon'),
label: z.string().min(1),
});
const relationSchema = fieldSchema.merge(
z.object({
type: z.literal(FieldMetadataType.Relation),
relation: z.object({
field: fieldSchema,
objectMetadataId: z.string().uuid(),
type: z.enum([
RelationMetadataType.OneToMany,
RelationMetadataType.OneToOne,
'MANY_TO_ONE',
]),
}),
}),
);
const selectSchema = fieldSchema.merge(
z.object({
type: z.literal(FieldMetadataType.Select),
select: z
.array(
z.object({
color: themeColorSchema,
id: z.string().optional(),
isDefault: z.boolean().optional(),
label: z.string().min(1),
}),
)
.nonempty(),
}),
);
const {
Select: _Select,
Relation: _Relation,
...otherFieldTypes
} = FieldMetadataType;
type OtherFieldType = Exclude<
FieldMetadataType,
FieldMetadataType.Relation | FieldMetadataType.Select
>;
const otherFieldTypesSchema = fieldSchema.merge(
z.object({
type: z.enum(
Object.values(otherFieldTypes) as [OtherFieldType, ...OtherFieldType[]],
),
}),
);
const schema = z.discriminatedUnion('type', [
relationSchema,
selectSchema,
otherFieldTypesSchema,
]);
type PartialFormValues = Partial<Omit<FormValues, 'relation'>> &
DeepPartial<Pick<FormValues, 'relation'>>;
export const useFieldMetadataForm = () => {
const [isInitialized, setIsInitialized] = useState(false);
const [initialFormValues, setInitialFormValues] = useState<FormValues>(
fieldMetadataFormDefaultValues,
);
const [formValues, setFormValues] = useState<FormValues>(
fieldMetadataFormDefaultValues,
);
const [hasFieldFormChanged, setHasFieldFormChanged] = useState(false);
const [hasRelationFormChanged, setHasRelationFormChanged] = useState(false);
const [hasSelectFormChanged, setHasSelectFormChanged] = useState(false);
const [validationResult, setValidationResult] = useState(
schema.safeParse(formValues),
);
const mergePartialValues = (
previousValues: FormValues,
nextValues: PartialFormValues,
): FormValues => ({
...previousValues,
...nextValues,
relation: {
...previousValues.relation,
...nextValues.relation,
field: {
...previousValues.relation?.field,
...nextValues.relation?.field,
},
},
});
const initForm = (lazyInitialFormValues: PartialFormValues) => {
if (isInitialized) return;
const mergedFormValues = mergePartialValues(
initialFormValues,
lazyInitialFormValues,
);
setInitialFormValues(mergedFormValues);
setFormValues(mergedFormValues);
setValidationResult(schema.safeParse(mergedFormValues));
setIsInitialized(true);
};
const handleFormChange = (values: PartialFormValues) => {
const nextFormValues = mergePartialValues(formValues, values);
setFormValues(nextFormValues);
setValidationResult(schema.safeParse(nextFormValues));
const {
relation: initialRelationFormValues,
select: initialSelectFormValues,
...initialFieldFormValues
} = initialFormValues;
const {
relation: nextRelationFormValues,
select: nextSelectFormValues,
...nextFieldFormValues
} = nextFormValues;
setHasFieldFormChanged(
!isDeeplyEqual(initialFieldFormValues, nextFieldFormValues),
);
setHasRelationFormChanged(
nextFieldFormValues.type === FieldMetadataType.Relation &&
!isDeeplyEqual(initialRelationFormValues, nextRelationFormValues),
);
setHasSelectFormChanged(
nextFieldFormValues.type === FieldMetadataType.Select &&
!isDeeplyEqual(initialSelectFormValues, nextSelectFormValues),
);
};
return {
formValues,
handleFormChange,
hasFieldFormChanged,
hasFormChanged:
hasFieldFormChanged || hasRelationFormChanged || hasSelectFormChanged,
hasRelationFormChanged,
hasSelectFormChanged,
initForm,
isInitialized,
isValid: validationResult.success,
validatedFormValues: validationResult.success
? validationResult.data
: undefined,
};
};

View File

@ -0,0 +1,73 @@
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { useLazyLoadIcon } from '@/ui/input/hooks/useLazyLoadIcon';
import { Field, FieldMetadataType } from '~/generated-metadata/graphql';
import { settingsFieldMetadataTypes } from '../constants/settingsFieldMetadataTypes';
import { SettingsObjectFieldSelectFormOption } from '../types/SettingsObjectFieldSelectFormOption';
import { useFieldPreviewValue } from './useFieldPreviewValue';
import { useRelationFieldPreviewValue } from './useRelationFieldPreviewValue';
export const useFieldPreview = ({
fieldMetadata,
objectMetadataId,
relationObjectMetadataId,
selectOptions,
}: {
fieldMetadata: Pick<Field, 'icon' | 'label' | 'type'> & { id?: string };
objectMetadataId: string;
relationObjectMetadataId?: string;
selectOptions?: SettingsObjectFieldSelectFormOption[];
}) => {
const { findObjectMetadataItemById } = useObjectMetadataItemForSettings();
const objectMetadataItem = findObjectMetadataItemById(objectMetadataId);
const { Icon: ObjectIcon } = useLazyLoadIcon(objectMetadataItem?.icon ?? '');
const { Icon: FieldIcon } = useLazyLoadIcon(fieldMetadata.icon ?? '');
const fieldName = fieldMetadata.id
? objectMetadataItem?.fields.find(({ id }) => id === fieldMetadata.id)?.name
: undefined;
const { value: firstRecordFieldValue } = useFieldPreviewValue({
fieldName: fieldName || '',
objectNamePlural: objectMetadataItem?.namePlural ?? '',
skip:
!fieldName ||
!objectMetadataItem ||
fieldMetadata.type === FieldMetadataType.Relation,
});
const { relationObjectMetadataItem, value: relationValue } =
useRelationFieldPreviewValue({
relationObjectMetadataId,
skip: fieldMetadata.type !== FieldMetadataType.Relation,
});
const settingsFieldMetadataType =
settingsFieldMetadataTypes[fieldMetadata.type];
const defaultSelectValue = selectOptions?.[0];
const selectValue =
fieldMetadata.type === FieldMetadataType.Select &&
typeof firstRecordFieldValue === 'string'
? selectOptions?.find(
(selectOption) => selectOption.value === firstRecordFieldValue,
)
: undefined;
return {
entityId: `${objectMetadataId}-field-form`,
FieldIcon,
fieldName: fieldName || `${fieldMetadata.type}-new-field`,
ObjectIcon,
objectMetadataItem,
relationObjectMetadataItem,
value:
fieldMetadata.type === FieldMetadataType.Relation
? relationValue
: fieldMetadata.type === FieldMetadataType.Select
? selectValue || defaultSelectValue
: firstRecordFieldValue || settingsFieldMetadataType?.defaultValue,
};
};

View File

@ -0,0 +1,30 @@
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { assertNotNull } from '~/utils/assert';
export const useFieldPreviewValue = ({
fieldName,
objectNamePlural,
skip,
}: {
fieldName: string;
objectNamePlural: string;
skip?: boolean;
}) => {
const { objectNameSingular } = useObjectNameSingularFromPlural({
objectNamePlural,
});
const { records } = useFindManyRecords({
objectNameSingular,
skip,
});
const firstRecordWithValue = records.find(
(record) => assertNotNull(record[fieldName]) && record[fieldName] !== '',
);
return {
value: firstRecordWithValue?.[fieldName],
};
};

View File

@ -0,0 +1,32 @@
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
export const useRelationFieldPreviewValue = ({
relationObjectMetadataId,
skip,
}: {
relationObjectMetadataId?: string;
skip?: boolean;
}) => {
const { findObjectMetadataItemById } = useObjectMetadataItemForSettings();
// TODO: make this impossible to be undefined
const relationObjectMetadataItem = relationObjectMetadataId
? findObjectMetadataItemById(relationObjectMetadataId)
: undefined;
const { records: relationObjects } = useFindManyRecords({
objectNameSingular: relationObjectMetadataItem?.nameSingular ?? 'company', // TODO fix this hack
skip: skip || !relationObjectMetadataItem,
});
const label = relationObjectMetadataItem?.labelSingular ?? '';
return {
relationObjectMetadataItem,
value: relationObjects?.[0] ?? {
company: { name: label }, // Temporary mock for opportunities, this needs to be replaced once labelIdentifiers are implemented
name: label,
},
};
};

View File

@ -0,0 +1,65 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { Checkbox } from '@/ui/input/components/Checkbox';
import { useLazyLoadIcon } from '@/ui/input/hooks/useLazyLoadIcon';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
type SettingsAvailableStandardObjectItemTableRowProps = {
isSelected?: boolean;
objectItem: ObjectMetadataItem;
onClick?: () => void;
};
export const StyledAvailableStandardObjectTableRow = styled(TableRow)`
grid-template-columns: 28px 148px 256px 80px;
`;
const StyledCheckboxTableCell = styled(TableCell)`
justify-content: center;
padding: 0;
padding-left: ${({ theme }) => theme.spacing(1)};
`;
const StyledNameTableCell = styled(TableCell)`
color: ${({ theme }) => theme.font.color.primary};
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledDescription = styled.div`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
export const SettingsAvailableStandardObjectItemTableRow = ({
isSelected,
objectItem,
onClick,
}: SettingsAvailableStandardObjectItemTableRowProps) => {
const theme = useTheme();
const { Icon } = useLazyLoadIcon(objectItem.icon ?? '');
return (
<StyledAvailableStandardObjectTableRow
key={objectItem.namePlural}
isSelected={isSelected}
onClick={onClick}
>
<StyledCheckboxTableCell>
<Checkbox checked={!!isSelected} />
</StyledCheckboxTableCell>
<StyledNameTableCell>
{!!Icon && <Icon size={theme.icon.size.md} />}
{objectItem.labelPlural}
</StyledNameTableCell>
<TableCell>
<StyledDescription>{objectItem.description}</StyledDescription>
</TableCell>
<TableCell align="right">{objectItem.fields.length}</TableCell>
</StyledAvailableStandardObjectTableRow>
);
};

View File

@ -0,0 +1,53 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { Section } from '@/ui/layout/section/components/Section';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import {
SettingsAvailableStandardObjectItemTableRow,
StyledAvailableStandardObjectTableRow,
} from './SettingsAvailableStandardObjectItemTableRow';
type SettingsAvailableStandardObjectsSectionProps = {
objectItems: ObjectMetadataItem[];
onChange: (selectedIds: Record<string, boolean>) => void;
selectedIds: Record<string, boolean>;
};
export const SettingsAvailableStandardObjectsSection = ({
objectItems,
onChange,
selectedIds,
}: SettingsAvailableStandardObjectsSectionProps) => (
<Section>
<H2Title
title="Available"
description="Select one or several standard objects to activate below"
/>
<Table>
<StyledAvailableStandardObjectTableRow>
<TableHeader></TableHeader>
<TableHeader>Name</TableHeader>
<TableHeader>Description</TableHeader>
<TableHeader align="right">Fields</TableHeader>
</StyledAvailableStandardObjectTableRow>
<TableBody>
{objectItems.map((objectItem) => (
<SettingsAvailableStandardObjectItemTableRow
key={objectItem.id}
isSelected={selectedIds[objectItem.id]}
objectItem={objectItem}
onClick={() =>
onChange({
...selectedIds,
[objectItem.id]: !selectedIds[objectItem.id],
})
}
/>
))}
</TableBody>
</Table>
</Section>
);

View File

@ -0,0 +1,70 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconBox, IconDatabase, IconFileCheck } from '@/ui/display/icon';
import { SettingsObjectTypeCard } from './SettingsObjectTypeCard';
export type NewObjectType = 'Standard' | 'Custom' | 'Remote';
type SettingsNewObjectTypeProps = {
selectedType?: NewObjectType;
onTypeSelect?: (type: NewObjectType) => void;
};
const StyledContainer = styled.div`
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
`;
export const SettingsNewObjectType = ({
selectedType,
onTypeSelect,
}: SettingsNewObjectTypeProps) => {
const theme = useTheme();
return (
<StyledContainer>
<SettingsObjectTypeCard
title={'Standard'}
color="blue"
selected={selectedType === 'Standard'}
prefixIcon={
<IconFileCheck
size={theme.icon.size.lg}
stroke={theme.icon.stroke.sm}
color={theme.font.color.tertiary}
/>
}
onClick={() => onTypeSelect?.('Standard')}
/>
<SettingsObjectTypeCard
title="Custom"
color="orange"
selected={selectedType === 'Custom'}
prefixIcon={
<IconBox
size={theme.icon.size.lg}
stroke={theme.icon.stroke.sm}
color={theme.font.color.tertiary}
/>
}
onClick={() => onTypeSelect?.('Custom')}
/>
<SettingsObjectTypeCard
title="Remote"
soon
disabled
color="green"
selected={selectedType === 'Remote'}
prefixIcon={
<IconDatabase
size={theme.icon.size.lg}
stroke={theme.icon.stroke.sm}
color={theme.font.color.tertiary}
/>
}
/>
</StyledContainer>
);
};

View File

@ -0,0 +1,81 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconCheck } from '@/ui/display/icon';
import { SoonPill } from '@/ui/display/pill/components/SoonPill';
import { Tag } from '@/ui/display/tag/components/Tag';
import { ThemeColor } from '@/ui/theme/constants/colors';
const StyledObjectTypeCard = styled.div<SettingsObjectTypeCardProps>`
${({ theme, disabled, selected }) => `
background: ${theme.background.transparent.primary};
cursor: ${disabled ? 'not-allowed' : 'pointer'};
display: flex;
flex-direction: row;
font-family: ${theme.font.family};
font-weight: 500;
border-style: solid;
border-width: '1px';
padding: ${theme.spacing(3)};
border-radius: ${theme.border.radius.sm};
gap: ${theme.spacing(2)};
border-color: ${
selected ? theme.border.color.inverted : theme.border.color.medium
};
color: ${theme.font.color.primary};
align-items: center;
width: 140px;
`}
`;
const StyledTag = styled(Tag)`
box-sizing: border-box;
height: ${({ theme }) => theme.spacing(5)};
`;
const StyledIconCheck = styled(IconCheck)`
margin-left: auto;
`;
const StyledSoonPill = styled(SoonPill)`
margin-left: auto;
`;
type SettingsObjectTypeCardProps = {
prefixIcon?: React.ReactNode;
title: string;
soon?: boolean;
disabled?: boolean;
color: ThemeColor;
selected: boolean;
onClick?: () => void;
};
export const SettingsObjectTypeCard = ({
prefixIcon,
title,
soon = false,
selected,
disabled = false,
color,
onClick,
}: SettingsObjectTypeCardProps) => {
const theme = useTheme();
return (
<StyledObjectTypeCard
title={title}
soon={soon}
disabled={disabled}
color={color}
selected={selected}
onClick={onClick}
>
{prefixIcon}
<StyledTag color={color} text={title} />
{soon && <StyledSoonPill />}
{!disabled && selected && <StyledIconCheck size={theme.icon.size.md} />}
</StyledObjectTypeCard>
);
};
export {};

View File

@ -0,0 +1,111 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconArchive, IconDotsVertical, IconPencil } from '@/ui/display/icon';
import { Tag } from '@/ui/display/tag/components/Tag';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { useLazyLoadIcon } from '@/ui/input/hooks/useLazyLoadIcon';
import { Card } from '@/ui/layout/card/components/Card';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
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 { Section } from '@/ui/layout/section/components/Section';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
type SettingsAboutSectionProps = {
iconKey?: string;
isCustom: boolean;
name: string;
onDisable: () => void;
onEdit: () => void;
};
const StyledCard = styled(Card)`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(2)};
`;
const StyledName = styled.div`
color: ${({ theme }) => theme.font.color.primary};
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
margin-right: auto;
`;
const StyledTag = styled(Tag)`
box-sizing: border-box;
height: ${({ theme }) => theme.spacing(6)};
`;
const dropdownScopeId = 'settings-object-edit-about-menu-dropdown';
export const SettingsAboutSection = ({
iconKey = '',
isCustom,
name,
onDisable,
onEdit,
}: SettingsAboutSectionProps) => {
const theme = useTheme();
const { Icon } = useLazyLoadIcon(iconKey);
const { closeDropdown } = useDropdown({ dropdownScopeId });
const handleEdit = () => {
onEdit();
closeDropdown();
};
const handleDisable = () => {
onDisable();
closeDropdown();
};
return (
<Section>
<H2Title title="About" description="Manage your object" />
<StyledCard>
<StyledName>
{!!Icon && <Icon size={theme.icon.size.md} />}
{name}
</StyledName>
{isCustom ? (
<StyledTag color="orange" text="Custom" />
) : (
<StyledTag color="blue" text="Standard" />
)}
<DropdownScope dropdownScopeId={dropdownScopeId}>
<Dropdown
clickableComponent={
<LightIconButton Icon={IconDotsVertical} accent="tertiary" />
}
dropdownComponents={
<DropdownMenu width="160px">
<DropdownMenuItemsContainer>
<MenuItem
text="Edit"
LeftIcon={IconPencil}
onClick={handleEdit}
/>
<MenuItem
text="Disable"
LeftIcon={IconArchive}
onClick={handleDisable}
/>
</DropdownMenuItemsContainer>
</DropdownMenu>
}
dropdownHotkeyScope={{
scope: dropdownScopeId,
}}
/>
</DropdownScope>
</StyledCard>
</Section>
);
};

View File

@ -0,0 +1,70 @@
import {
IconArchive,
IconDotsVertical,
IconEye,
IconPencil,
} from '@/ui/display/icon';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
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';
type SettingsObjectFieldActiveActionDropdownProps = {
isCustomField?: boolean;
onDisable: () => void;
onEdit: () => void;
scopeKey: string;
};
export const SettingsObjectFieldActiveActionDropdown = ({
isCustomField,
onDisable,
onEdit,
scopeKey,
}: SettingsObjectFieldActiveActionDropdownProps) => {
const dropdownScopeId = `${scopeKey}-settings-field-active-action-dropdown`;
const { closeDropdown } = useDropdown({ dropdownScopeId });
const handleEdit = () => {
onEdit();
closeDropdown();
};
const handleDisable = () => {
onDisable();
closeDropdown();
};
return (
<DropdownScope dropdownScopeId={dropdownScopeId}>
<Dropdown
clickableComponent={
<LightIconButton Icon={IconDotsVertical} accent="tertiary" />
}
dropdownComponents={
<DropdownMenu width="160px">
<DropdownMenuItemsContainer>
<MenuItem
text={isCustomField ? 'Edit' : 'View'}
LeftIcon={isCustomField ? IconPencil : IconEye}
onClick={handleEdit}
/>
<MenuItem
text="Disable"
LeftIcon={IconArchive}
onClick={handleDisable}
/>
</DropdownMenuItemsContainer>
</DropdownMenu>
}
dropdownHotkeyScope={{
scope: dropdownScopeId,
}}
/>
</DropdownScope>
);
};

View File

@ -0,0 +1,68 @@
import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconTwentyStar } from '@/ui/display/icon/components/IconTwentyStar';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { settingsFieldMetadataTypes } from '../../constants/settingsFieldMetadataTypes';
type SettingsObjectFieldDataTypeProps = {
onClick?: () => void;
Icon?: IconComponent;
label?: string;
value: FieldMetadataType;
};
const StyledDataType = styled.div<{ value: FieldMetadataType }>`
align-items: center;
border: 1px solid transparent;
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
gap: ${({ theme }) => theme.spacing(1)};
height: 20px;
overflow: hidden;
padding: 0 ${({ theme }) => theme.spacing(2)};
${({ onClick }) =>
onClick
? css`
cursor: pointer;
`
: ''}
${({ theme, value }) =>
value === FieldMetadataType.Relation
? css`
border-color: ${theme.color.purple20};
color: ${theme.color.purple};
`
: ''}
`;
const StyledLabelContainer = styled.div`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
export const SettingsObjectFieldDataType = ({
onClick,
value,
Icon = settingsFieldMetadataTypes[value]?.Icon ?? IconTwentyStar,
label = settingsFieldMetadataTypes[value]?.label,
}: SettingsObjectFieldDataTypeProps) => {
const theme = useTheme();
const StyledIcon = styled(Icon)`
flex: 1 0 auto;
`;
return (
<StyledDataType onClick={onClick} value={value}>
<StyledIcon size={theme.icon.size.sm} />
<StyledLabelContainer>{label}</StyledLabelContainer>
</StyledDataType>
);
};

View File

@ -0,0 +1,66 @@
import { IconArchiveOff, IconDotsVertical } from '@/ui/display/icon';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
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';
type SettingsObjectFieldDisabledActionDropdownProps = {
isCustomField?: boolean;
onActivate: () => void;
onErase: () => void;
scopeKey: string;
};
export const SettingsObjectFieldDisabledActionDropdown = ({
onActivate,
scopeKey,
}: SettingsObjectFieldDisabledActionDropdownProps) => {
const dropdownScopeId = `${scopeKey}-settings-field-disabled-action-dropdown`;
const { closeDropdown } = useDropdown({ dropdownScopeId });
const handleActivate = () => {
onActivate();
closeDropdown();
};
// const handleErase = () => {
// onErase();
// closeDropdown();
// };
return (
<DropdownScope dropdownScopeId={dropdownScopeId}>
<Dropdown
clickableComponent={
<LightIconButton Icon={IconDotsVertical} accent="tertiary" />
}
dropdownComponents={
<DropdownMenu width="160px">
<DropdownMenuItemsContainer>
<MenuItem
text="Activate"
LeftIcon={IconArchiveOff}
onClick={handleActivate}
/>
{/* {isCustomField && (
<MenuItem
text="Erase"
accent="danger"
LeftIcon={IconTrash}
onClick={handleErase}
/>
)} */}
</DropdownMenuItemsContainer>
</DropdownMenu>
}
dropdownHotkeyScope={{
scope: dropdownScopeId,
}}
/>
</DropdownScope>
);
};

View File

@ -0,0 +1,89 @@
import { ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRelationMetadata } from '@/object-metadata/hooks/useRelationMetadata';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug';
import { useLazyLoadIcon } from '@/ui/input/hooks/useLazyLoadIcon';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { relationTypes } from '../../constants/relationTypes';
import { settingsFieldMetadataTypes } from '../../constants/settingsFieldMetadataTypes';
import { SettingsObjectFieldDataType } from './SettingsObjectFieldDataType';
type SettingsObjectFieldItemTableRowProps = {
ActionIcon: ReactNode;
fieldMetadataItem: FieldMetadataItem;
};
export const StyledObjectFieldTableRow = styled(TableRow)`
grid-template-columns: 180px 148px 148px 36px;
`;
const StyledNameTableCell = styled(TableCell)`
color: ${({ theme }) => theme.font.color.primary};
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledIconTableCell = styled(TableCell)`
justify-content: center;
padding-right: ${({ theme }) => theme.spacing(1)};
`;
export const SettingsObjectFieldItemTableRow = ({
ActionIcon,
fieldMetadataItem: fieldMetadataItem,
}: SettingsObjectFieldItemTableRowProps) => {
const theme = useTheme();
const { Icon } = useLazyLoadIcon(fieldMetadataItem.icon ?? '');
const navigate = useNavigate();
// TODO: parse with zod and merge types with FieldType (create a subset of FieldType for example)
const fieldDataTypeIsSupported =
fieldMetadataItem.type in settingsFieldMetadataTypes;
const { relationObjectMetadataItem, relationType } = useRelationMetadata({
fieldMetadataItem,
});
if (!fieldDataTypeIsSupported) return null;
const RelationIcon = relationType
? relationTypes[relationType].Icon
: undefined;
return (
<StyledObjectFieldTableRow>
<StyledNameTableCell>
{!!Icon && <Icon size={theme.icon.size.md} />}
{fieldMetadataItem.label}
</StyledNameTableCell>
<TableCell>
{fieldMetadataItem.isCustom ? 'Custom' : 'Standard'}
</TableCell>
<TableCell>
<SettingsObjectFieldDataType
Icon={RelationIcon}
label={relationObjectMetadataItem?.labelPlural}
onClick={
relationObjectMetadataItem?.namePlural &&
!relationObjectMetadataItem.isSystem
? () =>
navigate(
`/settings/objects/${getObjectSlug(
relationObjectMetadataItem,
)}`,
)
: undefined
}
value={fieldMetadataItem.type}
/>
</TableCell>
<StyledIconTableCell>{ActionIcon}</StyledIconTableCell>
</StyledObjectFieldTableRow>
);
};

View File

@ -0,0 +1,65 @@
import { ReactNode } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { Tag } from '@/ui/display/tag/components/Tag';
import { useLazyLoadIcon } from '@/ui/input/hooks/useLazyLoadIcon';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
type SettingsObjectItemTableRowProps = {
action: ReactNode;
objectItem: ObjectMetadataItem;
onClick?: () => void;
};
export const StyledObjectTableRow = styled(TableRow)`
grid-template-columns: 180px 98.7px 98.7px 98.7px 36px;
`;
const StyledNameTableCell = styled(TableCell)`
color: ${({ theme }) => theme.font.color.primary};
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledActionTableCell = styled(TableCell)`
justify-content: center;
padding-right: ${({ theme }) => theme.spacing(1)};
`;
export const SettingsObjectItemTableRow = ({
action,
objectItem,
onClick,
}: SettingsObjectItemTableRowProps) => {
const theme = useTheme();
const { records } = useFindManyRecords({
objectNameSingular: objectItem.nameSingular,
});
const { Icon } = useLazyLoadIcon(objectItem.icon ?? '');
return (
<StyledObjectTableRow key={objectItem.namePlural} onClick={onClick}>
<StyledNameTableCell>
{!!Icon && <Icon size={theme.icon.size.md} />}
{objectItem.labelPlural}
</StyledNameTableCell>
<TableCell>
{objectItem.isCustom ? (
<Tag color="orange" text="Custom" />
) : (
<Tag color="blue" text="Standard" />
)}
</TableCell>
<TableCell align="right">
{objectItem.fields.filter((field) => !field.isSystem).length}
</TableCell>
<TableCell align="right">{records.length}</TableCell>
<StyledActionTableCell>{action}</StyledActionTableCell>
</StyledObjectTableRow>
);
};

View File

@ -0,0 +1,67 @@
import styled from '@emotion/styled';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { useLazyLoadIcon } from '@/ui/input/hooks/useLazyLoadIcon';
import { Section } from '@/ui/layout/section/components/Section';
import ArrowRight from '../assets/ArrowRight.svg';
import { SettingsObjectIconWithLabel } from './SettingsObjectIconWithLabel';
const StyledContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(4)};
`;
const StyledArrowContainer = styled.div`
align-items: center;
display: flex;
height: 32px;
justify-content: center;
`;
type SettingsObjectIconSectionProps = {
disabled?: boolean;
iconKey?: string;
label?: string;
onChange?: (icon: { Icon: IconComponent; iconKey: string }) => void;
};
export const SettingsObjectIconSection = ({
disabled,
iconKey = 'IconPigMoney',
label,
onChange,
}: SettingsObjectIconSectionProps) => {
const { Icon } = useLazyLoadIcon(iconKey);
return (
<Section>
<H2Title
title="Icon"
description="The icon that will be displayed in the sidebar."
/>
<StyledContainer>
<IconPicker
disabled={disabled}
selectedIconKey={iconKey}
onChange={(icon) => {
onChange?.({ Icon: icon.Icon, iconKey: icon.iconKey });
}}
/>
<StyledArrowContainer>
<img src={ArrowRight} alt="Arrow right" width={32} height={16} />
</StyledArrowContainer>
{Icon && (
<SettingsObjectIconWithLabel
Icon={Icon}
label={label || 'Investors'}
/>
)}
</StyledContainer>
</Section>
);
};

View File

@ -0,0 +1,47 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
const StyledContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(3)};
padding: ${({ theme }) => theme.spacing(1)};
`;
const StyledSubContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledItemLabel = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.sm};
font-style: normal;
font-weight: ${({ theme }) => theme.font.size.md};
line-height: ${({ theme }) => theme.text.lineHeight.md};
`;
type SettingsObjectIconWithLabelProps = {
Icon?: IconComponent;
label: string;
};
export const SettingsObjectIconWithLabel = ({
Icon,
label,
}: SettingsObjectIconWithLabelProps) => {
const theme = useTheme();
return (
<StyledContainer>
<StyledSubContainer>
{!!Icon && (
<Icon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
)}
<StyledItemLabel>{label}</StyledItemLabel>
</StyledSubContainer>
</StyledContainer>
);
};

View File

@ -0,0 +1,64 @@
import { useState } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconX } from '@/ui/display/icon';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { AnimatedFadeOut } from '@/ui/utilities/animation/components/AnimatedFadeOut';
import { cookieStorage } from '~/utils/cookie-storage';
import CoverImage from '../assets/cover.png';
const StyledCoverImageContainer = styled.div`
align-items: center;
background-image: url(${CoverImage.toString()});
background-size: cover;
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
box-sizing: border-box;
display: flex;
height: 153px;
justify-content: center;
position: relative;
`;
const StyledTitle = styled.span`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
padding-top: ${({ theme }) => theme.spacing(5)};
`;
const StyledLighIconButton = styled(LightIconButton)`
position: absolute;
right: ${({ theme }) => theme.spacing(1)};
top: ${({ theme }) => theme.spacing(1)};
`;
export const SettingsObjectCoverImage = () => {
const theme = useTheme();
const [cookieState, setCookieState] = useState(
cookieStorage.getItem('settings-object-cover-image'),
);
return (
<AnimatedFadeOut
isOpen={cookieState !== 'closed'}
marginBottom={theme.spacing(8)}
>
<StyledCoverImageContainer>
<StyledTitle>Build your business logic</StyledTitle>
<StyledLighIconButton
Icon={IconX}
accent="tertiary"
size="small"
onClick={() => {
cookieStorage.setItem('settings-object-cover-image', 'closed');
setCookieState('closed');
}}
/>
</StyledCoverImageContainer>
</AnimatedFadeOut>
);
};

View File

@ -0,0 +1,67 @@
import { IconDotsVertical } from '@/ui/display/icon';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { IconArchiveOff } from '@/ui/input/constants/icons';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
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';
type SettingsObjectDisabledMenuDropDownProps = {
isCustomObject: boolean;
onActivate: () => void;
onErase: () => void;
scopeKey: string;
};
export const SettingsObjectDisabledMenuDropDown = ({
onActivate,
scopeKey,
}: SettingsObjectDisabledMenuDropDownProps) => {
const dropdownScopeId = `${scopeKey}-settings-object-disabled-menu-dropdown`;
const { closeDropdown } = useDropdown({ dropdownScopeId });
const handleActivate = () => {
onActivate();
closeDropdown();
};
// const handleErase = () => {
// onErase();
// closeDropdown();
// };
return (
<DropdownScope dropdownScopeId={dropdownScopeId}>
<Dropdown
clickableComponent={
<LightIconButton Icon={IconDotsVertical} accent="tertiary" />
}
dropdownComponents={
<DropdownMenu width="160px">
<DropdownMenuItemsContainer>
<MenuItem
text="Activate"
LeftIcon={IconArchiveOff}
onClick={handleActivate}
/>
{/* {isCustomObject && (
<MenuItem
text="Erase"
LeftIcon={IconTrash}
accent="danger"
onClick={handleErase}
/>
)} */}
</DropdownMenuItemsContainer>
</DropdownMenu>
}
dropdownHotkeyScope={{
scope: dropdownScopeId,
}}
/>
</DropdownScope>
);
};

View File

@ -0,0 +1,87 @@
import { Decorator, Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { SettingsObjectDisabledMenuDropDown } from '../SettingsObjectDisabledMenuDropDown';
const handleActivateMockFunction = fn();
const handleEraseMockFunction = fn();
const ClearMocksDecorator: Decorator = (Story, context) => {
if (context.parameters.clearMocks) {
handleActivateMockFunction.mockClear();
handleEraseMockFunction.mockClear();
}
return <Story />;
};
const meta: Meta<typeof SettingsObjectDisabledMenuDropDown> = {
title: 'Modules/Settings/DataModel/SettingsObjectDisabledMenuDropDown',
component: SettingsObjectDisabledMenuDropDown,
args: {
scopeKey: 'settings-object-disabled-menu-dropdown',
onActivate: handleActivateMockFunction,
onErase: handleEraseMockFunction,
},
decorators: [ComponentDecorator, ClearMocksDecorator],
parameters: {
clearMocks: true,
},
};
export default meta;
type Story = StoryObj<typeof SettingsObjectDisabledMenuDropDown>;
export const Default: Story = {};
export const Open: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const dropdownButton = await canvas.getByRole('button');
await userEvent.click(dropdownButton);
},
};
export const WithActivate: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const dropdownButton = await canvas.getByRole('button');
await userEvent.click(dropdownButton);
await expect(handleActivateMockFunction).toHaveBeenCalledTimes(0);
const activateMenuItem = await canvas.getByText('Activate');
await userEvent.click(activateMenuItem);
await expect(handleActivateMockFunction).toHaveBeenCalledTimes(1);
await userEvent.click(dropdownButton);
},
};
export const WithErase: Story = {
args: { isCustomObject: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const dropdownButton = await canvas.getByRole('button');
await userEvent.click(dropdownButton);
await expect(handleEraseMockFunction).toHaveBeenCalledTimes(0);
const eraseMenuItem = await canvas.getByText('Erase');
await userEvent.click(eraseMenuItem);
await expect(handleEraseMockFunction).toHaveBeenCalledTimes(1);
await userEvent.click(dropdownButton);
},
};

View File

@ -0,0 +1,5 @@
import { RelationMetadataType } from '~/generated-metadata/graphql';
export type RelationType =
| Exclude<RelationMetadataType, 'MANY_TO_MANY'>
| 'MANY_TO_ONE';

View File

@ -0,0 +1,8 @@
import { ThemeColor } from '@/ui/theme/constants/colors';
export type SettingsObjectFieldSelectFormOption = {
color: ThemeColor;
isDefault?: boolean;
label: string;
value: string;
};