Migrate to a monorepo structure (#2909)

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

View File

@ -0,0 +1,212 @@
import { useCallback } from 'react';
import { Controller, SubmitHandler, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import styled from '@emotion/styled';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { z } from 'zod';
import { SubTitle } from '@/auth/components/SubTitle';
import { Title } from '@/auth/components/Title';
import { useOnboardingStatus } from '@/auth/hooks/useOnboardingStatus';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { ProfilePictureUploader } from '@/settings/profile/components/ProfilePictureUploader';
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { MainButton } from '@/ui/input/button/components/MainButton';
import { TextInput } from '@/ui/input/components/TextInput';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
const StyledContentContainer = styled.div`
width: 100%;
`;
const StyledSectionContainer = styled.div`
margin-top: ${({ theme }) => theme.spacing(8)};
`;
const StyledButtonContainer = styled.div`
margin-top: ${({ theme }) => theme.spacing(8)};
width: 200px;
`;
const StyledComboInputContainer = styled.div`
display: flex;
flex-direction: row;
> * + * {
margin-left: ${({ theme }) => theme.spacing(4)};
}
`;
const validationSchema = z
.object({
firstName: z.string().min(1, { message: 'First name can not be empty' }),
lastName: z.string().min(1, { message: 'Last name can not be empty' }),
})
.required();
type Form = z.infer<typeof validationSchema>;
export const CreateProfile = () => {
const navigate = useNavigate();
const onboardingStatus = useOnboardingStatus();
const { enqueueSnackBar } = useSnackBar();
const [currentWorkspaceMember, setCurrentWorkspaceMember] = useRecoilState(
currentWorkspaceMemberState,
);
const { updateOneRecord } = useUpdateOneRecord<WorkspaceMember>({
objectNameSingular: 'workspaceMember',
});
// Form
const {
control,
handleSubmit,
formState: { isValid, isSubmitting },
getValues,
} = useForm<Form>({
mode: 'onChange',
defaultValues: {
firstName: currentWorkspaceMember?.name?.firstName ?? '',
lastName: currentWorkspaceMember?.name?.lastName ?? '',
},
resolver: zodResolver(validationSchema),
});
const onSubmit: SubmitHandler<Form> = useCallback(
async (data) => {
try {
if (!currentWorkspaceMember?.id) {
throw new Error('User is not logged in');
}
if (!data.firstName || !data.lastName) {
throw new Error('First name or last name is missing');
}
await updateOneRecord({
idToUpdate: currentWorkspaceMember?.id,
input: {
name: {
firstName: data.firstName,
lastName: data.lastName,
},
},
});
setCurrentWorkspaceMember(
(current) =>
({
...current,
name: {
firstName: data.firstName,
lastName: data.lastName,
},
}) as any,
);
navigate('/');
} catch (error: any) {
enqueueSnackBar(error?.message, {
variant: 'error',
});
}
},
[
currentWorkspaceMember?.id,
enqueueSnackBar,
navigate,
setCurrentWorkspaceMember,
updateOneRecord,
],
);
useScopedHotkeys(
Key.Enter,
() => {
onSubmit(getValues());
},
PageHotkeyScope.CreateProfile,
[onSubmit],
);
if (onboardingStatus !== OnboardingStatus.OngoingProfileCreation) {
return null;
}
return (
<>
<Title>Create profile</Title>
<SubTitle>How you'll be identified on the app.</SubTitle>
<StyledContentContainer>
<StyledSectionContainer>
<H2Title title="Picture" />
<ProfilePictureUploader />
</StyledSectionContainer>
<StyledSectionContainer>
<H2Title
title="Name"
description="Your name as it will be displayed on the app"
/>
{/* TODO: When react-web-hook-form is added to edit page we should create a dedicated component with context */}
<StyledComboInputContainer>
<Controller
name="firstName"
control={control}
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => (
<TextInput
autoFocus
label="First Name"
value={value}
onBlur={onBlur}
onChange={onChange}
placeholder="Tim"
error={error?.message}
fullWidth
disableHotkeys
/>
)}
/>
<Controller
name="lastName"
control={control}
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => (
<TextInput
label="Last Name"
value={value}
onBlur={onBlur}
onChange={onChange}
placeholder="Cook"
error={error?.message}
fullWidth
disableHotkeys
/>
)}
/>
</StyledComboInputContainer>
</StyledSectionContainer>
</StyledContentContainer>
<StyledButtonContainer>
<MainButton
title="Continue"
onClick={handleSubmit(onSubmit)}
disabled={!isValid || isSubmitting}
fullWidth
/>
</StyledButtonContainer>
</>
);
};

View File

@ -0,0 +1,169 @@
import { useCallback } from 'react';
import { Controller, SubmitHandler, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import styled from '@emotion/styled';
import { zodResolver } from '@hookform/resolvers/zod';
import { useSetRecoilState } from 'recoil';
import { z } from 'zod';
import { SubTitle } from '@/auth/components/SubTitle';
import { Title } from '@/auth/components/Title';
import { useOnboardingStatus } from '@/auth/hooks/useOnboardingStatus';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus';
import { WorkspaceLogoUploader } from '@/settings/workspace/components/WorkspaceLogoUploader';
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { MainButton } from '@/ui/input/button/components/MainButton';
import { TextInput } from '@/ui/input/components/TextInput';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useUpdateWorkspaceMutation } from '~/generated/graphql';
const StyledContentContainer = styled.div`
width: 100%;
`;
const StyledSectionContainer = styled.div`
margin-top: ${({ theme }) => theme.spacing(8)};
`;
const StyledButtonContainer = styled.div`
margin-top: ${({ theme }) => theme.spacing(8)};
width: 200px;
`;
const validationSchema = z
.object({
name: z.string().min(1, { message: 'Name can not be empty' }),
})
.required();
type Form = z.infer<typeof validationSchema>;
export const CreateWorkspace = () => {
const navigate = useNavigate();
const { enqueueSnackBar } = useSnackBar();
const onboardingStatus = useOnboardingStatus();
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
const [updateWorkspace] = useUpdateWorkspaceMutation();
// Form
const {
control,
handleSubmit,
formState: { isValid, isSubmitting },
getValues,
} = useForm<Form>({
mode: 'onChange',
defaultValues: {
name: '',
},
resolver: zodResolver(validationSchema),
});
const onSubmit: SubmitHandler<Form> = useCallback(
async (data) => {
try {
const result = await updateWorkspace({
variables: {
input: {
displayName: data.name,
},
},
});
setCurrentWorkspace({
id: result.data?.updateWorkspace?.id ?? '',
displayName: data.name,
allowImpersonation:
result.data?.updateWorkspace?.allowImpersonation ?? false,
});
if (result.errors || !result.data?.updateWorkspace) {
throw result.errors ?? new Error('Unknown error');
}
setTimeout(() => {
navigate('/create/profile');
}, 20);
} catch (error: any) {
enqueueSnackBar(error?.message, {
variant: 'error',
});
}
},
[enqueueSnackBar, navigate, setCurrentWorkspace, updateWorkspace],
);
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
handleSubmit(onSubmit)();
}
};
useScopedHotkeys(
'enter',
() => {
onSubmit(getValues());
},
PageHotkeyScope.CreateWokspace,
[onSubmit],
);
if (onboardingStatus !== OnboardingStatus.OngoingWorkspaceCreation) {
return null;
}
return (
<>
<Title>Create your workspace</Title>
<SubTitle>
A shared environment where you will be able to manage your customer
relations with your team.
</SubTitle>
<StyledContentContainer>
<StyledSectionContainer>
<H2Title title="Workspace logo" />
<WorkspaceLogoUploader />
</StyledSectionContainer>
<StyledSectionContainer>
<H2Title
title="Workspace name"
description="The name of your organization"
/>
<Controller
name="name"
control={control}
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => (
<TextInput
autoFocus
value={value}
placeholder="Apple"
onBlur={onBlur}
onChange={onChange}
error={error?.message}
onKeyDown={handleKeyDown}
fullWidth
disableHotkeys
/>
)}
/>
</StyledSectionContainer>
</StyledContentContainer>
<StyledButtonContainer>
<MainButton
title="Continue"
onClick={handleSubmit(onSubmit)}
disabled={!isValid || isSubmitting}
fullWidth
/>
</StyledButtonContainer>
</>
);
};

View File

@ -0,0 +1,3 @@
import { SignInUpForm } from '../../modules/auth/sign-in-up/components/SignInUpForm';
export const SignInUp = () => <SignInUpForm />;

View File

@ -0,0 +1,45 @@
import { useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilValue } from 'recoil';
import { useAuth } from '@/auth/hooks/useAuth';
import { useIsLogged } from '@/auth/hooks/useIsLogged';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { AppPath } from '../../modules/types/AppPath';
export const VerifyEffect = () => {
const [searchParams] = useSearchParams();
const loginToken = searchParams.get('loginToken');
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const isLogged = useIsLogged();
const navigate = useNavigate();
const { verify } = useAuth();
useEffect(() => {
const getTokens = async () => {
if (!loginToken) {
navigate(AppPath.SignIn);
} else {
await verify(loginToken);
if (isNonEmptyString(currentWorkspace?.displayName)) {
navigate(AppPath.Index);
} else {
navigate(AppPath.CreateWorkspace);
}
}
};
if (!isLogged) {
getTokens();
}
// Verify only needs to run once at mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <></>;
};

View File

@ -0,0 +1,46 @@
import { getOperationName } from '@apollo/client/utilities';
import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test';
import { graphql } from 'msw';
import { AppPath } from '@/types/AppPath';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { mockedOnboardingUsersData } from '~/testing/mock-data/users';
import { GET_CURRENT_USER } from '../../../modules/users/graphql/queries/getCurrentUser';
import { CreateProfile } from '../CreateProfile';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Auth/CreateProfile',
component: CreateProfile,
decorators: [PageDecorator],
args: { routePath: AppPath.CreateProfile },
parameters: {
msw: [
graphql.query(
getOperationName(GET_CURRENT_USER) ?? '',
(req, res, ctx) => {
return res(
ctx.data({
currentUser: mockedOnboardingUsersData[1],
}),
);
},
),
],
},
};
export default meta;
export type Story = StoryObj<typeof CreateProfile>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('Create profile');
},
};

View File

