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,14 @@
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { Section } from '@/ui/layout/section/components/Section';
import { SettingsAccountsEmptyStateCard } from './SettingsAccountsEmptyStateCard';
export const SettingsAccountsConnectedAccountsSection = () => (
<Section>
<H2Title
title="Connected accounts"
description="Manage your internet accounts."
/>
<SettingsAccountsEmptyStateCard />
</Section>
);

View File

@ -0,0 +1,59 @@
import { useCallback } from 'react';
import styled from '@emotion/styled';
import { IconGoogle } from '@/ui/display/icon/components/IconGoogle';
import { Button } from '@/ui/input/button/components/Button';
import { Card } from '@/ui/layout/card/components/Card';
import { REACT_APP_SERVER_AUTH_URL } from '~/config';
import { useGenerateTransientTokenMutation } from '~/generated/graphql';
const StyledCard = styled(Card)`
border-radius: ${({ theme }) => theme.border.radius.md};
overflow: hidden;
padding: 0;
`;
const StyledHeader = styled.div`
align-items: center;
background-color: ${({ theme }) => theme.background.primary};
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
height: ${({ theme }) => theme.spacing(6)};
padding: ${({ theme }) => theme.spacing(2, 4)};
`;
const StyledBody = styled.div`
display: flex;
justify-content: center;
padding: ${({ theme }) => theme.spacing(4)};
`;
export const SettingsAccountsEmptyStateCard = () => {
const [generateTransientToken] = useGenerateTransientTokenMutation();
const handleGmailLogin = useCallback(async () => {
const authServerUrl = REACT_APP_SERVER_AUTH_URL;
const transientToken = await generateTransientToken();
const token =
transientToken.data?.generateTransientToken.transientToken.token;
window.location.href = `${authServerUrl}/google-gmail?transientToken=${token}`;
}, [generateTransientToken]);
return (
<StyledCard>
<StyledHeader>No connected account</StyledHeader>
<StyledBody>
<Button
Icon={IconGoogle}
title="Connect with Google"
variant="secondary"
onClick={handleGmailLogin}
/>
</StyledBody>
</StyledCard>
);
};

View File

@ -0,0 +1,43 @@
import { useNavigate } from 'react-router-dom';
import styled from '@emotion/styled';
import { SettingsNavigationCard } from '@/settings/components/SettingsNavigationCard';
import { IconCalendarEvent, IconMailCog } from '@/ui/display/icon';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { Section } from '@/ui/layout/section/components/Section';
const StyledCardsContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(4)};
margin-top: ${({ theme }) => theme.spacing(6)};
`;
export const SettingsAccountsSettingsSection = () => {
const navigate = useNavigate();
return (
<Section>
<H2Title
title="Settings"
description="Configure your emails and calendar settings."
/>
<StyledCardsContainer>
<SettingsNavigationCard
Icon={IconMailCog}
title="Emails"
onClick={() => navigate('/settings/accounts/emails')}
>
Set email visibility, manage your blocklist and more.
</SettingsNavigationCard>
<SettingsNavigationCard
Icon={IconCalendarEvent}
title="Calendar"
disabled
hasSoonPill
>
Configure and customize your calendar preferences.
</SettingsNavigationCard>
</StyledCardsContainer>
</Section>
);
};

View File

@ -0,0 +1,9 @@
import { LightButton } from '@/ui/input/button/components/LightButton';
type CancelButtonProps = {
onCancel?: () => void;
};
export const CancelButton = ({ onCancel }: CancelButtonProps) => {
return <LightButton title="Cancel" accent="tertiary" onClick={onCancel} />;
};

View File

@ -0,0 +1,29 @@
import styled from '@emotion/styled';
import { CancelButton } from './CancelButton';
import { SaveButton } from './SaveButton';
const StyledContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
type SaveAndCancelButtonsProps = {
onSave?: () => void;
onCancel?: () => void;
isSaveDisabled?: boolean;
};
export const SaveAndCancelButtons = ({
onSave,
onCancel,
isSaveDisabled,
}: SaveAndCancelButtonsProps) => {
return (
<StyledContainer>
<CancelButton onCancel={onCancel} />
<SaveButton onSave={onSave} disabled={isSaveDisabled} />
</StyledContainer>
);
};

View File

@ -0,0 +1,21 @@
import { Button } from '@/ui/input/button/components/Button';
import { IconDeviceFloppy } from '@/ui/input/constants/icons';
type SaveButtonProps = {
onSave?: () => void;
disabled?: boolean;
};
export const SaveButton = ({ onSave, disabled }: SaveButtonProps) => {
return (
<Button
title="Save"
variant="primary"
size="small"
accent="blue"
disabled={disabled}
onClick={onSave}
Icon={IconDeviceFloppy}
/>
);
};

View File

@ -0,0 +1,9 @@
import styled from '@emotion/styled';
const StyledSettingsHeaderContainer = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
`;
export { StyledSettingsHeaderContainer as SettingsHeaderContainer };

