feat: disallow removing all comment thread targets (#779)

* feat: disallow removing all comment thread targets

Closes #431

* Rename variables

* Fix console error

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Thaïs
2023-07-21 01:17:43 +02:00
committed by GitHub
parent 872ec9e6bb
commit a2087da624
6 changed files with 174 additions and 155 deletions

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import styled from '@emotion/styled';
import {
autoUpdate,
@ -78,56 +78,98 @@ const StyledMenuWrapper = styled.div`
export function CommentThreadRelationPicker({ commentThread }: OwnProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [searchFilter, setSearchFilter] = useState('');
const peopleIds =
commentThread?.commentThreadTargets
?.filter((relation) => relation.commentableType === 'Person')
.map((relation) => relation.commentableId) ?? [];
const companyIds =
commentThread?.commentThreadTargets
?.filter((relation) => relation.commentableType === 'Company')
.map((relation) => relation.commentableId) ?? [];
const personsForMultiSelect = useFilteredSearchPeopleQuery({
searchFilter,
selectedIds: peopleIds,
});
const companiesForMultiSelect = useFilteredSearchCompanyQuery({
searchFilter,
selectedIds: companyIds,
});
const [selectedEntityIds, setSelectedEntityIds] = useState<
Record<string, boolean>
>({});
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
function handleRelationContainerClick() {
const initialPeopleIds = useMemo(
() =>
commentThread?.commentThreadTargets
?.filter((relation) => relation.commentableType === 'Person')
.map((relation) => relation.commentableId) ?? [],
[commentThread?.commentThreadTargets],
);
const initialCompanyIds = useMemo(
() =>
commentThread?.commentThreadTargets
?.filter((relation) => relation.commentableType === 'Company')
.map((relation) => relation.commentableId) ?? [],
[commentThread?.commentThreadTargets],
);
const initialSelectedEntityIds = useMemo(
() =>
[...initialPeopleIds, ...initialCompanyIds].reduce<
Record<string, boolean>
>((result, entityId) => ({ ...result, [entityId]: true }), {}),
[initialPeopleIds, initialCompanyIds],
);
const personsForMultiSelect = useFilteredSearchPeopleQuery({
searchFilter,
selectedIds: initialPeopleIds,
});
const companiesForMultiSelect = useFilteredSearchCompanyQuery({
searchFilter,
selectedIds: initialCompanyIds,
});
const selectedEntities = flatMapAndSortEntityForSelectArrayOfArrayByName([
personsForMultiSelect.selectedEntities,
companiesForMultiSelect.selectedEntities,
]);
const filteredSelectedEntities =
flatMapAndSortEntityForSelectArrayOfArrayByName([
personsForMultiSelect.filteredSelectedEntities,
companiesForMultiSelect.filteredSelectedEntities,
]);
const entitiesToSelect = flatMapAndSortEntityForSelectArrayOfArrayByName([
personsForMultiSelect.entitiesToSelect,
companiesForMultiSelect.entitiesToSelect,
]);
const handleCheckItemsChange = useHandleCheckableCommentThreadTargetChange({
commentThread,
});
const exitEditMode = useCallback(() => {
goBackToPreviousHotkeyScope();
setIsMenuOpen(false);
setSearchFilter('');
if (Object.values(selectedEntityIds).some((value) => !!value)) {
handleCheckItemsChange(selectedEntityIds, entitiesToSelect);
}
}, [
entitiesToSelect,
selectedEntityIds,
goBackToPreviousHotkeyScope,
handleCheckItemsChange,
]);
const handleRelationContainerClick = useCallback(() => {
if (isMenuOpen) {
exitEditMode();
} else {
setIsMenuOpen(true);
setSelectedEntityIds(initialSelectedEntityIds);
setHotkeyScopeAndMemorizePreviousScope(
RelationPickerHotkeyScope.RelationPicker,
);
}
}
// TODO: Place in a scoped recoil atom family
function handleFilterChange(newSearchFilter: string) {
setSearchFilter(newSearchFilter);
}
const handleCheckItemChange = useHandleCheckableCommentThreadTargetChange({
commentThread,
});
function exitEditMode() {
goBackToPreviousHotkeyScope();
setIsMenuOpen(false);
setSearchFilter('');
}
}, [
initialSelectedEntityIds,
exitEditMode,
isMenuOpen,
setHotkeyScopeAndMemorizePreviousScope,
]);
useScopedHotkeys(
['esc', 'enter'],
@ -159,22 +201,6 @@ export function CommentThreadRelationPicker({ commentThread }: OwnProps) {
},
});
const selectedEntities = flatMapAndSortEntityForSelectArrayOfArrayByName([
personsForMultiSelect.selectedEntities,
companiesForMultiSelect.selectedEntities,
]);
const filteredSelectedEntities =
flatMapAndSortEntityForSelectArrayOfArrayByName([
personsForMultiSelect.filteredSelectedEntities,
companiesForMultiSelect.filteredSelectedEntities,
]);
const entitiesToSelect = flatMapAndSortEntityForSelectArrayOfArrayByName([
personsForMultiSelect.entitiesToSelect,
companiesForMultiSelect.entitiesToSelect,
]);
return (
<StyledContainer>
<StyledRelationContainer
@ -204,9 +230,10 @@ export function CommentThreadRelationPicker({ commentThread }: OwnProps) {
selectedEntities,
loading: false, // TODO implement skeleton loading
}}
onItemCheckChange={handleCheckItemChange}
onSearchFilterChange={handleFilterChange}
onChange={setSelectedEntityIds}
onSearchFilterChange={setSearchFilter}
searchFilter={searchFilter}
value={selectedEntityIds}
/>
</StyledMenuWrapper>
</RecoilScope>

View File

@ -6,8 +6,8 @@ import { GET_PEOPLE } from '@/people/queries';
import {
CommentThread,
CommentThreadTarget,
useAddCommentThreadTargetOnCommentThreadMutation,
useRemoveCommentThreadTargetOnCommentThreadMutation,
useAddCommentThreadTargetsOnCommentThreadMutation,
useRemoveCommentThreadTargetsOnCommentThreadMutation,
} from '~/generated/graphql';
import { GET_COMMENT_THREADS_BY_TARGETS } from '../queries';
@ -22,8 +22,8 @@ export function useHandleCheckableCommentThreadTargetChange({
>;
};
}) {
const [addCommentThreadTargetOnCommentThread] =
useAddCommentThreadTargetOnCommentThreadMutation({
const [addCommentThreadTargetsOnCommentThread] =
useAddCommentThreadTargetsOnCommentThreadMutation({
refetchQueries: [
getOperationName(GET_COMPANIES) ?? '',
getOperationName(GET_PEOPLE) ?? '',
@ -31,8 +31,8 @@ export function useHandleCheckableCommentThreadTargetChange({
],
});
const [removeCommentThreadTargetOnCommentThread] =
useRemoveCommentThreadTargetOnCommentThreadMutation({
const [removeCommentThreadTargetsOnCommentThread] =
useRemoveCommentThreadTargetsOnCommentThreadMutation({
refetchQueries: [
getOperationName(GET_COMPANIES) ?? '',
getOperationName(GET_PEOPLE) ?? '',
@ -40,36 +40,45 @@ export function useHandleCheckableCommentThreadTargetChange({
],
});
return function handleCheckItemChange(
newCheckedValue: boolean,
entity: CommentableEntityForSelect,
return async function handleCheckItemsChange(
entityValues: Record<string, boolean>,
entities: CommentableEntityForSelect[],
) {
if (!commentThread) {
return;
}
if (newCheckedValue) {
addCommentThreadTargetOnCommentThread({
const currentEntityIds = commentThread.commentThreadTargets.map(
({ commentableId }) => commentableId,
);
const entitiesToAdd = entities.filter(
({ id }) => entityValues[id] && !currentEntityIds.includes(id),
);
if (entitiesToAdd.length)
await addCommentThreadTargetsOnCommentThread({
variables: {
commentableEntityId: entity.id,
commentableEntityType: entity.entityType,
commentThreadId: commentThread.id,
commentThreadTargetCreationDate: new Date().toISOString(),
commentThreadTargetId: v4(),
commentThreadTargetInputs: entitiesToAdd.map((entity) => ({
id: v4(),
createdAt: new Date().toISOString(),
commentableType: entity.entityType,
commentableId: entity.id,
})),
},
});
} else {
const foundCorrespondingTarget = commentThread.commentThreadTargets?.find(
(target) => target.commentableId === entity.id,
);
if (foundCorrespondingTarget) {
removeCommentThreadTargetOnCommentThread({
variables: {
commentThreadId: commentThread.id,
commentThreadTargetId: foundCorrespondingTarget.id,
},
});
}
}
const commentThreadTargetIdsToDelete = commentThread.commentThreadTargets
.filter(({ commentableId }) => !entityValues[commentableId])
.map(({ id }) => id);
if (commentThreadTargetIdsToDelete.length)
await removeCommentThreadTargetsOnCommentThread({
variables: {
commentThreadId: commentThread.id,
commentThreadTargetIds: commentThreadTargetIdsToDelete,
},
});
};
}

View File

@ -1,26 +1,15 @@
import { gql } from '@apollo/client';
export const ADD_COMMENT_THREAD_TARGET = gql`
mutation AddCommentThreadTargetOnCommentThread(
export const ADD_COMMENT_THREAD_TARGETS = gql`
mutation AddCommentThreadTargetsOnCommentThread(
$commentThreadId: String!
$commentThreadTargetCreationDate: DateTime!
$commentThreadTargetId: String!
$commentableEntityId: String!
$commentableEntityType: CommentableType!
$commentThreadTargetInputs: [CommentThreadTargetCreateManyCommentThreadInput!]!
) {
updateOneCommentThread(
where: { id: $commentThreadId }
data: {
commentThreadTargets: {
connectOrCreate: {
create: {
id: $commentThreadTargetId
createdAt: $commentThreadTargetCreationDate
commentableType: $commentableEntityType
commentableId: $commentableEntityId
}
where: { id: $commentThreadTargetId }
}
createMany: { data: $commentThreadTargetInputs }
}
}
) {
@ -38,14 +27,18 @@ export const ADD_COMMENT_THREAD_TARGET = gql`
}
`;
export const REMOVE_COMMENT_THREAD_TARGET = gql`
mutation RemoveCommentThreadTargetOnCommentThread(
export const REMOVE_COMMENT_THREAD_TARGETS = gql`
mutation RemoveCommentThreadTargetsOnCommentThread(
$commentThreadId: String!
$commentThreadTargetId: String!
$commentThreadTargetIds: [String!]!
) {
updateOneCommentThread(
where: { id: $commentThreadId }
data: { commentThreadTargets: { delete: { id: $commentThreadTargetId } } }
data: {
commentThreadTargets: {
deleteMany: { id: { in: $commentThreadTargetIds } }
}
}
) {
id
createdAt

View File

@ -79,7 +79,8 @@ export function Checkbox({
indeterminate,
variant = CheckboxVariant.Primary,
}: OwnProps) {
const [isInternalChecked, setIsInternalChecked] = React.useState(false);
const [isInternalChecked, setIsInternalChecked] =
React.useState<boolean>(false);
React.useEffect(() => {
setIsInternalChecked(checked);

View File

@ -23,17 +23,16 @@ export function MultipleEntitySelect<
CustomEntityForSelect extends EntityForSelect,
>({
entities,
onItemCheckChange,
onChange,
onSearchFilterChange,
searchFilter,
value,
}: {
entities: EntitiesForMultipleEntitySelect<CustomEntityForSelect>;
searchFilter: string;
onSearchFilterChange: (newSearchFilter: string) => void;
onItemCheckChange: (
newCheckedValue: boolean,
entity: CustomEntityForSelect,
) => void;
onChange: (value: Record<string, boolean>) => void;
value: Record<string, boolean>;
}) {
const debouncedSetSearchFilter = debounce(onSearchFilterChange, 100, {
leading: true,
@ -61,13 +60,9 @@ export function MultipleEntitySelect<
{entitiesInDropdown?.map((entity) => (
<DropdownMenuCheckableItem
key={entity.id}
checked={
entities.selectedEntities
?.map((selectedEntity) => selectedEntity.id)
?.includes(entity.id) ?? false
}
checked={value[entity.id]}
onChange={(newCheckedValue) =>
onItemCheckChange(newCheckedValue, entity)
onChange({ ...value, [entity.id]: newCheckedValue })
}
>
<Avatar