@ -0,0 +1,46 @@
import { getOperationName } from '@apollo/client/utilities';
import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test';
import { graphql } from 'msw';
import { AppPath } from '@/types/AppPath';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { mockedOnboardingUsersData } from '~/testing/mock-data/users';
import { GET_CURRENT_USER } from '../../../modules/users/graphql/queries/getCurrentUser';
import { CreateWorkspace } from '../CreateWorkspace';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Auth/CreateWorkspace',
component: CreateWorkspace,
decorators: [PageDecorator],
args: { routePath: AppPath.CreateWorkspace },
parameters: {
msw: [
graphql.query(
getOperationName(GET_CURRENT_USER) ?? '',
(req, res, ctx) => {
return res(
ctx.data({
currentUser: mockedOnboardingUsersData[0],
}),
);
},
),
],
},
};
export default meta;
export type Story = StoryObj<typeof CreateWorkspace>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('Create your workspace');
},
};

View File

@ -0,0 +1,39 @@
import { Meta, StoryObj } from '@storybook/react';
import { fireEvent, within } from '@storybook/test';
import { AppPath } from '@/types/AppPath';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { SignInUp } from '../SignInUp';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Auth/SignInUp',
component: SignInUp,
decorators: [PageDecorator],
args: { routePath: AppPath.SignIn },
parameters: {
msw: graphqlMocks,
cookie: {
tokenPair: '{}',
},
},
};
export default meta;
export type Story = StoryObj<typeof SignInUp>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const continueWithEmailButton = await canvas.findByText(
'Continue With Email',
);
await fireEvent.click(continueWithEmailButton);
},
};

View File

@ -0,0 +1,60 @@
import { useCallback, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { useIsLogged } from '@/auth/hooks/useIsLogged';
import { currentUserState } from '@/auth/states/currentUserState';
import { tokenPairState } from '@/auth/states/tokenPairState';
import { useImpersonateMutation } from '~/generated/graphql';
import { AppPath } from '../../modules/types/AppPath';
export const ImpersonateEffect = () => {
const navigate = useNavigate();
const { userId } = useParams();
const [currentUser, setCurrentUser] = useRecoilState(currentUserState);
const setTokenPair = useSetRecoilState(tokenPairState);
const [impersonate] = useImpersonateMutation();
const isLogged = useIsLogged();
const handleImpersonate = useCallback(async () => {
if (!isNonEmptyString(userId)) {
return;
}
const impersonateResult = await impersonate({
variables: { userId },
});
if (impersonateResult.errors) {
throw impersonateResult.errors;
}
if (!impersonateResult.data?.impersonate) {
throw new Error('No impersonate result');
}
setCurrentUser({
...impersonateResult.data.impersonate.user,
// Todo also set WorkspaceMember
});
setTokenPair(impersonateResult.data?.impersonate.tokens);
return impersonateResult.data?.impersonate;
}, [userId, impersonate, setCurrentUser, setTokenPair]);
useEffect(() => {
if (isLogged && currentUser?.canImpersonate && isNonEmptyString(userId)) {
handleImpersonate();
} else {
// User is not allowed to impersonate or not logged in
navigate(AppPath.Index);
}
}, [userId, currentUser, isLogged, handleImpersonate, navigate]);
return <></>;
};

View File

@ -0,0 +1,34 @@
import { Meta, StoryObj } from '@storybook/react';
import { AppPath } from '@/types/AppPath';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { sleep } from '~/testing/sleep';
import { ImpersonateEffect } from '../ImpersonateEffect';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Impersonate/Impersonate',
component: ImpersonateEffect,
decorators: [PageDecorator],
args: {
routePath: AppPath.Impersonate,
routeParams: { ':userId': '1' },
},
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof ImpersonateEffect>;
export const Default: Story = {
play: async () => {
await sleep(100);
},
};

View File

@ -0,0 +1,69 @@
import { useNavigate } from 'react-router-dom';
import styled from '@emotion/styled';
import { SignInBackgroundMockPage } from '@/sign-in-background-mock/components/SignInBackgroundMockPage';
import { MainButton } from '@/ui/input/button/components/MainButton';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
const StyledBackDrop = styled.div`
align-items: center;
backdrop-filter: ${({ theme }) => theme.blur.light};
background: ${({ theme }) => theme.background.transparent.secondary};
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
left: 0;
position: fixed;
top: 0;
width: 100%;
z-index: 10000;
`;
const StyledTextContainer = styled.div`
align-items: center;
display: flex;
flex-direction: column;
justify-content: center;
padding: ${({ theme }) => theme.spacing(15)};
`;
const StyledButtonContainer = styled.div`
width: 200px;
`;
type StyledInfoProps = {
color: 'dark' | 'light';
};
const StyledInfo = styled.div<StyledInfoProps>`
color: ${(props) =>
props.color === 'light'
? props.theme.font.color.extraLight
: props.theme.font.color.primary};
font-size: ${() => (useIsMobile() ? '2.5rem' : '4rem')};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
`;
export const NotFound = () => {
const navigate = useNavigate();
return (
<>
<StyledBackDrop>
<StyledTextContainer>
<StyledInfo color="dark">404</StyledInfo>
<StyledInfo color="light">Page not found</StyledInfo>
</StyledTextContainer>
<StyledButtonContainer>
<MainButton
title="Back to content"
fullWidth
onClick={() => navigate('/')}
/>
</StyledButtonContainer>
</StyledBackDrop>
<SignInBackgroundMockPage />
</>
);
};

View File

@ -0,0 +1,36 @@
import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test';
import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator';
import { PageDecoratorArgs } from '~/testing/decorators/PageDecorator';
import { RelationPickerDecorator } from '~/testing/decorators/RelationPickerDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { NotFound } from '../NotFound';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/NotFound/Default',
component: NotFound,
decorators: [
ComponentWithRouterDecorator,
SnackBarDecorator,
RelationPickerDecorator,
],
args: {
routePath: 'toto-not-found',
},
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof NotFound>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('Page not found');
},
};

View File

@ -0,0 +1,59 @@
import styled from '@emotion/styled';
import { CompanyBoard } from '@/companies/board/components/CompanyBoard';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { PipelineAddButton } from '@/pipeline/components/PipelineAddButton';
import { usePipelineSteps } from '@/pipeline/hooks/usePipelineSteps';
import { PipelineStep } from '@/pipeline/types/PipelineStep';
import { IconTargetArrow } from '@/ui/display/icon';
import { PageBody } from '@/ui/layout/page/PageBody';
import { PageContainer } from '@/ui/layout/page/PageContainer';
import { PageHeader } from '@/ui/layout/page/PageHeader';
const StyledBoardContainer = styled.div`
display: flex;
height: 100%;
width: 100%;
`;
export const Opportunities = () => {
const { handlePipelineStepAdd, handlePipelineStepDelete } =
usePipelineSteps();
const { updateOneRecord: updateOnePipelineStep } =
useUpdateOneRecord<PipelineStep>({
objectNameSingular: 'pipelineStep',
});
const handleEditColumnTitle = ({
columnId,
title,
color,
}: {
columnId: string;
title: string;
color: string;
}) => {
updateOnePipelineStep?.({
idToUpdate: columnId,
input: { name: title, color },
});
};
return (
<PageContainer>
<PageHeader title="Opportunities" Icon={IconTargetArrow}>
<PipelineAddButton />
</PageHeader>
<PageBody>
<StyledBoardContainer>
<CompanyBoard
onColumnAdd={handlePipelineStepAdd}
onColumnDelete={handlePipelineStepDelete}
onEditColumnTitle={handleEditColumnTitle}
/>
</StyledBoardContainer>
</PageBody>
</PageContainer>
);
};

View File

@ -0,0 +1,69 @@
import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/test';
import { AppPath } from '@/types/AppPath';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { Opportunities } from '../Opportunities';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Opportunities/Default',
component: Opportunities,
decorators: [PageDecorator],
args: { routePath: AppPath.OpportunitiesPage },
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof Opportunities>;
export const Default: Story = {};
export const AddCompanyFromHeader: Story = {
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
await step('Click on the add company button', async () => {
const button = await canvas.findByTestId('add-company-progress-button');
await userEvent.click(button);
await canvas.findByRole(
'listitem',
{ name: (_, element) => !!element?.textContent?.includes('Algolia') },
{ timeout: 1000 },
);
});
await step('Change pipeline stage', async () => {
const pipelineStepDropdownHeader = await canvas.findByRole(
'listitem',
{ name: (_, element) => !!element?.textContent?.includes('New') },
{ timeout: 1000 },
);
const pipelineStepDropdownUnfoldButton = within(
pipelineStepDropdownHeader,
).getByRole('button');
await userEvent.click(pipelineStepDropdownUnfoldButton);
const menuItem1 = await canvas.findByRole(
'listitem',
{ name: (_, element) => !!element?.textContent?.includes('Screening') },
{ timeout: 1000 },
);
await userEvent.click(menuItem1);
});
// TODO: mock add company mutation and add step for company creation
},
};

View File

@ -0,0 +1,30 @@
import { FilterDefinitionByEntity } from '@/object-record/object-filter-dropdown/types/FilterDefinitionByEntity';
import { Opportunity } from '@/pipeline/types/Opportunity';
export const opportunityBoardFilterDefinitions: FilterDefinitionByEntity<Opportunity>[] =
[
{
fieldMetadataId: 'amount',
label: 'Amount',
iconName: 'IconCurrencyDollar',
type: 'NUMBER',
},
{
fieldMetadataId: 'closeDate',
label: 'Close date',
iconName: 'IconCalendarEvent',
type: 'DATE_TIME',
},
{
fieldMetadataId: 'companyId',
label: 'Company',
iconName: 'IconBuildingSkyscraper',
type: 'RELATION',
},
{
fieldMetadataId: 'pointOfContactId',
label: 'Point of contact',
iconName: 'IconUser',
type: 'RELATION',
},
];

View File

@ -0,0 +1,19 @@
import { SortDefinition } from '@/object-record/object-sort-dropdown/types/SortDefinition';
export const opportunityBoardSortDefinitions: SortDefinition[] = [
{
fieldMetadataId: 'createdAt',
label: 'Creation',
iconName: 'IconCalendarEvent',
},
{
fieldMetadataId: 'amount',
label: 'Amount',
iconName: 'IconCurrencyDollar',
},
{
fieldMetadataId: 'closeDate',
label: 'Expected close date',
iconName: 'IconCalendarEvent',
},
];

