Display and update fields from fromManyObjects relations in Show card (#5801)

In this PR, we implement the display and update of fields from
fromManyObjects (e.g update Employees for a Company).

Product requirement
- update should be triggered at each box check/uncheck, not at lose of
focus

Left to do in upcoming PRs
- add the column in the table views (e.g. column "Employees" on
"Companies" table view)
- add "Add new" possibility when there is no records (as is currently
exists for "one" side of relations:)
<img width="374" alt="Capture d’écran 2024-06-10 à 17 38 02"
src="https://github.com/twentyhq/twenty/assets/51697796/6f0cc494-e44f-4620-a762-d7b438951eec">

- update cache after an update affecting other records (e.g "Listings"
have one "Person"; if listing A belonged to Person A but then we
attribute listing A to Person B, Person A is no longer owner of Listing
A. For the moment that would not be reflected immediatly leading, to
potential false information if information is accessed from cache)
- try to get rid of the glitch - we also have it on the task page
example. (probably) due to the fact that we are using a recoil state to
read, update then re-read


https://github.com/twentyhq/twenty/assets/51697796/54f71674-237a-4946-866e-b8d96353c458
This commit is contained in:
Marie
2024-06-11 15:53:17 +02:00
committed by GitHub
parent 4994a9c3a9
commit b84042ddbb
23 changed files with 823 additions and 186 deletions

View File

@ -0,0 +1,163 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useDebouncedCallback } from 'use-debounce';
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 { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
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';
export const StyledSelectableItem = styled(SelectableItem)`
height: 100%;
width: 100%;
`;
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;
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [internalSelectedRecords, setInternalSelectedRecords] = useState<
ObjectRecordForSelect[]
>([]);
useEffect(() => {
if (!loading) {
setInternalSelectedRecords(selectedObjectRecords);
}
}, [selectedObjectRecords, loading]);
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 selectableItemIds = entitiesInDropdown.map(
(entity) => entity.record.id,
);
return (
<>
<MultipleObjectRecordOnClickOutsideEffect
containerRef={containerRef}
onClickOutside={() => {
onSubmit?.(internalSelectedRecords);
}}
/>
<DropdownMenu ref={containerRef} data-select-disable>
<DropdownMenuSearchInput
value={searchFilter}
onChange={handleFilterChange}
autoFocus
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{loading ? (
<MenuItem text="Loading..." />
) : (
<>
<SelectableList
selectableListId={MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID}
selectableItemIdArray={selectableItemIds}
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
);
},
)}
/>
))}
</SelectableList>
{entitiesInDropdown?.length === 0 && (
<MenuItem text="No result" />
)}
</>
)}
</DropdownMenuItemsContainer>
</DropdownMenu>
</>
);
};

View File

@ -1,37 +1,18 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMemo, useState } from 'react';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useDebouncedCallback } from 'use-debounce';
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 { MultiRecordSelect } from '@/object-record/relation-picker/components/MultiRecordSelect';
import {
ObjectRecordForSelect,
SelectedObjectRecordId,
useMultiObjectSearch,
} from '@/object-record/relation-picker/hooks/useMultiObjectSearch';
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';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
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';
export const StyledSelectableItem = styled(SelectableItem)`
height: 100%;
width: 100%;
`;
export type EntitiesForMultipleObjectRecordSelect = {
filteredSelectedObjectRecords: ObjectRecordForSelect[];
objectRecordsToSelect: ObjectRecordForSelect[];
loading: boolean;
};
export const MultipleObjectRecordSelect = ({
onChange,
onSubmit,
@ -41,12 +22,9 @@ export const MultipleObjectRecordSelect = ({
changedRecordForSelect: ObjectRecordForSelect,
newSelectedValue: boolean,
) => void;
onCancel?: (objectRecordsForSelect: ObjectRecordForSelect[]) => void;
onSubmit?: (objectRecordsForSelect: ObjectRecordForSelect[]) => void;
selectedObjectRecordIds: SelectedObjectRecordId[];
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [searchFilter, setSearchFilter] = useState<string>('');
const {
@ -73,122 +51,18 @@ export const MultipleObjectRecordSelect = ({
[selectedObjectRecords, selectedObjectRecordIds],
);
const [internalSelectedRecords, setInternalSelectedRecords] = useState<
ObjectRecordForSelect[]
>([]);
useEffect(() => {
if (!loading) {
setInternalSelectedRecords(selectedObjectRecordsForSelect);
}
}, [selectedObjectRecordsForSelect, loading]);
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(
() =>
[
return (
<MultiRecordSelect
onChange={onChange}
onSubmit={onSubmit}
selectedObjectRecords={selectedObjectRecordsForSelect}
allRecords={[
...(filteredSelectedObjectRecords ?? []),
...(objectRecordsToSelect ?? []),
].filter((entity) => isNonEmptyString(entity.recordIdentifier.id)),
[filteredSelectedObjectRecords, objectRecordsToSelect],
);
const selectableItemIds = entitiesInDropdown.map(
(entity) => entity.record.id,
);
return (
<>
<MultipleObjectRecordOnClickOutsideEffect
containerRef={containerRef}
onClickOutside={() => {
onSubmit?.(internalSelectedRecords);
}}
/>
<DropdownMenu ref={containerRef} data-select-disable>
<DropdownMenuSearchInput
value={searchFilter}
onChange={handleFilterChange}
autoFocus
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{loading ? (
<MenuItem text="Loading..." />
) : (
<>
<SelectableList
selectableListId={MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID}
selectableItemIdArray={selectableItemIds}
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
);
},
)}
/>
))}
</SelectableList>
{entitiesInDropdown?.length === 0 && (
<MenuItem text="No result" />
)}
</>
)}
</DropdownMenuItemsContainer>
</DropdownMenu>
</>
]}
loading={loading}
searchFilter={searchFilter}
setSearchFilter={setSearchFilter}
/>
);
};