View File

@ -0,0 +1,79 @@
import { ReactNode } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconChevronRight } from '@/ui/display/icon';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { SoonPill } from '@/ui/display/pill/components/SoonPill';
import { Card } from '@/ui/layout/card/components/Card';
type SettingsNavigationCardProps = {
children: ReactNode;
disabled?: boolean;
hasSoonPill?: boolean;
Icon: IconComponent;
onClick?: () => void;
title: string;
};
const StyledCard = styled(Card)<{
disabled?: boolean;
onClick?: () => void;
}>`
color: ${({ theme }) => theme.font.color.tertiary};
cursor: ${({ disabled, onClick }) =>
disabled ? 'not-allowed' : onClick ? 'pointer' : 'default'};
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
padding-bottom: ${({ theme }) => theme.spacing(4)};
padding-top: ${({ theme }) => theme.spacing(4)};
`;
const StyledHeader = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(3)};
`;
const StyledTitle = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
display: flex;
flex: 1 0 auto;
font-weight: ${({ theme }) => theme.font.weight.medium};
gap: ${({ theme }) => theme.spacing(2)};
justify-content: flex-start;
`;
const StyledIconChevronRight = styled(IconChevronRight)`
color: ${({ theme }) => theme.font.color.light};
`;
const StyledDescription = styled.div`
padding-left: ${({ theme }) => theme.spacing(8)};
`;
export const SettingsNavigationCard = ({
children,
disabled,
hasSoonPill,
Icon,
onClick,
title,
}: SettingsNavigationCardProps) => {
const theme = useTheme();
return (
<StyledCard disabled={disabled} onClick={onClick}>
<StyledHeader>
<Icon size={theme.icon.size.lg} stroke={theme.icon.stroke.sm} />
<StyledTitle>
{title}
{hasSoonPill && <SoonPill />}
</StyledTitle>
<StyledIconChevronRight size={theme.icon.size.sm} />
</StyledHeader>
<StyledDescription>{children}</StyledDescription>
</StyledCard>
);
};

View File

