7665 handle the select all case inside the action menu (#7742)
Closes #7665 - Handle select all - Handle Filters --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
@ -66,7 +66,7 @@ export const RecordBoard = () => {
|
||||
|
||||
useListenClickOutsideByClassName({
|
||||
classNames: ['record-board-card'],
|
||||
excludeClassNames: ['bottom-bar', 'context-menu'],
|
||||
excludeClassNames: ['bottom-bar', 'action-menu-dropdown'],
|
||||
callback: resetRecordSelection,
|
||||
});
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Filter } from '@/object-record/object-filter-dropdown/types/Filter';
|
||||
import { turnObjectDropdownFilterIntoQueryFilter } from '@/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter';
|
||||
import { turnFiltersIntoQueryFilter } from '@/object-record/record-filter/utils/turnFiltersIntoQueryFilter';
|
||||
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
|
||||
import { getCompaniesMock } from '~/testing/mock-data/companies';
|
||||
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
|
||||
@ -16,7 +16,7 @@ const personMockObjectMetadataItem = generatedMockObjectMetadataItems.find(
|
||||
|
||||
jest.useFakeTimers().setSystemTime(new Date('2020-01-01'));
|
||||
|
||||
describe('turnObjectDropdownFilterIntoQueryFilter', () => {
|
||||
describe('turnFiltersIntoQueryFilter', () => {
|
||||
it('should work as expected for single filter', () => {
|
||||
const companyMockNameFieldMetadataId =
|
||||
companyMockObjectMetadataItem.fields.find(
|
||||
@ -37,7 +37,7 @@ describe('turnObjectDropdownFilterIntoQueryFilter', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = turnObjectDropdownFilterIntoQueryFilter(
|
||||
const result = turnFiltersIntoQueryFilter(
|
||||
[nameFilter],
|
||||
companyMockObjectMetadataItem.fields,
|
||||
);
|
||||
@ -88,7 +88,7 @@ describe('turnObjectDropdownFilterIntoQueryFilter', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = turnObjectDropdownFilterIntoQueryFilter(
|
||||
const result = turnFiltersIntoQueryFilter(
|
||||
[nameFilter, employeesFilter],
|
||||
companyMockObjectMetadataItem.fields,
|
||||
);
|
||||
@ -173,7 +173,7 @@ describe('should work as expected for the different field types', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = turnObjectDropdownFilterIntoQueryFilter(
|
||||
const result = turnFiltersIntoQueryFilter(
|
||||
[
|
||||
addressFilterContains,
|
||||
addressFilterDoesNotContain,
|
||||
@ -554,7 +554,7 @@ describe('should work as expected for the different field types', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = turnObjectDropdownFilterIntoQueryFilter(
|
||||
const result = turnFiltersIntoQueryFilter(
|
||||
[
|
||||
phonesFilterContains,
|
||||
phonesFilterDoesNotContain,
|
||||
@ -754,7 +754,7 @@ describe('should work as expected for the different field types', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = turnObjectDropdownFilterIntoQueryFilter(
|
||||
const result = turnFiltersIntoQueryFilter(
|
||||
[
|
||||
emailsFilterContains,
|
||||
emailsFilterDoesNotContain,
|
||||
@ -908,7 +908,7 @@ describe('should work as expected for the different field types', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = turnObjectDropdownFilterIntoQueryFilter(
|
||||
const result = turnFiltersIntoQueryFilter(
|
||||
[
|
||||
dateFilterIsAfter,
|
||||
dateFilterIsBefore,
|
||||
@ -1023,7 +1023,7 @@ describe('should work as expected for the different field types', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = turnObjectDropdownFilterIntoQueryFilter(
|
||||
const result = turnFiltersIntoQueryFilter(
|
||||
[
|
||||
employeesFilterIsGreaterThan,
|
||||
employeesFilterIsLessThan,
|
||||
@ -31,7 +31,7 @@ import { z } from 'zod';
|
||||
|
||||
// TODO: break this down into smaller functions and make the whole thing immutable
|
||||
// Especially applyEmptyFilters
|
||||
export const turnObjectDropdownFilterIntoQueryFilter = (
|
||||
export const turnFiltersIntoQueryFilter = (
|
||||
rawUIFilters: Filter[],
|
||||
fields: Pick<Field, 'id' | 'name'>[],
|
||||
): RecordGqlOperationFilter | undefined => {
|
||||
@ -2,8 +2,7 @@ import { useCallback, useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
|
||||
import { contextStoreCurrentObjectMetadataIdState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdState';
|
||||
import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState';
|
||||
import { contextStoreTargetedRecordsRuleState } from '@/context-store/states/contextStoreTargetedRecordsRuleState';
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug';
|
||||
import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard';
|
||||
@ -121,32 +120,23 @@ export const RecordIndexBoardDataLoaderEffect = ({
|
||||
|
||||
const selectedRecordIds = useRecoilValue(selectedRecordIdsSelector());
|
||||
|
||||
const setContextStoreTargetedRecordIds = useSetRecoilState(
|
||||
contextStoreTargetedRecordIdsState,
|
||||
);
|
||||
|
||||
const setContextStoreCurrentObjectMetadataItem = useSetRecoilState(
|
||||
contextStoreCurrentObjectMetadataIdState,
|
||||
const setContextStoreTargetedRecords = useSetRecoilState(
|
||||
contextStoreTargetedRecordsRuleState,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setContextStoreTargetedRecordIds(selectedRecordIds);
|
||||
}, [selectedRecordIds, setContextStoreTargetedRecordIds]);
|
||||
|
||||
useEffect(() => {
|
||||
setContextStoreTargetedRecordIds(selectedRecordIds);
|
||||
setContextStoreCurrentObjectMetadataItem(objectMetadataItem?.id);
|
||||
setContextStoreTargetedRecords({
|
||||
mode: 'selection',
|
||||
selectedRecordIds: selectedRecordIds,
|
||||
});
|
||||
|
||||
return () => {
|
||||
setContextStoreTargetedRecordIds([]);
|
||||
setContextStoreCurrentObjectMetadataItem(null);
|
||||
setContextStoreTargetedRecords({
|
||||
mode: 'selection',
|
||||
selectedRecordIds: [],
|
||||
});
|
||||
};
|
||||
}, [
|
||||
objectMetadataItem?.id,
|
||||
selectedRecordIds,
|
||||
setContextStoreCurrentObjectMetadataItem,
|
||||
setContextStoreTargetedRecordIds,
|
||||
]);
|
||||
}, [selectedRecordIds, setContextStoreTargetedRecords]);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
@ -22,12 +22,8 @@ import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-
|
||||
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
|
||||
import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/components/SpreadsheetImportProvider';
|
||||
|
||||
import { RecordActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter';
|
||||
import { ActionMenuBar } from '@/action-menu/components/ActionMenuBar';
|
||||
import { ActionMenuConfirmationModals } from '@/action-menu/components/ActionMenuConfirmationModals';
|
||||
import { ActionMenuDropdown } from '@/action-menu/components/ActionMenuDropdown';
|
||||
import { ActionMenuEffect } from '@/action-menu/components/ActionMenuEffect';
|
||||
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
|
||||
import { ActionMenu } from '@/action-menu/components/ActionMenu';
|
||||
import { contextStoreTargetedRecordsRuleState } from '@/context-store/states/contextStoreTargetedRecordsRuleState';
|
||||
import { ViewBar } from '@/views/components/ViewBar';
|
||||
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
|
||||
import { ViewField } from '@/views/types/ViewField';
|
||||
@ -106,6 +102,10 @@ export const RecordIndexContainer = () => {
|
||||
[columnDefinitions, setTableColumns],
|
||||
);
|
||||
|
||||
const setContextStoreTargetedRecordsRule = useSetRecoilState(
|
||||
contextStoreTargetedRecordsRuleState,
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<InformationBannerWrapper />
|
||||
@ -119,7 +119,7 @@ export const RecordIndexContainer = () => {
|
||||
optionsDropdownButton={
|
||||
<RecordIndexOptionsDropdown
|
||||
recordIndexId={recordIndexId}
|
||||
objectNameSingular={objectNameSingular}
|
||||
objectMetadataItem={objectMetadataItem}
|
||||
viewType={recordIndexViewType ?? ViewType.Table}
|
||||
/>
|
||||
}
|
||||
@ -135,6 +135,13 @@ export const RecordIndexContainer = () => {
|
||||
setRecordIndexFilters(
|
||||
mapViewFiltersToFilters(view.viewFilters, filterDefinitions),
|
||||
);
|
||||
setContextStoreTargetedRecordsRule((prev) => ({
|
||||
...prev,
|
||||
filters: mapViewFiltersToFilters(
|
||||
view.viewFilters,
|
||||
filterDefinitions,
|
||||
),
|
||||
}));
|
||||
setTableSorts(
|
||||
mapViewSortsToSorts(view.viewSorts, sortDefinitions),
|
||||
);
|
||||
@ -179,15 +186,7 @@ export const RecordIndexContainer = () => {
|
||||
/>
|
||||
</StyledContainerWithPadding>
|
||||
)}
|
||||
<ActionMenuComponentInstanceContext.Provider
|
||||
value={{ instanceId: recordIndexId }}
|
||||
>
|
||||
<ActionMenuEffect />
|
||||
<RecordActionMenuEntriesSetter />
|
||||
<ActionMenuBar />
|
||||
<ActionMenuDropdown />
|
||||
<ActionMenuConfirmationModals />
|
||||
</ActionMenuComponentInstanceContext.Provider>
|
||||
<ActionMenu actionMenuId={recordIndexId} />
|
||||
</RecordFieldValueSelectorContextProvider>
|
||||
</ViewComponentInstanceContext.Provider>
|
||||
</StyledContainer>
|
||||
|
||||
@ -0,0 +1,66 @@
|
||||
import { contextStoreNumberOfSelectedRecordsState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsState';
|
||||
import { contextStoreTargetedRecordsRuleState } from '@/context-store/states/contextStoreTargetedRecordsRuleState';
|
||||
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
|
||||
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||
import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext';
|
||||
import { useFindManyParams } from '@/object-record/record-index/hooks/useLoadRecordIndexTable';
|
||||
import { useContext, useEffect } from 'react';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
|
||||
export const RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect =
|
||||
() => {
|
||||
const setContextStoreNumberOfSelectedRecords = useSetRecoilState(
|
||||
contextStoreNumberOfSelectedRecordsState,
|
||||
);
|
||||
|
||||
const contextStoreTargetedRecordsRule = useRecoilValue(
|
||||
contextStoreTargetedRecordsRuleState,
|
||||
);
|
||||
|
||||
const { objectNamePlural } = useContext(RecordIndexRootPropsContext);
|
||||
|
||||
const { objectNameSingular } = useObjectNameSingularFromPlural({
|
||||
objectNamePlural,
|
||||
});
|
||||
|
||||
const { objectMetadataItem } = useObjectMetadataItem({
|
||||
objectNameSingular,
|
||||
});
|
||||
|
||||
const findManyRecordsParams = useFindManyParams(
|
||||
objectMetadataItem?.nameSingular ?? '',
|
||||
objectMetadataItem?.namePlural ?? '',
|
||||
);
|
||||
|
||||
const { totalCount } = useFindManyRecords({
|
||||
...findManyRecordsParams,
|
||||
recordGqlFields: {
|
||||
id: true,
|
||||
},
|
||||
filter: computeContextStoreFilters(
|
||||
contextStoreTargetedRecordsRule,
|
||||
objectMetadataItem,
|
||||
),
|
||||
limit: 1,
|
||||
skip: contextStoreTargetedRecordsRule.mode === 'selection',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (contextStoreTargetedRecordsRule.mode === 'selection') {
|
||||
setContextStoreNumberOfSelectedRecords(
|
||||
contextStoreTargetedRecordsRule.selectedRecordIds.length,
|
||||
);
|
||||
}
|
||||
if (contextStoreTargetedRecordsRule.mode === 'exclusion') {
|
||||
setContextStoreNumberOfSelectedRecords(totalCount ?? 0);
|
||||
}
|
||||
}, [
|
||||
contextStoreTargetedRecordsRule,
|
||||
setContextStoreNumberOfSelectedRecords,
|
||||
totalCount,
|
||||
]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@ -0,0 +1,31 @@
|
||||
import { contextStoreCurrentObjectMetadataIdState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdState';
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
|
||||
import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext';
|
||||
import { useContext, useEffect } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
|
||||
export const RecordIndexContainerContextStoreObjectMetadataEffect = () => {
|
||||
const setContextStoreCurrentObjectMetadataItem = useSetRecoilState(
|
||||
contextStoreCurrentObjectMetadataIdState,
|
||||
);
|
||||
const { objectNamePlural } = useContext(RecordIndexRootPropsContext);
|
||||
|
||||
const { objectNameSingular } = useObjectNameSingularFromPlural({
|
||||
objectNamePlural,
|
||||
});
|
||||
|
||||
const { objectMetadataItem } = useObjectMetadataItem({
|
||||
objectNameSingular,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setContextStoreCurrentObjectMetadataItem(objectMetadataItem.id);
|
||||
|
||||
return () => {
|
||||
setContextStoreCurrentObjectMetadataItem(null);
|
||||
};
|
||||
}, [objectMetadataItem.id, setContextStoreCurrentObjectMetadataItem]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@ -1,8 +1,7 @@
|
||||
import { useContext, useEffect } from 'react';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
|
||||
import { contextStoreCurrentObjectMetadataIdState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdState';
|
||||
import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState';
|
||||
import { contextStoreTargetedRecordsRuleState } from '@/context-store/states/contextStoreTargetedRecordsRuleState';
|
||||
import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata';
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext';
|
||||
@ -24,18 +23,12 @@ export const RecordIndexTableContainerEffect = () => {
|
||||
selectedRowIdsSelector,
|
||||
setOnToggleColumnFilter,
|
||||
setOnToggleColumnSort,
|
||||
hasUserSelectedAllRowsState,
|
||||
unselectedRowIdsSelector,
|
||||
} = useRecordTable({
|
||||
recordTableId: recordIndexId,
|
||||
});
|
||||
|
||||
const setContextStoreTargetedRecordIds = useSetRecoilState(
|
||||
contextStoreTargetedRecordIdsState,
|
||||
);
|
||||
|
||||
const setContextStoreCurrentObjectMetadataItem = useSetRecoilState(
|
||||
contextStoreCurrentObjectMetadataIdState,
|
||||
);
|
||||
|
||||
const { objectMetadataItem } = useObjectMetadataItem({
|
||||
objectNameSingular,
|
||||
});
|
||||
@ -50,8 +43,6 @@ export const RecordIndexTableContainerEffect = () => {
|
||||
setAvailableTableColumns(columnDefinitions);
|
||||
}, [columnDefinitions, setAvailableTableColumns]);
|
||||
|
||||
const selectedRowIds = useRecoilValue(selectedRowIdsSelector());
|
||||
|
||||
const handleToggleColumnFilter = useHandleToggleColumnFilter({
|
||||
objectNameSingular,
|
||||
viewBarId,
|
||||
@ -82,19 +73,38 @@ export const RecordIndexTableContainerEffect = () => {
|
||||
);
|
||||
}, [setRecordCountInCurrentView, setOnEntityCountChange]);
|
||||
|
||||
const setContextStoreTargetedRecords = useSetRecoilState(
|
||||
contextStoreTargetedRecordsRuleState,
|
||||
);
|
||||
const hasUserSelectedAllRows = useRecoilValue(hasUserSelectedAllRowsState);
|
||||
const selectedRowIds = useRecoilValue(selectedRowIdsSelector());
|
||||
const unselectedRowIds = useRecoilValue(unselectedRowIdsSelector());
|
||||
|
||||
useEffect(() => {
|
||||
setContextStoreTargetedRecordIds(selectedRowIds);
|
||||
setContextStoreCurrentObjectMetadataItem(objectMetadataItem?.id);
|
||||
if (hasUserSelectedAllRows) {
|
||||
setContextStoreTargetedRecords({
|
||||
mode: 'exclusion',
|
||||
excludedRecordIds: unselectedRowIds,
|
||||
filters: [],
|
||||
});
|
||||
} else {
|
||||
setContextStoreTargetedRecords({
|
||||
mode: 'selection',
|
||||
selectedRecordIds: selectedRowIds,
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
setContextStoreTargetedRecordIds([]);
|
||||
setContextStoreCurrentObjectMetadataItem(null);
|
||||
setContextStoreTargetedRecords({
|
||||
mode: 'selection',
|
||||
selectedRecordIds: [],
|
||||
});
|
||||
};
|
||||
}, [
|
||||
objectMetadataItem?.id,
|
||||
hasUserSelectedAllRows,
|
||||
selectedRowIds,
|
||||
setContextStoreCurrentObjectMetadataItem,
|
||||
setContextStoreTargetedRecordIds,
|
||||
setContextStoreTargetedRecords,
|
||||
unselectedRowIds,
|
||||
]);
|
||||
|
||||
return <></>;
|
||||
|
||||
@ -5,7 +5,7 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata
|
||||
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
|
||||
import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard';
|
||||
import { turnObjectDropdownFilterIntoQueryFilter } from '@/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter';
|
||||
import { turnFiltersIntoQueryFilter } from '@/object-record/record-filter/utils/turnFiltersIntoQueryFilter';
|
||||
import { useRecordBoardRecordGqlFields } from '@/object-record/record-index/hooks/useRecordBoardRecordGqlFields';
|
||||
import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState';
|
||||
import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState';
|
||||
@ -44,7 +44,7 @@ export const useLoadRecordIndexBoard = ({
|
||||
|
||||
const recordIndexFilters = useRecoilValue(recordIndexFiltersState);
|
||||
const recordIndexSorts = useRecoilValue(recordIndexSortsState);
|
||||
const requestFilters = turnObjectDropdownFilterIntoQueryFilter(
|
||||
const requestFilters = turnFiltersIntoQueryFilter(
|
||||
recordIndexFilters,
|
||||
objectMetadataItem?.fields ?? [],
|
||||
);
|
||||
|
||||
@ -5,7 +5,7 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata
|
||||
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
|
||||
import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard';
|
||||
import { turnObjectDropdownFilterIntoQueryFilter } from '@/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter';
|
||||
import { turnFiltersIntoQueryFilter } from '@/object-record/record-filter/utils/turnFiltersIntoQueryFilter';
|
||||
import { useRecordBoardRecordGqlFields } from '@/object-record/record-index/hooks/useRecordBoardRecordGqlFields';
|
||||
import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState';
|
||||
import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState';
|
||||
@ -35,7 +35,7 @@ export const useLoadRecordIndexBoardColumn = ({
|
||||
|
||||
const recordIndexFilters = useRecoilValue(recordIndexFiltersState);
|
||||
const recordIndexSorts = useRecoilValue(recordIndexSortsState);
|
||||
const requestFilters = turnObjectDropdownFilterIntoQueryFilter(
|
||||
const requestFilters = turnFiltersIntoQueryFilter(
|
||||
recordIndexFilters,
|
||||
objectMetadataItem?.fields ?? [],
|
||||
);
|
||||
|
||||
@ -5,7 +5,7 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
|
||||
import { turnObjectDropdownFilterIntoQueryFilter } from '@/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter';
|
||||
import { turnFiltersIntoQueryFilter } from '@/object-record/record-filter/utils/turnFiltersIntoQueryFilter';
|
||||
import { useRecordTableRecordGqlFields } from '@/object-record/record-index/hooks/useRecordTableRecordGqlFields';
|
||||
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
|
||||
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
|
||||
@ -27,7 +27,7 @@ export const useFindManyParams = (
|
||||
const tableFilters = useRecoilValue(tableFiltersState);
|
||||
const tableSorts = useRecoilValue(tableSortsState);
|
||||
|
||||
const filter = turnObjectDropdownFilterIntoQueryFilter(
|
||||
const filter = turnFiltersIntoQueryFilter(
|
||||
tableFilters,
|
||||
objectMetadataItem?.fields ?? [],
|
||||
);
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { RecordIndexOptionsDropdownButton } from '@/object-record/record-index/options/components/RecordIndexOptionsDropdownButton';
|
||||
import { RecordIndexOptionsDropdownContent } from '@/object-record/record-index/options/components/RecordIndexOptionsDropdownContent';
|
||||
import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId';
|
||||
@ -7,13 +8,13 @@ import { ViewType } from '@/views/types/ViewType';
|
||||
|
||||
type RecordIndexOptionsDropdownProps = {
|
||||
viewType: ViewType;
|
||||
objectNameSingular: string;
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
recordIndexId: string;
|
||||
};
|
||||
|
||||
export const RecordIndexOptionsDropdown = ({
|
||||
recordIndexId,
|
||||
objectNameSingular,
|
||||
objectMetadataItem,
|
||||
viewType,
|
||||
}: RecordIndexOptionsDropdownProps) => {
|
||||
return (
|
||||
@ -26,7 +27,7 @@ export const RecordIndexOptionsDropdown = ({
|
||||
dropdownComponents={
|
||||
<RecordIndexOptionsDropdownContent
|
||||
viewType={viewType}
|
||||
objectNameSingular={objectNameSingular}
|
||||
objectMetadataItem={objectMetadataItem}
|
||||
recordIndexId={recordIndexId}
|
||||
/>
|
||||
}
|
||||
|
||||
@ -14,10 +14,12 @@ import {
|
||||
import { useObjectNamePluralFromSingular } from '@/object-metadata/hooks/useObjectNamePluralFromSingular';
|
||||
import { useHandleToggleTrashColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleTrashColumnFilter';
|
||||
import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId';
|
||||
|
||||
import {
|
||||
displayedExportProgress,
|
||||
useExportTableData,
|
||||
} from '@/object-record/record-index/options/hooks/useExportTableData';
|
||||
useExportRecordData,
|
||||
} from '@/action-menu/hooks/useExportRecordData';
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { useRecordIndexOptionsForBoard } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard';
|
||||
import { useRecordIndexOptionsForTable } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForTable';
|
||||
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
|
||||
@ -44,14 +46,14 @@ type RecordIndexOptionsMenu = 'fields' | 'hiddenFields';
|
||||
|
||||
type RecordIndexOptionsDropdownContentProps = {
|
||||
recordIndexId: string;
|
||||
objectNameSingular: string;
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
viewType: ViewType;
|
||||
};
|
||||
|
||||
export const RecordIndexOptionsDropdownContent = ({
|
||||
viewType,
|
||||
recordIndexId,
|
||||
objectNameSingular,
|
||||
objectMetadataItem,
|
||||
}: RecordIndexOptionsDropdownContentProps) => {
|
||||
const { currentViewWithCombinedFiltersAndSorts } = useGetCurrentView();
|
||||
|
||||
@ -68,7 +70,7 @@ export const RecordIndexOptionsDropdownContent = ({
|
||||
};
|
||||
|
||||
const { objectNamePlural } = useObjectNamePluralFromSingular({
|
||||
objectNameSingular: objectNameSingular,
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
});
|
||||
|
||||
const settingsUrl = getSettingsPagePath(SettingsPath.ObjectDetail, {
|
||||
@ -92,7 +94,7 @@ export const RecordIndexOptionsDropdownContent = ({
|
||||
|
||||
const { handleToggleTrashColumnFilter, toggleSoftDeleteFilterState } =
|
||||
useHandleToggleTrashColumnFilter({
|
||||
objectNameSingular,
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
viewBarId: recordIndexId,
|
||||
});
|
||||
|
||||
@ -104,7 +106,7 @@ export const RecordIndexOptionsDropdownContent = ({
|
||||
isCompactModeActive,
|
||||
setAndPersistIsCompactModeActive,
|
||||
} = useRecordIndexOptionsForBoard({
|
||||
objectNameSingular,
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
recordBoardId: recordIndexId,
|
||||
viewBarId: recordIndexId,
|
||||
});
|
||||
@ -126,12 +128,12 @@ export const RecordIndexOptionsDropdownContent = ({
|
||||
: handleColumnVisibilityChange;
|
||||
|
||||
const { openObjectRecordsSpreasheetImportDialog } =
|
||||
useOpenObjectRecordsSpreasheetImportDialog(objectNameSingular);
|
||||
useOpenObjectRecordsSpreasheetImportDialog(objectMetadataItem.nameSingular);
|
||||
|
||||
const { progress, download } = useExportTableData({
|
||||
const { progress, download } = useExportRecordData({
|
||||
delayMs: 100,
|
||||
filename: `${objectNameSingular}.csv`,
|
||||
objectNameSingular,
|
||||
filename: `${objectMetadataItem.nameSingular}.csv`,
|
||||
objectMetadataItem,
|
||||
recordIndexId,
|
||||
viewType,
|
||||
});
|
||||
|
||||
@ -1,107 +0,0 @@
|
||||
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
|
||||
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
|
||||
|
||||
import { RelationDefinitionType } from '~/generated-metadata/graphql';
|
||||
import {
|
||||
csvDownloader,
|
||||
displayedExportProgress,
|
||||
download,
|
||||
generateCsv,
|
||||
} from '../useExportTableData';
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
describe('download', () => {
|
||||
it('creates a download link and clicks it', () => {
|
||||
const link = document.createElement('a');
|
||||
document.createElement = jest.fn().mockReturnValue(link);
|
||||
const appendChild = jest.spyOn(document.body, 'appendChild');
|
||||
const click = jest.spyOn(link, 'click');
|
||||
|
||||
URL.createObjectURL = jest.fn().mockReturnValue('fake-url');
|
||||
download(new Blob(['test'], { type: 'text/plain' }), 'test.txt');
|
||||
|
||||
expect(appendChild).toHaveBeenCalledWith(link);
|
||||
expect(link.href).toEqual('http://localhost/fake-url');
|
||||
expect(link.getAttribute('download')).toEqual('test.txt');
|
||||
expect(click).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateCsv', () => {
|
||||
it('generates a csv with formatted headers', async () => {
|
||||
const columns = [
|
||||
{ label: 'Foo', metadata: { fieldName: 'foo' } },
|
||||
{ label: 'Empty', metadata: { fieldName: 'empty' } },
|
||||
{ label: 'Nested', metadata: { fieldName: 'nested' } },
|
||||
{
|
||||
label: 'Relation',
|
||||
metadata: {
|
||||
fieldName: 'relation',
|
||||
relationType: RelationDefinitionType.ManyToOne,
|
||||
},
|
||||
},
|
||||
] as ColumnDefinition<FieldMetadata>[];
|
||||
const rows = [
|
||||
{
|
||||
id: '1',
|
||||
bar: 'another field',
|
||||
empty: null,
|
||||
foo: 'some field',
|
||||
nested: { __typename: 'type', foo: 'foo', nested: 'nested' },
|
||||
relation: 'a relation',
|
||||
},
|
||||
];
|
||||
const csv = generateCsv({ columns, rows });
|
||||
expect(csv).toEqual(`Id,Foo,Empty,Nested Foo,Nested Nested,Relation
|
||||
1,some field,,foo,nested,a relation`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('csvDownloader', () => {
|
||||
it('downloads a csv', () => {
|
||||
const filename = 'test.csv';
|
||||
const data = {
|
||||
rows: [
|
||||
{ id: 1, name: 'John' },
|
||||
{ id: 2, name: 'Alice' },
|
||||
],
|
||||
columns: [],
|
||||
objectNameSingular: '',
|
||||
};
|
||||
|
||||
const link = document.createElement('a');
|
||||
document.createElement = jest.fn().mockReturnValue(link);
|
||||
const createObjectURL = jest.spyOn(URL, 'createObjectURL');
|
||||
|
||||
csvDownloader(filename, data);
|
||||
|
||||
expect(link.getAttribute('download')).toEqual('test.csv');
|
||||
expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob));
|
||||
expect(createObjectURL).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'text/csv' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('displayedExportProgress', () => {
|
||||
it.each([
|
||||
[undefined, undefined, 'percentage', 'Export'],
|
||||
[20, 50, 'percentage', 'Export (40%)'],
|
||||
[0, 100, 'number', 'Export (0)'],
|
||||
[10, 10, 'percentage', 'Export (100%)'],
|
||||
[10, 10, 'number', 'Export (10)'],
|
||||
[7, 9, 'percentage', 'Export (78%)'],
|
||||
])(
|
||||
'displays the export progress',
|
||||
(exportedRecordCount, totalRecordCount, displayType, expected) => {
|
||||
expect(
|
||||
displayedExportProgress({
|
||||
exportedRecordCount,
|
||||
totalRecordCount,
|
||||
displayType: displayType as 'percentage' | 'number',
|
||||
}),
|
||||
).toEqual(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
@ -1,6 +1,6 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { act } from 'react';
|
||||
import { percentage, sleep, useTableData } from '../useTableData';
|
||||
import { percentage, sleep, useRecordData } from '../useRecordData';
|
||||
|
||||
import { PERSON_FRAGMENT_WITH_DEPTH_ZERO_RELATIONS } from '@/object-record/hooks/__mocks__/personFragments';
|
||||
import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard';
|
||||
@ -11,7 +11,7 @@ import { ViewType } from '@/views/types/ViewType';
|
||||
import { MockedResponse } from '@apollo/client/testing';
|
||||
import gql from 'graphql-tag';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
|
||||
import { getJestMetadataAndApolloMocksAndContextStoreWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndContextStoreWrapper';
|
||||
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
|
||||
|
||||
const defaultResponseData = {
|
||||
@ -127,9 +127,16 @@ const mocks: MockedResponse[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const WrapperWithResponse = getJestMetadataAndApolloMocksWrapper({
|
||||
apolloMocks: mocks,
|
||||
});
|
||||
const WrapperWithResponse = getJestMetadataAndApolloMocksAndContextStoreWrapper(
|
||||
{
|
||||
apolloMocks: mocks,
|
||||
contextStoreTargetedRecordsRule: {
|
||||
mode: 'selection',
|
||||
selectedRecordIds: [],
|
||||
},
|
||||
contextStoreCurrentObjectMetadataNameSingular: 'person',
|
||||
},
|
||||
);
|
||||
|
||||
const graphqlEmptyResponse = [
|
||||
{
|
||||
@ -145,28 +152,41 @@ const graphqlEmptyResponse = [
|
||||
},
|
||||
];
|
||||
|
||||
const WrapperWithEmptyResponse = getJestMetadataAndApolloMocksWrapper({
|
||||
apolloMocks: graphqlEmptyResponse,
|
||||
});
|
||||
const WrapperWithEmptyResponse =
|
||||
getJestMetadataAndApolloMocksAndContextStoreWrapper({
|
||||
apolloMocks: graphqlEmptyResponse,
|
||||
contextStoreTargetedRecordsRule: {
|
||||
mode: 'selection',
|
||||
selectedRecordIds: [],
|
||||
},
|
||||
contextStoreCurrentObjectMetadataNameSingular: 'person',
|
||||
});
|
||||
|
||||
describe('useTableData', () => {
|
||||
describe('useRecordData', () => {
|
||||
const recordIndexId = 'people';
|
||||
const objectNameSingular = 'person';
|
||||
const objectMetadataItem = generatedMockObjectMetadataItems.find(
|
||||
(item) => item.nameSingular === 'person',
|
||||
);
|
||||
if (!objectMetadataItem) {
|
||||
throw new Error('Object metadata item not found');
|
||||
}
|
||||
describe('data fetching', () => {
|
||||
it('should handle no records', async () => {
|
||||
const callback = jest.fn();
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableData({
|
||||
useRecordData({
|
||||
recordIndexId,
|
||||
objectNameSingular,
|
||||
objectMetadataItem,
|
||||
pageSize: 30,
|
||||
callback,
|
||||
delayMs: 0,
|
||||
viewType: ViewType.Kanban,
|
||||
}),
|
||||
{ wrapper: WrapperWithEmptyResponse },
|
||||
{
|
||||
wrapper: WrapperWithEmptyResponse,
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
@ -182,9 +202,9 @@ describe('useTableData', () => {
|
||||
const callback = jest.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableData({
|
||||
useRecordData({
|
||||
recordIndexId,
|
||||
objectNameSingular,
|
||||
objectMetadataItem,
|
||||
callback,
|
||||
pageSize: 30,
|
||||
|
||||
@ -211,9 +231,9 @@ describe('useTableData', () => {
|
||||
recordIndexId,
|
||||
);
|
||||
return {
|
||||
tableData: useTableData({
|
||||
tableData: useRecordData({
|
||||
recordIndexId,
|
||||
objectNameSingular,
|
||||
objectMetadataItem,
|
||||
callback,
|
||||
pageSize: 30,
|
||||
maximumRequests: 100,
|
||||
@ -223,7 +243,7 @@ describe('useTableData', () => {
|
||||
useRecordBoardHook: useRecordBoard(recordIndexId),
|
||||
kanbanFieldName: useRecoilValue(kanbanFieldNameState),
|
||||
kanbanData: useRecordIndexOptionsForBoard({
|
||||
objectNameSingular,
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
recordBoardId: recordIndexId,
|
||||
viewBarId: recordIndexId,
|
||||
}),
|
||||
@ -304,9 +324,9 @@ describe('useTableData', () => {
|
||||
recordIndexId,
|
||||
);
|
||||
return {
|
||||
tableData: useTableData({
|
||||
tableData: useRecordData({
|
||||
recordIndexId,
|
||||
objectNameSingular,
|
||||
objectMetadataItem,
|
||||
callback,
|
||||
pageSize: 30,
|
||||
maximumRequests: 100,
|
||||
@ -316,7 +336,7 @@ describe('useTableData', () => {
|
||||
setKanbanFieldName: useRecordBoard(recordIndexId),
|
||||
kanbanFieldName: useRecoilValue(kanbanFieldNameState),
|
||||
kanbanData: useRecordIndexOptionsForBoard({
|
||||
objectNameSingular,
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
recordBoardId: recordIndexId,
|
||||
viewBarId: recordIndexId,
|
||||
}),
|
||||
@ -1,43 +0,0 @@
|
||||
import { useFavorites } from '@/favorites/hooks/useFavorites';
|
||||
import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords';
|
||||
import { UseTableDataOptions } from '@/object-record/record-index/options/hooks/useTableData';
|
||||
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
|
||||
|
||||
type UseDeleteTableDataOptions = Pick<
|
||||
UseTableDataOptions,
|
||||
'objectNameSingular' | 'recordIndexId'
|
||||
>;
|
||||
|
||||
export const useDeleteTableData = ({
|
||||
objectNameSingular,
|
||||
recordIndexId,
|
||||
}: UseDeleteTableDataOptions) => {
|
||||
const { resetTableRowSelection } = useRecordTable({
|
||||
recordTableId: recordIndexId,
|
||||
});
|
||||
|
||||
const { deleteManyRecords } = useDeleteManyRecords({
|
||||
objectNameSingular,
|
||||
});
|
||||
const { favorites, deleteFavorite } = useFavorites();
|
||||
|
||||
const deleteRecords = async (recordIdsToDelete: string[]) => {
|
||||
resetTableRowSelection();
|
||||
|
||||
for (const recordIdToDelete of recordIdsToDelete) {
|
||||
const foundFavorite = favorites?.find(
|
||||
(favorite) => favorite.recordId === recordIdToDelete,
|
||||
);
|
||||
|
||||
if (foundFavorite !== undefined) {
|
||||
deleteFavorite(foundFavorite.id);
|
||||
}
|
||||
}
|
||||
|
||||
await deleteManyRecords(recordIdsToDelete, {
|
||||
delayInMsBetweenRequests: 50,
|
||||
});
|
||||
};
|
||||
|
||||
return { deleteTableData: deleteRecords };
|
||||
};
|
||||
@ -1,174 +0,0 @@
|
||||
import { json2csv } from 'json-2-csv';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
|
||||
import { EXPORT_TABLE_DATA_DEFAULT_PAGE_SIZE } from '@/object-record/record-index/options/constants/ExportTableDataDefaultPageSize';
|
||||
import { useProcessRecordsForCSVExport } from '@/object-record/record-index/options/hooks/useProcessRecordsForCSVExport';
|
||||
import {
|
||||
useTableData,
|
||||
UseTableDataOptions,
|
||||
} from '@/object-record/record-index/options/hooks/useTableData';
|
||||
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
|
||||
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { RelationDefinitionType } from '~/generated-metadata/graphql';
|
||||
import { FieldMetadataType } from '~/generated/graphql';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
||||
|
||||
export const download = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.parentNode?.removeChild(link);
|
||||
};
|
||||
|
||||
type GenerateExportOptions = {
|
||||
columns: ColumnDefinition<FieldMetadata>[];
|
||||
rows: object[];
|
||||
};
|
||||
|
||||
type GenerateExport = (data: GenerateExportOptions) => string;
|
||||
|
||||
type ExportProgress = {
|
||||
exportedRecordCount?: number;
|
||||
totalRecordCount?: number;
|
||||
displayType: 'percentage' | 'number';
|
||||
};
|
||||
|
||||
export const generateCsv: GenerateExport = ({
|
||||
columns,
|
||||
rows,
|
||||
}: GenerateExportOptions): string => {
|
||||
const columnsToExport = columns.filter(
|
||||
(col) =>
|
||||
!('relationType' in col.metadata && col.metadata.relationType) ||
|
||||
col.metadata.relationType === RelationDefinitionType.ManyToOne,
|
||||
);
|
||||
|
||||
const objectIdColumn: ColumnDefinition<FieldMetadata> = {
|
||||
fieldMetadataId: '',
|
||||
type: FieldMetadataType.Uuid,
|
||||
iconName: '',
|
||||
label: `Id`,
|
||||
metadata: {
|
||||
fieldName: 'id',
|
||||
},
|
||||
position: 0,
|
||||
size: 0,
|
||||
};
|
||||
|
||||
const columnsToExportWithIdColumn = [objectIdColumn, ...columnsToExport];
|
||||
|
||||
const keys = columnsToExportWithIdColumn.flatMap((col) => {
|
||||
const column = {
|
||||
field: `${col.metadata.fieldName}${col.type === 'RELATION' ? 'Id' : ''}`,
|
||||
title: [col.label, col.type === 'RELATION' ? 'Id' : null]
|
||||
.filter(isDefined)
|
||||
.join(' '),
|
||||
};
|
||||
|
||||
const fieldsWithSubFields = rows.find((row) => {
|
||||
const fieldValue = (row as any)[column.field];
|
||||
|
||||
const hasSubFields =
|
||||
fieldValue &&
|
||||
typeof fieldValue === 'object' &&
|
||||
!Array.isArray(fieldValue);
|
||||
|
||||
return hasSubFields;
|
||||
});
|
||||
|
||||
if (isDefined(fieldsWithSubFields)) {
|
||||
const nestedFieldsWithoutTypename = Object.keys(
|
||||
(fieldsWithSubFields as any)[column.field],
|
||||
)
|
||||
.filter((key) => key !== '__typename')
|
||||
.map((key) => ({
|
||||
field: `${column.field}.${key}`,
|
||||
title: `${column.title} ${key[0].toUpperCase() + key.slice(1)}`,
|
||||
}));
|
||||
|
||||
return nestedFieldsWithoutTypename;
|
||||
}
|
||||
|
||||
return [column];
|
||||
});
|
||||
|
||||
return json2csv(rows, {
|
||||
keys,
|
||||
emptyFieldValue: '',
|
||||
});
|
||||
};
|
||||
|
||||
const percentage = (part: number, whole: number): number => {
|
||||
return Math.round((part / whole) * 100);
|
||||
};
|
||||
|
||||
export const displayedExportProgress = (progress?: ExportProgress): string => {
|
||||
if (isUndefinedOrNull(progress?.exportedRecordCount)) {
|
||||
return 'Export';
|
||||
}
|
||||
|
||||
if (
|
||||
progress.displayType === 'percentage' &&
|
||||
isDefined(progress?.totalRecordCount)
|
||||
) {
|
||||
return `Export (${percentage(
|
||||
progress.exportedRecordCount,
|
||||
progress.totalRecordCount,
|
||||
)}%)`;
|
||||
}
|
||||
|
||||
return `Export (${progress.exportedRecordCount})`;
|
||||
};
|
||||
|
||||
const downloader = (mimeType: string, generator: GenerateExport) => {
|
||||
return (filename: string, data: GenerateExportOptions) => {
|
||||
const blob = new Blob([generator(data)], { type: mimeType });
|
||||
download(blob, filename);
|
||||
};
|
||||
};
|
||||
|
||||
export const csvDownloader = downloader('text/csv', generateCsv);
|
||||
|
||||
type UseExportTableDataOptions = Omit<UseTableDataOptions, 'callback'> & {
|
||||
filename: string;
|
||||
};
|
||||
|
||||
export const useExportTableData = ({
|
||||
delayMs,
|
||||
filename,
|
||||
maximumRequests = 100,
|
||||
objectNameSingular,
|
||||
pageSize = EXPORT_TABLE_DATA_DEFAULT_PAGE_SIZE,
|
||||
recordIndexId,
|
||||
viewType,
|
||||
}: UseExportTableDataOptions) => {
|
||||
const { processRecordsForCSVExport } =
|
||||
useProcessRecordsForCSVExport(objectNameSingular);
|
||||
|
||||
const downloadCsv = useMemo(
|
||||
() =>
|
||||
(records: ObjectRecord[], columns: ColumnDefinition<FieldMetadata>[]) => {
|
||||
const recordsProcessedForExport = processRecordsForCSVExport(records);
|
||||
|
||||
csvDownloader(filename, { rows: recordsProcessedForExport, columns });
|
||||
},
|
||||
[filename, processRecordsForCSVExport],
|
||||
);
|
||||
|
||||
const { getTableData: download, progress } = useTableData({
|
||||
delayMs,
|
||||
maximumRequests,
|
||||
objectNameSingular,
|
||||
pageSize,
|
||||
recordIndexId,
|
||||
callback: downloadCsv,
|
||||
viewType,
|
||||
});
|
||||
|
||||
return { progress, download };
|
||||
};
|
||||
@ -1,18 +1,21 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { useLazyFindManyRecords } from '@/object-record/hooks/useLazyFindManyRecords';
|
||||
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
|
||||
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
|
||||
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
|
||||
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
import { contextStoreTargetedRecordsRuleState } from '@/context-store/states/contextStoreTargetedRecordsRuleState';
|
||||
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { useLazyFindManyRecords } from '@/object-record/hooks/useLazyFindManyRecords';
|
||||
import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates';
|
||||
import { useFindManyParams } from '@/object-record/record-index/hooks/useLoadRecordIndexTable';
|
||||
import { EXPORT_TABLE_DATA_DEFAULT_PAGE_SIZE } from '@/object-record/record-index/options/constants/ExportTableDataDefaultPageSize';
|
||||
import { useRecordIndexOptionsForBoard } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard';
|
||||
import { ViewType } from '@/views/types/ViewType';
|
||||
import { useFindManyParams } from '../../hooks/useLoadRecordIndexTable';
|
||||
|
||||
export const sleep = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@ -21,10 +24,10 @@ export const percentage = (part: number, whole: number): number => {
|
||||
return Math.round((part / whole) * 100);
|
||||
};
|
||||
|
||||
export type UseTableDataOptions = {
|
||||
export type UseRecordDataOptions = {
|
||||
delayMs: number;
|
||||
maximumRequests?: number;
|
||||
objectNameSingular: string;
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
pageSize?: number;
|
||||
recordIndexId: string;
|
||||
callback: (
|
||||
@ -40,15 +43,15 @@ type ExportProgress = {
|
||||
displayType: 'percentage' | 'number';
|
||||
};
|
||||
|
||||
export const useTableData = ({
|
||||
export const useRecordData = ({
|
||||
objectMetadataItem,
|
||||
delayMs,
|
||||
maximumRequests = 100,
|
||||
objectNameSingular,
|
||||
pageSize = EXPORT_TABLE_DATA_DEFAULT_PAGE_SIZE,
|
||||
recordIndexId,
|
||||
callback,
|
||||
viewType = ViewType.Table,
|
||||
}: UseTableDataOptions) => {
|
||||
}: UseRecordDataOptions) => {
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [inflight, setInflight] = useState(false);
|
||||
const [pageCount, setPageCount] = useState(0);
|
||||
@ -57,15 +60,10 @@ export const useTableData = ({
|
||||
});
|
||||
const [previousRecordCount, setPreviousRecordCount] = useState(0);
|
||||
|
||||
const {
|
||||
visibleTableColumnsSelector,
|
||||
selectedRowIdsSelector,
|
||||
tableRowIdsState,
|
||||
hasUserSelectedAllRowsState,
|
||||
} = useRecordTableStates(recordIndexId);
|
||||
const { visibleTableColumnsSelector } = useRecordTableStates(recordIndexId);
|
||||
|
||||
const { hiddenBoardFields } = useRecordIndexOptionsForBoard({
|
||||
objectNameSingular,
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
recordBoardId: recordIndexId,
|
||||
viewBarId: recordIndexId,
|
||||
});
|
||||
@ -76,61 +74,21 @@ export const useTableData = ({
|
||||
(column) => column.metadata.fieldName === kanbanFieldMetadataName,
|
||||
);
|
||||
const columns = useRecoilValue(visibleTableColumnsSelector());
|
||||
const selectedRowIds = useRecoilValue(selectedRowIdsSelector());
|
||||
|
||||
const hasUserSelectedAllRows = useRecoilValue(hasUserSelectedAllRowsState);
|
||||
const tableRowIds = useRecoilValue(tableRowIdsState);
|
||||
const contextStoreTargetedRecordsRule = useRecoilValue(
|
||||
contextStoreTargetedRecordsRuleState,
|
||||
);
|
||||
|
||||
// user has checked select all and then unselected some rows
|
||||
const userHasUnselectedSomeRows =
|
||||
hasUserSelectedAllRows && selectedRowIds.length < tableRowIds.length;
|
||||
|
||||
const hasSelectedRows =
|
||||
selectedRowIds.length > 0 &&
|
||||
!(hasUserSelectedAllRows && selectedRowIds.length === tableRowIds.length);
|
||||
|
||||
const unselectedRowIds = useMemo(
|
||||
() =>
|
||||
userHasUnselectedSomeRows
|
||||
? tableRowIds.filter((id) => !selectedRowIds.includes(id))
|
||||
: [],
|
||||
[userHasUnselectedSomeRows, tableRowIds, selectedRowIds],
|
||||
const queryFilter = computeContextStoreFilters(
|
||||
contextStoreTargetedRecordsRule,
|
||||
objectMetadataItem,
|
||||
);
|
||||
|
||||
const findManyRecordsParams = useFindManyParams(
|
||||
objectNameSingular,
|
||||
objectMetadataItem.nameSingular,
|
||||
recordIndexId,
|
||||
);
|
||||
|
||||
const selectedFindManyParams = {
|
||||
...findManyRecordsParams,
|
||||
filter: {
|
||||
...findManyRecordsParams.filter,
|
||||
id: {
|
||||
in: selectedRowIds,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const unselectedFindManyParams = {
|
||||
...findManyRecordsParams,
|
||||
filter: {
|
||||
...findManyRecordsParams.filter,
|
||||
not: {
|
||||
id: {
|
||||
in: unselectedRowIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const usedFindManyParams =
|
||||
hasSelectedRows && !userHasUnselectedSomeRows
|
||||
? selectedFindManyParams
|
||||
: userHasUnselectedSomeRows
|
||||
? unselectedFindManyParams
|
||||
: findManyRecordsParams;
|
||||
|
||||
const {
|
||||
findManyRecords,
|
||||
totalCount,
|
||||
@ -138,7 +96,8 @@ export const useTableData = ({
|
||||
fetchMoreRecordsWithPagination,
|
||||
loading,
|
||||
} = useLazyFindManyRecords({
|
||||
...usedFindManyParams,
|
||||
...findManyRecordsParams,
|
||||
filter: queryFilter,
|
||||
limit: pageSize,
|
||||
});
|
||||
|
||||
@ -1,41 +1,22 @@
|
||||
import { Key } from 'ts-key-enum';
|
||||
|
||||
import { SOFT_FOCUS_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-table/constants/SoftFocusClickOutsideListenerId';
|
||||
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
|
||||
import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope';
|
||||
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||
import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener';
|
||||
import {
|
||||
ClickOutsideMode,
|
||||
useListenClickOutsideByClassName,
|
||||
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
import { useListenClickOutsideByClassName } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
|
||||
type RecordTableInternalEffectProps = {
|
||||
recordTableId: string;
|
||||
tableBodyRef: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
export const RecordTableInternalEffect = ({
|
||||
recordTableId,
|
||||
tableBodyRef,
|
||||
}: RecordTableInternalEffectProps) => {
|
||||
const { leaveTableFocus, resetTableRowSelection, useMapKeyboardToSoftFocus } =
|
||||
useRecordTable({ recordTableId });
|
||||
|
||||
useMapKeyboardToSoftFocus();
|
||||
|
||||
const { useListenClickOutside } = useClickOutsideListener(
|
||||
SOFT_FOCUS_CLICK_OUTSIDE_LISTENER_ID,
|
||||
);
|
||||
|
||||
useListenClickOutside({
|
||||
refs: [tableBodyRef],
|
||||
callback: () => {
|
||||
leaveTableFocus();
|
||||
},
|
||||
mode: ClickOutsideMode.compareHTMLRef,
|
||||
});
|
||||
|
||||
useScopedHotkeys(
|
||||
[Key.Escape],
|
||||
() => {
|
||||
@ -46,9 +27,9 @@ export const RecordTableInternalEffect = ({
|
||||
|
||||
useListenClickOutsideByClassName({
|
||||
classNames: ['entity-table-cell'],
|
||||
excludeClassNames: ['bottom-bar', 'context-menu'],
|
||||
excludeClassNames: ['bottom-bar', 'action-menu-dropdown'],
|
||||
callback: () => {
|
||||
resetTableRowSelection();
|
||||
leaveTableFocus();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -87,10 +87,7 @@ export const RecordTableWithWrappers = ({
|
||||
onDragSelectionChange={setRowSelected}
|
||||
/>
|
||||
</StyledTableInternalContainer>
|
||||
<RecordTableInternalEffect
|
||||
recordTableId={recordTableId}
|
||||
tableBodyRef={tableBodyRef}
|
||||
/>
|
||||
<RecordTableInternalEffect recordTableId={recordTableId} />
|
||||
</StyledTableContainer>
|
||||
</StyledTableWithHeader>
|
||||
</RecordUpdateContext.Provider>
|
||||
|
||||
@ -6,21 +6,19 @@ import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotV
|
||||
|
||||
import { TableHotkeyScope } from '../../types/TableHotkeyScope';
|
||||
|
||||
import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection';
|
||||
import { useCloseCurrentTableCellInEditMode } from './useCloseCurrentTableCellInEditMode';
|
||||
import { useDisableSoftFocus } from './useDisableSoftFocus';
|
||||
import { useSetHasUserSelectedAllRows } from './useSetAllRowSelectedState';
|
||||
|
||||
export const useLeaveTableFocus = (recordTableId?: string) => {
|
||||
const disableSoftFocus = useDisableSoftFocus(recordTableId);
|
||||
const closeCurrentCellInEditMode =
|
||||
useCloseCurrentTableCellInEditMode(recordTableId);
|
||||
|
||||
const setHasUserSelectedAllRows = useSetHasUserSelectedAllRows(recordTableId);
|
||||
|
||||
const selectAllRows = useSetHasUserSelectedAllRows(recordTableId);
|
||||
|
||||
const { isSoftFocusActiveState } = useRecordTableStates(recordTableId);
|
||||
|
||||
const resetTableRowSelection = useResetTableRowSelection(recordTableId);
|
||||
|
||||
return useRecoilCallback(
|
||||
({ snapshot }) =>
|
||||
() => {
|
||||
@ -33,6 +31,8 @@ export const useLeaveTableFocus = (recordTableId?: string) => {
|
||||
.getLoadable(currentHotkeyScopeState)
|
||||
.getValue();
|
||||
|
||||
resetTableRowSelection();
|
||||
|
||||
if (!isSoftFocusActive) {
|
||||
return;
|
||||
}
|
||||
@ -43,15 +43,12 @@ export const useLeaveTableFocus = (recordTableId?: string) => {
|
||||
|
||||
closeCurrentCellInEditMode();
|
||||
disableSoftFocus();
|
||||
setHasUserSelectedAllRows(false);
|
||||
selectAllRows(false);
|
||||
},
|
||||
[
|
||||
closeCurrentCellInEditMode,
|
||||
disableSoftFocus,
|
||||
isSoftFocusActiveState,
|
||||
selectAllRows,
|
||||
setHasUserSelectedAllRows,
|
||||
resetTableRowSelection,
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
@ -19,6 +19,7 @@ import { allRowsSelectedStatusComponentSelector } from '@/object-record/record-t
|
||||
import { hiddenTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/hiddenTableColumnsComponentSelector';
|
||||
import { numberOfTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/numberOfTableColumnsComponentSelector';
|
||||
import { selectedRowIdsComponentSelector } from '@/object-record/record-table/states/selectors/selectedRowIdsComponentSelector';
|
||||
import { unselectedRowIdsComponentSelector } from '@/object-record/record-table/states/selectors/unselectedRowIdsComponentSelector';
|
||||
import { visibleTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/visibleTableColumnsComponentSelector';
|
||||
import { softFocusPositionComponentState } from '@/object-record/record-table/states/softFocusPositionComponentState';
|
||||
import { tableColumnsComponentState } from '@/object-record/record-table/states/tableColumnsComponentState';
|
||||
@ -134,6 +135,10 @@ export const useRecordTableStates = (recordTableId?: string) => {
|
||||
selectedRowIdsComponentSelector,
|
||||
scopeId,
|
||||
),
|
||||
unselectedRowIdsSelector: extractComponentReadOnlySelector(
|
||||
unselectedRowIdsComponentSelector,
|
||||
scopeId,
|
||||
),
|
||||
visibleTableColumnsSelector: extractComponentReadOnlySelector(
|
||||
visibleTableColumnsComponentSelector,
|
||||
scopeId,
|
||||
|
||||
@ -46,17 +46,16 @@ export const useSetRecordTableData = ({
|
||||
const recordIds = newRecords.map((record) => record.id);
|
||||
|
||||
if (!isDeeplyEqual(currentRowIds, recordIds)) {
|
||||
set(tableRowIdsState, recordIds);
|
||||
}
|
||||
|
||||
if (hasUserSelectedAllRows) {
|
||||
for (const rowId of recordIds) {
|
||||
set(isRowSelectedFamilyState(rowId), true);
|
||||
if (hasUserSelectedAllRows) {
|
||||
for (const rowId of recordIds) {
|
||||
set(isRowSelectedFamilyState(rowId), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set(numberOfTableRowsState, totalCount ?? 0);
|
||||
onEntityCountChange(totalCount);
|
||||
set(tableRowIdsState, recordIds);
|
||||
set(numberOfTableRowsState, totalCount ?? 0);
|
||||
onEntityCountChange(totalCount);
|
||||
}
|
||||
},
|
||||
[
|
||||
numberOfTableRowsState,
|
||||
|
||||
@ -42,6 +42,7 @@ export const useRecordTable = (props?: useRecordTableProps) => {
|
||||
isRecordTableInitialLoadingState,
|
||||
tableLastRowVisibleState,
|
||||
selectedRowIdsSelector,
|
||||
unselectedRowIdsSelector,
|
||||
onToggleColumnFilterState,
|
||||
onToggleColumnSortState,
|
||||
pendingRecordIdState,
|
||||
@ -223,6 +224,7 @@ export const useRecordTable = (props?: useRecordTableProps) => {
|
||||
setSoftFocusPosition,
|
||||
isSomeCellInEditModeState,
|
||||
selectedRowIdsSelector,
|
||||
unselectedRowIdsSelector,
|
||||
setHasUserSelectedAllRows,
|
||||
setOnToggleColumnFilter,
|
||||
setOnToggleColumnSort,
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
|
||||
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
|
||||
import { RecordTableTd } from '@/object-record/record-table/record-table-cell/components/RecordTableTd';
|
||||
import { useSetCurrentRowSelected } from '@/object-record/record-table/record-table-row/hooks/useSetCurrentRowSelected';
|
||||
import { Checkbox } from '@/ui/input/components/Checkbox';
|
||||
@ -21,19 +19,16 @@ const StyledContainer = styled.div`
|
||||
export const RecordTableCellCheckbox = () => {
|
||||
const { isSelected } = useContext(RecordTableRowContext);
|
||||
|
||||
const { recordId } = useContext(RecordTableRowContext);
|
||||
const { isRowSelectedFamilyState } = useRecordTableStates();
|
||||
const { setCurrentRowSelected } = useSetCurrentRowSelected();
|
||||
const currentRowSelected = useRecoilValue(isRowSelectedFamilyState(recordId));
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setCurrentRowSelected(!currentRowSelected);
|
||||
}, [currentRowSelected, setCurrentRowSelected]);
|
||||
setCurrentRowSelected(!isSelected);
|
||||
}, [isSelected, setCurrentRowSelected]);
|
||||
|
||||
return (
|
||||
<RecordTableTd isSelected={isSelected} hasRightBorder={false}>
|
||||
<StyledContainer onClick={handleClick}>
|
||||
<Checkbox hoverable checked={currentRowSelected} />
|
||||
<Checkbox hoverable checked={isSelected} />
|
||||
</StyledContainer>
|
||||
</RecordTableTd>
|
||||
);
|
||||
|
||||
@ -37,7 +37,6 @@ export const RecordTableHeaderCheckboxColumn = () => {
|
||||
setHasUserSelectedAllRows(true);
|
||||
selectAllRows();
|
||||
} else {
|
||||
setHasUserSelectedAllRows(false);
|
||||
resetTableRowSelection();
|
||||
}
|
||||
};
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
import { isRowSelectedComponentFamilyState } from '@/object-record/record-table/record-table-row/states/isRowSelectedComponentFamilyState';
|
||||
import { tableRowIdsComponentState } from '@/object-record/record-table/states/tableRowIdsComponentState';
|
||||
import { createComponentReadOnlySelector } from '@/ui/utilities/state/component-state/utils/createComponentReadOnlySelector';
|
||||
|
||||
export const unselectedRowIdsComponentSelector =
|
||||
createComponentReadOnlySelector<string[]>({
|
||||
key: 'unselectedRowIdsComponentSelector',
|
||||
get:
|
||||
({ scopeId }) =>
|
||||
({ get }) => {
|
||||
const rowIds = get(tableRowIdsComponentState({ scopeId }));
|
||||
|
||||
return rowIds.filter(
|
||||
(rowId) =>
|
||||
get(
|
||||
isRowSelectedComponentFamilyState({
|
||||
scopeId,
|
||||
familyKey: rowId,
|
||||
}),
|
||||
) === false,
|
||||
);
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user