View File

@ -0,0 +1,13 @@
import { CompanyBoardCard } from '@/companies/components/CompanyBoardCard';
import { NewOpportunityButton } from '@/companies/components/NewOpportunityButton';
import { BoardOptions } from '@/object-record/record-board/types/BoardOptions';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
export const opportunitiesBoardOptions: BoardOptions = {
newCardComponent: (
<RecoilScope>
<NewOpportunityButton />
</RecoilScope>
),
CardComponent: CompanyBoardCard,
};

View File

@ -0,0 +1,30 @@
import styled from '@emotion/styled';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { IconSettings } from '@/ui/display/icon';
import { H1Title } from '@/ui/display/typography/components/H1Title';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { ColorSchemePicker } from '@/ui/input/color-scheme/components/ColorSchemePicker';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { useColorScheme } from '@/ui/theme/hooks/useColorScheme';
const StyledH1Title = styled(H1Title)`
margin-bottom: 0;
`;
export const SettingsAppearance = () => {
const { colorScheme, setColorScheme } = useColorScheme();
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<StyledH1Title title="Appearance" />
<Section>
<H2Title title="Theme" />
<ColorSchemePicker value={colorScheme} onChange={setColorScheme} />
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -0,0 +1,42 @@
import styled from '@emotion/styled';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { DeleteAccount } from '@/settings/profile/components/DeleteAccount';
import { EmailField } from '@/settings/profile/components/EmailField';
import { NameFields } from '@/settings/profile/components/NameFields';
import { ProfilePictureUploader } from '@/settings/profile/components/ProfilePictureUploader';
import { IconSettings } from '@/ui/display/icon';
import { H1Title } from '@/ui/display/typography/components/H1Title';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
const StyledH1Title = styled(H1Title)`
margin-bottom: 0;
`;
export const SettingsProfile = () => (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer width={350}>
<StyledH1Title title="Profile" />
<Section>
<H2Title title="Picture" />
<ProfilePictureUploader />
</Section>
<Section>
<H2Title title="Name" description="Your name as it will be displayed" />
<NameFields />
</Section>
<Section>
<H2Title
title="Email"
description="The email associated to your account"
/>
<EmailField />
</Section>
<Section>
<DeleteAccount />
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);

View File

@ -0,0 +1,42 @@
import styled from '@emotion/styled';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { DeleteWorkspace } from '@/settings/profile/components/DeleteWorkspace';
import { NameField } from '@/settings/workspace/components/NameField';
import { ToggleImpersonate } from '@/settings/workspace/components/ToggleImpersonate';
import { WorkspaceLogoUploader } from '@/settings/workspace/components/WorkspaceLogoUploader';
import { IconSettings } from '@/ui/display/icon';
import { H1Title } from '@/ui/display/typography/components/H1Title';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
const StyledH1Title = styled(H1Title)`
margin-bottom: 0;
`;
export const SettingsWorkspace = () => (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer width={350}>
<StyledH1Title title="General" />
<Section>
<H2Title title="Picture" />
<WorkspaceLogoUploader />
</Section>
<Section>
<H2Title title="Name" description="Name of your workspace" />
<NameField />
</Section>
<Section>
<H2Title
title="Support"
addornment={<ToggleImpersonate />}
description="Grant Twenty support temporary access to your workspace so we can troubleshoot problems or recover content on your behalf. You can revoke access at any time."
/>
</Section>
<Section>
<DeleteWorkspace />
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);

View File

@ -0,0 +1,112 @@
import { useState } from 'react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { IconSettings, IconTrash } from '@/ui/display/icon';
import { H1Title } from '@/ui/display/typography/components/H1Title';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { IconButton } from '@/ui/input/button/components/IconButton';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { WorkspaceInviteLink } from '@/workspace/components/WorkspaceInviteLink';
import { WorkspaceMemberCard } from '@/workspace/components/WorkspaceMemberCard';
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
const StyledH1Title = styled(H1Title)`
margin-bottom: 0;
`;
const StyledButtonContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
margin-left: ${({ theme }) => theme.spacing(3)};
`;
export const SettingsWorkspaceMembers = () => {
const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(false);
const [workspaceMemberToDelete, setWorkspaceMemberToDelete] = useState<
string | undefined
>();
const { records: workspaceMembers } = useFindManyRecords<WorkspaceMember>({
objectNameSingular: 'workspaceMember',
});
const { deleteOneRecord: deleteOneWorkspaceMember } =
useDeleteOneRecord<WorkspaceMember>({
objectNameSingular: 'workspaceMember',
});
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const handleRemoveWorkspaceMember = async (workspaceMemberId: string) => {
await deleteOneWorkspaceMember?.(workspaceMemberId);
setIsConfirmationModalOpen(false);
};
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer width={350}>
<StyledH1Title title="Members" />
{currentWorkspace?.inviteHash && (
<Section>
<H2Title
title="Invite"
description="Send an invitation to use Twenty"
/>
<WorkspaceInviteLink
inviteLink={`${window.location.origin}/invite/${currentWorkspace?.inviteHash}`}
/>
</Section>
)}
<Section>
<H2Title
title="Members"
description="Manage the members of your space here"
/>
{workspaceMembers?.map((member) => (
<WorkspaceMemberCard
key={member.id}
workspaceMember={member as WorkspaceMember}
accessory={
currentWorkspace?.id !== member.id && (
<StyledButtonContainer>
<IconButton
onClick={() => {
setIsConfirmationModalOpen(true);
setWorkspaceMemberToDelete(member.id);
}}
variant="tertiary"
size="medium"
Icon={IconTrash}
/>
</StyledButtonContainer>
)
}
/>
))}
</Section>
</SettingsPageContainer>
<ConfirmationModal
isOpen={isConfirmationModalOpen}
setIsOpen={setIsConfirmationModalOpen}
title="Account Deletion"
subtitle={
<>
This action cannot be undone. This will permanently delete this user
and remove them from all their assignements.
</>
}
onConfirmClick={() =>
workspaceMemberToDelete &&
handleRemoveWorkspaceMember(workspaceMemberToDelete)
}
deleteButtonText="Delete account"
/>
</SubMenuTopBarContainer>
);
};

View File

@ -0,0 +1,25 @@
import { Meta, StoryObj } from '@storybook/react';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { SettingsAppearance } from '../SettingsAppearance';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/SettingsAppearance',
component: SettingsAppearance,
decorators: [PageDecorator],
args: { routePath: '/settings/appearance' },
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsAppearance>;
export const Default: Story = {};

View File

@ -0,0 +1,34 @@
import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { SettingsProfile } from '../SettingsProfile';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/SettingsProfile',
component: SettingsProfile,
decorators: [PageDecorator],
args: { routePath: '/settings/profile' },
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsProfile>;
export const Default: Story = {};
export const LogOut: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const logoutButton = await canvas.findByText('Logout');
await logoutButton.click();
},
};

View File

@ -0,0 +1,25 @@
import { Meta, StoryObj } from '@storybook/react';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { SettingsWorkspace } from '../SettingsWorkspace';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/SettingsWorkspace',
component: SettingsWorkspace,
decorators: [PageDecorator],
args: { routePath: '/settings/workspace' },
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsWorkspace>;
export const Default: Story = {};

View File

@ -0,0 +1,35 @@
import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { sleep } from '~/testing/sleep';
import { SettingsWorkspaceMembers } from '../SettingsWorkspaceMembers';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/SettingsWorkspaceMembers',
component: SettingsWorkspaceMembers,
decorators: [PageDecorator],
args: { routePath: '/settings/workspace-members' },
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsWorkspaceMembers>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await sleep(1000);
await canvas.getByRole('button', { name: 'Copy link' });
},
};

View File

@ -0,0 +1,18 @@
import { SettingsAccountsConnectedAccountsSection } from '@/settings/accounts/components/SettingsAccountsConnectedAccountsSection';
import { SettingsAccountsSettingsSection } from '@/settings/accounts/components/SettingsAccountsSettingsSection';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { IconSettings } from '@/ui/display/icon';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
export const SettingsAccounts = () => {
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<Breadcrumb links={[{ children: 'Accounts' }]} />
<SettingsAccountsConnectedAccountsSection />
<SettingsAccountsSettingsSection />
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -0,0 +1,17 @@
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { IconSettings } from '@/ui/display/icon';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
export const SettingsAccountsEmails = () => (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<Breadcrumb
links={[
{ children: 'Accounts', href: '/settings/accounts' },
{ children: 'Emails' },
]}
/>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);

View File

@ -0,0 +1,28 @@
import { Meta, StoryObj } from '@storybook/react';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { SettingsAccounts } from '../SettingsAccounts';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/Accounts/SettingsAccounts',
component: SettingsAccounts,
decorators: [PageDecorator],
args: {
routePath: '/settings/accounts',
},
parameters: {
layout: 'fullscreen',
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsAccounts>;
export const Default: Story = {};

View File

@ -0,0 +1,28 @@
import { Meta, StoryObj } from '@storybook/react';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { SettingsAccountsEmails } from '../SettingsAccountsEmails';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/Accounts/SettingsAccountsEmails',
component: SettingsAccountsEmails,
decorators: [PageDecorator],
args: {
routePath: '/settings/accounts/emails',
},
parameters: {
layout: 'fullscreen',
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsAccountsEmails>;
export const Default: Story = {};

View File

@ -0,0 +1,162 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsObjectFormSection } from '@/settings/data-model/components/SettingsObjectFormSection';
import { SettingsAvailableStandardObjectsSection } from '@/settings/data-model/new-object/components/SettingsAvailableStandardObjectsSection';
import {
NewObjectType,
SettingsNewObjectType,
} from '@/settings/data-model/new-object/components/SettingsNewObjectType';
import { SettingsObjectIconSection } from '@/settings/data-model/object-edit/SettingsObjectIconSection';
import { IconSettings } from '@/ui/display/icon';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
export const SettingsNewObject = () => {
const navigate = useNavigate();
const [selectedObjectType, setSelectedObjectType] =
useState<NewObjectType>('Standard');
const { enqueueSnackBar } = useSnackBar();
const {
activateObjectMetadataItem: activateObject,
createObjectMetadataItem: createObject,
disabledObjectMetadataItems: disabledObjects,
} = useObjectMetadataItemForSettings();
const [
selectedStandardObjectMetadataIds,
setSelectedStandardObjectMetadataIds,
] = useState<Record<string, boolean>>({});
const [customFormValues, setCustomFormValues] = useState<{
description?: string;
icon: string;
labelPlural: string;
labelSingular: string;
}>({ icon: 'IconPigMoney', labelPlural: '', labelSingular: '' });
const canSave =
(selectedObjectType === 'Standard' &&
Object.values(selectedStandardObjectMetadataIds).some(
(isSelected) => isSelected,
)) ||
(selectedObjectType === 'Custom' &&
!!customFormValues.labelPlural &&
!!customFormValues.labelSingular);
const handleSave = async () => {
if (selectedObjectType === 'Standard') {
await Promise.all(
Object.entries(selectedStandardObjectMetadataIds).map(
([standardObjectMetadataId, isSelected]) =>
isSelected
? activateObject({ id: standardObjectMetadataId })
: undefined,
),
);
navigate('/settings/objects');
}
if (selectedObjectType === 'Custom') {
try {
const createdObject = await createObject({
labelPlural: customFormValues.labelPlural,
labelSingular: customFormValues.labelSingular,
description: customFormValues.description,
icon: customFormValues.icon,
});
navigate(
createdObject.data?.createOneObject.isActive
? `/settings/objects/${getObjectSlug(
createdObject.data.createOneObject,
)}`
: '/settings/objects',
);
} catch (error) {
enqueueSnackBar((error as Error).message, {
variant: 'error',
});
}
}
};
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'Objects', href: '/settings/objects' },
{ children: 'New' },
]}
/>
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => {
navigate('/settings/objects');
}}
onSave={handleSave}
/>
</SettingsHeaderContainer>
<Section>
<H2Title
title="Object type"
description="The type of object you want to add"
/>
<SettingsNewObjectType
selectedType={selectedObjectType}
onTypeSelect={setSelectedObjectType}
/>
</Section>
{selectedObjectType === 'Standard' && (
<SettingsAvailableStandardObjectsSection
objectItems={disabledObjects.filter(({ isCustom }) => !isCustom)}
onChange={(selectedIds) =>
setSelectedStandardObjectMetadataIds((previousSelectedIds) => ({
...previousSelectedIds,
...selectedIds,
}))
}
selectedIds={selectedStandardObjectMetadataIds}
/>
)}
{selectedObjectType === 'Custom' && (
<>
<SettingsObjectIconSection
label={customFormValues.labelPlural}
iconKey={customFormValues.icon}
onChange={({ iconKey }) => {
setCustomFormValues((previousValues) => ({
...previousValues,
icon: iconKey,
}));
}}
/>
<SettingsObjectFormSection
singularName={customFormValues.labelSingular}
pluralName={customFormValues.labelPlural}
description={customFormValues.description}
onChange={(formValues) => {
setCustomFormValues((previousValues) => ({
...previousValues,
...formValues,
}));
}}
/>
</>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -0,0 +1,156 @@
import { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import styled from '@emotion/styled';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { getFieldSlug } from '@/object-metadata/utils/getFieldSlug';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsAboutSection } from '@/settings/data-model/object-details/components/SettingsObjectAboutSection';
import { SettingsObjectFieldActiveActionDropdown } from '@/settings/data-model/object-details/components/SettingsObjectFieldActiveActionDropdown';
import { SettingsObjectFieldDisabledActionDropdown } from '@/settings/data-model/object-details/components/SettingsObjectFieldDisabledActionDropdown';
import {
SettingsObjectFieldItemTableRow,
StyledObjectFieldTableRow,
} from '@/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow';
import { AppPath } from '@/types/AppPath';
import { IconPlus, IconSettings } from '@/ui/display/icon';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { Button } from '@/ui/input/button/components/Button';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableSection } from '@/ui/layout/table/components/TableSection';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
const StyledDiv = styled.div`
display: flex;
justify-content: flex-end;
padding-top: ${({ theme }) => theme.spacing(2)};
`;
export const SettingsObjectDetail = () => {
const navigate = useNavigate();
const { objectSlug = '' } = useParams();
const { disableObjectMetadataItem, findActiveObjectMetadataItemBySlug } =
useObjectMetadataItemForSettings();
const activeObjectMetadataItem =
findActiveObjectMetadataItemBySlug(objectSlug);
useEffect(() => {
if (!activeObjectMetadataItem) navigate(AppPath.NotFound);
}, [activeObjectMetadataItem, navigate]);
const { activateMetadataField, disableMetadataField, eraseMetadataField } =
useFieldMetadataItem();
if (!activeObjectMetadataItem) return null;
const activeMetadataFields = activeObjectMetadataItem.fields.filter(
(metadataField) => metadataField.isActive && !metadataField.isSystem,
);
const disabledMetadataFields = activeObjectMetadataItem.fields.filter(
(metadataField) => !metadataField.isActive && !metadataField.isSystem,
);
const handleDisable = async () => {
await disableObjectMetadataItem(activeObjectMetadataItem);
navigate('/settings/objects');
};
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<Breadcrumb
links={[
{ children: 'Objects', href: '/settings/objects' },
{ children: activeObjectMetadataItem.labelPlural },
]}
/>
<SettingsAboutSection
iconKey={activeObjectMetadataItem.icon ?? undefined}
name={activeObjectMetadataItem.labelPlural || ''}
isCustom={activeObjectMetadataItem.isCustom}
onDisable={handleDisable}
onEdit={() => navigate('./edit')}
/>
<Section>
<H2Title
title="Fields"
description={`Customise the fields available in the ${activeObjectMetadataItem.labelSingular} views and their display order in the ${activeObjectMetadataItem.labelSingular} detail view and menus.`}
/>
<Table>
<StyledObjectFieldTableRow>
<TableHeader>Name</TableHeader>
<TableHeader>Field type</TableHeader>
<TableHeader>Data type</TableHeader>
<TableHeader></TableHeader>
</StyledObjectFieldTableRow>
{!!activeMetadataFields.length && (
<TableSection title="Active">
{activeMetadataFields.map((activeMetadataField) => (
<SettingsObjectFieldItemTableRow
key={activeMetadataField.id}
fieldMetadataItem={activeMetadataField}
ActionIcon={
<SettingsObjectFieldActiveActionDropdown
isCustomField={!!activeMetadataField.isCustom}
scopeKey={activeMetadataField.id}
onEdit={() =>
navigate(`./${getFieldSlug(activeMetadataField)}`)
}
onDisable={() =>
disableMetadataField(activeMetadataField)
}
/>
}
/>
))}
</TableSection>
)}
{!!disabledMetadataFields.length && (
<TableSection isInitiallyExpanded={false} title="Disabled">
{disabledMetadataFields.map((disabledMetadataField) => (
<SettingsObjectFieldItemTableRow
key={disabledMetadataField.id}
fieldMetadataItem={disabledMetadataField}
ActionIcon={
<SettingsObjectFieldDisabledActionDropdown
isCustomField={!!disabledMetadataField.isCustom}
scopeKey={disabledMetadataField.id}
onActivate={() =>
activateMetadataField(disabledMetadataField)
}
onErase={() =>
eraseMetadataField(disabledMetadataField)
}
/>
}
/>
))}
</TableSection>
)}
</Table>
<StyledDiv>
<Button
Icon={IconPlus}
title="Add Field"
size="small"
variant="secondary"
onClick={() =>
navigate(
disabledMetadataFields.length
? './new-field/step-1'
: './new-field/step-2',
)
}
/>
</StyledDiv>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -0,0 +1,151 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsObjectFormSection } from '@/settings/data-model/components/SettingsObjectFormSection';
import { SettingsObjectIconSection } from '@/settings/data-model/object-edit/SettingsObjectIconSection';
import { AppPath } from '@/types/AppPath';
import { IconArchive, IconSettings } from '@/ui/display/icon';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Button } from '@/ui/input/button/components/Button';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
export const SettingsObjectEdit = () => {
const navigate = useNavigate();
const { enqueueSnackBar } = useSnackBar();
const { objectSlug = '' } = useParams();
const {
disableObjectMetadataItem,
editObjectMetadataItem,
findActiveObjectMetadataItemBySlug,
} = useObjectMetadataItemForSettings();
const activeObjectMetadataItem =
findActiveObjectMetadataItemBySlug(objectSlug);
const [formValues, setFormValues] = useState<
Partial<{
icon: string;
labelSingular: string;
labelPlural: string;
description: string;
}>
>({});
useEffect(() => {
if (!activeObjectMetadataItem) {
navigate(AppPath.NotFound);
return;
}
if (!Object.keys(formValues).length) {
setFormValues({
icon: activeObjectMetadataItem.icon ?? undefined,
labelSingular: activeObjectMetadataItem.labelSingular,
labelPlural: activeObjectMetadataItem.labelPlural,
description: activeObjectMetadataItem.description ?? undefined,
});
}
}, [activeObjectMetadataItem, formValues, navigate]);
if (!activeObjectMetadataItem) return null;
const areRequiredFieldsFilled =
!!formValues.labelSingular && !!formValues.labelPlural;
const hasChanges =
formValues.description !== activeObjectMetadataItem.description ||
formValues.icon !== activeObjectMetadataItem.icon ||
formValues.labelPlural !== activeObjectMetadataItem.labelPlural ||
formValues.labelSingular !== activeObjectMetadataItem.labelSingular;
const canSave = areRequiredFieldsFilled && hasChanges;
const handleSave = async () => {
const editedObjectMetadataItem = {
...activeObjectMetadataItem,
...formValues,
};
try {
await editObjectMetadataItem(editedObjectMetadataItem);
navigate(`/settings/objects/${getObjectSlug(editedObjectMetadataItem)}`);
} catch (error) {
enqueueSnackBar((error as Error).message, {
variant: 'error',
});
}
};
const handleDisable = async () => {
await disableObjectMetadataItem(activeObjectMetadataItem);
navigate('/settings/objects');
};
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'Objects', href: '/settings/objects' },
{
children: activeObjectMetadataItem.labelPlural,
href: `/settings/objects/${objectSlug}`,
},
{ children: 'Edit' },
]}
/>
{activeObjectMetadataItem.isCustom && (
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => navigate(`/settings/objects/${objectSlug}`)}
onSave={handleSave}
/>
)}
</SettingsHeaderContainer>
<SettingsObjectIconSection
disabled={!activeObjectMetadataItem.isCustom}
iconKey={formValues.icon}
label={formValues.labelPlural}
onChange={({ iconKey }) =>
setFormValues((previousFormValues) => ({
...previousFormValues,
icon: iconKey,
}))
}
/>
<SettingsObjectFormSection
disabled={!activeObjectMetadataItem.isCustom}
singularName={formValues.labelSingular}
pluralName={formValues.labelPlural}
description={formValues.description}
onChange={(values) =>
setFormValues((previousFormValues) => ({
...previousFormValues,
...values,
}))
}
/>
<Section>
<H2Title title="Danger zone" description="Disable object" />
<Button
Icon={IconArchive}
title="Disable"
size="small"
onClick={handleDisable}
/>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -0,0 +1,207 @@
import { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { useRelationMetadata } from '@/object-metadata/hooks/useRelationMetadata';
import { getFieldSlug } from '@/object-metadata/utils/getFieldSlug';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsObjectFieldFormSection } from '@/settings/data-model/components/SettingsObjectFieldFormSection';
import { SettingsObjectFieldTypeSelectSection } from '@/settings/data-model/components/SettingsObjectFieldTypeSelectSection';
import { useFieldMetadataForm } from '@/settings/data-model/hooks/useFieldMetadataForm';
import { AppPath } from '@/types/AppPath';
import { IconArchive, IconSettings } from '@/ui/display/icon';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Button } from '@/ui/input/button/components/Button';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import {
FieldMetadataType,
RelationMetadataType,
} from '~/generated-metadata/graphql';
export const SettingsObjectFieldEdit = () => {
const navigate = useNavigate();
const { enqueueSnackBar } = useSnackBar();
const { objectSlug = '', fieldSlug = '' } = useParams();
const { findActiveObjectMetadataItemBySlug } =
useObjectMetadataItemForSettings();
const activeObjectMetadataItem =
findActiveObjectMetadataItemBySlug(objectSlug);
const { disableMetadataField, editMetadataField } = useFieldMetadataItem();
const activeMetadataField = activeObjectMetadataItem?.fields.find(
(metadataField) =>
metadataField.isActive && getFieldSlug(metadataField) === fieldSlug,
);
const {
relationFieldMetadataItem,
relationObjectMetadataItem,
relationType,
} = useRelationMetadata({ fieldMetadataItem: activeMetadataField });
const {
formValues,
handleFormChange,
hasFieldFormChanged,
hasFormChanged,
hasRelationFormChanged,
hasSelectFormChanged,
initForm,
isInitialized,
isValid,
validatedFormValues,
} = useFieldMetadataForm();
useEffect(() => {
if (!activeObjectMetadataItem || !activeMetadataField) {
navigate(AppPath.NotFound);
return;
}
const selectOptions = activeMetadataField.options?.map((option) => ({
...option,
isDefault: activeMetadataField.defaultValue === option.value,
}));
selectOptions?.sort(
(optionA, optionB) => optionA.position - optionB.position,
);
initForm({
icon: activeMetadataField.icon ?? undefined,
label: activeMetadataField.label,
description: activeMetadataField.description ?? undefined,
type: activeMetadataField.type,
relation: {
field: {
icon: relationFieldMetadataItem?.icon,
label: relationFieldMetadataItem?.label || '',
},
objectMetadataId: relationObjectMetadataItem?.id || '',
type: relationType || RelationMetadataType.OneToMany,
},
...(selectOptions?.length ? { select: selectOptions } : {}),
});
}, [
activeMetadataField,
activeObjectMetadataItem,
initForm,
navigate,
relationFieldMetadataItem?.icon,
relationFieldMetadataItem?.label,
relationObjectMetadataItem?.id,
relationType,
]);
if (!isInitialized || !activeObjectMetadataItem || !activeMetadataField)
return null;
const canSave = isValid && hasFormChanged;
const handleSave = async () => {
if (!validatedFormValues) return;
try {
if (
validatedFormValues.type === FieldMetadataType.Relation &&
relationFieldMetadataItem?.id &&
hasRelationFormChanged
) {
await editMetadataField({
icon: validatedFormValues.relation.field.icon,
id: relationFieldMetadataItem.id,
label: validatedFormValues.relation.field.label,
});
}
if (hasFieldFormChanged || hasSelectFormChanged) {
await editMetadataField({
description: validatedFormValues.description,
icon: validatedFormValues.icon,
id: activeMetadataField.id,
label: validatedFormValues.label,
options:
validatedFormValues.type === FieldMetadataType.Select
? validatedFormValues.select
: undefined,
});
}
navigate(`/settings/objects/${objectSlug}`);
} catch (error) {
enqueueSnackBar((error as Error).message, {
variant: 'error',
});
}
};
const handleDisable = async () => {
await disableMetadataField(activeMetadataField);
navigate(`/settings/objects/${objectSlug}`);
};
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'Objects', href: '/settings/objects' },
{
children: activeObjectMetadataItem.labelPlural,
href: `/settings/objects/${objectSlug}`,
},
{ children: activeMetadataField.label },
]}
/>
{activeMetadataField.isCustom && (
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => navigate(`/settings/objects/${objectSlug}`)}
onSave={handleSave}
/>
)}
</SettingsHeaderContainer>
<SettingsObjectFieldFormSection
disabled={!activeMetadataField.isCustom}
disableNameEdition
name={formValues.label}
description={formValues.description}
iconKey={formValues.icon}
onChange={handleFormChange}
/>
<SettingsObjectFieldTypeSelectSection
fieldMetadata={{
icon: formValues.icon,
label: formValues.label || 'Employees',
id: activeMetadataField.id,
}}
objectMetadataId={activeObjectMetadataItem.id}
onChange={handleFormChange}
relationFieldMetadata={relationFieldMetadataItem}
values={{
type: formValues.type,
relation: formValues.relation,
select: formValues.select,
}}
/>
<Section>
<H2Title title="Danger zone" description="Disable this field" />
<Button
Icon={IconArchive}
title="Disable"
size="small"
onClick={handleDisable}
/>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -0,0 +1,182 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import styled from '@emotion/styled';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import {
SettingsObjectFieldItemTableRow,
StyledObjectFieldTableRow,
} from '@/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow';
import { AppPath } from '@/types/AppPath';
import { IconMinus, IconPlus, IconSettings } from '@/ui/display/icon';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { Button } from '@/ui/input/button/components/Button';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableSection } from '@/ui/layout/table/components/TableSection';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
const StyledSection = styled(Section)`
display: flex;
flex-direction: column;
`;
const StyledAddCustomFieldButton = styled(Button)`
align-self: flex-end;
margin-top: ${({ theme }) => theme.spacing(2)};
`;
export const SettingsObjectNewFieldStep1 = () => {
const navigate = useNavigate();
const { objectSlug = '' } = useParams();
const { findActiveObjectMetadataItemBySlug } =
useObjectMetadataItemForSettings();
const activeObjectMetadataItem =
findActiveObjectMetadataItemBySlug(objectSlug);
const { activateMetadataField, disableMetadataField } =
useFieldMetadataItem();
const [metadataFields, setMetadataFields] = useState(
activeObjectMetadataItem?.fields ?? [],
);
const activeMetadataFields = metadataFields.filter((field) => field.isActive);
const disabledMetadataFields = metadataFields.filter(
(field) => !field.isActive,
);
const canSave = metadataFields.some(
(field, index) =>
field.isActive !== activeObjectMetadataItem?.fields[index].isActive,
);
useEffect(() => {
if (!activeObjectMetadataItem) {
navigate(AppPath.NotFound);
return;
}
if (!metadataFields.length)
setMetadataFields(activeObjectMetadataItem.fields);
}, [activeObjectMetadataItem, metadataFields.length, navigate]);
if (!activeObjectMetadataItem) return null;
const handleToggleField = (fieldMetadataId: string) =>
setMetadataFields((previousFields) =>
previousFields.map((field) =>
field.id === fieldMetadataId
? { ...field, isActive: !field.isActive }
: field,
),
);
const handleSave = async () => {
await Promise.all(
metadataFields.map((metadataField, index) => {
if (
metadataField.isActive ===
activeObjectMetadataItem.fields[index].isActive
) {
return;
}
return metadataField.isActive
? activateMetadataField(metadataField)
: disableMetadataField(metadataField);
}),
);
navigate(`/settings/objects/${objectSlug}`);
};
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'Objects', href: '/settings/objects' },
{
children: activeObjectMetadataItem.labelPlural,
href: `/settings/objects/${objectSlug}`,
},
{ children: 'New Field' },
]}
/>
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => navigate(`/settings/objects/${objectSlug}`)}
onSave={handleSave}
/>
</SettingsHeaderContainer>
<StyledSection>
<H2Title
title="Check disabled fields"
description="Before creating a custom field, check if it already exists in the disabled section."
/>
<Table>
<StyledObjectFieldTableRow>
<TableHeader>Name</TableHeader>
<TableHeader>Field type</TableHeader>
<TableHeader>Data type</TableHeader>
<TableHeader></TableHeader>
</StyledObjectFieldTableRow>
{!!activeMetadataFields.length && (
<TableSection isInitiallyExpanded={false} title="Active">
{activeMetadataFields.map((field) => (
<SettingsObjectFieldItemTableRow
key={field.id}
fieldMetadataItem={field}
ActionIcon={
<LightIconButton
Icon={IconMinus}
accent="tertiary"
onClick={() => handleToggleField(field.id)}
/>
}
/>
))}
</TableSection>
)}
{!!disabledMetadataFields.length && (
<TableSection title="Disabled">
{disabledMetadataFields.map((field) => (
<SettingsObjectFieldItemTableRow
key={field.name}
fieldMetadataItem={field}
ActionIcon={
<LightIconButton
Icon={IconPlus}
accent="tertiary"
onClick={() => handleToggleField(field.id)}
/>
}
/>
))}
</TableSection>
)}
</Table>
<StyledAddCustomFieldButton
Icon={IconPlus}
title="Add Custom Field"
size="small"
variant="secondary"
onClick={() =>
navigate(`/settings/objects/${objectSlug}/new-field/step-2`)
}
/>
</StyledSection>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -0,0 +1,304 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useCreateOneRelationMetadataItem } from '@/object-metadata/hooks/useCreateOneRelationMetadataItem';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { PaginatedRecordTypeResults } from '@/object-record/types/PaginatedRecordTypeResults';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsObjectFieldFormSection } from '@/settings/data-model/components/SettingsObjectFieldFormSection';
import { SettingsObjectFieldTypeSelectSection } from '@/settings/data-model/components/SettingsObjectFieldTypeSelectSection';
import { useFieldMetadataForm } from '@/settings/data-model/hooks/useFieldMetadataForm';
import { AppPath } from '@/types/AppPath';
import { IconSettings } from '@/ui/display/icon';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { View } from '@/views/types/View';
import { ViewType } from '@/views/types/ViewType';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const SettingsObjectNewFieldStep2 = () => {
const navigate = useNavigate();
const { objectSlug = '' } = useParams();
const { enqueueSnackBar } = useSnackBar();
const {
findActiveObjectMetadataItemBySlug,
findObjectMetadataItemById,
findObjectMetadataItemByNamePlural,
} = useObjectMetadataItemForSettings();
const activeObjectMetadataItem =
findActiveObjectMetadataItemBySlug(objectSlug);
const { createMetadataField } = useFieldMetadataItem();
const isRelationFieldTypeEnabled = useIsFeatureEnabled(
'IS_RELATION_FIELD_TYPE_ENABLED',
);
const {
formValues,
handleFormChange,
initForm,
isValid: canSave,
validatedFormValues,
} = useFieldMetadataForm();
useEffect(() => {
if (!activeObjectMetadataItem) {
navigate(AppPath.NotFound);
return;
}
initForm({
relation: {
field: { icon: activeObjectMetadataItem.icon },
objectMetadataId:
findObjectMetadataItemByNamePlural('people')?.id || '',
},
});
}, [
activeObjectMetadataItem,
findObjectMetadataItemByNamePlural,
initForm,
navigate,
]);
const [objectViews, setObjectViews] = useState<View[]>([]);
const [relationObjectViews, setRelationObjectViews] = useState<View[]>([]);
const { modifyRecordFromCache: modifyViewFromCache } = useObjectMetadataItem({
objectNameSingular: 'view',
});
useFindManyRecords({
objectNameSingular: 'view',
filter: {
type: { eq: ViewType.Table },
objectMetadataId: { eq: activeObjectMetadataItem?.id },
},
onCompleted: async (data: PaginatedRecordTypeResults<View>) => {
const views = data.edges;
if (!views) return;
setObjectViews(data.edges.map(({ node }) => node));
},
});
useFindManyRecords({
objectNameSingular: 'view',
skip: !formValues.relation?.objectMetadataId,
filter: {
type: { eq: ViewType.Table },
objectMetadataId: { eq: formValues.relation?.objectMetadataId },
},
onCompleted: async (data: PaginatedRecordTypeResults<View>) => {
const views = data.edges;
if (!views) return;
setRelationObjectViews(data.edges.map(({ node }) => node));
},
});
const { createOneRelationMetadataItem: createOneRelationMetadata } =
useCreateOneRelationMetadataItem();
if (!activeObjectMetadataItem) return null;
const handleSave = async () => {
if (!validatedFormValues) return;
try {
if (validatedFormValues.type === FieldMetadataType.Relation) {
const createdRelation = await createOneRelationMetadata({
relationType: validatedFormValues.relation.type,
field: {
description: validatedFormValues.description,
icon: validatedFormValues.icon,
label: validatedFormValues.label,
},
objectMetadataId: activeObjectMetadataItem.id,
connect: {
field: {
icon: validatedFormValues.relation.field.icon,
label: validatedFormValues.relation.field.label,
},
objectMetadataId: validatedFormValues.relation.objectMetadataId,
},
});
const relationObjectMetadataItem = findObjectMetadataItemById(
validatedFormValues.relation.objectMetadataId,
);
objectViews.forEach(async (view) => {
const viewFieldToCreate = {
viewId: view.id,
fieldMetadataId:
validatedFormValues.relation.type === 'MANY_TO_ONE'
? createdRelation.data?.createOneRelation.toFieldMetadataId
: createdRelation.data?.createOneRelation.fromFieldMetadataId,
position: activeObjectMetadataItem.fields.length,
isVisible: true,
size: 100,
};
modifyViewFromCache(view.id, {
// Todo fix typing
viewFields: (viewFields: any) => {
return {
edges: viewFields.edges.concat({ node: viewFieldToCreate }),
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: '',
endCursor: '',
},
};
},
});
});
relationObjectViews.forEach(async (view) => {
const viewFieldToCreate = {
viewId: view.id,
fieldMetadataId:
validatedFormValues.relation.type === 'MANY_TO_ONE'
? createdRelation.data?.createOneRelation.fromFieldMetadataId
: createdRelation.data?.createOneRelation.toFieldMetadataId,
position: relationObjectMetadataItem?.fields.length,
isVisible: true,
size: 100,
};
modifyViewFromCache(view.id, {
// Todo fix typing
viewFields: (viewFields: any) => {
return {
edges: viewFields.edges.concat({ node: viewFieldToCreate }),
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: '',
endCursor: '',
},
};
},
});
});
} else {
const createdMetadataField = await createMetadataField({
description: validatedFormValues.description,
icon: validatedFormValues.icon,
label: validatedFormValues.label ?? '',
objectMetadataId: activeObjectMetadataItem.id,
type: validatedFormValues.type,
options:
validatedFormValues.type === FieldMetadataType.Select
? validatedFormValues.select
: undefined,
});
objectViews.forEach(async (view) => {
const viewFieldToCreate = {
viewId: view.id,
fieldMetadataId: createdMetadataField.data?.createOneField.id,
position: activeObjectMetadataItem.fields.length,
isVisible: true,
size: 100,
};
modifyViewFromCache(view.id, {
// Todo fix typing
viewFields: (viewFields: any) => {
return {
edges: viewFields.edges.concat({ node: viewFieldToCreate }),
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: '',
endCursor: '',
},
};
},
});
});
}
navigate(`/settings/objects/${objectSlug}`);
} catch (error) {
enqueueSnackBar((error as Error).message, {
variant: 'error',
});
}
};
const excludedFieldTypes = [
FieldMetadataType.Currency,
FieldMetadataType.Email,
FieldMetadataType.FullName,
FieldMetadataType.Link,
FieldMetadataType.MultiSelect,
FieldMetadataType.Numeric,
FieldMetadataType.Phone,
FieldMetadataType.Probability,
FieldMetadataType.Rating,
FieldMetadataType.Select,
FieldMetadataType.Uuid,
];
if (!isRelationFieldTypeEnabled) {
excludedFieldTypes.push(FieldMetadataType.Relation);
}
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'Objects', href: '/settings/objects' },
{
children: activeObjectMetadataItem.labelPlural,
href: `/settings/objects/${objectSlug}`,
},
{ children: 'New Field' },
]}
/>
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => navigate(`/settings/objects/${objectSlug}`)}
onSave={handleSave}
/>
</SettingsHeaderContainer>
<SettingsObjectFieldFormSection
iconKey={formValues.icon}
name={formValues.label}
description={formValues.description}
onChange={handleFormChange}
/>
<SettingsObjectFieldTypeSelectSection
excludedFieldTypes={excludedFieldTypes}
fieldMetadata={{
icon: formValues.icon,
label: formValues.label || 'Employees',
}}
objectMetadataId={activeObjectMetadataItem.id}
onChange={handleFormChange}
values={{
type: formValues.type,
relation: formValues.relation,
select: formValues.select,
}}
/>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -0,0 +1,126 @@
import { useNavigate } from 'react-router-dom';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import {
SettingsObjectItemTableRow,
StyledObjectTableRow,
} from '@/settings/data-model/object-details/components/SettingsObjectItemTableRow';
import { SettingsObjectCoverImage } from '@/settings/data-model/objects/SettingsObjectCoverImage';
import { SettingsObjectDisabledMenuDropDown } from '@/settings/data-model/objects/SettingsObjectDisabledMenuDropDown';
import { IconChevronRight, IconPlus, IconSettings } from '@/ui/display/icon';
import { H1Title } from '@/ui/display/typography/components/H1Title';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { Button } from '@/ui/input/button/components/Button';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableSection } from '@/ui/layout/table/components/TableSection';
const StyledIconChevronRight = styled(IconChevronRight)`
color: ${({ theme }) => theme.font.color.tertiary};
`;
const StyledH1Title = styled(H1Title)`
margin-bottom: 0;
`;
export const SettingsObjects = () => {
const theme = useTheme();
const navigate = useNavigate();
const {
activateObjectMetadataItem,
activeObjectMetadataItems,
disabledObjectMetadataItems,
eraseObjectMetadataItem,
} = useObjectMetadataItemForSettings();
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<SettingsHeaderContainer>
<StyledH1Title title="Objects" />
<Button
Icon={IconPlus}
title="New object"
accent="blue"
size="small"
onClick={() => navigate('/settings/objects/new')}
/>
</SettingsHeaderContainer>
<div>
<SettingsObjectCoverImage />
<Section>
<H2Title title="Existing objects" />
<Table>
<StyledObjectTableRow>
<TableHeader>Name</TableHeader>
<TableHeader>Type</TableHeader>
<TableHeader align="right">Fields</TableHeader>
<TableHeader align="right">Instances</TableHeader>
<TableHeader></TableHeader>
</StyledObjectTableRow>
{!!activeObjectMetadataItems.length && (
<TableSection title="Active">
{activeObjectMetadataItems.map((activeObjectMetadataItem) => (
<SettingsObjectItemTableRow
key={activeObjectMetadataItem.namePlural}
objectItem={activeObjectMetadataItem}
action={
<StyledIconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
}
onClick={() =>
navigate(
`/settings/objects/${getObjectSlug(
activeObjectMetadataItem,
)}`,
)
}
/>
))}
</TableSection>
)}
{!!disabledObjectMetadataItems.length && (
<TableSection title="Disabled">
{disabledObjectMetadataItems.map(
(disabledObjectMetadataItem) => (
<SettingsObjectItemTableRow
key={disabledObjectMetadataItem.namePlural}
objectItem={disabledObjectMetadataItem}
action={
<SettingsObjectDisabledMenuDropDown
isCustomObject={disabledObjectMetadataItem.isCustom}
scopeKey={disabledObjectMetadataItem.namePlural}
onActivate={() =>
activateObjectMetadataItem(
disabledObjectMetadataItem,
)
}
onErase={() =>
eraseObjectMetadataItem(
disabledObjectMetadataItem,
)
}
/>
}
/>
),
)}
</TableSection>
)}
</Table>
</Section>
</div>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -0,0 +1,45 @@
import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/test';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { sleep } from '~/testing/sleep';
import { SettingsNewObject } from '../SettingsNewObject';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/DataModel/SettingsNewObject',
component: SettingsNewObject,
decorators: [PageDecorator],
args: {
routePath: '/settings/objects/new',
},
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsNewObject>;
export const WithStandardSelected: Story = {
play: async () => {
await sleep(100);
},
};
export const WithCustomSelected: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await sleep(1000);
const customButtonElement = canvas.getByText('Custom');
await userEvent.click(customButtonElement);
},
};

View File

@ -0,0 +1,39 @@
import { Meta, StoryObj } from '@storybook/react';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { sleep } from '~/testing/sleep';
import { SettingsObjectDetail } from '../SettingsObjectDetail';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/DataModel/SettingsObjectDetail',
component: SettingsObjectDetail,
decorators: [PageDecorator],
args: {
routePath: '/settings/objects/:objectSlug',
routeParams: { ':objectSlug': 'companies' },
},
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsObjectDetail>;
export const StandardObject: Story = {
play: async () => {
await sleep(100);
},
};
export const CustomObject: Story = {
args: {
routeParams: { ':objectSlug': 'workspaces' },
},
};

View File

@ -0,0 +1,39 @@
import { Meta, StoryObj } from '@storybook/react';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { sleep } from '~/testing/sleep';
import { SettingsObjectEdit } from '../SettingsObjectEdit';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/DataModel/SettingsObjectEdit',
component: SettingsObjectEdit,
decorators: [PageDecorator],
args: {
routePath: '/settings/objects/:objectSlug/edit',
routeParams: { ':objectSlug': 'companies' },
},
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsObjectEdit>;
export const StandardObject: Story = {
play: async () => {
await sleep(100);
},
};
export const CustomObject: Story = {
args: {
routeParams: { ':objectSlug': 'workspaces' },
},
};

View File

@ -0,0 +1,37 @@
import { Meta, StoryObj } from '@storybook/react';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { SettingsObjectFieldEdit } from '../SettingsObjectFieldEdit';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/DataModel/SettingsObjectFieldEdit',
component: SettingsObjectFieldEdit,
decorators: [PageDecorator],
args: {
routePath: '/settings/objects/:objectSlug/:fieldSlug',
routeParams: { ':objectSlug': 'companies', ':fieldSlug': 'name' },
},
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsObjectFieldEdit>;
export const StandardField: Story = {};
export const CustomField: Story = {
args: {
routeParams: {
':objectSlug': 'companies',
':fieldSlug': 'employees',
},
},
};

View File

@ -0,0 +1,34 @@
import { Meta, StoryObj } from '@storybook/react';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { sleep } from '~/testing/sleep';
import { SettingsObjectNewFieldStep1 } from '../../SettingsObjectNewField/SettingsObjectNewFieldStep1';
const meta: Meta<PageDecoratorArgs> = {
title:
'Pages/Settings/DataModel/SettingsObjectNewField/SettingsObjectNewFieldStep1',
component: SettingsObjectNewFieldStep1,
decorators: [PageDecorator],
args: {
routePath: '/settings/objects/:objectSlug/new-field/step-1',
routeParams: { ':objectSlug': 'companies' },
},
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsObjectNewFieldStep1>;
export const Default: Story = {
play: async () => {
await sleep(100);
},
};

View File

@ -0,0 +1,34 @@
import { Meta, StoryObj } from '@storybook/react';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { sleep } from '~/testing/sleep';
import { SettingsObjectNewFieldStep2 } from '../../SettingsObjectNewField/SettingsObjectNewFieldStep2';
const meta: Meta<PageDecoratorArgs> = {
title:
'Pages/Settings/DataModel/SettingsObjectNewField/SettingsObjectNewFieldStep2',
component: SettingsObjectNewFieldStep2,
decorators: [PageDecorator],
args: {
routePath: '/settings/objects/:objectSlug/new-field/step-2',
routeParams: { ':objectSlug': 'companies' },
},
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsObjectNewFieldStep2>;
export const Default: Story = {
play: async () => {
await sleep(100);
},
};

View File

@ -0,0 +1,38 @@
import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { sleep } from '~/testing/sleep';
import { SettingsObjects } from '../SettingsObjects';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/DataModel/SettingsObjects',
component: SettingsObjects,
decorators: [PageDecorator],
args: { routePath: '/settings/objects' },
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsObjects>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await sleep(1000);
await canvas.getByRole('heading', {
level: 2,
name: 'Objects',
});
},
};

View File

@ -0,0 +1,200 @@
import { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import styled from '@emotion/styled';
import { DateTime } from 'luxon';
import { useRecoilState } from 'recoil';
import { useOptimisticEvict } from '@/apollo/optimistic-effect/hooks/useOptimisticEvict';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { ApiKeyInput } from '@/settings/developers/components/ApiKeyInput';
import { useGeneratedApiKeys } from '@/settings/developers/hooks/useGeneratedApiKeys';
import { generatedApiKeyFamilyState } from '@/settings/developers/states/generatedApiKeyFamilyState';
import { ApiKey } from '@/settings/developers/types/ApiKey';
import { computeNewExpirationDate } from '@/settings/developers/utils/compute-new-expiration-date';
import { formatExpiration } from '@/settings/developers/utils/format-expiration';
import { IconRepeat, IconSettings, IconTrash } from '@/ui/display/icon';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { Button } from '@/ui/input/button/components/Button';
import { TextInput } from '@/ui/input/components/TextInput';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { useGenerateApiKeyTokenMutation } from '~/generated/graphql';
const StyledInfo = styled.span`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular};
`;
const StyledInputContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
export const SettingsDevelopersApiKeyDetail = () => {
const navigate = useNavigate();
const { apiKeyId = '' } = useParams();
const setGeneratedApi = useGeneratedApiKeys();
const [generatedApiKey] = useRecoilState(
generatedApiKeyFamilyState(apiKeyId),
);
const { performOptimisticEvict } = useOptimisticEvict();
const [generateOneApiKeyToken] = useGenerateApiKeyTokenMutation();
const { createOneRecord: createOneApiKey } = useCreateOneRecord<ApiKey>({
objectNameSingular: 'apiKey',
});
const { updateOneRecord: updateApiKey } = useUpdateOneRecord<ApiKey>({
objectNameSingular: 'apiKey',
});
const { record: apiKeyData } = useFindOneRecord({
objectNameSingular: 'apiKey',
objectRecordId: apiKeyId,
});
const deleteIntegration = async (redirect = true) => {
await updateApiKey?.({
idToUpdate: apiKeyId,
input: { revokedAt: DateTime.now().toString() },
});
performOptimisticEvict('ApiKey', 'id', apiKeyId);
if (redirect) {
navigate('/settings/developers/api-keys');
}
};
const createIntegration = async (
name: string,
newExpiresAt: string | null,
) => {
const newApiKey = await createOneApiKey?.({
name: name,
expiresAt: newExpiresAt,
});
if (!newApiKey) {
return;
}
const tokenData = await generateOneApiKeyToken({
variables: {
apiKeyId: newApiKey.id,
expiresAt: newApiKey?.expiresAt,
},
});
return {
id: newApiKey.id,
token: tokenData.data?.generateApiKeyToken.token,
};
};
const regenerateApiKey = async () => {
if (apiKeyData?.name) {
const newExpiresAt = computeNewExpirationDate(
apiKeyData.expiresAt,
apiKeyData.createdAt,
);
const apiKey = await createIntegration(apiKeyData.name, newExpiresAt);
await deleteIntegration(false);
if (apiKey && apiKey.token) {
setGeneratedApi(apiKey.id, apiKey.token);
navigate(`/settings/developers/api-keys/${apiKey.id}`);
}
}
};
useEffect(() => {
if (apiKeyData) {
return () => {
setGeneratedApi(apiKeyId, null);
};
}
});
return (
<>
{apiKeyData?.name && (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'APIs', href: '/settings/developers/api-keys' },
{ children: apiKeyData.name },
]}
/>
</SettingsHeaderContainer>
<Section>
{generatedApiKey ? (
<>
<H2Title
title="Api Key"
description="Copy this key as it will only be visible this one time"
/>
<ApiKeyInput apiKey={generatedApiKey} />
<StyledInfo>
{formatExpiration(apiKeyData?.expiresAt || '', true, false)}
</StyledInfo>
</>
) : (
<>
<H2Title
title="Api Key"
description="Regenerate an Api key"
/>
<StyledInputContainer>
<Button
title="Regenerate Key"
Icon={IconRepeat}
onClick={regenerateApiKey}
/>
<StyledInfo>
{formatExpiration(
apiKeyData?.expiresAt || '',
true,
false,
)}
</StyledInfo>
</StyledInputContainer>
</>
)}
</Section>
<Section>
<H2Title title="Name" description="Name of your API key" />
<TextInput
placeholder="E.g. backoffice integration"
value={apiKeyData.name}
disabled
fullWidth
/>
</Section>
<Section>
<H2Title
title="Danger zone"
description="Delete this integration"
/>
<Button
accent="danger"
variant="secondary"
title="Disable"
Icon={IconTrash}
onClick={() => deleteIntegration()}
/>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
)}
</>
);
};

View File

@ -0,0 +1,103 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import styled from '@emotion/styled';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { objectSettingsWidth } from '@/settings/data-model/constants/objectSettings';
import { SettingsApiKeysFieldItemTableRow } from '@/settings/developers/components/SettingsApiKeysFieldItemTableRow';
import { ApiFieldItem } from '@/settings/developers/types/ApiFieldItem';
import { formatExpirations } from '@/settings/developers/utils/format-expiration';
import { IconPlus, IconSettings } from '@/ui/display/icon';
import { H1Title } from '@/ui/display/typography/components/H1Title';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { Button } from '@/ui/input/button/components/Button';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
const StyledContainer = styled.div`
height: fit-content;
padding: ${({ theme }) => theme.spacing(8)};
width: ${objectSettingsWidth};
`;
const StyledTableRow = styled(TableRow)`
grid-template-columns: 180px 98.7px 98.7px 98.7px 36px;
`;
const StyledHeader = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
margin-bottom: ${({ theme }) => theme.spacing(8)};
`;
const StyledH1Title = styled(H1Title)`
margin-bottom: 0;
`;
export const SettingsDevelopersApiKeys = () => {
const navigate = useNavigate();
const [apiKeys, setApiKeys] = useState<Array<ApiFieldItem>>([]);
const filter = { revokedAt: { is: 'NULL' } };
useFindManyRecords({
objectNameSingular: 'apiKey',
filter,
orderBy: {},
onCompleted: (data) => {
setApiKeys(
formatExpirations(
data.edges.map((apiKey) => ({
id: apiKey.node.id,
name: apiKey.node.name,
expiresAt: apiKey.node.expiresAt,
})),
),
);
},
});
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<StyledContainer>
<StyledHeader>
<StyledH1Title title="APIs" />
<Button
Icon={IconPlus}
title="Create Key"
accent="blue"
size="small"
onClick={() => {
navigate('/settings/developers/api-keys/new');
}}
/>
</StyledHeader>
<H2Title
title="Active Keys"
description="Active APIs keys created by you or your team"
/>
<Table>
<StyledTableRow>
<TableHeader>Name</TableHeader>
<TableHeader>Type</TableHeader>
<TableHeader>Expiration</TableHeader>
<TableHeader></TableHeader>
</StyledTableRow>
{apiKeys.map((fieldItem) => (
<SettingsApiKeysFieldItemTableRow
key={fieldItem.id}
fieldItem={fieldItem}
onClick={() => {
navigate(`/settings/developers/api-keys/${fieldItem.id}`);
}}
/>
))}
</Table>
</StyledContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -0,0 +1,114 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { DateTime } from 'luxon';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { ExpirationDates } from '@/settings/developers/constants/expirationDates';
import { useGeneratedApiKeys } from '@/settings/developers/hooks/useGeneratedApiKeys';
import { ApiKey } from '@/settings/developers/types/ApiKey';
import { IconSettings } from '@/ui/display/icon';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { Select } from '@/ui/input/components/Select';
import { TextInput } from '@/ui/input/components/TextInput';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { useGenerateApiKeyTokenMutation } from '~/generated/graphql';
export const SettingsDevelopersApiKeysNew = () => {
const [generateOneApiKeyToken] = useGenerateApiKeyTokenMutation();
const navigate = useNavigate();
const setGeneratedApi = useGeneratedApiKeys();
const [formValues, setFormValues] = useState<{
name: string;
expirationDate: number | null;
}>({
expirationDate: ExpirationDates[0].value,
name: '',
});
const { createOneRecord: createOneApiKey } = useCreateOneRecord<ApiKey>({
objectNameSingular: 'apiKey',
});
const onSave = async () => {
const expiresAt = DateTime.now()
.plus({ days: formValues.expirationDate ?? 30 })
.toString();
const newApiKey = await createOneApiKey?.({
name: formValues.name,
expiresAt,
});
if (!newApiKey) {
return;
}
const tokenData = await generateOneApiKeyToken({
variables: {
apiKeyId: newApiKey.id,
expiresAt: expiresAt,
},
});
if (tokenData.data?.generateApiKeyToken) {
setGeneratedApi(newApiKey.id, tokenData.data.generateApiKeyToken.token);
navigate(`/settings/developers/api-keys/${newApiKey.id}`);
}
};
const canSave = !!formValues.name && createOneApiKey;
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'APIs', href: '/settings/developers/api-keys' },
{ children: 'New' },
]}
/>
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => {
navigate('/settings/developers/api-keys');
}}
onSave={onSave}
/>
</SettingsHeaderContainer>
<Section>
<H2Title title="Name" description="Name of your API key" />
<TextInput
placeholder="E.g. backoffice integration"
value={formValues.name}
onChange={(value) => {
setFormValues((prevState) => ({
...prevState,
name: value,
}));
}}
fullWidth
/>
</Section>
<Section>
<H2Title
title="Expiration Date"
description="When the API key will expire."
/>
<Select
dropdownScopeId="object-field-type-select"
options={ExpirationDates}
value={formValues.expirationDate}
onChange={(value) => {
setFormValues((prevState) => ({
...prevState,
expirationDate: value,
}));
}}
/>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -0,0 +1,29 @@
import { Meta, StoryObj } from '@storybook/react';
import { SettingsDevelopersApiKeys } from '~/pages/settings/developers/api-keys/SettingsDevelopersApiKeys';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { sleep } from '~/testing/sleep';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/Developers/ApiKeys/SettingsDevelopersApiKeys',
component: SettingsDevelopersApiKeys,
decorators: [PageDecorator],
args: { routePath: '/settings/developers/api-keys' },
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsDevelopersApiKeys>;
export const Default: Story = {
play: async () => {
await sleep(100);
},
};

