Relations many in table view (#5842)

Closes #5924.

Adding the "many" side of relations in the table view, and fixing some
issues (glitch in Multi record select, cache update after update).

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Marie
2024-06-27 11:28:03 +02:00
committed by GitHub
parent dcb709feee
commit 7eb69a78ef
82 changed files with 1531 additions and 751 deletions

View File

@ -0,0 +1,132 @@
import { useEffect } from 'react';
import {
useRecoilCallback,
useRecoilState,
useRecoilValue,
useSetRecoilState,
} from 'recoil';
import { useObjectRecordMultiSelectScopedStates } from '@/activities/hooks/useObjectRecordMultiSelectScopedStates';
import { objectRecordMultiSelectComponentFamilyState } from '@/object-record/record-field/states/objectRecordMultiSelectComponentFamilyState';
import { useRelationPickerScopedStates } from '@/object-record/relation-picker/hooks/internal/useRelationPickerScopedStates';
import {
ObjectRecordForSelect,
SelectedObjectRecordId,
useMultiObjectSearch,
} from '@/object-record/relation-picker/hooks/useMultiObjectSearch';
import { RelationPickerScopeInternalContext } from '@/object-record/relation-picker/scopes/scope-internal-context/RelationPickerScopeInternalContext';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const ActivityTargetInlineCellEditModeMultiRecordsEffect = ({
selectedObjectRecordIds,
}: {
selectedObjectRecordIds: SelectedObjectRecordId[];
}) => {
const scopeId = useAvailableScopeIdOrThrow(
RelationPickerScopeInternalContext,
);
const {
objectRecordsIdsMultiSelectState,
objectRecordMultiSelectCheckedRecordsIdsState,
recordMultiSelectIsLoadingState,
} = useObjectRecordMultiSelectScopedStates(scopeId);
const [objectRecordsIdsMultiSelect, setObjectRecordsIdsMultiSelect] =
useRecoilState(objectRecordsIdsMultiSelectState);
const setRecordMultiSelectIsLoading = useSetRecoilState(
recordMultiSelectIsLoadingState,
);
const relationPickerScopedId = useAvailableScopeIdOrThrow(
RelationPickerScopeInternalContext,
);
const { relationPickerSearchFilterState } = useRelationPickerScopedStates({
relationPickerScopedId,
});
const relationPickerSearchFilter = useRecoilValue(
relationPickerSearchFilterState,
);
const { filteredSelectedObjectRecords, loading, objectRecordsToSelect } =
useMultiObjectSearch({
searchFilterValue: relationPickerSearchFilter,
selectedObjectRecordIds,
excludedObjectRecordIds: [],
limit: 10,
});
const [
objectRecordMultiSelectCheckedRecordsIds,
setObjectRecordMultiSelectCheckedRecordsIds,
] = useRecoilState(objectRecordMultiSelectCheckedRecordsIdsState);
const updateRecords = useRecoilCallback(
({ snapshot, set }) =>
(newRecords: ObjectRecordForSelect[]) => {
for (const newRecord of newRecords) {
const currentRecord = snapshot
.getLoadable(
objectRecordMultiSelectComponentFamilyState({
scopeId: scopeId,
familyKey: newRecord.record.id,
}),
)
.getValue();
const newRecordWithSelected = {
...newRecord,
selected: objectRecordMultiSelectCheckedRecordsIds.some(
(checkedRecordId) => checkedRecordId === newRecord.record.id,
),
};
if (
!isDeeplyEqual(
newRecordWithSelected.selected,
currentRecord?.selected,
)
) {
set(
objectRecordMultiSelectComponentFamilyState({
scopeId: scopeId,
familyKey: newRecordWithSelected.record.id,
}),
newRecordWithSelected,
);
}
}
},
[objectRecordMultiSelectCheckedRecordsIds, scopeId],
);
useEffect(() => {
const allRecords = [
...(filteredSelectedObjectRecords ?? []),
...(objectRecordsToSelect ?? []),
];
updateRecords(allRecords);
const allRecordsIds = allRecords.map((record) => record.record.id);
if (!isDeeplyEqual(allRecordsIds, objectRecordsIdsMultiSelect)) {
setObjectRecordsIdsMultiSelect(allRecordsIds);
}
}, [
filteredSelectedObjectRecords,
objectRecordsIdsMultiSelect,
objectRecordsToSelect,
setObjectRecordsIdsMultiSelect,
updateRecords,
]);
useEffect(() => {
setObjectRecordMultiSelectCheckedRecordsIds(
selectedObjectRecordIds.map((rec) => rec.id),
);
}, [selectedObjectRecordIds, setObjectRecordMultiSelectCheckedRecordsIds]);
useEffect(() => {
setRecordMultiSelectIsLoading(loading);
}, [loading, setRecordMultiSelectIsLoading]);
return <></>;
};

View File

@ -1,12 +1,14 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useRef } from 'react';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useDebouncedCallback } from 'use-debounce';
import { useObjectRecordMultiSelectScopedStates } from '@/activities/hooks/useObjectRecordMultiSelectScopedStates';
import { MultipleObjectRecordOnClickOutsideEffect } from '@/object-record/relation-picker/components/MultipleObjectRecordOnClickOutsideEffect';
import { MultipleObjectRecordSelectItem } from '@/object-record/relation-picker/components/MultipleObjectRecordSelectItem';
import { MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID } from '@/object-record/relation-picker/constants/MultiObjectRecordSelectSelectableListId';
import { ObjectRecordForSelect } from '@/object-record/relation-picker/hooks/useMultiObjectSearch';
import { useRelationPickerScopedStates } from '@/object-record/relation-picker/hooks/internal/useRelationPickerScopedStates';
import { RelationPickerScopeInternalContext } from '@/object-record/relation-picker/scopes/scope-internal-context/RelationPickerScopeInternalContext';
import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
@ -15,7 +17,7 @@ import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownM
import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { isDefined } from '~/utils/isDefined';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
export const StyledSelectableItem = styled(SelectableItem)`
height: 100%;
@ -24,134 +26,80 @@ export const StyledSelectableItem = styled(SelectableItem)`
export const MultiRecordSelect = ({
onChange,
onSubmit,
selectedObjectRecords,
allRecords,
loading,
searchFilter,
setSearchFilter,
}: {
onChange?: (
changedRecordForSelect: ObjectRecordForSelect,
newSelectedValue: boolean,
) => void;
onSubmit?: (objectRecordsForSelect: ObjectRecordForSelect[]) => void;
selectedObjectRecords: ObjectRecordForSelect[];
allRecords: ObjectRecordForSelect[];
loading: boolean;
searchFilter: string;
setSearchFilter: (searchFilter: string) => void;
onChange?: (changedRecordForSelectId: string) => void;
onSubmit?: () => void;
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [internalSelectedRecords, setInternalSelectedRecords] = useState<
ObjectRecordForSelect[]
>([]);
const relationPickerScopedId = useAvailableScopeIdOrThrow(
RelationPickerScopeInternalContext,
);
useEffect(() => {
if (!loading) {
setInternalSelectedRecords(selectedObjectRecords);
}
}, [selectedObjectRecords, loading]);
const { objectRecordsIdsMultiSelectState, recordMultiSelectIsLoadingState } =
useObjectRecordMultiSelectScopedStates(relationPickerScopedId);
const recordMultiSelectIsLoading = useRecoilValue(
recordMultiSelectIsLoadingState,
);
const objectRecordsIdsMultiSelect = useRecoilValue(
objectRecordsIdsMultiSelectState,
);
const { relationPickerSearchFilterState } = useRelationPickerScopedStates({
relationPickerScopedId,
});
const setSearchFilter = useSetRecoilState(relationPickerSearchFilterState);
const relationPickerSearchFilter = useRecoilValue(
relationPickerSearchFilterState,
);
const debouncedSetSearchFilter = useDebouncedCallback(setSearchFilter, 100, {
leading: true,
});
const handleFilterChange = (event: React.ChangeEvent<HTMLInputElement>) => {
debouncedSetSearchFilter(event.currentTarget.value);
};
const handleSelectChange = (
changedRecordForSelect: ObjectRecordForSelect,
newSelectedValue: boolean,
) => {
const newSelectedRecords = newSelectedValue
? [...internalSelectedRecords, changedRecordForSelect]
: internalSelectedRecords.filter(
(selectedRecord) =>
selectedRecord.record.id !== changedRecordForSelect.record.id,
);
setInternalSelectedRecords(newSelectedRecords);
onChange?.(changedRecordForSelect, newSelectedValue);
};
const entitiesInDropdown = useMemo(
() =>
[...(allRecords ?? [])].filter((entity) =>
isNonEmptyString(entity.recordIdentifier.id),
),
[allRecords],
const handleFilterChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
debouncedSetSearchFilter(event.currentTarget.value);
},
[debouncedSetSearchFilter],
);
const selectableItemIds = entitiesInDropdown.map(
(entity) => entity.record.id,
);
return (
<>
<MultipleObjectRecordOnClickOutsideEffect
containerRef={containerRef}
onClickOutside={() => {
onSubmit?.(internalSelectedRecords);
onSubmit?.();
}}
/>
<DropdownMenu ref={containerRef} data-select-disable>
<DropdownMenuSearchInput
value={searchFilter}
value={relationPickerSearchFilter}
onChange={handleFilterChange}
autoFocus
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{loading ? (
{recordMultiSelectIsLoading ? (
<MenuItem text="Loading..." />
) : (
<>
<SelectableList
selectableListId={MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID}
selectableItemIdArray={selectableItemIds}
selectableItemIdArray={objectRecordsIdsMultiSelect}
hotkeyScope={RelationPickerHotkeyScope.RelationPicker}
onEnter={(recordId) => {
const recordIsSelected = internalSelectedRecords?.some(
(selectedRecord) => selectedRecord.record.id === recordId,
);
const correspondingRecordForSelect = entitiesInDropdown?.find(
(entity) => entity.record.id === recordId,
);
if (isDefined(correspondingRecordForSelect)) {
handleSelectChange(
correspondingRecordForSelect,
!recordIsSelected,
);
}
}}
>
{entitiesInDropdown?.map((objectRecordForSelect) => (
<MultipleObjectRecordSelectItem
key={objectRecordForSelect.record.id}
objectRecordForSelect={objectRecordForSelect}
onSelectedChange={(newSelectedValue) =>
handleSelectChange(
objectRecordForSelect,
newSelectedValue,
)
}
selected={internalSelectedRecords?.some(
(selectedRecord) => {
return (
selectedRecord.record.id ===
objectRecordForSelect.record.id
);
},
)}
/>
))}
{objectRecordsIdsMultiSelect?.map((recordId) => {
return (
<MultipleObjectRecordSelectItem
key={recordId}
objectRecordId={recordId}
onChange={onChange}
/>
);
})}
</SelectableList>
{entitiesInDropdown?.length === 0 && (
{objectRecordsIdsMultiSelect?.length === 0 && (
<MenuItem text="No result" />
)}
</>

View File

@ -1,68 +0,0 @@
import { useMemo, useState } from 'react';
import styled from '@emotion/styled';
import { MultiRecordSelect } from '@/object-record/relation-picker/components/MultiRecordSelect';
import {
ObjectRecordForSelect,
SelectedObjectRecordId,
useMultiObjectSearch,
} from '@/object-record/relation-picker/hooks/useMultiObjectSearch';
import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
export const StyledSelectableItem = styled(SelectableItem)`
height: 100%;
width: 100%;
`;
export const MultipleObjectRecordSelect = ({
onChange,
onSubmit,
selectedObjectRecordIds,
}: {
onChange?: (
changedRecordForSelect: ObjectRecordForSelect,
newSelectedValue: boolean,
) => void;
onSubmit?: (objectRecordsForSelect: ObjectRecordForSelect[]) => void;
selectedObjectRecordIds: SelectedObjectRecordId[];
}) => {
const [searchFilter, setSearchFilter] = useState<string>('');
const {
filteredSelectedObjectRecords,
loading,
objectRecordsToSelect,
selectedObjectRecords,
} = useMultiObjectSearch({
searchFilterValue: searchFilter,
selectedObjectRecordIds,
excludedObjectRecordIds: [],
limit: 10,
});
const selectedObjectRecordsForSelect = useMemo(
() =>
selectedObjectRecords.filter((selectedObjectRecord) =>
selectedObjectRecordIds.some(
(selectedObjectRecordId) =>
selectedObjectRecordId.id ===
selectedObjectRecord.recordIdentifier.id,
),
),
[selectedObjectRecords, selectedObjectRecordIds],
);
return (
<MultiRecordSelect
onChange={onChange}
onSubmit={onSubmit}
selectedObjectRecords={selectedObjectRecordsForSelect}
allRecords={[
...(filteredSelectedObjectRecords ?? []),
...(objectRecordsToSelect ?? []),
]}
loading={loading}
searchFilter={searchFilter}
setSearchFilter={setSearchFilter}
/>
);
};

View File

@ -3,12 +3,15 @@ import { useRecoilValue } from 'recoil';
import { Avatar } from 'twenty-ui';
import { v4 } from 'uuid';
import { useObjectRecordMultiSelectScopedStates } from '@/activities/hooks/useObjectRecordMultiSelectScopedStates';
import { MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID } from '@/object-record/relation-picker/constants/MultiObjectRecordSelectSelectableListId';
import { ObjectRecordForSelect } from '@/object-record/relation-picker/hooks/useMultiObjectSearch';
import { RelationPickerScopeInternalContext } from '@/object-record/relation-picker/scopes/scope-internal-context/RelationPickerScopeInternalContext';
import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { MenuItemMultiSelectAvatar } from '@/ui/navigation/menu-item/components/MenuItemMultiSelectAvatar';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64';
import { isDefined } from '~/utils/isDefined';
export const StyledSelectableItem = styled(SelectableItem)`
height: 100%;
@ -16,45 +19,60 @@ export const StyledSelectableItem = styled(SelectableItem)`
`;
export const MultipleObjectRecordSelectItem = ({
objectRecordForSelect,
onSelectedChange,
selected,
objectRecordId,
onChange,
}: {
objectRecordForSelect: ObjectRecordForSelect;
onSelectedChange?: (selected: boolean) => void;
selected: boolean;
objectRecordId: string;
onChange?: (changedRecordForSelectId: string) => void;
}) => {
const { isSelectedItemIdSelector } = useSelectableList(
MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID,
);
const isSelectedByKeyboard = useRecoilValue(
isSelectedItemIdSelector(objectRecordForSelect.record.id),
isSelectedItemIdSelector(objectRecordId),
);
const scopeId = useAvailableScopeIdOrThrow(
RelationPickerScopeInternalContext,
);
const { objectRecordMultiSelectFamilyState } =
useObjectRecordMultiSelectScopedStates(scopeId);
const record = useRecoilValue(
objectRecordMultiSelectFamilyState(objectRecordId),
);
if (!record) {
return null;
}
const handleSelectChange = () => {
onChange?.(objectRecordId);
};
const { selected, recordIdentifier } = record;
if (!isDefined(recordIdentifier)) {
return null;
}
return (
<StyledSelectableItem
itemId={objectRecordForSelect.record.id}
key={objectRecordForSelect.record.id + v4()}
>
<StyledSelectableItem itemId={objectRecordId} key={objectRecordId + v4()}>
<MenuItemMultiSelectAvatar
selected={selected}
onSelectChange={onSelectedChange}
onSelectChange={(_isNewlySelectedValue) => handleSelectChange()}
isKeySelected={isSelectedByKeyboard}
selected={selected}
avatar={
<Avatar
avatarUrl={getImageAbsoluteURIOrBase64(
objectRecordForSelect.recordIdentifier.avatarUrl,
)}
entityId={objectRecordForSelect.record.id}
placeholder={objectRecordForSelect.recordIdentifier.name}
avatarUrl={getImageAbsoluteURIOrBase64(recordIdentifier.avatarUrl)}
entityId={objectRecordId}
placeholder={recordIdentifier.name}
size="md"
type={
objectRecordForSelect.recordIdentifier.avatarType ?? 'rounded'
}
type={recordIdentifier.avatarType ?? 'rounded'}
/>
}
text={objectRecordForSelect.recordIdentifier.name}
text={recordIdentifier.name}
/>
</StyledSelectableItem>
);

View File

@ -44,7 +44,6 @@ export const SingleEntitySelectMenuItemsWithSearch = ({
const { entities, relationPickerSearchFilter } =
useRelationPickerEntitiesOptions({
relationObjectNameSingular,
relationPickerScopeId,
selectedRelationRecordIds,
excludedRelationRecordIds,
});

View File

@ -0,0 +1,5 @@
export const TABLE_COLUMNS_DENY_LIST = [
'attachments',
'activities',
'timelineActivities',
];

View File

@ -1,22 +1,26 @@
import { useRecoilValue } from 'recoil';
import { useRelationPickerScopedStates } from '@/object-record/relation-picker/hooks/internal/useRelationPickerScopedStates';
import { RelationPickerScopeInternalContext } from '@/object-record/relation-picker/scopes/scope-internal-context/RelationPickerScopeInternalContext';
import { useFilteredSearchEntityQuery } from '@/search/hooks/useFilteredSearchEntityQuery';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
export const useRelationPickerEntitiesOptions = ({
relationObjectNameSingular,
relationPickerScopeId = 'relation-picker',
selectedRelationRecordIds = [],
excludedRelationRecordIds = [],
}: {
relationObjectNameSingular: string;
relationPickerScopeId?: string;
selectedRelationRecordIds?: string[];
excludedRelationRecordIds?: string[];
}) => {
const scopeId = useAvailableScopeIdOrThrow(
RelationPickerScopeInternalContext,
);
const { searchQueryState, relationPickerSearchFilterState } =
useRelationPickerScopedStates({
relationPickerScopedId: relationPickerScopeId,
relationPickerScopedId: scopeId,
});
const relationPickerSearchFilter = useRecoilValue(
relationPickerSearchFilterState,