Aggregate fast follows (1) (#9233)

Some fixes required: 

- [Aggregate value should not disappear when dropdown is
open](https://discord.com/channels/1130383047699738754/1319328950475817001/1319328950475817001)
- [Delay the apparition of the tooltip on
kanban](https://discord.com/channels/1130383047699738754/1319327824632352860/1319327824632352860)
- [Group options in
sub-menus](https://discord.com/channels/1130383047699738754/1319326443951362059/1319326443951362059)

![image](https://github.com/user-attachments/assets/b2b58cec-a042-4253-a185-01d273320960)
- Display the currently selected option with a checkmark

![image](https://github.com/user-attachments/assets/95270f9c-773e-4af7-aaf5-249469ae7d2d)
- [Loading -> Aggregates should appear at the same time that records,
not
before](https://discord.com/channels/1130383047699738754/1319329819749646456/1319329899630301318)
This commit is contained in:
Marie
2024-12-26 17:00:56 +01:00
committed by GitHub
parent 68d47e9543
commit b91656eb5d
31 changed files with 493 additions and 171 deletions

View File

@ -1,11 +1,13 @@
import { ObjectOptionsDropdownContextValue } from '@/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext';
import { RecordBoardColumnHeaderAggregateDropdownContextValue } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext';
import { RecordTableColumnAggregateFooterDropdownContextValue } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext';
import { useDropdown as useDropdownUi } from '@/ui/layout/dropdown/hooks/useDropdown';
import { Context, useCallback, useContext } from 'react';
export const useDropdown = <
T extends
| RecordBoardColumnHeaderAggregateDropdownContextValue
| RecordTableColumnAggregateFooterDropdownContextValue
| ObjectOptionsDropdownContextValue,
>({
context,

View File

@ -6,8 +6,8 @@ import { RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext } from
import { RecordBoardColumnHeaderAggregateDropdownButton } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownButton';
import { AggregateDropdownContent } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContent';
import { RecordBoardColumnHeaderAggregateDropdownContext } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext';
import { AggregateContentId } from '@/object-record/record-board/types/AggregateContentId';
import { RecordBoardColumnHotkeyScope } from '@/object-record/record-board/types/BoardColumnHotkeyScope';
import { RecordBoardColumnHeaderAggregateContentId } from '@/object-record/record-board/types/RecordBoardColumnHeaderAggregateContentId';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
type RecordBoardColumnHeaderAggregateDropdownProps = {
@ -24,13 +24,14 @@ export const RecordBoardColumnHeaderAggregateDropdown = ({
dropdownId,
}: RecordBoardColumnHeaderAggregateDropdownProps) => {
const { currentContentId, handleContentChange, handleResetContent } =
useCurrentContentId<AggregateContentId>();
useCurrentContentId<RecordBoardColumnHeaderAggregateContentId>();
return (
<RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext.Provider
value={{ instanceId: dropdownId }}
>
<Dropdown
onClose={handleResetContent}
dropdownId={dropdownId}
dropdownHotkeyScope={{
scope: RecordBoardColumnHotkeyScope.ColumnHeader,

View File

@ -1,4 +1,5 @@
import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/StyledHeaderDropdownButton';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import styled from '@emotion/styled';
import { AppTooltip, Tag, TooltipDelay } from 'twenty-ui';
@ -15,18 +16,25 @@ export const RecordBoardColumnHeaderAggregateDropdownButton = ({
value?: string | number;
tooltip?: string;
}) => {
const { isDropdownOpen } = useDropdown(dropdownId);
return (
<div id={dropdownId}>
<StyledButton>
<Tag text={value ? value.toString() : '-'} color={'transparent'} />
<AppTooltip
anchorSelect={`#${dropdownId}`}
content={tooltip}
noArrow
place="right"
positionStrategy="fixed"
delay={TooltipDelay.shortDelay}
<Tag
text={value ? value.toString() : '-'}
color={'transparent'}
weight={'regular'}
/>
{!isDropdownOpen && (
<AppTooltip
anchorSelect={`#${dropdownId}`}
content={tooltip}
noArrow
place="right"
positionStrategy="fixed"
delay={TooltipDelay.mediumDelay}
/>
)}
</StyledButton>
</div>
);

View File

@ -2,6 +2,7 @@ import { useDropdown } from '@/dropdown/hooks/useDropdown';
import { RecordBoardColumnHeaderAggregateDropdownContext } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext';
import { RecordBoardColumnHeaderAggregateDropdownFieldsContent } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownFieldsContent';
import { RecordBoardColumnHeaderAggregateDropdownMenuContent } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuContent';
import { RecordBoardColumnHeaderAggregateDropdownMoreOptionsContent } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMoreOptionsContent';
export const AggregateDropdownContent = () => {
const { currentContentId } = useDropdown({
@ -9,6 +10,8 @@ export const AggregateDropdownContent = () => {
});
switch (currentContentId) {
case 'moreAggregateOperationOptions':
return <RecordBoardColumnHeaderAggregateDropdownMoreOptionsContent />;
case 'aggregateFields':
return <RecordBoardColumnHeaderAggregateDropdownFieldsContent />;
default:

View File

@ -1,11 +1,11 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { AggregateContentId } from '@/object-record/record-board/types/AggregateContentId';
import { RecordBoardColumnHeaderAggregateContentId } from '@/object-record/record-board/types/RecordBoardColumnHeaderAggregateContentId';
import { createContext } from 'react';
export type RecordBoardColumnHeaderAggregateDropdownContextValue = {
objectMetadataItem: ObjectMetadataItem;
currentContentId: AggregateContentId | null;
onContentChange: (key: AggregateContentId) => void;
currentContentId: RecordBoardColumnHeaderAggregateContentId | null;
onContentChange: (key: RecordBoardColumnHeaderAggregateContentId) => void;
resetContent: () => void;
dropdownId: string;
};

View File

@ -3,15 +3,23 @@ import { RecordBoardColumnHeaderAggregateDropdownContext } from '@/object-record
import { aggregateOperationComponentState } from '@/object-record/record-board/record-board-column/states/aggregateOperationComponentState';
import { availableFieldIdsForAggregateOperationComponentState } from '@/object-record/record-board/record-board-column/states/availableFieldIdsForAggregateOperationComponentState';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { recordIndexKanbanAggregateOperationState } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useUpdateViewAggregate } from '@/views/hooks/useUpdateViewAggregate';
import { Icon123, IconChevronLeft, MenuItem, useIcons } from 'twenty-ui';
import { useRecoilValue } from 'recoil';
import {
Icon123,
IconCheck,
IconChevronLeft,
MenuItem,
useIcons,
} from 'twenty-ui';
import { isDefined } from '~/utils/isDefined';
export const RecordBoardColumnHeaderAggregateDropdownFieldsContent = () => {
const { closeDropdown, resetContent, objectMetadataItem } = useDropdown({
const { closeDropdown, objectMetadataItem, onContentChange } = useDropdown({
context: RecordBoardColumnHeaderAggregateDropdownContext,
});
@ -27,11 +35,18 @@ export const RecordBoardColumnHeaderAggregateDropdownFieldsContent = () => {
availableFieldIdsForAggregateOperationComponentState,
);
const recordIndexKanbanAggregateOperation = useRecoilValue(
recordIndexKanbanAggregateOperationState,
);
if (!isDefined(aggregateOperation)) return <></>;
return (
<>
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={resetContent}>
<DropdownMenuHeader
StartIcon={IconChevronLeft}
onClick={() => onContentChange('moreAggregateOperationOptions')}
>
{getAggregateOperationLabel(aggregateOperation)}
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
@ -53,6 +68,14 @@ export const RecordBoardColumnHeaderAggregateDropdownFieldsContent = () => {
}}
LeftIcon={getIcon(fieldMetadata.icon) ?? Icon123}
text={fieldMetadata.label}
RightIcon={
recordIndexKanbanAggregateOperation?.fieldMetadataId ===
fieldId &&
recordIndexKanbanAggregateOperation?.operation ===
aggregateOperation
? IconCheck
: undefined
}
/>
);
})}

View File

@ -1,5 +1,5 @@
import { Key } from 'ts-key-enum';
import { MenuItem } from 'twenty-ui';
import { IconCheck, MenuItem } from 'twenty-ui';
import { useDropdown } from '@/dropdown/hooks/useDropdown';
import {
@ -7,23 +7,18 @@ import {
RecordBoardColumnHeaderAggregateDropdownContextValue,
} from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext';
import { RecordBoardColumnHeaderAggregateDropdownMenuItem } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuItem';
import { aggregateOperationComponentState } from '@/object-record/record-board/record-board-column/states/aggregateOperationComponentState';
import { availableFieldIdsForAggregateOperationComponentState } from '@/object-record/record-board/record-board-column/states/availableFieldIdsForAggregateOperationComponentState';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { recordIndexKanbanAggregateOperationState } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
import { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation';
import { getAvailableFieldsIdsForAggregationFromObjectFields } from '@/object-record/utils/getAvailableFieldsIdsForAggregationFromObjectFields';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useUpdateViewAggregate } from '@/views/hooks/useUpdateViewAggregate';
import isEmpty from 'lodash.isempty';
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from '~/utils/isDefined';
export const RecordBoardColumnHeaderAggregateDropdownMenuContent = () => {
const { objectMetadataItem, onContentChange, closeDropdown } =
const { onContentChange, closeDropdown } =
useDropdown<RecordBoardColumnHeaderAggregateDropdownContextValue>({
context: RecordBoardColumnHeaderAggregateDropdownContext,
});
@ -36,24 +31,12 @@ export const RecordBoardColumnHeaderAggregateDropdownMenuContent = () => {
TableOptionsHotkeyScope.Dropdown,
);
const availableAggregations: AvailableFieldsForAggregateOperation = useMemo(
() =>
getAvailableFieldsIdsForAggregationFromObjectFields(
objectMetadataItem.fields,
),
[objectMetadataItem.fields],
);
const setAggregateOperation = useSetRecoilComponentStateV2(
aggregateOperationComponentState,
);
const setAvailableFieldsForAggregateOperation = useSetRecoilComponentStateV2(
availableFieldIdsForAggregateOperationComponentState,
);
const { updateViewAggregate } = useUpdateViewAggregate();
const recordIndexKanbanAggregateOperation = useRecoilValue(
recordIndexKanbanAggregateOperationState,
);
return (
<>
<DropdownMenuItemsContainer>
@ -63,35 +46,24 @@ export const RecordBoardColumnHeaderAggregateDropdownMenuContent = () => {
kanbanAggregateOperationFieldMetadataId: null,
kanbanAggregateOperation: AGGREGATE_OPERATIONS.count,
});
closeDropdown();
}}
text={getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)}
RightIcon={
!isDefined(recordIndexKanbanAggregateOperation?.operation) ||
recordIndexKanbanAggregateOperation?.operation ===
AGGREGATE_OPERATIONS.count
? IconCheck
: undefined
}
/>
<MenuItem
onClick={() => {
onContentChange('moreAggregateOperationOptions');
}}
text={'More options'}
hasSubMenu
/>
{Object.entries(availableAggregations).map(
([
availableAggregationOperation,
availableAggregationFieldsIdsForOperation,
]) =>
isEmpty(availableAggregationFieldsIdsForOperation) ? (
<></>
) : (
<RecordBoardColumnHeaderAggregateDropdownMenuItem
key={`aggregate-dropdown-menu-content-${availableAggregationOperation}`}
onContentChange={() => {
setAggregateOperation(
availableAggregationOperation as AGGREGATE_OPERATIONS,
);
setAvailableFieldsForAggregateOperation(
availableAggregationFieldsIdsForOperation,
);
onContentChange('aggregateFields');
}}
text={getAggregateOperationLabel(
availableAggregationOperation as AGGREGATE_OPERATIONS,
)}
hasSubMenu
/>
),
)}
</DropdownMenuItemsContainer>
</>
);

View File

@ -1,15 +1,22 @@
import { MenuItem } from 'twenty-ui';
import { IconComponent, MenuItem } from 'twenty-ui';
export const RecordBoardColumnHeaderAggregateDropdownMenuItem = ({
onContentChange,
text,
hasSubMenu,
RightIcon,
}: {
onContentChange: () => void;
hasSubMenu: boolean;
text: string;
RightIcon?: IconComponent | null;
}) => {
return (
<MenuItem onClick={onContentChange} text={text} hasSubMenu={hasSubMenu} />
<MenuItem
onClick={onContentChange}
text={text}
hasSubMenu={hasSubMenu}
RightIcon={RightIcon}
/>
);
};

View File

@ -0,0 +1,89 @@
import { useDropdown } from '@/dropdown/hooks/useDropdown';
import {
RecordBoardColumnHeaderAggregateDropdownContext,
RecordBoardColumnHeaderAggregateDropdownContextValue,
} from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext';
import { RecordBoardColumnHeaderAggregateDropdownMenuItem } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuItem';
import { aggregateOperationComponentState } from '@/object-record/record-board/record-board-column/states/aggregateOperationComponentState';
import { availableFieldIdsForAggregateOperationComponentState } from '@/object-record/record-board/record-board-column/states/availableFieldIdsForAggregateOperationComponentState';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
import { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation';
import { getAvailableFieldsIdsForAggregationFromObjectFields } from '@/object-record/utils/getAvailableFieldsIdsForAggregationFromObjectFields';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import isEmpty from 'lodash.isempty';
import { useMemo } from 'react';
import { Key } from 'ts-key-enum';
import { IconChevronLeft } from 'twenty-ui';
export const RecordBoardColumnHeaderAggregateDropdownMoreOptionsContent =
() => {
const { objectMetadataItem, onContentChange, closeDropdown, resetContent } =
useDropdown<RecordBoardColumnHeaderAggregateDropdownContextValue>({
context: RecordBoardColumnHeaderAggregateDropdownContext,
});
useScopedHotkeys(
[Key.Escape],
() => {
closeDropdown();
},
TableOptionsHotkeyScope.Dropdown,
);
const availableAggregations: AvailableFieldsForAggregateOperation = useMemo(
() =>
getAvailableFieldsIdsForAggregationFromObjectFields(
objectMetadataItem.fields,
),
[objectMetadataItem.fields],
);
const setAggregateOperation = useSetRecoilComponentStateV2(
aggregateOperationComponentState,
);
const setAvailableFieldsForAggregateOperation =
useSetRecoilComponentStateV2(
availableFieldIdsForAggregateOperationComponentState,
);
return (
<>
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={resetContent}>
More options
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
{Object.entries(availableAggregations)
.filter(([, fields]) => !isEmpty(fields))
.map(
([
availableAggregationOperation,
availableAggregationFieldsIdsForOperation,
]) => (
<RecordBoardColumnHeaderAggregateDropdownMenuItem
key={`aggregate-dropdown-menu-content-${availableAggregationOperation}`}
onContentChange={() => {
setAggregateOperation(
availableAggregationOperation as AGGREGATE_OPERATIONS,
);
setAvailableFieldsForAggregateOperation(
availableAggregationFieldsIdsForOperation,
);
onContentChange('aggregateFields');
}}
text={getAggregateOperationLabel(
availableAggregationOperation as AGGREGATE_OPERATIONS,
)}
hasSubMenu
/>
),
)}
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -141,6 +141,7 @@ describe('computeAggregateValueAndLabel', () => {
expect(result).toEqual({
value: 42,
label: 'Count',
labelWithFieldName: 'Count',
});
});

View File

@ -35,6 +35,7 @@ export const computeAggregateValueAndLabel = ({
return {
value: data?.[fallbackFieldName]?.[AGGREGATE_OPERATIONS.count],
label: `${getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)}`,
labelWithFieldName: `${getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)}`,
};
}

View File

@ -1 +0,0 @@
export type AggregateContentId = 'aggregateOperations' | 'aggregateFields';

View File

@ -0,0 +1,4 @@
export type RecordBoardColumnHeaderAggregateContentId =
| 'aggregateOperations'
| 'aggregateFields'
| 'moreAggregateOperationOptions';

View File

@ -30,7 +30,7 @@ import { useSetRecordGroup } from '@/object-record/record-group/hooks/useSetReco
import { RecordIndexFiltersToContextStoreEffect } from '@/object-record/record-index/components/RecordIndexFiltersToContextStoreEffect';
import { recordIndexKanbanAggregateOperationState } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { recordIndexViewFilterGroupsState } from '@/object-record/record-index/states/recordIndexViewFilterGroupsState';
import { aggregateOperationForViewFieldState } from '@/object-record/record-table/record-table-footer/states/aggregateOperationForViewFieldState';
import { viewFieldAggregateOperationState } from '@/object-record/record-table/record-table-footer/states/viewFieldAggregateOperationState';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { ViewBar } from '@/views/components/ViewBar';
import { ViewField } from '@/views/types/ViewField';
@ -126,7 +126,7 @@ export const RecordIndexContainer = () => {
for (const viewField of viewFields) {
const aggregateOperationForViewField = snapshot
.getLoadable(
aggregateOperationForViewFieldState({
viewFieldAggregateOperationState({
viewFieldId: viewField.id,
}),
)
@ -134,7 +134,7 @@ export const RecordIndexContainer = () => {
if (aggregateOperationForViewField !== viewField.aggregateOperation) {
set(
aggregateOperationForViewFieldState({
viewFieldAggregateOperationState({
viewFieldId: viewField.id,
}),
viewField.aggregateOperation,

View File

@ -6,7 +6,7 @@ import { useRecordIndexContextOrThrow } from '@/object-record/record-index/conte
import { useHandleToggleColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleColumnFilter';
import { useHandleToggleColumnSort } from '@/object-record/record-index/hooks/useHandleToggleColumnSort';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { aggregateOperationForViewFieldState } from '@/object-record/record-table/record-table-footer/states/aggregateOperationForViewFieldState';
import { viewFieldAggregateOperationState } from '@/object-record/record-table/record-table-footer/states/viewFieldAggregateOperationState';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { useSetRecordCountInCurrentView } from '@/views/hooks/useSetRecordCountInCurrentView';
import { ViewField } from '@/views/types/ViewField';
@ -77,7 +77,7 @@ export const RecordIndexTableContainerEffect = () => {
(viewField: ViewField) => {
const aggregateOperationForViewField = snapshot
.getLoadable(
aggregateOperationForViewFieldState({
viewFieldAggregateOperationState({
viewFieldId: viewField.id,
}),
)
@ -85,7 +85,7 @@ export const RecordIndexTableContainerEffect = () => {
if (aggregateOperationForViewField !== viewField.aggregateOperation) {
set(
aggregateOperationForViewFieldState({
viewFieldAggregateOperationState({
viewFieldId: viewField.id,
}),
viewField.aggregateOperation,

View File

@ -96,9 +96,12 @@ export const RecordTable = () => {
<RecordTableRecordGroupsBody />
)}
<RecordTableStickyEffect />
{isAggregateQueryEnabled && !hasRecordGroups && (
<RecordTableAggregateFooter endOfTableSticky />
)}
{isAggregateQueryEnabled &&
!hasRecordGroups &&
!isRecordTableInitialLoading &&
allRecordIds.length > 0 && (
<RecordTableAggregateFooter endOfTableSticky />
)}
</StyledTable>
<DragSelect
dragSelectable={tableBodyRef}

View File

@ -0,0 +1,60 @@
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext';
import { useViewFieldAggregateOperation } from '@/object-record/record-table/record-table-footer/hooks/useViewFieldAggregateOperation';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { ReactNode, useContext } from 'react';
import { IconCheck, isDefined, MenuItem } from 'twenty-ui';
export const RecordTableColumnAggregateFooterAggregateOperationMenuItems = ({
aggregateOperations,
children,
}: {
aggregateOperations: AGGREGATE_OPERATIONS[];
children?: ReactNode;
}) => {
const {
updateViewFieldAggregateOperation,
currentViewFieldAggregateOperation,
} = useViewFieldAggregateOperation();
const { dropdownId, resetContent } = useContext(
RecordTableColumnAggregateFooterDropdownContext,
);
const { closeDropdown } = useDropdown(dropdownId);
return (
<DropdownMenuItemsContainer>
{aggregateOperations.map((operation) => (
<MenuItem
key={operation}
onClick={() => {
updateViewFieldAggregateOperation(operation);
closeDropdown();
}}
text={getAggregateOperationLabel(operation)}
RightIcon={
currentViewFieldAggregateOperation === operation
? IconCheck
: undefined
}
aria-selected={currentViewFieldAggregateOperation === operation}
/>
))}
{children}
<MenuItem
key={'none'}
onClick={() => {
updateViewFieldAggregateOperation(null);
resetContent();
closeDropdown();
}}
text={'None'}
RightIcon={
!isDefined(currentViewFieldAggregateOperation) ? IconCheck : undefined
}
aria-selected={!isDefined(currentViewFieldAggregateOperation)}
/>
</DropdownMenuItemsContainer>
);
};

View File

@ -1,79 +0,0 @@
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { getAvailableAggregateOperationsForFieldMetadataType } from '@/object-record/record-table/record-table-footer/utils/getAvailableAggregateOperationsForFieldMetadataType';
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { usePersistViewFieldRecords } from '@/views/hooks/internal/usePersistViewFieldRecords';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { useMemo } from 'react';
import { Key } from 'ts-key-enum';
import { MenuItem } from 'twenty-ui';
export const RecordTableColumnAggregateFooterDropdown = ({
column,
dropdownId,
}: {
column: ColumnDefinition<FieldMetadata>;
dropdownId: string;
}) => {
const { closeDropdown } = useDropdown(dropdownId);
const { objectMetadataItem } = useRecordTableContextOrThrow();
const { currentViewWithSavedFiltersAndSorts } = useGetCurrentView();
const currentViewField =
currentViewWithSavedFiltersAndSorts?.viewFields?.find(
(viewField) => viewField.fieldMetadataId === column.fieldMetadataId,
);
useScopedHotkeys(
[Key.Escape],
() => {
closeDropdown();
},
TableOptionsHotkeyScope.Dropdown,
);
const availableAggregateOperations = useMemo(
() =>
getAvailableAggregateOperationsForFieldMetadataType({
fieldMetadataType: objectMetadataItem.fields.find(
(field) => field.id === column.fieldMetadataId,
)?.type,
}),
[column.fieldMetadataId, objectMetadataItem.fields],
);
const { updateViewFieldRecords } = usePersistViewFieldRecords();
const handleAggregationChange = (
aggregateOperation: AGGREGATE_OPERATIONS,
) => {
if (!currentViewField) {
throw new Error('ViewField not found');
}
updateViewFieldRecords([
{ ...currentViewField, aggregateOperation: aggregateOperation },
]);
};
return (
<>
<DropdownMenuItemsContainer>
{availableAggregateOperations.map((aggregation) => (
<MenuItem
key={aggregation}
onClick={() => {
handleAggregationChange(aggregation);
closeDropdown();
}}
text={getAggregateOperationLabel(aggregation)}
/>
))}
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -0,0 +1,17 @@
import { useDropdown } from '@/dropdown/hooks/useDropdown';
import { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext';
import { RecordTableColumnAggregateFooterDropdownMoreOptionsContent } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownMoreOptionsContent';
import { RecordTableColumnAggregateFooterMenuContent } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterMenuContent';
export const RecordTableColumnAggregateFooterDropdownContent = () => {
const { currentContentId } = useDropdown({
context: RecordTableColumnAggregateFooterDropdownContext,
});
switch (currentContentId) {
case 'moreAggregateOperationOptions':
return <RecordTableColumnAggregateFooterDropdownMoreOptionsContent />;
default:
return <RecordTableColumnAggregateFooterMenuContent />;
}
};

View File

@ -0,0 +1,15 @@
import { RecordTableFooterAggregateContentId } from '@/object-record/record-table/record-table-footer/types/RecordTableFooterAggregateContentId';
import { createContext } from 'react';
export type RecordTableColumnAggregateFooterDropdownContextValue = {
currentContentId: RecordTableFooterAggregateContentId | null;
onContentChange: (key: RecordTableFooterAggregateContentId) => void;
resetContent: () => void;
dropdownId: string;
fieldMetadataId: string;
};
export const RecordTableColumnAggregateFooterDropdownContext =
createContext<RecordTableColumnAggregateFooterDropdownContextValue>(
{} as RecordTableColumnAggregateFooterDropdownContextValue,
);

View File

@ -0,0 +1,57 @@
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableColumnAggregateFooterAggregateOperationMenuItems } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterAggregateOperationMenuItems';
import { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext';
import { STANDARD_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/standardAggregateOperationOptions';
import { getAvailableAggregateOperationsForFieldMetadataType } from '@/object-record/record-table/record-table-footer/utils/getAvailableAggregateOperationsForFieldMetadataType';
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useContext, useMemo } from 'react';
import { Key } from 'ts-key-enum';
import { IconChevronLeft } from 'twenty-ui';
export const RecordTableColumnAggregateFooterDropdownMoreOptionsContent =
() => {
const { fieldMetadataId, dropdownId, resetContent } = useContext(
RecordTableColumnAggregateFooterDropdownContext,
);
const { closeDropdown } = useDropdown(dropdownId);
const { objectMetadataItem } = useRecordTableContextOrThrow();
useScopedHotkeys(
[Key.Escape],
() => {
resetContent();
closeDropdown();
},
TableOptionsHotkeyScope.Dropdown,
);
const availableAggregateOperations = useMemo(
() =>
getAvailableAggregateOperationsForFieldMetadataType({
fieldMetadataType: objectMetadataItem.fields.find(
(field) => field.id === fieldMetadataId,
)?.type,
}).filter(
(aggregateOperation) =>
!STANDARD_AGGREGATE_OPERATION_OPTIONS.includes(aggregateOperation),
),
[fieldMetadataId, objectMetadataItem.fields],
);
return (
<>
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={resetContent}>
More options
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
<RecordTableColumnAggregateFooterAggregateOperationMenuItems
aggregateOperations={availableAggregateOperations}
/>
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -0,0 +1,68 @@
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableColumnAggregateFooterAggregateOperationMenuItems } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterAggregateOperationMenuItems';
import { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext';
import { STANDARD_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/standardAggregateOperationOptions';
import { getAvailableAggregateOperationsForFieldMetadataType } from '@/object-record/record-table/record-table-footer/utils/getAvailableAggregateOperationsForFieldMetadataType';
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useContext, useMemo } from 'react';
import { Key } from 'ts-key-enum';
import { MenuItem } from 'twenty-ui';
export const RecordTableColumnAggregateFooterMenuContent = () => {
const { fieldMetadataId, dropdownId, onContentChange } = useContext(
RecordTableColumnAggregateFooterDropdownContext,
);
const { closeDropdown } = useDropdown(dropdownId);
const { objectMetadataItem } = useRecordTableContextOrThrow();
useScopedHotkeys(
[Key.Escape],
() => {
closeDropdown();
},
TableOptionsHotkeyScope.Dropdown,
);
const availableAggregateOperation = useMemo(
() =>
getAvailableAggregateOperationsForFieldMetadataType({
fieldMetadataType: objectMetadataItem.fields.find(
(field) => field.id === fieldMetadataId,
)?.type,
}),
[fieldMetadataId, objectMetadataItem.fields],
);
const standardAvailableAggregateOperation =
availableAggregateOperation.filter((aggregateOperation) =>
STANDARD_AGGREGATE_OPERATION_OPTIONS.includes(aggregateOperation),
);
const otherAvailableAggregateOperation = availableAggregateOperation.filter(
(aggregateOperation) =>
!STANDARD_AGGREGATE_OPERATION_OPTIONS.includes(aggregateOperation),
);
return (
<>
<DropdownMenuItemsContainer>
<RecordTableColumnAggregateFooterAggregateOperationMenuItems
aggregateOperations={standardAvailableAggregateOperation}
>
{otherAvailableAggregateOperation.length > 0 ? (
<MenuItem
key={'more-options'}
onClick={() => {
onContentChange('moreAggregateOperationOptions');
}}
text={'More options'}
hasSubMenu
/>
) : null}
</RecordTableColumnAggregateFooterAggregateOperationMenuItems>
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -1,3 +1,4 @@
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useState } from 'react';
@ -74,8 +75,11 @@ export const RecordTableColumnAggregateFooterValue = ({
aggregateLabel?: string;
}) => {
const [isHovered, setIsHovered] = useState(false);
const { isDropdownOpen } = useDropdown(dropdownId);
const sanitizedId = `tooltip-${dropdownId.replace(/[^a-zA-Z0-9-_]/g, '-')}`;
const theme = useTheme();
const shouldShowValue =
isHovered || isDropdownOpen || isDefined(aggregateValue) || isFirstCell;
return (
<div
onMouseEnter={() => {
@ -84,7 +88,7 @@ export const RecordTableColumnAggregateFooterValue = ({
onMouseLeave={() => setIsHovered(false)}
>
<StyledCell>
{isHovered || isDefined(aggregateValue) || isFirstCell ? (
{shouldShowValue ? (
<>
{isDefined(aggregateValue) ? (
<StyledValueContainer>

View File

@ -1,7 +1,10 @@
import { useCurrentContentId } from '@/dropdown/hooks/useCurrentContentId';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { RecordTableColumnAggregateFooterDropdown } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdown';
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 { RecordTableColumnAggregateFooterValue } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterValue';
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 { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useToggleScrollWrapper } from '@/ui/utilities/scroll/hooks/useToggleScrollWrapper';
@ -18,6 +21,9 @@ export const RecordTableColumnFooterWithDropdown = ({
currentRecordGroupId,
isFirstCell,
}: RecordTableColumnFooterWithDropdownProps) => {
const { currentContentId, handleContentChange, handleResetContent } =
useCurrentContentId<RecordTableFooterAggregateContentId>();
const { toggleScrollXWrapper, toggleScrollYWrapper } =
useToggleScrollWrapper();
@ -27,9 +33,10 @@ export const RecordTableColumnFooterWithDropdown = ({
}, [toggleScrollXWrapper, toggleScrollYWrapper]);
const handleDropdownClose = useCallback(() => {
handleResetContent();
toggleScrollXWrapper(true);
toggleScrollYWrapper(true);
}, [toggleScrollXWrapper, toggleScrollYWrapper]);
}, [handleResetContent, toggleScrollXWrapper, toggleScrollYWrapper]);
const { aggregateValue, aggregateLabel } =
useAggregateRecordsForRecordTableColumnFooter(column.fieldMetadataId);
@ -52,10 +59,17 @@ export const RecordTableColumnFooterWithDropdown = ({
/>
}
dropdownComponents={
<RecordTableColumnAggregateFooterDropdown
column={column}
dropdownId={dropdownId}
/>
<RecordTableColumnAggregateFooterDropdownContext.Provider
value={{
currentContentId,
onContentChange: handleContentChange,
resetContent: handleResetContent,
dropdownId: dropdownId,
fieldMetadataId: column.fieldMetadataId,
}}
>
<RecordTableColumnAggregateFooterDropdownContent />
</RecordTableColumnAggregateFooterDropdownContext.Provider>
}
dropdownOffset={{ x: -1 }}
dropdownPlacement="bottom-start"

View File

@ -0,0 +1,5 @@
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
export const STANDARD_AGGREGATE_OPERATION_OPTIONS = [
AGGREGATE_OPERATIONS.count,
];

View File

@ -6,7 +6,7 @@ import { useRecordGroupFilter } from '@/object-record/record-group/hooks/useReco
import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState';
import { recordIndexViewFilterGroupsState } from '@/object-record/record-index/states/recordIndexViewFilterGroupsState';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { aggregateOperationForViewFieldState } from '@/object-record/record-table/record-table-footer/states/aggregateOperationForViewFieldState';
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 { useRecoilValue } from 'recoil';
@ -43,7 +43,7 @@ export const useAggregateRecordsForRecordTableColumnFooter = (
)?.id ?? '';
const aggregateOperationForViewField = useRecoilValue(
aggregateOperationForViewFieldState({ viewFieldId: viewFieldId }),
viewFieldAggregateOperationState({ viewFieldId: viewFieldId }),
);
const fieldName = objectMetadataItem.fields.find(

View File

@ -0,0 +1,41 @@
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext';
import { viewFieldAggregateOperationState } from '@/object-record/record-table/record-table-footer/states/viewFieldAggregateOperationState';
import { usePersistViewFieldRecords } from '@/views/hooks/internal/usePersistViewFieldRecords';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
export const useViewFieldAggregateOperation = () => {
const { fieldMetadataId } = useContext(
RecordTableColumnAggregateFooterDropdownContext,
);
const { currentViewWithSavedFiltersAndSorts } = useGetCurrentView();
const currentViewField =
currentViewWithSavedFiltersAndSorts?.viewFields?.find(
(viewField) => viewField.fieldMetadataId === fieldMetadataId,
);
const { updateViewFieldRecords } = usePersistViewFieldRecords();
const updateViewFieldAggregateOperation = (
aggregateOperation: AGGREGATE_OPERATIONS | null,
) => {
if (!currentViewField) {
throw new Error('ViewField not found');
}
updateViewFieldRecords([
{ ...currentViewField, aggregateOperation: aggregateOperation },
]);
};
const currentViewFieldAggregateOperation = useRecoilValue(
viewFieldAggregateOperationState({
viewFieldId: currentViewField?.id ?? '',
}),
);
return {
updateViewFieldAggregateOperation,
currentViewFieldAggregateOperation,
};
};

View File

@ -1,10 +1,10 @@
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState';
export const aggregateOperationForViewFieldState = createFamilyState<
export const viewFieldAggregateOperationState = createFamilyState<
AGGREGATE_OPERATIONS | null | undefined,
{ viewFieldId: string }
>({
key: 'aggregateOperationForViewFieldState',
key: 'viewFieldAggregateOperationState',
defaultValue: null,
});

View File

@ -0,0 +1,2 @@
export type RecordTableFooterAggregateContentId =
'moreAggregateOperationOptions';

View File

@ -13,7 +13,7 @@ export const useUpdateViewAggregate = () => {
kanbanAggregateOperation,
}: {
kanbanAggregateOperationFieldMetadataId: string | null;
kanbanAggregateOperation: AGGREGATE_OPERATIONS;
kanbanAggregateOperation: AGGREGATE_OPERATIONS | null;
}) =>
updateView({
id: currentViewId,

View File

@ -25,6 +25,7 @@ export type MenuItemProps = {
isIconDisplayedOnHoverOnly?: boolean;
isTooltipOpen?: boolean;
LeftIcon?: IconComponent | null;
RightIcon?: IconComponent | null;
onClick?: (event: MouseEvent<HTMLDivElement>) => void;
onMouseEnter?: (event: MouseEvent<HTMLDivElement>) => void;
onMouseLeave?: (event: MouseEvent<HTMLDivElement>) => void;
@ -40,6 +41,7 @@ export const MenuItem = ({
iconButtons,
isIconDisplayedOnHoverOnly = true,
LeftIcon,
RightIcon,
onClick,
onMouseEnter,
onMouseLeave,
@ -81,6 +83,9 @@ export const MenuItem = ({
<LightIconButtonGroup iconButtons={iconButtons} size="small" />
)}
</div>
{RightIcon && (
<RightIcon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
)}
{hasSubMenu && (
<IconChevronRight
size={theme.icon.size.sm}