Migrate to a monorepo structure (#2909)
This commit is contained in:
@ -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>
|
||||
);
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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} />;
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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 };
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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 };
|
||||
@ -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: () => {},
|
||||
},
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
@ -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 |
@ -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 |
@ -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 |
@ -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>
|
||||
);
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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(),
|
||||
},
|
||||
])
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
@ -0,0 +1,24 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
|
||||
import { SettingsObjectFieldFormSection } from '../SettingsObjectFieldFormSection';
|
||||
|
||||
const meta: Meta<typeof SettingsObjectFieldFormSection> = {
|
||||
title: 'Modules/Settings/DataModel/SettingsObjectFieldFormSection',
|
||||
component: SettingsObjectFieldFormSection,
|
||||
decorators: [ComponentDecorator],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SettingsObjectFieldFormSection>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const WithDefaultValues: Story = {
|
||||
args: {
|
||||
iconKey: 'IconLink',
|
||||
name: 'URL',
|
||||
description: 'Lorem ipsum',
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,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,
|
||||
},
|
||||
};
|
||||
@ -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' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -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',
|
||||
},
|
||||
};
|
||||
@ -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',
|
||||
},
|
||||
];
|
||||
@ -0,0 +1 @@
|
||||
export const objectSettingsWidth = '512px';
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
@ -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 },
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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],
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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 {};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,5 @@
|
||||
import { RelationMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
export type RelationType =
|
||||
| Exclude<RelationMetadataType, 'MANY_TO_MANY'>
|
||||
| 'MANY_TO_ONE';
|
||||
@ -0,0 +1,8 @@
|
||||
import { ThemeColor } from '@/ui/theme/constants/colors';
|
||||
|
||||
export type SettingsObjectFieldSelectFormOption = {
|
||||
color: ThemeColor;
|
||||
isDefault?: boolean;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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 = {};
|
||||
@ -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 = {};
|
||||
@ -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 },
|
||||
];
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,9 @@
|
||||
import { atomFamily } from 'recoil';
|
||||
|
||||
export const generatedApiKeyFamilyState = atomFamily<
|
||||
string | null | undefined,
|
||||
string
|
||||
>({
|
||||
key: 'generatedApiKeyFamilyState',
|
||||
default: null,
|
||||
});
|
||||
@ -0,0 +1,6 @@
|
||||
export type ApiFieldItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'internal' | 'published';
|
||||
expiration: string;
|
||||
};
|
||||
@ -0,0 +1,8 @@
|
||||
export type ApiKey = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
name: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
};
|
||||
@ -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',
|
||||
};
|
||||
});
|
||||
};
|
||||
@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user