feat: refactoring casl permission checks for recursive nested operations (#778)

* feat: nested casl abilities

* fix: remove unused packages

* Fixes

* Fix createMany broken

* Fix lint

* Fix lint

* Fix lint

* Fix lint

* Fixes

* Fix CommentThread

* Fix bugs

* Fix lint

* Fix bugs

* Fixed auto routing

* Fixed app path

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Jérémy M
2023-07-26 01:37:22 +02:00
committed by GitHub
parent 92b9e987a5
commit 51cfc0d82c
69 changed files with 1192 additions and 883 deletions

View File

@ -1,6 +1,6 @@
import { useTheme } from '@emotion/react';
import { Button } from '@/ui/button/components/Button';
import { Button, ButtonVariant } from '@/ui/button/components/Button';
import { ButtonGroup } from '@/ui/button/components/ButtonGroup';
import { IconCheckbox, IconNotes, IconTimelineEvent } from '@/ui/icon/index';
@ -17,7 +17,7 @@ export function CommentThreadCreateButton({
}: CommentThreadCreateButtonProps) {
const theme = useTheme();
return (
<ButtonGroup variant="secondary">
<ButtonGroup variant={ButtonVariant.Secondary}>
<Button
icon={<IconNotes size={theme.icon.size.sm} />}
title="Note"

View File

@ -0,0 +1,190 @@
import React, { useCallback, useState } from 'react';
import { getOperationName } from '@apollo/client/utilities';
import styled from '@emotion/styled';
import { CommentThreadBodyEditor } from '@/activities/components/CommentThreadBodyEditor';
import { CommentThreadComments } from '@/activities/components/CommentThreadComments';
import { CommentThreadRelationPicker } from '@/activities/components/CommentThreadRelationPicker';
import { CommentThreadTypeDropdown } from '@/activities/components/CommentThreadTypeDropdown';
import { GET_COMMENT_THREADS_BY_TARGETS } from '@/activities/queries';
import { PropertyBox } from '@/ui/editable-field/property-box/components/PropertyBox';
import { PropertyBoxItem } from '@/ui/editable-field/property-box/components/PropertyBoxItem';
import { useIsMobile } from '@/ui/hooks/useIsMobile';
import { IconArrowUpRight } from '@/ui/icon/index';
import {
CommentThread,
CommentThreadTarget,
useUpdateCommentThreadMutation,
} from '~/generated/graphql';
import { debounce } from '~/utils/debounce';
import { CommentThreadActionBar } from '../right-drawer/components/CommentThreadActionBar';
import { CommentForDrawer } from '../types/CommentForDrawer';
import '@blocknote/core/style.css';
const StyledContainer = styled.div`
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-between;
overflow-y: auto;
`;
const StyledUpperPartContainer = styled.div`
align-items: flex-start;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
justify-content: flex-start;
`;
const StyledTopContainer = styled.div`
align-items: flex-start;
align-self: stretch;
background: ${({ theme }) => theme.background.secondary};
border-bottom: ${({ theme }) =>
useIsMobile() ? 'none' : `1px solid ${theme.border.color.medium}`};
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px 24px 24px 48px;
`;
const StyledEditableTitleInput = styled.input`
background: transparent;
border: none;
color: ${({ theme }) => theme.font.color.primary};
display: flex;
flex: 1 0 0;
flex-direction: column;
font-family: Inter;
font-size: ${({ theme }) => theme.font.size.xl};
font-style: normal;
font-weight: ${({ theme }) => theme.font.weight.semiBold};
justify-content: center;
line-height: ${({ theme }) => theme.text.lineHeight.md};
outline: none;
width: calc(100% - ${({ theme }) => theme.spacing(2)});
&::placeholder {
color: ${({ theme }) => theme.font.color.light};
}
`;
const StyledTopActionsContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
`;
type OwnProps = {
commentThread: Pick<CommentThread, 'id' | 'title' | 'body' | 'type'> & {
comments?: Array<CommentForDrawer> | null;
} & {
commentThreadTargets?: Array<
Pick<CommentThreadTarget, 'id' | 'commentableId' | 'commentableType'>
> | null;
};
showComment?: boolean;
autoFillTitle?: boolean;
};
export function CommentThreadEditor({
commentThread,
showComment = true,
autoFillTitle = false,
}: OwnProps) {
const [hasUserManuallySetTitle, setHasUserManuallySetTitle] =
useState<boolean>(false);
const [title, setTitle] = useState<string | null>(commentThread.title ?? '');
const [updateCommentThreadMutation] = useUpdateCommentThreadMutation();
const updateTitle = useCallback(
(newTitle: string) => {
updateCommentThreadMutation({
variables: {
id: commentThread.id,
title: newTitle ?? '',
},
refetchQueries: [
getOperationName(GET_COMMENT_THREADS_BY_TARGETS) ?? '',
],
});
},
[commentThread, updateCommentThreadMutation],
);
const debouncedUpdateTitle = debounce(updateTitle, 200);
function updateTitleFromBody(body: string) {
const parsedTitle = JSON.parse(body)[0]?.content[0]?.text;
if (!hasUserManuallySetTitle && autoFillTitle) {
setTitle(parsedTitle);
debouncedUpdateTitle(parsedTitle);
}
}
if (!commentThread) {
return <></>;
}
return (
<StyledContainer>
<StyledUpperPartContainer>
<StyledTopContainer>
<StyledTopActionsContainer>
<CommentThreadTypeDropdown commentThread={commentThread} />
<CommentThreadActionBar commentThreadId={commentThread?.id ?? ''} />
</StyledTopActionsContainer>
<StyledEditableTitleInput
autoComplete="off"
autoFocus
placeholder={`${commentThread.type} title (optional)`}
onChange={(event) => {
setHasUserManuallySetTitle(true);
setTitle(event.target.value);
debouncedUpdateTitle(event.target.value);
}}
value={title ?? ''}
/>
<PropertyBox>
<PropertyBoxItem
icon={<IconArrowUpRight />}
value={
<CommentThreadRelationPicker
commentThread={{
id: commentThread.id,
commentThreadTargets:
commentThread.commentThreadTargets ?? [],
}}
/>
}
label="Relations"
/>
</PropertyBox>
</StyledTopContainer>
<CommentThreadBodyEditor
commentThread={commentThread}
onChange={updateTitleFromBody}
/>
</StyledUpperPartContainer>
{showComment && (
<CommentThreadComments
commentThread={{
id: commentThread.id,
comments: commentThread.comments ?? [],
}}
/>
)}
</StyledContainer>
);
}

View File

@ -1,88 +1,8 @@
import React, { useEffect, useMemo, useState } from 'react';
import { getOperationName } from '@apollo/client/utilities';
import styled from '@emotion/styled';
import { CommentThreadBodyEditor } from '@/activities/components/CommentThreadBodyEditor';
import { CommentThreadComments } from '@/activities/components/CommentThreadComments';
import { CommentThreadRelationPicker } from '@/activities/components/CommentThreadRelationPicker';
import { CommentThreadTypeDropdown } from '@/activities/components/CommentThreadTypeDropdown';
import { GET_COMMENT_THREAD } from '@/activities/queries';
import { PropertyBox } from '@/ui/editable-field/property-box/components/PropertyBox';
import { PropertyBoxItem } from '@/ui/editable-field/property-box/components/PropertyBoxItem';
import { useIsMobile } from '@/ui/hooks/useIsMobile';
import { IconArrowUpRight } from '@/ui/icon/index';
import {
useGetCommentThreadQuery,
useUpdateCommentThreadMutation,
} from '~/generated/graphql';
import { debounce } from '~/utils/debounce';
import { CommentThreadActionBar } from './CommentThreadActionBar';
import { CommentThreadEditor } from '@/activities/components/CommentThreadEditor';
import { useGetCommentThreadQuery } from '~/generated/graphql';
import '@blocknote/core/style.css';
const StyledContainer = styled.div`
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-between;
overflow-y: auto;
`;
const StyledUpperPartContainer = styled.div`
align-items: flex-start;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
justify-content: flex-start;
`;
const StyledTopContainer = styled.div`
align-items: flex-start;
align-self: stretch;
background: ${({ theme }) => theme.background.secondary};
border-bottom: ${({ theme }) =>
useIsMobile() ? 'none' : `1px solid ${theme.border.color.medium}`};
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px 24px 24px 48px;
`;
const StyledEditableTitleInput = styled.input`
background: transparent;
border: none;
color: ${({ theme }) => theme.font.color.primary};
display: flex;
flex: 1 0 0;
flex-direction: column;
font-family: Inter;
font-size: ${({ theme }) => theme.font.size.xl};
font-style: normal;
font-weight: ${({ theme }) => theme.font.weight.semiBold};
justify-content: center;
line-height: ${({ theme }) => theme.text.lineHeight.md};
outline: none;
width: calc(100% - ${({ theme }) => theme.spacing(2)});
&::placeholder {
color: ${({ theme }) => theme.font.color.light};
}
`;
const StyledTopActionsContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
`;
type OwnProps = {
commentThreadId: string;
showComment?: boolean;
@ -101,103 +21,14 @@ export function CommentThread({
skip: !commentThreadId,
});
const commentThread = data?.findManyCommentThreads[0];
const [hasUserManuallySetTitle, setHasUserManuallySetTitle] =
useState<boolean>(false);
const [title, setTitle] = useState<string | null>(null);
useEffect(() => {
if (!hasUserManuallySetTitle) {
setTitle(commentThread?.title ?? '');
}
}, [setTitle, commentThread?.title, hasUserManuallySetTitle]);
const [updateCommentThreadMutation] = useUpdateCommentThreadMutation();
const debounceUpdateTitle = useMemo(() => {
function updateTitle(title: string) {
if (commentThread) {
updateCommentThreadMutation({
variables: {
id: commentThreadId,
title: title ?? '',
},
refetchQueries: [getOperationName(GET_COMMENT_THREAD) ?? ''],
optimisticResponse: {
__typename: 'Mutation',
updateOneCommentThread: {
__typename: 'CommentThread',
id: commentThreadId,
title: title,
type: commentThread.type,
},
},
});
}
}
return debounce(updateTitle, 200);
}, [commentThreadId, updateCommentThreadMutation, commentThread]);
function updateTitleFromBody(body: string) {
const parsedTitle = JSON.parse(body)[0]?.content[0]?.text;
if (!hasUserManuallySetTitle && autoFillTitle) {
setTitle(parsedTitle);
debounceUpdateTitle(parsedTitle);
}
}
if (!commentThread) {
return <></>;
}
return (
<StyledContainer>
<StyledUpperPartContainer>
<StyledTopContainer>
<StyledTopActionsContainer>
<CommentThreadTypeDropdown commentThread={commentThread} />
<CommentThreadActionBar commentThreadId={commentThread?.id ?? ''} />
</StyledTopActionsContainer>
<StyledEditableTitleInput
autoComplete="off"
autoFocus
placeholder={`${commentThread.type} title (optional)`}
onChange={(event) => {
setHasUserManuallySetTitle(true);
setTitle(event.target.value);
debounceUpdateTitle(event.target.value);
}}
value={title ?? ''}
/>
<PropertyBox>
<PropertyBoxItem
icon={<IconArrowUpRight />}
value={
<CommentThreadRelationPicker
commentThread={{
id: commentThread.id,
commentThreadTargets:
commentThread.commentThreadTargets ?? [],
}}
/>
}
label="Relations"
/>
</PropertyBox>
</StyledTopContainer>
<CommentThreadBodyEditor
commentThread={commentThread}
onChange={updateTitleFromBody}
/>
</StyledUpperPartContainer>
{showComment && (
<CommentThreadComments
commentThread={{
id: commentThread.id,
comments: commentThread.comments ?? [],
}}
/>
)}
</StyledContainer>
return commentThread ? (
<CommentThreadEditor
commentThread={commentThread}
showComment={showComment}
autoFillTitle={autoFillTitle}
/>
) : (
<></>
);
}

View File

@ -6,7 +6,7 @@ import { useRecoilState } from 'recoil';
import { GET_COMMENT_THREADS_BY_TARGETS } from '@/activities/queries';
import { GET_COMPANIES } from '@/companies/queries';
import { GET_PEOPLE } from '@/people/queries';
import { Button } from '@/ui/button/components/Button';
import { Button, ButtonVariant } from '@/ui/button/components/Button';
import { IconTrash } from '@/ui/icon';
import { isRightDrawerOpenState } from '@/ui/right-drawer/states/isRightDrawerOpenState';
import { useDeleteCommentThreadMutation } from '~/generated/graphql';
@ -44,7 +44,7 @@ export function CommentThreadActionBar({ commentThreadId }: OwnProps) {
<IconTrash size={theme.icon.size.sm} stroke={theme.icon.stroke.md} />
}
onClick={deleteCommentThread}
variant="tertiary"
variant={ButtonVariant.Tertiary}
/>
</StyledContainer>
);

View File

@ -1,4 +1,3 @@
import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { companyAddressFamilyState } from '@/companies/states/companyAddressFamilyState';
@ -15,28 +14,21 @@ export function EditableCompanyAddressCell() {
companyAddressFamilyState(currentRowEntityId ?? ''),
);
const [internalValue, setInternalValue] = useState(address ?? '');
useEffect(() => {
setInternalValue(address ?? '');
}, [address]);
return (
<EditableCellText
value={internalValue}
onChange={setInternalValue}
onSubmit={() =>
value={address || ''}
onSubmit={(newAddress) =>
updateCompany({
variables: {
where: {
id: currentRowEntityId,
},
data: {
address: internalValue,
address: newAddress,
},
},
})
}
onCancel={() => setInternalValue(address ?? '')}
/>
);
}

View File

@ -1,4 +1,3 @@
import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { companyEmployeesFamilyState } from '@/companies/states/companyEmployeesFamilyState';
@ -15,30 +14,22 @@ export function EditableCompanyEmployeesCell() {
companyEmployeesFamilyState(currentRowEntityId ?? ''),
);
const [internalValue, setInternalValue] = useState(employees ?? '');
useEffect(() => {
setInternalValue(employees ?? '');
}, [employees]);
return (
// TODO: Create an EditableCellNumber component
<EditableCellText
value={internalValue}
onChange={setInternalValue}
onSubmit={() =>
value={employees || ''}
onSubmit={(newValue) =>
updateCompany({
variables: {
where: {
id: currentRowEntityId,
},
data: {
employees: parseInt(internalValue),
employees: parseInt(newValue),
},
},
})
}
onCancel={() => setInternalValue(employees ?? '')}
/>
);
}

View File

@ -1,4 +1,3 @@
import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { useCurrentRowEntityId } from '@/ui/table/hooks/useCurrentEntityId';
@ -15,28 +14,22 @@ export function EditableCompanyLinkedinUrlCell() {
const linkedinUrl = useRecoilValue(
companyLinkedinUrlFamilyState(currentRowEntityId ?? ''),
);
const [internalValue, setInternalValue] = useState(linkedinUrl ?? '');
useEffect(() => {
setInternalValue(linkedinUrl ?? '');
}, [linkedinUrl]);
return (
<EditableCellURL
url={internalValue}
onChange={setInternalValue}
onSubmit={() =>
url={linkedinUrl || ''}
onSubmit={(newUrl) =>
updateCompany({
variables: {
where: {
id: currentRowEntityId,
},
data: {
linkedinUrl: internalValue,
linkedinUrl: newUrl,
},
},
})
}
onCancel={() => setInternalValue(linkedinUrl ?? '')}
/>
);
}

View File

@ -1,4 +1,3 @@
import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { peopleEmailFamilyState } from '@/people/states/peopleEmailFamilyState';
@ -15,29 +14,21 @@ export function EditablePeopleEmailCell() {
peopleEmailFamilyState(currentRowEntityId ?? ''),
);
const [internalValue, setInternalValue] = useState(email ?? '');
useEffect(() => {
setInternalValue(email ?? '');
}, [email]);
return (
<EditableCellText
value={internalValue}
onChange={setInternalValue}
onSubmit={() =>
value={email || ''}
onSubmit={(newEmail: string) =>
updatePerson({
variables: {
where: {
id: currentRowEntityId,
},
data: {
email: internalValue,
email: newEmail,
},
},
})
}
onCancel={() => setInternalValue(email ?? '')}
/>
);
}

View File

@ -1,16 +1,16 @@
export enum AppPath {
// Not logged-in
Verify = 'verify',
SignIn = 'sign-in',
SignUp = 'sign-up',
Invite = 'invite/:workspaceInviteHash',
Verify = '/verify',
SignIn = '/sign-in',
SignUp = '/sign-up',
Invite = '/invite/:workspaceInviteHash',
// Onboarding
CreateWorkspace = 'create/workspace',
CreateProfile = 'create/profile',
CreateWorkspace = '/create/workspace',
CreateProfile = '/create/profile',
// Onboarded
Index = '',
Index = '/',
PeoplePage = '/people',
CompaniesPage = '/companies',
CompanyShowPage = '/companies/:companyId',

View File

@ -19,11 +19,11 @@ export function ButtonGroup({ children, variant, size }: ButtonGroupProps) {
let position: ButtonPosition;
if (index === 0) {
position = 'left';
position = ButtonPosition.Left;
} else if (index === children.length - 1) {
position = 'right';
position = ButtonPosition.Right;
} else {
position = 'middle';
position = ButtonPosition.Middle;
}
const additionalProps: any = { position };

View File

@ -48,6 +48,7 @@ export function InplaceInputTextEditMode({
return (
<StyledInput
autoComplete="off"
ref={wrapperRef}
placeholder={placeholder}
onChange={handleChange}

View File

@ -2,7 +2,7 @@ import React from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Button } from '@/ui/button/components/Button';
import { Button, ButtonVariant } from '@/ui/button/components/Button';
import { IconFileUpload, IconTrash, IconUpload } from '@/ui/icon';
const Container = styled.div`
@ -123,7 +123,7 @@ export function ImageInput({
<Button
icon={<IconUpload size={theme.icon.size.sm} />}
onClick={onUploadButtonClick}
variant="secondary"
variant={ButtonVariant.Secondary}
title="Upload"
disabled={disabled}
fullWidth
@ -131,7 +131,7 @@ export function ImageInput({
<Button
icon={<IconTrash size={theme.icon.size.sm} />}
onClick={onRemove}
variant="secondary"
variant={ButtonVariant.Secondary}
title="Remove"
disabled={!picture || disabled}
fullWidth

View File

@ -48,9 +48,9 @@ export function EditableCellDoubleTextEditMode({
setSecondInternalValue(secondValue);
}, [firstValue, secondValue]);
function handleOnChange(firstValue: string, secondValue: string): void {
setFirstInternalValue(firstValue);
setSecondInternalValue(secondValue);
function handleOnChange(newFirstValue: string, newSecondValue: string): void {
setFirstInternalValue(newFirstValue);
setSecondInternalValue(newSecondValue);
}
const [focusPosition, setFocusPosition] = useState<'left' | 'right'>('left');
@ -142,18 +142,18 @@ export function EditableCellDoubleTextEditMode({
autoFocus
placeholder={firstValuePlaceholder}
ref={firstValueInputRef}
value={firstValue}
value={firstInternalValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
handleOnChange(event.target.value, secondValue);
handleOnChange(event.target.value, secondInternalValue);
}}
/>
<StyledInput
autoComplete="off"
placeholder={secondValuePlaceholder}
ref={secondValueInputRef}
value={secondValue}
value={secondInternalValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
handleOnChange(firstValue, event.target.value);
handleOnChange(firstInternalValue, event.target.value);
}}
/>
</StyledContainer>

View File

@ -14,7 +14,6 @@ export function EditableCellPhone({ value, placeholder, onSubmit }: OwnProps) {
<EditableCell
editModeContent={
<InplaceInputTextEditMode
autoComplete="off"
autoFocus
placeholder={placeholder || ''}
value={value}

View File

@ -32,7 +32,6 @@ export function EditableCellText({
autoFocus
value={value}
onSubmit={(newText) => onSubmit?.(newText)}
autoComplete="off"
/>
}
nonEditModeContent={

View File

@ -27,7 +27,6 @@ export function EditableCellURL({
editModeContent={
<InplaceInputTextEditMode
placeholder={placeholder}
autoComplete="off"
autoFocus
value={url}
onSubmit={(newURL) => onSubmit?.(newURL)}

View File

@ -56,7 +56,6 @@ export function EditableCellChip({
placeholder={placeholder || ''}
autoFocus
value={inputValue}
autoComplete="off"
onSubmit={(newValue) => onSubmit?.(newValue)}
/>
}

View File

@ -1,7 +1,7 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Button } from '@/ui/button/components/Button';
import { Button, ButtonVariant } from '@/ui/button/components/Button';
import { IconCopy, IconLink } from '@/ui/icon';
import { TextInput } from '@/ui/input/components/TextInput';
import { useSnackBar } from '@/ui/snack-bar/hooks/useSnackBar';
@ -33,7 +33,7 @@ export function WorkspaceInviteLink({ inviteLink }: OwnProps) {
</StyledLinkContainer>
<Button
icon={<IconLink size={theme.icon.size.md} />}
variant="primary"
variant={ButtonVariant.Primary}
title="Copy link"
onClick={() => {
enqueueSnackBar('Link copied to clipboard', {