View File

@ -1,12 +1,9 @@
import { useRecoilValue } from 'recoil';
import { ObjectMetadataItemsRelationPickerEffect } from '@/object-metadata/components/ObjectMetadataItemsRelationPickerEffect';
import {
SingleEntitySelectMenuItems,
SingleEntitySelectMenuItemsProps,
} from '@/object-record/relation-picker/components/SingleEntitySelectMenuItems';
import { useRelationPickerScopedStates } from '@/object-record/relation-picker/hooks/internal/useRelationPickerScopedStates';
import { useFilteredSearchEntityQuery } from '@/search/hooks/useFilteredSearchEntityQuery';
import { useRelationPickerEntitiesOptions } from '@/object-record/relation-picker/hooks/useRelationPickerEntitiesOptions';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { isDefined } from '~/utils/isDefined';
@ -44,32 +41,16 @@ export const SingleEntitySelectMenuItemsWithSearch = ({
relationPickerScopeId,
});
const { searchQueryState, relationPickerSearchFilterState } =
useRelationPickerScopedStates({
relationPickerScopedId: relationPickerScopeId,
const { entities, relationPickerSearchFilter } =
useRelationPickerEntitiesOptions({
relationObjectNameSingular,
relationPickerScopeId,
selectedRelationRecordIds,
excludedRelationRecordIds,
});
const searchQuery = useRecoilValue(searchQueryState);
const relationPickerSearchFilter = useRecoilValue(
relationPickerSearchFilterState,
);
const showCreateButton = isDefined(onCreate);
const entities = useFilteredSearchEntityQuery({
filters: [
{
fieldNames:
searchQuery?.computeFilterFields?.(relationObjectNameSingular) ?? [],
filter: relationPickerSearchFilter,
},
],
orderByField: 'createdAt',
selectedIds: selectedRelationRecordIds,
excludeEntityIds: excludedRelationRecordIds,
objectNameSingular: relationObjectNameSingular,
});
let onCreateWithInput = undefined;
if (isDefined(onCreate)) {

View File

@ -1,4 +1,4 @@
import { useRecoilState, useSetRecoilState } from 'recoil';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { useRelationPickerScopedStates } from '@/object-record/relation-picker/hooks/internal/useRelationPickerScopedStates';
import { RelationPickerScopeInternalContext } from '@/object-record/relation-picker/scopes/scope-internal-context/RelationPickerScopeInternalContext';
@ -28,6 +28,10 @@ export const useRelationPicker = (props?: useRelationPickeProps) => {
relationPickerSearchFilterState,
);
const relationPickerSearchFilter = useRecoilValue(
relationPickerSearchFilterState,
);
const [relationPickerPreselectedId, setRelationPickerPreselectedId] =
useRecoilState(relationPickerPreselectedIdState);
@ -37,5 +41,6 @@ export const useRelationPicker = (props?: useRelationPickeProps) => {
setRelationPickerSearchFilter,
relationPickerPreselectedId,
setRelationPickerPreselectedId,
relationPickerSearchFilter,
};
};

View File

@ -0,0 +1,41 @@
import { useRecoilValue } from 'recoil';
import { useRelationPickerScopedStates } from '@/object-record/relation-picker/hooks/internal/useRelationPickerScopedStates';
import { useFilteredSearchEntityQuery } from '@/search/hooks/useFilteredSearchEntityQuery';
export const useRelationPickerEntitiesOptions = ({
relationObjectNameSingular,
relationPickerScopeId = 'relation-picker',
selectedRelationRecordIds = [],
excludedRelationRecordIds = [],
}: {
relationObjectNameSingular: string;
relationPickerScopeId?: string;
selectedRelationRecordIds?: string[];
excludedRelationRecordIds?: string[];
}) => {
const { searchQueryState, relationPickerSearchFilterState } =
useRelationPickerScopedStates({
relationPickerScopedId: relationPickerScopeId,
});
const relationPickerSearchFilter = useRecoilValue(
relationPickerSearchFilterState,
);
const searchQuery = useRecoilValue(searchQueryState);
const entities = useFilteredSearchEntityQuery({
filters: [
{
fieldNames:
searchQuery?.computeFilterFields?.(relationObjectNameSingular) ?? [],
filter: relationPickerSearchFilter,
},
],
orderByField: 'createdAt',
selectedIds: selectedRelationRecordIds,
excludeEntityIds: excludedRelationRecordIds,
objectNameSingular: relationObjectNameSingular,
});
return { entities, relationPickerSearchFilter };
};