Refactor duplication of hard coded soft delete filter logic (#10058)

This PR adds a useCheckIsSoftDeleteFilter hook instead of the
undocumented in-place logic to retrieve the soft delete filter.

Also took the opportunity to refactor a recent change of @prastoin with
it.

Split VariantFilterChip into SoftDeleteFilterChip and RecordFilterChip
to separate concerns about this soft delete filtering.
This commit is contained in:
Lucas Bordeau
2025-02-07 11:03:13 +01:00
committed by GitHub
parent e081d8ab5e
commit 30e4fdbd06
11 changed files with 138 additions and 85 deletions

View File

@ -8,8 +8,8 @@ import { BACKEND_BATCH_REQUEST_MAX_COUNT } from '@/object-record/constants/Backe
import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize'; import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize';
import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords'; import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords';
import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords'; import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords';
import { useCheckIsSoftDeleteFilter } from '@/object-record/record-filter/hooks/useCheckIsSoftDeleteFilter';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies'; import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
@ -50,14 +50,10 @@ export const useDeleteMultipleRecordsAction: ActionHookWithObjectMetadataItem =
filterValueDependencies, filterValueDependencies,
); );
const deletedAtFieldMetadata = objectMetadataItem.fields.find( const { checkIsSoftDeleteFilter } = useCheckIsSoftDeleteFilter();
(field) => field.name === 'deletedAt',
);
const isDeletedFilterActive = contextStoreFilters.some( const isDeletedFilterActive = contextStoreFilters.some(
(filter) => checkIsSoftDeleteFilter,
filter.fieldMetadataId === deletedAtFieldMetadata?.id &&
filter.operand === RecordFilterOperand.IsNotEmpty,
); );
const { fetchAllRecords: fetchAllRecordIds } = useLazyFetchAllRecords({ const { fetchAllRecords: fetchAllRecordIds } = useLazyFetchAllRecords({

View File

@ -45,7 +45,6 @@ describe('computeContextStoreFilters', () => {
const contextStoreFilters: RecordFilter[] = [ const contextStoreFilters: RecordFilter[] = [
{ {
id: 'name-filter', id: 'name-filter',
variant: 'default',
fieldMetadataId: personObjectMetadataItem.fields.find( fieldMetadataId: personObjectMetadataItem.fields.find(
(field) => field.name === 'name', (field) => field.name === 'name',
)!.id, )!.id,

View File

@ -0,0 +1,35 @@
import { useFilterableFieldMetadataItems } from '@/object-record/record-filter/hooks/useFilterableFieldMetadataItems';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { isSoftDeleteFilterActiveComponentState } from '@/object-record/record-table/states/isSoftDeleteFilterActiveComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { isDefined } from 'twenty-shared';
export const useCheckIsSoftDeleteFilter = () => {
const { filterableFieldMetadataItems } = useFilterableFieldMetadataItems();
const isSoftDeleteFilterActive = useRecoilComponentValueV2(
isSoftDeleteFilterActiveComponentState,
);
const checkIsSoftDeleteFilter = (recordFilter: RecordFilter) => {
const foundFieldMetadataItem = filterableFieldMetadataItems.find(
(fieldMetadataItem) =>
fieldMetadataItem.id === recordFilter.fieldMetadataId,
);
if (!isDefined(foundFieldMetadataItem)) {
throw new Error(
`Field metadata item not found for field metadata id: ${recordFilter.fieldMetadataId}`,
);
}
return (
foundFieldMetadataItem.name === 'deletedAt' &&
isSoftDeleteFilterActive &&
recordFilter.operand === RecordFilterOperand.IsNotEmpty
);
};
return { checkIsSoftDeleteFilter };
};

View File

@ -4,7 +4,6 @@ import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
export type RecordFilter = { export type RecordFilter = {
id: string; id: string;
variant?: 'default' | 'danger';
fieldMetadataId: string; fieldMetadataId: string;
value: string; value: string;
displayValue: string; displayValue: string;

View File

@ -59,10 +59,11 @@ export const useHandleToggleTrashColumnFilter = ({
const newFilter: RecordFilter = { const newFilter: RecordFilter = {
id: v4(), id: v4(),
variant: 'danger',
fieldMetadataId: trashFieldMetadata.id, fieldMetadataId: trashFieldMetadata.id,
operand: ViewFilterOperand.IsNotEmpty, operand: ViewFilterOperand.IsNotEmpty,
displayValue: '', displayValue: '',
type: filterType,
label: `Deleted`,
definition: { definition: {
label: `Deleted`, label: `Deleted`,
iconName: 'IconTrash', iconName: 'IconTrash',

View File

@ -1,6 +1,7 @@
import { IconFilterOff } from 'twenty-ui'; import { IconFilterOff } from 'twenty-ui';
import { useObjectLabel } from '@/object-metadata/hooks/useObjectLabel'; import { useObjectLabel } from '@/object-metadata/hooks/useObjectLabel';
import { useCheckIsSoftDeleteFilter } from '@/object-record/record-filter/hooks/useCheckIsSoftDeleteFilter';
import { useRemoveRecordFilter } from '@/object-record/record-filter/hooks/useRemoveRecordFilter'; import { useRemoveRecordFilter } from '@/object-record/record-filter/hooks/useRemoveRecordFilter';
import { useHandleToggleTrashColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleTrashColumnFilter'; import { useHandleToggleTrashColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleTrashColumnFilter';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
@ -8,6 +9,7 @@ import { RecordTableEmptyStateDisplay } from '@/object-record/record-table/empty
import { tableFiltersComponentState } from '@/object-record/record-table/states/tableFiltersComponentState'; import { tableFiltersComponentState } from '@/object-record/record-table/states/tableFiltersComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useDeleteCombinedViewFilters } from '@/views/hooks/useDeleteCombinedViewFilters'; import { useDeleteCombinedViewFilters } from '@/views/hooks/useDeleteCombinedViewFilters';
import { isDefined } from 'twenty-shared';
export const RecordTableEmptyStateSoftDelete = () => { export const RecordTableEmptyStateSoftDelete = () => {
const { objectMetadataItem, objectNameSingular, recordTableId } = const { objectMetadataItem, objectNameSingular, recordTableId } =
@ -28,12 +30,12 @@ export const RecordTableEmptyStateSoftDelete = () => {
const { removeRecordFilter } = useRemoveRecordFilter(); const { removeRecordFilter } = useRemoveRecordFilter();
const handleButtonClick = async () => { const { checkIsSoftDeleteFilter } = useCheckIsSoftDeleteFilter();
const deletedFilter = tableFilters.find(
(filter) => filter.label === 'Deleted' && filter.operand === 'isNotEmpty',
);
if (!deletedFilter) { const handleButtonClick = async () => {
const deletedFilter = tableFilters.find(checkIsSoftDeleteFilter);
if (!isDefined(deletedFilter)) {
throw new Error('Deleted filter not found'); throw new Error('Deleted filter not found');
} }

View File

@ -1,9 +1,9 @@
import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const isSoftDeleteFilterActiveComponentState = export const isSoftDeleteFilterActiveComponentState =
createComponentStateV2<boolean>({ createComponentStateV2<boolean>({
key: 'isSoftDeleteFilterActiveComponentState', key: 'isSoftDeleteFilterActiveComponentState',
defaultValue: false, defaultValue: false,
componentInstanceContext: RecordTableComponentInstanceContext, componentInstanceContext: RecordFiltersComponentInstanceContext,
}); });

View File

@ -1,36 +1,18 @@
import { useIcons } from 'twenty-ui'; import { useIcons } from 'twenty-ui';
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById'; import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
import { useRemoveRecordFilter } from '@/object-record/record-filter/hooks/useRemoveRecordFilter'; import { useRemoveRecordFilter } from '@/object-record/record-filter/hooks/useRemoveRecordFilter';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { useHandleToggleTrashColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleTrashColumnFilter';
import { SortOrFilterChip } from '@/views/components/SortOrFilterChip'; import { SortOrFilterChip } from '@/views/components/SortOrFilterChip';
import { useDeleteCombinedViewFilters } from '@/views/hooks/useDeleteCombinedViewFilters'; import { useDeleteCombinedViewFilters } from '@/views/hooks/useDeleteCombinedViewFilters';
import { useParams } from 'react-router-dom';
type VariantFilterChipProps = { type RecordFilterChipProps = {
recordFilter: RecordFilter; recordFilter: RecordFilter;
viewBarId: string;
}; };
export const VariantFilterChip = ({ export const RecordFilterChip = ({ recordFilter }: RecordFilterChipProps) => {
recordFilter,
viewBarId,
}: VariantFilterChipProps) => {
const { deleteCombinedViewFilter } = useDeleteCombinedViewFilters(); const { deleteCombinedViewFilter } = useDeleteCombinedViewFilters();
const { objectNamePlural } = useParams();
const { objectNameSingular } = useObjectNameSingularFromPlural({
objectNamePlural: objectNamePlural ?? '',
});
const { toggleSoftDeleteFilterState } = useHandleToggleTrashColumnFilter({
objectNameSingular,
viewBarId,
});
const { fieldMetadataItem } = useFieldMetadataItemById( const { fieldMetadataItem } = useFieldMetadataItemById(
recordFilter.fieldMetadataId, recordFilter.fieldMetadataId,
); );
@ -44,20 +26,11 @@ export const VariantFilterChip = ({
const handleRemoveClick = () => { const handleRemoveClick = () => {
deleteCombinedViewFilter(recordFilter.id); deleteCombinedViewFilter(recordFilter.id);
removeRecordFilter(recordFilter.fieldMetadataId); removeRecordFilter(recordFilter.fieldMetadataId);
if (
recordFilter.label === 'Deleted' &&
recordFilter.operand === 'isNotEmpty'
) {
toggleSoftDeleteFilterState(false);
}
}; };
return ( return (
<SortOrFilterChip <SortOrFilterChip
key={recordFilter.fieldMetadataId}
testId={recordFilter.fieldMetadataId} testId={recordFilter.fieldMetadataId}
variant={recordFilter.variant}
labelValue={recordFilter.label ?? ''} labelValue={recordFilter.label ?? ''}
Icon={FieldMetadataItemIcon} Icon={FieldMetadataItemIcon}
onRemove={handleRemoveClick} onRemove={handleRemoveClick}

View File

@ -0,0 +1,48 @@
import { useIcons } from 'twenty-ui';
import { useRemoveRecordFilter } from '@/object-record/record-filter/hooks/useRemoveRecordFilter';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { isSoftDeleteFilterActiveComponentState } from '@/object-record/record-table/states/isSoftDeleteFilterActiveComponentState';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { SortOrFilterChip } from '@/views/components/SortOrFilterChip';
import { useDeleteCombinedViewFilters } from '@/views/hooks/useDeleteCombinedViewFilters';
type SoftDeleteFilterChipProps = {
recordFilter: RecordFilter;
viewBarId: string;
};
export const SoftDeleteFilterChip = ({
recordFilter,
viewBarId,
}: SoftDeleteFilterChipProps) => {
const { deleteCombinedViewFilter } = useDeleteCombinedViewFilters();
const setIsSoftDeleteFilterActive = useSetRecoilComponentStateV2(
isSoftDeleteFilterActiveComponentState,
viewBarId,
);
const { removeRecordFilter } = useRemoveRecordFilter();
const { getIcon } = useIcons();
const handleRemoveClick = () => {
deleteCombinedViewFilter(recordFilter.id);
removeRecordFilter(recordFilter.fieldMetadataId);
setIsSoftDeleteFilterActive(false);
};
const ChipIcon = getIcon('IconTrash');
return (
<SortOrFilterChip
testId={recordFilter.fieldMetadataId}
variant={'danger'}
labelValue={recordFilter.label ?? ''}
Icon={ChipIcon}
onRemove={handleRemoveClick}
/>
);
};

View File

@ -2,7 +2,7 @@ import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { IconComponent, IconX } from 'twenty-ui'; import { IconComponent, IconX } from 'twenty-ui';
const StyledChip = styled.div<{ variant: SortOrFitlerChipVariant }>` const StyledChip = styled.div<{ variant: SortOrFilterChipVariant }>`
align-items: center; align-items: center;
background-color: ${({ theme, variant }) => { background-color: ${({ theme, variant }) => {
switch (variant) { switch (variant) {
@ -55,7 +55,7 @@ const StyledIcon = styled.div`
display: flex; display: flex;
`; `;
const StyledDelete = styled.button<{ variant: SortOrFitlerChipVariant }>` const StyledDelete = styled.button<{ variant: SortOrFilterChipVariant }>`
box-sizing: border-box; box-sizing: border-box;
height: 20px; height: 20px;
width: 20px; width: 20px;
@ -89,12 +89,12 @@ const StyledLabelKey = styled.div`
font-weight: ${({ theme }) => theme.font.weight.medium}; font-weight: ${({ theme }) => theme.font.weight.medium};
`; `;
type SortOrFitlerChipVariant = 'default' | 'danger'; export type SortOrFilterChipVariant = 'default' | 'danger';
type SortOrFilterChipProps = { type SortOrFilterChipProps = {
labelKey?: string; labelKey?: string;
labelValue: string; labelValue: string;
variant?: SortOrFitlerChipVariant; variant?: SortOrFilterChipVariant;
Icon?: IconComponent; Icon?: IconComponent;
onRemove: () => void; onRemove: () => void;
onClick?: () => void; onClick?: () => void;

View File

@ -13,7 +13,9 @@ import { EditableSortChip } from '@/views/components/EditableSortChip';
import { ViewBarFilterEffect } from '@/views/components/ViewBarFilterEffect'; import { ViewBarFilterEffect } from '@/views/components/ViewBarFilterEffect';
import { useViewFromQueryParams } from '@/views/hooks/internal/useViewFromQueryParams'; import { useViewFromQueryParams } from '@/views/hooks/internal/useViewFromQueryParams';
import { useCheckIsSoftDeleteFilter } from '@/object-record/record-filter/hooks/useCheckIsSoftDeleteFilter';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { SoftDeleteFilterChip } from '@/views/components/SoftDeleteFilterChip';
import { useApplyCurrentViewFiltersToCurrentRecordFilters } from '@/views/hooks/useApplyCurrentViewFiltersToCurrentRecordFilters'; import { useApplyCurrentViewFiltersToCurrentRecordFilters } from '@/views/hooks/useApplyCurrentViewFiltersToCurrentRecordFilters';
import { useAreViewFiltersDifferentFromRecordFilters } from '@/views/hooks/useAreViewFiltersDifferentFromRecordFilters'; import { useAreViewFiltersDifferentFromRecordFilters } from '@/views/hooks/useAreViewFiltersDifferentFromRecordFilters';
import { useAreViewSortsDifferentFromRecordSorts } from '@/views/hooks/useAreViewSortsDifferentFromRecordSorts'; import { useAreViewSortsDifferentFromRecordSorts } from '@/views/hooks/useAreViewSortsDifferentFromRecordSorts';
@ -22,8 +24,8 @@ import { useResetUnsavedViewStates } from '@/views/hooks/useResetUnsavedViewStat
import { availableSortDefinitionsComponentState } from '@/views/states/availableSortDefinitionsComponentState'; import { availableSortDefinitionsComponentState } from '@/views/states/availableSortDefinitionsComponentState';
import { isViewBarExpandedComponentState } from '@/views/states/isViewBarExpandedComponentState'; import { isViewBarExpandedComponentState } from '@/views/states/isViewBarExpandedComponentState';
import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts'; import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts';
import { isNonEmptyArray } from '@sniptt/guards';
import { isDefined } from 'twenty-shared'; import { isDefined } from 'twenty-shared';
import { VariantFilterChip } from './VariantFilterChip';
export type ViewBarDetailsProps = { export type ViewBarDetailsProps = {
hasFilterButton?: boolean; hasFilterButton?: boolean;
@ -144,22 +146,19 @@ export const ViewBarDetails = ({
viewSortsAreDifferentFromRecordSorts) && viewSortsAreDifferentFromRecordSorts) &&
!hasFiltersQueryParams; !hasFiltersQueryParams;
const otherViewFilters = useMemo(() => { const { checkIsSoftDeleteFilter } = useCheckIsSoftDeleteFilter();
return currentRecordFilters.filter(
(viewFilter) =>
viewFilter.variant &&
viewFilter.variant !== 'default' &&
!viewFilter.viewFilterGroupId,
);
}, [currentRecordFilters]);
const defaultViewFilters = useMemo(() => { const softDeleteFilter = currentRecordFilters.find((recordFilter) =>
checkIsSoftDeleteFilter(recordFilter),
);
const recordFilters = useMemo(() => {
return currentRecordFilters.filter( return currentRecordFilters.filter(
(viewFilter) => (recordFilter) =>
(!viewFilter.variant || viewFilter.variant === 'default') && !recordFilter.viewFilterGroupId &&
!viewFilter.viewFilterGroupId, !checkIsSoftDeleteFilter(recordFilter),
); );
}, [currentRecordFilters]); }, [currentRecordFilters, checkIsSoftDeleteFilter]);
const { applyCurrentViewFiltersToCurrentRecordFilters } = const { applyCurrentViewFiltersToCurrentRecordFilters } =
useApplyCurrentViewFiltersToCurrentRecordFilters(); useApplyCurrentViewFiltersToCurrentRecordFilters();
@ -190,45 +189,46 @@ export const ViewBarDetails = ({
<StyledBar> <StyledBar>
<StyledFilterContainer> <StyledFilterContainer>
<StyledChipcontainer> <StyledChipcontainer>
{otherViewFilters.map((viewFilter) => ( {isDefined(softDeleteFilter) && (
<VariantFilterChip <SoftDeleteFilterChip
key={viewFilter.fieldMetadataId} key={softDeleteFilter.fieldMetadataId}
recordFilter={viewFilter} recordFilter={softDeleteFilter}
viewBarId={viewBarId} viewBarId={viewBarId}
/> />
))} )}
{!!otherViewFilters.length && {isDefined(softDeleteFilter) && (
!!currentViewWithCombinedFiltersAndSorts?.viewSorts?.length && ( <StyledSeperatorContainer>
<StyledSeperatorContainer> <StyledSeperator />
<StyledSeperator /> </StyledSeperatorContainer>
</StyledSeperatorContainer> )}
)}
{mapViewSortsToSorts( {mapViewSortsToSorts(
currentViewWithCombinedFiltersAndSorts?.viewSorts ?? [], currentViewWithCombinedFiltersAndSorts?.viewSorts ?? [],
availableSortDefinitions, availableSortDefinitions,
).map((sort) => ( ).map((sort) => (
<EditableSortChip key={sort.fieldMetadataId} viewSort={sort} /> <EditableSortChip key={sort.fieldMetadataId} viewSort={sort} />
))} ))}
{!!currentViewWithCombinedFiltersAndSorts?.viewSorts?.length && {isNonEmptyArray(recordFilters) &&
!!defaultViewFilters.length && ( isNonEmptyArray(
currentViewWithCombinedFiltersAndSorts?.viewSorts,
) && (
<StyledSeperatorContainer> <StyledSeperatorContainer>
<StyledSeperator /> <StyledSeperator />
</StyledSeperatorContainer> </StyledSeperatorContainer>
)} )}
{showAdvancedFilterDropdownButton && <AdvancedFilterDropdownButton />} {showAdvancedFilterDropdownButton && <AdvancedFilterDropdownButton />}
{defaultViewFilters.map((viewFilter) => ( {recordFilters.map((recordFilter) => (
<ObjectFilterDropdownComponentInstanceContext.Provider <ObjectFilterDropdownComponentInstanceContext.Provider
key={viewFilter.id} key={recordFilter.id}
value={{ instanceId: viewFilter.id }} value={{ instanceId: recordFilter.id }}
> >
<DropdownScope dropdownScopeId={viewFilter.id}> <DropdownScope dropdownScopeId={recordFilter.id}>
<ViewBarFilterEffect filterDropdownId={viewFilter.id} /> <ViewBarFilterEffect filterDropdownId={recordFilter.id} />
<EditableFilterDropdownButton <EditableFilterDropdownButton
viewFilter={viewFilter} viewFilter={recordFilter}
hotkeyScope={{ hotkeyScope={{
scope: viewFilter.id, scope: recordFilter.id,
}} }}
viewFilterDropdownId={viewFilter.id} viewFilterDropdownId={recordFilter.id}
/> />
</DropdownScope> </DropdownScope>
</ObjectFilterDropdownComponentInstanceContext.Provider> </ObjectFilterDropdownComponentInstanceContext.Provider>