View File

@ -0,0 +1,30 @@
import { Meta, StoryObj } from '@storybook/react';
import { SettingsDevelopersApiKeyDetail } from '~/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { sleep } from '~/testing/sleep';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/Developers/ApiKeys/SettingsDevelopersApiKeyDetail',
component: SettingsDevelopersApiKeyDetail,
decorators: [PageDecorator],
args: {
routePath: '/settings/apis/f7c6d736-8fcd-4e9c-ab99-28f6a9031570',
},
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsDevelopersApiKeyDetail>;
export const Default: Story = {
play: async () => {
await sleep(100);
},
};

View File

@ -0,0 +1,29 @@
import { Meta, StoryObj } from '@storybook/react';
import { SettingsDevelopersApiKeysNew } from '~/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { sleep } from '~/testing/sleep';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/Developers/ApiKeys/SettingsDevelopersApiKeysNew',
component: SettingsDevelopersApiKeysNew,
decorators: [PageDecorator],
args: { routePath: '/settings/developers/api-keys/new' },
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsDevelopersApiKeysNew>;
export const Default: Story = {
play: async () => {
await sleep(100);
},
};

View File

@ -0,0 +1,80 @@
import styled from '@emotion/styled';
import { TasksRecoilScopeContext } from '@/activities/states/recoil-scope-contexts/TasksRecoilScopeContext';
import { PageAddTaskButton } from '@/activities/tasks/components/PageAddTaskButton';
import { TaskGroups } from '@/activities/tasks/components/TaskGroups';
import { ObjectFilterDropdownButton } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownButton';
import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope';
import { IconArchive, IconCheck, IconCheckbox } from '@/ui/display/icon/index';
import { PageBody } from '@/ui/layout/page/PageBody';
import { PageContainer } from '@/ui/layout/page/PageContainer';
import { PageHeader } from '@/ui/layout/page/PageHeader';
import { TabList } from '@/ui/layout/tab/components/TabList';
import { TopBar } from '@/ui/layout/top-bar/TopBar';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { TasksEffect } from './TasksEffect';
const StyledTasksContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
overflow: auto;
`;
const StyledTabListContainer = styled.div`
align-items: end;
display: flex;
height: 40px;
margin-left: ${({ theme }) => `-${theme.spacing(2)}`};
`;
export const Tasks = () => {
const TASK_TABS = [
{
id: 'to-do',
title: 'To do',
Icon: IconCheck,
},
{
id: 'done',
title: 'Done',
Icon: IconArchive,
},
];
const filterDropdownId = 'tasks-assignee-filter';
return (
<PageContainer>
<RecoilScope CustomRecoilScopeContext={TasksRecoilScopeContext}>
<TasksEffect filterDropdownId={filterDropdownId} />
<PageHeader title="Tasks" Icon={IconCheckbox}>
<PageAddTaskButton filterDropdownId={filterDropdownId} />
</PageHeader>
<PageBody>
<StyledTasksContainer>
<TopBar
leftComponent={
<StyledTabListContainer>
<TabList context={TasksRecoilScopeContext} tabs={TASK_TABS} />
</StyledTabListContainer>
}
rightComponent={
<ObjectFilterDropdownButton
filterDropdownId={filterDropdownId}
key="tasks-filter-dropdown-button"
hotkeyScope={{
scope: RelationPickerHotkeyScope.RelationPicker,
}}
/>
}
/>
<TaskGroups filterDropdownId={filterDropdownId} />
</StyledTasksContainer>
</PageBody>
</RecoilScope>
</PageContainer>
);
};

View File

@ -0,0 +1,41 @@
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { tasksFilterDefinitions } from './tasks-filter-definitions';
type TasksEffectProps = {
filterDropdownId: string;
};
export const TasksEffect = ({ filterDropdownId }: TasksEffectProps) => {
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { setSelectedFilter, setAvailableFilterDefinitions } =
useFilterDropdown({
filterDropdownId: filterDropdownId,
});
useEffect(() => {
setAvailableFilterDefinitions(tasksFilterDefinitions);
}, [setAvailableFilterDefinitions]);
useEffect(() => {
if (currentWorkspaceMember) {
setSelectedFilter({
fieldMetadataId: 'assigneeId',
value: currentWorkspaceMember.id,
operand: ViewFilterOperand.Is,
displayValue:
currentWorkspaceMember.name?.firstName +
' ' +
currentWorkspaceMember.name?.lastName,
displayAvatarUrl: currentWorkspaceMember.avatarUrl ?? undefined,
definition: tasksFilterDefinitions[0],
});
}
}, [currentWorkspaceMember, setSelectedFilter]);
return <></>;
};

View File

@ -0,0 +1,31 @@
import { Meta, StoryObj } from '@storybook/react';
import { AppPath } from '@/types/AppPath';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { sleep } from '~/testing/sleep';
import { Tasks } from '../Tasks';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Tasks/Default',
component: Tasks,
decorators: [PageDecorator],
args: { routePath: AppPath.TasksPage },
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof Tasks>;
export const Default: Story = {
play: async () => {
await sleep(100);
},
};

View File

@ -0,0 +1,16 @@
import { Activity } from '@/activities/types/Activity';
import { FilterDefinitionByEntity } from '@/object-record/object-filter-dropdown/types/FilterDefinitionByEntity';
import { IconUserCircle } from '@/ui/display/icon';
export const tasksFilterDefinitions: FilterDefinitionByEntity<Activity>[] = [
{
fieldMetadataId: 'assigneeId',
label: 'Assignee',
iconName: 'IconUser',
type: 'RELATION',
relationObjectMetadataNamePlural: 'workspaceMembers',
relationObjectMetadataNameSingular: 'workspaceMember',
selectAllLabel: 'All assignees',
SelectAllIcon: IconUserCircle,
},
];