@ -0,0 +1,152 @@
import { useCallback } from 'react';
import { useMatch, useNavigate, useResolvedPath } from 'react-router-dom';
import { useAuth } from '@/auth/hooks/useAuth';
import { AppPath } from '@/types/AppPath';
import {
IconAt,
IconCalendarEvent,
IconColorSwatch,
IconHierarchy2,
IconLogout,
IconMail,
IconRobot,
IconSettings,
IconUserCircle,
IconUsers,
} from '@/ui/display/icon';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { NavigationDrawerItemGroup } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemGroup';
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
export const SettingsNavigationDrawerItems = () => {
const navigate = useNavigate();
const { signOut } = useAuth();
const handleLogout = useCallback(() => {
signOut();
navigate(AppPath.SignIn);
}, [signOut, navigate]);
const isMessagingEnabled = useIsFeatureEnabled('IS_MESSAGING_ENABLED');
const isAccountsItemActive = !!useMatch({
path: useResolvedPath('/settings/accounts').pathname,
end: true,
});
const isAccountsEmailsItemActive = !!useMatch({
path: useResolvedPath('/settings/accounts/emails').pathname,
end: true,
});
return (
<>
<NavigationDrawerSection>
<NavigationDrawerSectionTitle label="User" />
<NavigationDrawerItem
label="Profile"
to="/settings/profile"
Icon={IconUserCircle}
active={
!!useMatch({
path: useResolvedPath('/settings/profile').pathname,
end: true,
})
}
/>
<NavigationDrawerItem
label="Appearance"
to="/settings/profile/appearance"
Icon={IconColorSwatch}
active={
!!useMatch({
path: useResolvedPath('/settings/profile/appearance').pathname,
end: true,
})
}
/>
{isMessagingEnabled && (
<NavigationDrawerItemGroup>
<NavigationDrawerItem
label="Accounts"
to="/settings/accounts"
Icon={IconAt}
active={isAccountsItemActive}
/>
<NavigationDrawerItem
level={2}
label="Emails"
to="/settings/accounts/emails"
Icon={IconMail}
active={isAccountsEmailsItemActive}
/>
<NavigationDrawerItem
level={2}
label="Calendars"
Icon={IconCalendarEvent}
soon
/>
</NavigationDrawerItemGroup>
)}
</NavigationDrawerSection>
<NavigationDrawerSection>
<NavigationDrawerSectionTitle label="Workspace" />
<NavigationDrawerItem
label="General"
to="/settings/workspace"
Icon={IconSettings}
active={
!!useMatch({
path: useResolvedPath('/settings/workspace').pathname,
end: true,
})
}
/>
<NavigationDrawerItem
label="Members"
to="/settings/workspace-members"
Icon={IconUsers}
active={
!!useMatch({
path: useResolvedPath('/settings/workspace-members').pathname,
end: true,
})
}
/>
<NavigationDrawerItem
label="Data model"
to="/settings/objects"
Icon={IconHierarchy2}
active={
!!useMatch({
path: useResolvedPath('/settings/objects').pathname,
end: false,
})
}
/>
<NavigationDrawerItem
label="Developers"
to="/settings/developers/api-keys"
Icon={IconRobot}
active={
!!useMatch({
path: useResolvedPath('/settings/developers/api-keys').pathname,
end: true,
})
}
/>
</NavigationDrawerSection>
<NavigationDrawerSection>
<NavigationDrawerSectionTitle label="Other" />
<NavigationDrawerItem
label="Logout"
onClick={handleLogout}
Icon={IconLogout}
/>
</NavigationDrawerSection>
</>
);
};

View File

@ -0,0 +1,14 @@
import styled from '@emotion/styled';
import { objectSettingsWidth } from '../data-model/constants/objectSettings';
const StyledSettingsPageContainer = styled.div<{ width?: number }>`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(8)};
overflow: auto;
padding: ${({ theme }) => theme.spacing(8)};
width: ${({ width }) => (width ? width + 'px' : objectSettingsWidth)};
`;
export { StyledSettingsPageContainer as SettingsPageContainer };

View File

@ -0,0 +1,20 @@
import { Meta, StoryObj } from '@storybook/react';
import { CancelButton } from '../SaveAndCancelButtons/CancelButton';
const meta: Meta<typeof CancelButton> = {
title: 'Modules/Settings/CancelButton',
component: CancelButton,
};
export default meta;
type Story = StoryObj<typeof CancelButton>;
export const Default: Story = {
argTypes: {
onCancel: { control: false },
},
args: {
onCancel: () => {},
},
};

View File

@ -0,0 +1,31 @@
import { Meta, StoryObj } from '@storybook/react';
import { SaveButton } from '../SaveAndCancelButtons/SaveButton';
const meta: Meta<typeof SaveButton> = {
title: 'Modules/Settings/SaveButton',
component: SaveButton,
};
export default meta;
type Story = StoryObj<typeof SaveButton>;
export const Default: Story = {
argTypes: {
onSave: { control: false },
},
args: {
onSave: () => {},
disabled: false,
},
};
export const Disabled: Story = {
argTypes: {
onSave: { control: false },
},
args: {
onSave: () => {},
disabled: true,
},
};

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;
};

View File

@ -0,0 +1,44 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconCopy } from '@/ui/display/icon';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Button } from '@/ui/input/button/components/Button';
import { TextInput } from '@/ui/input/components/TextInput';
const StyledContainer = styled.div`
display: flex;
flex-direction: row;
`;
const StyledLinkContainer = styled.div`
flex: 1;
margin-right: ${({ theme }) => theme.spacing(2)};
`;
type ApiKeyInputProps = { apiKey: string };
export const ApiKeyInput = ({ apiKey }: ApiKeyInputProps) => {
const theme = useTheme();
const { enqueueSnackBar } = useSnackBar();
return (
<StyledContainer>
<StyledLinkContainer>
<TextInput value={apiKey} fullWidth />
</StyledLinkContainer>
<Button
Icon={IconCopy}
title="Copy"
onClick={() => {
enqueueSnackBar('Api Key copied to clipboard', {
variant: 'success',
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
});
navigator.clipboard.writeText(apiKey);
}}
/>
</StyledContainer>
);
};

