Fix glitch at aggregate operation update (#9253)

Before:

https://github.com/user-attachments/assets/6e76b19c-b99c-4870-9c93-b75e7cf86103

After:

https://github.com/user-attachments/assets/b5827d3e-5891-4204-bf91-6fa4504f30d3

Isolated the value change in a separate component to avoid re-renders of
the parent component that has the down chevron.
Also added a context at foot cell-level to centralize viewFieldId and
fieldMetadataId that were queried in children components calling heavy
hooks.
This commit is contained in:
Marie
2024-12-27 14:36:24 +01:00
committed by GitHub
parent 546a793aed
commit 2bb71bb79a
8 changed files with 186 additions and 124 deletions

View File

@ -2,6 +2,7 @@ import styled from '@emotion/styled';
import { MOBILE_VIEWPORT } from 'twenty-ui'; import { MOBILE_VIEWPORT } from 'twenty-ui';
import { RecordTableAggregateFooterCell } from '@/object-record/record-table/record-table-footer/components/RecordTableAggregateFooterCell'; import { RecordTableAggregateFooterCell } from '@/object-record/record-table/record-table-footer/components/RecordTableAggregateFooterCell';
import { RecordTableColumnAggregateFooterCellContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterCellContext';
import { FIRST_TH_WIDTH } from '@/object-record/record-table/record-table-header/components/RecordTableHeader'; import { FIRST_TH_WIDTH } from '@/object-record/record-table/record-table-header/components/RecordTableHeader';
import { visibleTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/visibleTableColumnsComponentSelector'; import { visibleTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/visibleTableColumnsComponentSelector';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
@ -90,12 +91,18 @@ export const RecordTableAggregateFooter = ({
<StyledTh /> <StyledTh />
<StyledTh /> <StyledTh />
{visibleTableColumns.map((column, index) => ( {visibleTableColumns.map((column, index) => (
<RecordTableAggregateFooterCell <RecordTableColumnAggregateFooterCellContext.Provider
key={`${column.fieldMetadataId}${currentRecordGroupId ? '-' + currentRecordGroupId : ''}`} key={`${column.fieldMetadataId}${currentRecordGroupId ? '-' + currentRecordGroupId : ''}`}
column={column} value={{
currentRecordGroupId={currentRecordGroupId} viewFieldId: column.viewFieldId || '',
isFirstCell={index === 0} fieldMetadataId: column.fieldMetadataId,
/> }}
>
<RecordTableAggregateFooterCell
currentRecordGroupId={currentRecordGroupId}
isFirstCell={index === 0}
/>
</RecordTableColumnAggregateFooterCellContext.Provider>
))} ))}
</tr> </tr>
</StyledTableFoot> </StyledTableFoot>

View File

@ -1,10 +1,9 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useMemo } from 'react'; import { useContext, useMemo } from 'react';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { RecordTableColumnAggregateFooterCellContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterCellContext';
import { RecordTableColumnFooterWithDropdown } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterWithDropdown'; import { RecordTableColumnFooterWithDropdown } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterWithDropdown';
import { tableColumnsComponentState } from '@/object-record/record-table/states/tableColumnsComponentState'; import { tableColumnsComponentState } from '@/object-record/record-table/states/tableColumnsComponentState';
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { mapArrayToObject } from '~/utils/array/mapArrayToObject'; import { mapArrayToObject } from '~/utils/array/mapArrayToObject';
@ -12,20 +11,19 @@ const COLUMN_MIN_WIDTH = 104;
const StyledColumnFooterCell = styled.th<{ const StyledColumnFooterCell = styled.th<{
columnWidth: number; columnWidth: number;
isResizing?: boolean;
}>` }>`
color: ${({ theme }) => theme.font.color.tertiary};
padding: 0;
text-align: left;
transition: 0.3s ease;
background-color: ${({ theme }) => theme.background.primary}; background-color: ${({ theme }) => theme.background.primary};
color: ${({ theme }) => theme.font.color.tertiary};
overflow: hidden;
padding: 0;
position: relative;
${({ columnWidth }) => ` ${({ columnWidth }) => `
min-width: ${columnWidth}px; min-width: ${columnWidth}px;
width: ${columnWidth}px; width: ${columnWidth}px;
`} `}
position: relative; text-align: left;
user-select: none; transition: 0.3s ease;
${({ theme }) => { ${({ theme }) => {
return ` return `
&:hover { &:hover {
@ -36,23 +34,14 @@ const StyledColumnFooterCell = styled.th<{
}; };
`; `;
}}; }};
${({ isResizing, theme }) => {
if (isResizing === true) {
return `&:after {
background-color: ${theme.color.blue};
bottom: 0;
content: '';
display: block;
position: absolute;
right: -1px;
top: 0;
width: 2px;
}`;
}
}};
// TODO: refactor this, each component should own its CSS user-select: none;
overflow: auto; overflow: auto;
scrollbar-width: none;
-ms-overflow-style: none;
*::-webkit-scrollbar {
display: none;
}
`; `;
const StyledColumnFootContainer = styled.div` const StyledColumnFootContainer = styled.div`
@ -62,11 +51,9 @@ const StyledColumnFootContainer = styled.div`
`; `;
export const RecordTableAggregateFooterCell = ({ export const RecordTableAggregateFooterCell = ({
column,
isFirstCell = false, isFirstCell = false,
currentRecordGroupId, currentRecordGroupId,
}: { }: {
column: ColumnDefinition<FieldMetadata>;
isFirstCell?: boolean; isFirstCell?: boolean;
currentRecordGroupId?: string; currentRecordGroupId?: string;
}) => { }) => {
@ -76,18 +63,19 @@ export const RecordTableAggregateFooterCell = ({
mapArrayToObject(tableColumns, ({ fieldMetadataId }) => fieldMetadataId), mapArrayToObject(tableColumns, ({ fieldMetadataId }) => fieldMetadataId),
[tableColumns], [tableColumns],
); );
const { fieldMetadataId } = useContext(
RecordTableColumnAggregateFooterCellContext,
);
return ( return (
<StyledColumnFooterCell <StyledColumnFooterCell
key={column.fieldMetadataId}
columnWidth={Math.max( columnWidth={Math.max(
tableColumnsByKey[column.fieldMetadataId].size + 24, tableColumnsByKey[fieldMetadataId].size + 24,
COLUMN_MIN_WIDTH, COLUMN_MIN_WIDTH,
)} )}
> >
<StyledColumnFootContainer> <StyledColumnFootContainer>
<RecordTableColumnFooterWithDropdown <RecordTableColumnFooterWithDropdown
column={column}
currentRecordGroupId={currentRecordGroupId} currentRecordGroupId={currentRecordGroupId}
isFirstCell={isFirstCell} isFirstCell={isFirstCell}
/> />

View File

@ -0,0 +1,11 @@
import { createContext } from 'react';
export type RecordTableColumnAggregateFooterCellValue = {
viewFieldId: string;
fieldMetadataId: string;
};
export const RecordTableColumnAggregateFooterCellContext =
createContext<RecordTableColumnAggregateFooterCellValue>(
{} as RecordTableColumnAggregateFooterCellValue,
);

View File

@ -1,23 +1,6 @@
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; import { useAggregateRecordsForRecordTableColumnFooter } from '@/object-record/record-table/record-table-footer/hooks/useAggregateRecordsForRecordTableColumnFooter';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useState } from 'react'; import { isDefined } from 'twenty-ui';
import { IconChevronDown, isDefined } from 'twenty-ui';
const StyledCell = styled.div`
align-items: center;
display: flex;
flex-direction: row;
flex-shrink: 0;
font-weight: ${({ theme }) => theme.font.weight.medium};
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ theme }) => theme.spacing(7)};
justify-content: space-between;
min-width: ${({ theme }) => theme.spacing(7)};
flex-grow: 1;
width: 100%;
`;
const StyledText = styled.span` const StyledText = styled.span`
overflow: hidden; overflow: hidden;
@ -54,56 +37,34 @@ const StyledValue = styled.div`
flex: 1 0 0; flex: 1 0 0;
`; `;
const StyledIcon = styled(IconChevronDown)`
align-items: center;
display: flex;
height: 20px;
justify-content: center;
flex-grow: 0;
padding-right: ${({ theme }) => theme.spacing(2)};
`;
export const RecordTableColumnAggregateFooterValue = ({ export const RecordTableColumnAggregateFooterValue = ({
dropdownId, dropdownId,
aggregateValue, fieldMetadataId,
aggregateLabel,
isFirstCell,
}: { }: {
dropdownId: string; dropdownId: string;
isFirstCell: boolean; fieldMetadataId: string;
aggregateValue?: string | number | null;
aggregateLabel?: string;
}) => { }) => {
const [isHovered, setIsHovered] = useState(false);
const { isDropdownOpen } = useDropdown(dropdownId);
const sanitizedId = `tooltip-${dropdownId.replace(/[^a-zA-Z0-9-_]/g, '-')}`; const sanitizedId = `tooltip-${dropdownId.replace(/[^a-zA-Z0-9-_]/g, '-')}`;
const theme = useTheme();
const shouldShowValue = const { aggregateValue, aggregateLabel, isLoading } =
isHovered || isDropdownOpen || isDefined(aggregateValue) || isFirstCell; useAggregateRecordsForRecordTableColumnFooter(fieldMetadataId);
return ( return (
<div <>
onMouseEnter={() => { {isDefined(aggregateValue) || isLoading ? (
setIsHovered(true); <StyledValueContainer>
}} {isLoading ? (
onMouseLeave={() => setIsHovered(false)} <></>
> ) : (
<StyledCell> <>
{shouldShowValue ? ( <StyledLabel>{aggregateLabel}</StyledLabel>
<> <StyledValue>{aggregateValue}</StyledValue>
{isDefined(aggregateValue) ? ( </>
<StyledValueContainer> )}
<StyledLabel>{aggregateLabel}</StyledLabel> </StyledValueContainer>
<StyledValue>{aggregateValue}</StyledValue> ) : (
</StyledValueContainer> <StyledText id={sanitizedId}>Calculate</StyledText>
) : ( )}
<StyledText id={sanitizedId}>Calculate</StyledText> </>
)}
<StyledIcon fontWeight={'light'} size={theme.icon.size.sm} />
</>
) : (
<></>
)}
</StyledCell>
</div>
); );
}; };

View File

@ -0,0 +1,80 @@
import { RecordTableColumnAggregateFooterCellContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterCellContext';
import { RecordTableColumnAggregateFooterValue } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterValue';
import { hasAggregateOperationForViewFieldFamilySelector } from '@/object-record/record-table/record-table-footer/states/hasAggregateOperationForViewFieldFamilySelector';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useContext, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { IconChevronDown } from 'twenty-ui';
const StyledCell = styled.div`
align-items: center;
display: flex;
flex-direction: row;
flex-shrink: 0;
font-weight: ${({ theme }) => theme.font.weight.medium};
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ theme }) => theme.spacing(7)};
justify-content: space-between;
min-width: ${({ theme }) => theme.spacing(7)};
flex-grow: 1;
width: 100%;
`;
const StyledIcon = styled(IconChevronDown)`
align-items: center;
display: flex;
height: 20px;
justify-content: center;
flex-grow: 0;
padding-right: ${({ theme }) => theme.spacing(2)};
`;
export const RecordTableColumnAggregateFooterValueCell = ({
dropdownId,
isFirstCell,
}: {
dropdownId: string;
isFirstCell: boolean;
}) => {
const [isHovered, setIsHovered] = useState(false);
const { isDropdownOpen } = useDropdown(dropdownId);
const theme = useTheme();
const { viewFieldId, fieldMetadataId } = useContext(
RecordTableColumnAggregateFooterCellContext,
);
const hasAggregateOperationForViewField = useRecoilValue(
hasAggregateOperationForViewFieldFamilySelector({
viewFieldId,
}),
);
return (
<div
onMouseEnter={() => {
setIsHovered(true);
}}
onMouseLeave={() => setIsHovered(false)}
>
<StyledCell>
{isHovered ||
isDropdownOpen ||
hasAggregateOperationForViewField ||
isFirstCell ? (
<>
<RecordTableColumnAggregateFooterValue
fieldMetadataId={fieldMetadataId}
dropdownId={dropdownId}
/>
<StyledIcon fontWeight={'light'} size={theme.icon.size.sm} />
</>
) : (
<></>
)}
</StyledCell>
</div>
);
};

View File

@ -1,29 +1,29 @@
import { useCurrentContentId } from '@/dropdown/hooks/useCurrentContentId'; import { useCurrentContentId } from '@/dropdown/hooks/useCurrentContentId';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { RecordTableColumnAggregateFooterCellContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterCellContext';
import { RecordTableColumnAggregateFooterDropdownContent } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContent'; import { RecordTableColumnAggregateFooterDropdownContent } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContent';
import { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext'; import { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext';
import { RecordTableColumnAggregateFooterValue } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterValue'; import { RecordTableColumnAggregateFooterValueCell } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterValueCell';
import { useAggregateRecordsForRecordTableColumnFooter } from '@/object-record/record-table/record-table-footer/hooks/useAggregateRecordsForRecordTableColumnFooter';
import { RecordTableFooterAggregateContentId } from '@/object-record/record-table/record-table-footer/types/RecordTableFooterAggregateContentId'; import { RecordTableFooterAggregateContentId } from '@/object-record/record-table/record-table-footer/types/RecordTableFooterAggregateContentId';
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useToggleScrollWrapper } from '@/ui/utilities/scroll/hooks/useToggleScrollWrapper'; import { useToggleScrollWrapper } from '@/ui/utilities/scroll/hooks/useToggleScrollWrapper';
import { useCallback } from 'react'; import { useCallback, useContext } from 'react';
type RecordTableColumnFooterWithDropdownProps = { type RecordTableColumnFooterWithDropdownProps = {
column: ColumnDefinition<FieldMetadata>;
isFirstCell: boolean; isFirstCell: boolean;
currentRecordGroupId?: string; currentRecordGroupId?: string;
}; };
export const RecordTableColumnFooterWithDropdown = ({ export const RecordTableColumnFooterWithDropdown = ({
column,
currentRecordGroupId, currentRecordGroupId,
isFirstCell, isFirstCell,
}: RecordTableColumnFooterWithDropdownProps) => { }: RecordTableColumnFooterWithDropdownProps) => {
const { currentContentId, handleContentChange, handleResetContent } = const { currentContentId, handleContentChange, handleResetContent } =
useCurrentContentId<RecordTableFooterAggregateContentId>(); useCurrentContentId<RecordTableFooterAggregateContentId>();
const { fieldMetadataId } = useContext(
RecordTableColumnAggregateFooterCellContext,
);
const { toggleScrollXWrapper, toggleScrollYWrapper } = const { toggleScrollXWrapper, toggleScrollYWrapper } =
useToggleScrollWrapper(); useToggleScrollWrapper();
@ -38,12 +38,9 @@ export const RecordTableColumnFooterWithDropdown = ({
toggleScrollYWrapper(true); toggleScrollYWrapper(true);
}, [handleResetContent, toggleScrollXWrapper, toggleScrollYWrapper]); }, [handleResetContent, toggleScrollXWrapper, toggleScrollYWrapper]);
const { aggregateValue, aggregateLabel } =
useAggregateRecordsForRecordTableColumnFooter(column.fieldMetadataId);
const dropdownId = currentRecordGroupId const dropdownId = currentRecordGroupId
? `${column.fieldMetadataId}-footer-${currentRecordGroupId}` ? `${fieldMetadataId}-footer-${currentRecordGroupId}`
: `${column.fieldMetadataId}-footer`; : `${fieldMetadataId}-footer`;
return ( return (
<Dropdown <Dropdown
@ -51,9 +48,7 @@ export const RecordTableColumnFooterWithDropdown = ({
onClose={handleDropdownClose} onClose={handleDropdownClose}
dropdownId={dropdownId} dropdownId={dropdownId}
clickableComponent={ clickableComponent={
<RecordTableColumnAggregateFooterValue <RecordTableColumnAggregateFooterValueCell
aggregateLabel={aggregateLabel}
aggregateValue={aggregateValue}
dropdownId={dropdownId} dropdownId={dropdownId}
isFirstCell={isFirstCell} isFirstCell={isFirstCell}
/> />
@ -65,7 +60,7 @@ export const RecordTableColumnFooterWithDropdown = ({
onContentChange: handleContentChange, onContentChange: handleContentChange,
resetContent: handleResetContent, resetContent: handleResetContent,
dropdownId: dropdownId, dropdownId: dropdownId,
fieldMetadataId: column.fieldMetadataId, fieldMetadataId: fieldMetadataId,
}} }}
> >
<RecordTableColumnAggregateFooterDropdownContent /> <RecordTableColumnAggregateFooterDropdownContent />

View File

@ -6,9 +6,10 @@ import { useRecordGroupFilter } from '@/object-record/record-group/hooks/useReco
import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState'; import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState';
import { recordIndexViewFilterGroupsState } from '@/object-record/record-index/states/recordIndexViewFilterGroupsState'; import { recordIndexViewFilterGroupsState } from '@/object-record/record-index/states/recordIndexViewFilterGroupsState';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableColumnAggregateFooterCellContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterCellContext';
import { viewFieldAggregateOperationState } from '@/object-record/record-table/record-table-footer/states/viewFieldAggregateOperationState'; import { viewFieldAggregateOperationState } from '@/object-record/record-table/record-table-footer/states/viewFieldAggregateOperationState';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
@ -21,7 +22,6 @@ export const useAggregateRecordsForRecordTableColumnFooter = (
const { objectMetadataItem } = useRecordTableContextOrThrow(); const { objectMetadataItem } = useRecordTableContextOrThrow();
const { recordGroupFilter } = useRecordGroupFilter(objectMetadataItem.fields); const { recordGroupFilter } = useRecordGroupFilter(objectMetadataItem.fields);
const { currentViewWithSavedFiltersAndSorts } = useGetCurrentView();
const recordIndexViewFilterGroups = useRecoilValue( const recordIndexViewFilterGroups = useRecoilValue(
recordIndexViewFilterGroupsState, recordIndexViewFilterGroupsState,
); );
@ -37,13 +37,11 @@ export const useAggregateRecordsForRecordTableColumnFooter = (
recordIndexViewFilterGroups, recordIndexViewFilterGroups,
); );
const viewFieldId = const { viewFieldId } = useContext(
currentViewWithSavedFiltersAndSorts?.viewFields?.find( RecordTableColumnAggregateFooterCellContext,
(viewField) => viewField.fieldMetadataId === fieldMetadataId, );
)?.id ?? '';
const aggregateOperationForViewField = useRecoilValue( const aggregateOperationForViewField = useRecoilValue(
viewFieldAggregateOperationState({ viewFieldId: viewFieldId }), viewFieldAggregateOperationState({ viewFieldId }),
); );
const fieldName = objectMetadataItem.fields.find( const fieldName = objectMetadataItem.fields.find(
@ -57,7 +55,7 @@ export const useAggregateRecordsForRecordTableColumnFooter = (
} }
: {}; : {};
const { data } = useAggregateRecords({ const { data, loading } = useAggregateRecords({
objectNameSingular: objectMetadataItem.nameSingular, objectNameSingular: objectMetadataItem.nameSingular,
recordGqlFieldsAggregate, recordGqlFieldsAggregate,
filter: { ...requestFilters, ...recordGroupFilter }, filter: { ...requestFilters, ...recordGroupFilter },
@ -75,5 +73,6 @@ export const useAggregateRecordsForRecordTableColumnFooter = (
return { return {
aggregateValue: value, aggregateValue: value,
aggregateLabel: isDefined(value) ? label : undefined, aggregateLabel: isDefined(value) ? label : undefined,
isLoading: loading,
}; };
}; };

View File

@ -0,0 +1,21 @@
import { viewFieldAggregateOperationState } from '@/object-record/record-table/record-table-footer/states/viewFieldAggregateOperationState';
import { selectorFamily } from 'recoil';
import { isDefined } from '~/utils/isDefined';
export const hasAggregateOperationForViewFieldFamilySelector = selectorFamily<
boolean,
{ viewFieldId: string }
>({
key: 'hasAggregateOperationForViewField',
get:
({ viewFieldId }) =>
({ get }) => {
const aggregateOperation = get(
viewFieldAggregateOperationState({
viewFieldId,
}),
);
return isDefined(aggregateOperation);
},
});