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:
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user