View File

@ -0,0 +1,57 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ApiFieldItem } from '@/settings/developers/types/ApiFieldItem';
import { IconChevronRight } from '@/ui/display/icon';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
export const StyledApisFieldTableRow = 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)};
`;
const StyledIconChevronRight = styled(IconChevronRight)`
color: ${({ theme }) => theme.font.color.tertiary};
`;
export const SettingsApiKeysFieldItemTableRow = ({
fieldItem,
onClick,
}: {
fieldItem: ApiFieldItem;
onClick: () => void;
}) => {
const theme = useTheme();
return (
<StyledApisFieldTableRow onClick={() => onClick()}>
<StyledNameTableCell>{fieldItem.name}</StyledNameTableCell>
<TableCell color={theme.font.color.tertiary}>Internal</TableCell>{' '}
<TableCell
color={
fieldItem.expiration === 'Expired'
? theme.font.color.danger
: theme.font.color.tertiary
}
>
{fieldItem.expiration}
</TableCell>
<StyledIconTableCell>
<StyledIconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
</StyledIconTableCell>
</StyledApisFieldTableRow>
);
};

View File

@ -0,0 +1,19 @@
import { Meta, StoryObj } from '@storybook/react';
import { ApiKeyInput } from '@/settings/developers/components/ApiKeyInput';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
const meta: Meta<typeof ApiKeyInput> = {
title: 'Pages/Settings/Developers/ApiKeys/ApiKeyInput',
component: ApiKeyInput,
decorators: [ComponentDecorator, SnackBarDecorator],
args: {
apiKey:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0d2VudHktN2VkOWQyMTItMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNjk4MTQyODgyLCJleHAiOjE2OTk0MDE1OTksImp0aSI6ImMyMmFiNjQxLTVhOGYtNGQwMC1iMDkzLTk3MzUwYTM2YzZkOSJ9.JIe2TX5IXrdNl3n-kRFp3jyfNUE7unzXZLAzm2Gxl98',
},
};
export default meta;
type Story = StoryObj<typeof ApiKeyInput>;
export const Default: Story = {};

View File

@ -0,0 +1,23 @@
import { Meta, StoryObj } from '@storybook/react';
import { SettingsApiKeysFieldItemTableRow } from '@/settings/developers/components/SettingsApiKeysFieldItemTableRow';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
const meta: Meta<typeof SettingsApiKeysFieldItemTableRow> = {
title: 'Pages/Settings/Developers/ApiKeys/SettingsApiKeysFieldItemTableRow',
component: SettingsApiKeysFieldItemTableRow,
decorators: [ComponentDecorator],
args: {
fieldItem: {
id: '3f4a42e8-b81f-4f8c-9c20-1602e6b34791',
name: 'Zapier Api Key',
type: 'internal',
expiration: 'In 3 days',
},
},
};
export default meta;
type Story = StoryObj<typeof SettingsApiKeysFieldItemTableRow>;
export const Default: Story = {};

View File

@ -0,0 +1,11 @@
export const ExpirationDates: {
value: number | null;
label: string;
}[] = [
{ label: '15 days', value: 15 },
{ label: '30 days', value: 30 },
{ label: '90 days', value: 90 },
{ label: '1 year', value: 365 },
{ label: '2 years', value: 2 * 365 },
{ label: 'Never', value: null },
];

View File

@ -0,0 +1,12 @@
import { useRecoilCallback } from 'recoil';
import { generatedApiKeyFamilyState } from '@/settings/developers/states/generatedApiKeyFamilyState';
export const useGeneratedApiKeys = () => {
return useRecoilCallback(
({ set }) =>
(apiKeyId: string, apiKey: string | null) => {
set(generatedApiKeyFamilyState(apiKeyId), apiKey);
},
);
};

View File

@ -0,0 +1,9 @@
import { atomFamily } from 'recoil';
export const generatedApiKeyFamilyState = atomFamily<
string | null | undefined,
string
>({
key: 'generatedApiKeyFamilyState',
default: null,
});

View File

@ -0,0 +1,6 @@
export type ApiFieldItem = {
id: string;
name: string;
type: 'internal' | 'published';
expiration: string;
};

View File

@ -0,0 +1,8 @@
export type ApiKey = {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
name: string;
expiresAt: string;
};

View File

@ -0,0 +1,23 @@
import { computeNewExpirationDate } from '@/settings/developers/utils/compute-new-expiration-date';
jest.useFakeTimers().setSystemTime(new Date('2024-01-01T00:00:00.000Z'));
describe('computeNewExpirationDate', () => {
it('should compute properly', () => {
const expiresAt = '2023-01-10T00:00:00.000Z';
const createdAt = '2023-01-01T00:00:00.000Z';
const result = computeNewExpirationDate(expiresAt, createdAt);
expect(result).toEqual('2024-01-10T00:00:00.000Z');
});
it('should compute properly with same values', () => {
const expiresAt = '2023-01-01T10:00:00.000Z';
const createdAt = '2023-01-01T10:00:00.000Z';
const result = computeNewExpirationDate(expiresAt, createdAt);
expect(result).toEqual('2024-01-01T00:00:00.000Z');
});
it('should compute properly with no expiration', () => {
const createdAt = '2023-01-01T10:00:00.000Z';
const result = computeNewExpirationDate(undefined, createdAt);
expect(result).toEqual(null);
});
});

View File

@ -0,0 +1,34 @@
import { formatExpiration } from '@/settings/developers/utils/format-expiration';
jest.useFakeTimers().setSystemTime(new Date('2024-01-01T00:00:00.000Z'));
describe('formatExpiration', () => {
it('should format properly when expiresAt is null', () => {
const expiresAt = null;
const result = formatExpiration(expiresAt);
expect(result).toEqual('Never');
const resultWithExpiresMention = formatExpiration(expiresAt, true);
expect(resultWithExpiresMention).toEqual('Never expires');
});
it('should format properly when expiresAt is not null', () => {
const expiresAt = '2024-01-10T00:00:00.000Z';
const result = formatExpiration(expiresAt);
expect(result).toEqual('In 9 days');
const resultWithExpiresMention = formatExpiration(expiresAt, true);
expect(resultWithExpiresMention).toEqual('Expires in 9 days');
});
it('should format properly when expiresAt is large', () => {
const expiresAt = '2034-01-10T00:00:00.000Z';
const result = formatExpiration(expiresAt);
expect(result).toEqual('In 10 years');
const resultWithExpiresMention = formatExpiration(expiresAt, true);
expect(resultWithExpiresMention).toEqual('Expires in 10 years');
});
it('should format properly when expiresAt is large and long version', () => {
const expiresAt = '2034-01-10T00:00:00.000Z';
const result = formatExpiration(expiresAt, undefined, false);
expect(result).toEqual('In 10 years and 9 days');
const resultWithExpiresMention = formatExpiration(expiresAt, true, false);
expect(resultWithExpiresMention).toEqual('Expires in 10 years and 9 days');
});
});

View File

@ -0,0 +1,14 @@
import { DateTime } from 'luxon';
export const computeNewExpirationDate = (
expiresAt: string | null | undefined,
createdAt: string,
): string | null => {
if (!expiresAt) {
return null;
}
const days = DateTime.fromISO(expiresAt).diff(DateTime.fromISO(createdAt), [
'days',
]).days;
return DateTime.utc().plus({ days }).toISO();
};

View File

@ -0,0 +1,31 @@
import { ApiFieldItem } from '@/settings/developers/types/ApiFieldItem';
import { ApiKey } from '@/settings/developers/types/ApiKey';
import { beautifyDateDiff } from '~/utils/date-utils';
export const formatExpiration = (
expiresAt: string | null,
withExpiresMention: boolean = false,
short: boolean = true,
) => {
if (expiresAt) {
const dateDiff = beautifyDateDiff(expiresAt, undefined, short);
if (dateDiff.includes('-')) {
return 'Expired';
}
return withExpiresMention ? `Expires in ${dateDiff}` : `In ${dateDiff}`;
}
return withExpiresMention ? 'Never expires' : 'Never';
};
export const formatExpirations = (
apiKeys: Array<Pick<ApiKey, 'id' | 'name' | 'expiresAt'>>,
): ApiFieldItem[] => {
return apiKeys.map(({ id, name, expiresAt }) => {
return {
id,
name,
expiration: formatExpiration(expiresAt || null),
type: 'internal',
};
});
};

View File

@ -0,0 +1,64 @@
import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { useAuth } from '@/auth/hooks/useAuth';
import { currentUserState } from '@/auth/states/currentUserState';
import { AppPath } from '@/types/AppPath';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { Button } from '@/ui/input/button/components/Button';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useDeleteUserAccountMutation } from '~/generated/graphql';
export const DeleteAccount = () => {
const [isDeleteAccountModalOpen, setIsDeleteAccountModalOpen] =
useState(false);
const [deleteUserAccount] = useDeleteUserAccountMutation();
const currentUser = useRecoilValue(currentUserState);
const userEmail = currentUser?.email;
const { signOut } = useAuth();
const navigate = useNavigate();
const handleLogout = useCallback(() => {
signOut();
navigate(AppPath.SignIn);
}, [signOut, navigate]);
const deleteAccount = async () => {
await deleteUserAccount();
handleLogout();
};
return (
<>
<H2Title
title="Danger zone"
description="Delete account and all the associated data"
/>
<Button
accent="danger"
onClick={() => setIsDeleteAccountModalOpen(true)}
variant="secondary"
title="Delete account"
/>
<ConfirmationModal
confirmationValue={userEmail}
confirmationPlaceholder={userEmail ?? ''}
isOpen={isDeleteAccountModalOpen}
setIsOpen={setIsDeleteAccountModalOpen}
title="Account Deletion"
subtitle={
<>
This action cannot be undone. This will permanently delete your
entire account. <br /> Please type in your email to confirm.
</>
}
onConfirmClick={deleteAccount}
deleteButtonText="Delete account"
/>
</>
);
};

View File

@ -0,0 +1,61 @@
import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { useAuth } from '@/auth/hooks/useAuth';
import { currentUserState } from '@/auth/states/currentUserState';
import { AppPath } from '@/types/AppPath';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import {
ConfirmationModal,
StyledConfirmationButton,
} from '@/ui/layout/modal/components/ConfirmationModal';
import { useDeleteCurrentWorkspaceMutation } from '~/generated/graphql';
export const DeleteWorkspace = () => {
const [isDeleteWorkSpaceModalOpen, setIsDeleteWorkSpaceModalOpen] =
useState(false);
const [deleteCurrentWorkspace] = useDeleteCurrentWorkspaceMutation();
const currentUser = useRecoilValue(currentUserState);
const userEmail = currentUser?.email;
const { signOut } = useAuth();
const navigate = useNavigate();
const handleLogout = useCallback(() => {
signOut();
navigate(AppPath.SignIn);
}, [signOut, navigate]);
const deleteWorkspace = async () => {
await deleteCurrentWorkspace();
handleLogout();
};
return (
<>
<H2Title title="Danger zone" description="Delete your whole workspace" />
<StyledConfirmationButton
onClick={() => setIsDeleteWorkSpaceModalOpen(true)}
variant="secondary"
title="Delete workspace"
/>
<ConfirmationModal
confirmationPlaceholder={userEmail}
confirmationValue={userEmail}
isOpen={isDeleteWorkSpaceModalOpen}
setIsOpen={setIsDeleteWorkSpaceModalOpen}
title="Workspace Deletion"
subtitle={
<>
This action cannot be undone. This will permanently delete your
entire workspace. <br /> Please type in your email to confirm.
</>
}
onConfirmClick={deleteWorkspace}
deleteButtonText="Delete workspace"
/>
</>
);
};

View File

@ -0,0 +1,17 @@
import { useRecoilValue } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState';
import { TextInput } from '@/ui/input/components/TextInput';
export const EmailField = () => {
const currentUser = useRecoilValue(currentUserState);
return (
<TextInput
value={currentUser?.email}
disabled
fullWidth
key={'email-' + currentUser?.id}
/>
);
};

View File

@ -0,0 +1,126 @@
import { useEffect, useState } from 'react';
import styled from '@emotion/styled';
import debounce from 'lodash.debounce';
import { useRecoilState, useRecoilValue } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { TextInput } from '@/ui/input/components/TextInput';
import { logError } from '~/utils/logError';
const StyledComboInputContainer = styled.div`
display: flex;
flex-direction: row;
> * + * {
margin-left: ${({ theme }) => theme.spacing(4)};
}
`;
type NameFieldsProps = {
autoSave?: boolean;
onFirstNameUpdate?: (firstName: string) => void;
onLastNameUpdate?: (lastName: string) => void;
};
export const NameFields = ({
autoSave = true,
onFirstNameUpdate,
onLastNameUpdate,
}: NameFieldsProps) => {
const currentUser = useRecoilValue(currentUserState);
const [currentWorkspaceMember, setCurrentWorkspaceMember] = useRecoilState(
currentWorkspaceMemberState,
);
const [firstName, setFirstName] = useState(
currentWorkspaceMember?.name?.firstName ?? '',
);
const [lastName, setLastName] = useState(
currentWorkspaceMember?.name?.lastName ?? '',
);
const { updateOneRecord } = useUpdateOneRecord({
objectNameSingular: 'workspaceMember',
});
// TODO: Enhance this with react-web-hook-form (https://www.react-hook-form.com)
const debouncedUpdate = debounce(async () => {
if (onFirstNameUpdate) {
onFirstNameUpdate(firstName);
}
if (onLastNameUpdate) {
onLastNameUpdate(lastName);
}
try {
if (!currentWorkspaceMember?.id) {
throw new Error('User is not logged in');
}
if (autoSave) {
await updateOneRecord({
idToUpdate: currentWorkspaceMember?.id,
input: {
name: {
firstName: firstName,
lastName: lastName,
},
},
});
setCurrentWorkspaceMember({
...currentWorkspaceMember,
name: {
firstName,
lastName,
},
});
}
} catch (error) {
logError(error);
}
}, 500);
useEffect(() => {
if (!currentWorkspaceMember) {
return;
}
if (
currentWorkspaceMember.name?.firstName !== firstName ||
currentWorkspaceMember.name?.lastName !== lastName
) {
debouncedUpdate();
}
return () => {
debouncedUpdate.cancel();
};
}, [
firstName,
lastName,
currentUser,
debouncedUpdate,
autoSave,
currentWorkspaceMember,
]);
return (
<StyledComboInputContainer>
<TextInput
label="First Name"
value={firstName}
onChange={setFirstName}
placeholder="Tim"
fullWidth
/>
<TextInput
label="Last Name"
value={lastName}
onChange={setLastName}
placeholder="Cook"
fullWidth
/>
</StyledComboInputContainer>
);
};

View File

@ -0,0 +1,105 @@
import { useState } from 'react';
import { useRecoilState } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { ImageInput } from '@/ui/input/components/ImageInput';
import { getImageAbsoluteURIOrBase64 } from '@/users/utils/getProfilePictureAbsoluteURI';
import { useUploadProfilePictureMutation } from '~/generated/graphql';
export const ProfilePictureUploader = () => {
const [uploadPicture, { loading: isUploading }] =
useUploadProfilePictureMutation();
const [currentWorkspaceMember, setCurrentWorkspaceMember] = useRecoilState(
currentWorkspaceMemberState,
);
const [uploadController, setUploadController] =
useState<AbortController | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const { updateOneRecord } = useUpdateOneRecord({
objectNameSingular: 'workspaceMember',
});
const handleUpload = async (file: File) => {
if (!file) {
return;
}
const controller = new AbortController();
setUploadController(controller);
try {
if (!currentWorkspaceMember?.id) {
throw new Error('User is not logged in');
}
const result = await uploadPicture({
variables: {
file,
},
context: {
fetchOptions: {
signal: controller.signal,
},
},
});
setUploadController(null);
setErrorMessage(null);
const avatarUrl = result?.data?.uploadProfilePicture;
if (!avatarUrl) {
throw new Error('Avatar URL not found');
}
await updateOneRecord({
idToUpdate: currentWorkspaceMember?.id,
input: {
avatarUrl,
},
});
setCurrentWorkspaceMember({ ...currentWorkspaceMember, avatarUrl });
return result;
} catch (error) {
setErrorMessage('An error occured while uploading the picture.');
}
};
const handleAbort = async () => {
if (uploadController) {
uploadController.abort();
setUploadController(null);
}
};
const handleRemove = async () => {
if (!currentWorkspaceMember?.id) {
throw new Error('User is not logged in');
}
await updateOneRecord({
idToUpdate: currentWorkspaceMember?.id,
input: {
avatarUrl: null,
},
});
setCurrentWorkspaceMember({ ...currentWorkspaceMember, avatarUrl: null });
};
return (
<ImageInput
picture={getImageAbsoluteURIOrBase64(currentWorkspaceMember?.avatarUrl)}
onUpload={handleUpload}
onRemove={handleRemove}
onAbort={handleAbort}
isUploading={isUploading}
errorMessage={errorMessage}
/>
);
};

View File

@ -0,0 +1,81 @@
import { useCallback, useEffect, useState } from 'react';
import styled from '@emotion/styled';
import debounce from 'lodash.debounce';
import { useRecoilValue } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { TextInput } from '@/ui/input/components/TextInput';
import { useUpdateWorkspaceMutation } from '~/generated/graphql';
import { logError } from '~/utils/logError';
const StyledComboInputContainer = styled.div`
display: flex;
flex-direction: row;
> * + * {
margin-left: ${({ theme }) => theme.spacing(4)};
}
`;
type NameFieldProps = {
autoSave?: boolean;
onNameUpdate?: (name: string) => void;
};
export const NameField = ({
autoSave = true,
onNameUpdate,
}: NameFieldProps) => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const [displayName, setDisplayName] = useState(
currentWorkspace?.displayName ?? '',
);
const [updateWorkspace] = useUpdateWorkspaceMutation();
// TODO: Enhance this with react-web-hook-form (https://www.react-hook-form.com)
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedUpdate = useCallback(
debounce(async (name: string) => {
if (onNameUpdate) {
onNameUpdate(displayName);
}
if (!autoSave || !name) {
return;
}
try {
const { data, errors } = await updateWorkspace({
variables: {
input: {
displayName: name,
},
},
});
if (errors || !data?.updateWorkspace) {
throw errors;
}
} catch (error) {
logError(error);
}
}, 500),
[updateWorkspace],
);
useEffect(() => {
debouncedUpdate(displayName);
return debouncedUpdate.cancel;
}, [debouncedUpdate, displayName]);
return (
<StyledComboInputContainer>
<TextInput
label="Name"
value={displayName}
onChange={setDisplayName}
placeholder="Apple"
fullWidth
/>
</StyledComboInputContainer>
);
};

View File

@ -0,0 +1,46 @@
import { useRecoilState } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Toggle } from '@/ui/input/components/Toggle';
import { useUpdateWorkspaceMutation } from '~/generated/graphql';
export const ToggleImpersonate = () => {
const { enqueueSnackBar } = useSnackBar();
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
);
const [updateWorkspace] = useUpdateWorkspaceMutation();
const handleChange = async (value: boolean) => {
try {
if (!currentWorkspace?.id) {
throw new Error('User is not logged in');
}
await updateWorkspace({
variables: {
input: {
allowImpersonation: value,
},
},
});
setCurrentWorkspace({
...currentWorkspace,
allowImpersonation: value,
});
} catch (err: any) {
enqueueSnackBar(err?.message, {
variant: 'error',
});
}
};
return (
<Toggle
value={currentWorkspace?.allowImpersonation}
onChange={handleChange}
/>
);
};

View File

@ -0,0 +1,64 @@
import { useRecoilState } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { ImageInput } from '@/ui/input/components/ImageInput';
import { getImageAbsoluteURIOrBase64 } from '@/users/utils/getProfilePictureAbsoluteURI';
import {
useUpdateWorkspaceMutation,
useUploadWorkspaceLogoMutation,
} from '~/generated/graphql';
export const WorkspaceLogoUploader = () => {
const [uploadLogo] = useUploadWorkspaceLogoMutation();
const [updateWorkspce] = useUpdateWorkspaceMutation();
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
);
const onUpload = async (file: File) => {
if (!file) {
return;
}
if (!currentWorkspace?.id) {
throw new Error('Workspace id not found');
}
await uploadLogo({
variables: {
file,
},
onCompleted: (data) => {
setCurrentWorkspace({
...currentWorkspace,
logo: data.uploadWorkspaceLogo,
});
},
});
};
const onRemove = async () => {
if (!currentWorkspace?.id) {
throw new Error('Workspace id not found');
}
await updateWorkspce({
variables: {
input: {
logo: null,
},
},
onCompleted: () => {
setCurrentWorkspace({
...currentWorkspace,
logo: null,
});
},
});
};
return (
<ImageInput
picture={getImageAbsoluteURIOrBase64(currentWorkspace?.logo)}
onUpload={onUpload}
onRemove={onRemove}
/>
);
};