Migrate to a monorepo structure (#2909)
This commit is contained in:
212
packages/twenty-front/src/pages/auth/CreateProfile.tsx
Normal file
212
packages/twenty-front/src/pages/auth/CreateProfile.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
169
packages/twenty-front/src/pages/auth/CreateWorkspace.tsx
Normal file
169
packages/twenty-front/src/pages/auth/CreateWorkspace.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
3
packages/twenty-front/src/pages/auth/SignInUp.tsx
Normal file
3
packages/twenty-front/src/pages/auth/SignInUp.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
import { SignInUpForm } from '../../modules/auth/sign-in-up/components/SignInUpForm';
|
||||
|
||||
export const SignInUp = () => <SignInUpForm />;
|
||||
45
packages/twenty-front/src/pages/auth/VerifyEffect.tsx
Normal file
45
packages/twenty-front/src/pages/auth/VerifyEffect.tsx
Normal 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 <></>;
|
||||
};
|
||||
@ -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');
|
||||
},
|
||||
};
|
||||
@ -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');
|
||||
},
|
||||
};
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
@ -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 <></>;
|
||||
};
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
69
packages/twenty-front/src/pages/not-found/NotFound.tsx
Normal file
69
packages/twenty-front/src/pages/not-found/NotFound.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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');
|
||||
},
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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
|
||||
},
|
||||
};
|
||||
@ -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',
|
||||
},
|
||||
];
|
||||
@ -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',
|
||||
},
|
||||
];
|
||||
@ -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,
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
42
packages/twenty-front/src/pages/settings/SettingsProfile.tsx
Normal file
42
packages/twenty-front/src/pages/settings/SettingsProfile.tsx
Normal 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>
|
||||
);
|
||||
@ -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>
|
||||
);
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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 = {};
|
||||
@ -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();
|
||||
},
|
||||
};
|
||||
@ -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 = {};
|
||||
@ -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' });
|
||||
},
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
@ -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 = {};
|
||||
@ -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 = {};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
@ -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' },
|
||||
},
|
||||
};
|
||||
@ -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' },
|
||||
},
|
||||
};
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
@ -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',
|
||||
});
|
||||
},
|
||||
};
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
80
packages/twenty-front/src/pages/tasks/Tasks.tsx
Normal file
80
packages/twenty-front/src/pages/tasks/Tasks.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
41
packages/twenty-front/src/pages/tasks/TasksEffect.tsx
Normal file
41
packages/twenty-front/src/pages/tasks/TasksEffect.tsx
Normal 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 <></>;
|
||||
};
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user