feat: view groups (#7176)

Fix #4244 and #4356

This pull request introduces the new "view groups" capability, enabling
the reordering, hiding, and showing of columns in Kanban mode. The core
enhancement includes the addition of a new entity named `ViewGroup`,
which manages column behaviors and interactions.

#### Key Changes:
1. **ViewGroup Entity**:  
The newly added `ViewGroup` entity is responsible for handling the
organization and state of columns.
This includes:
   - The ability to reorder columns.
- The option to hide or show specific columns based on user preferences.

#### Conclusion:
This PR adds a significant new feature that enhances the flexibility of
Kanban views through the `ViewGroup` entity.
We'll later add the view group logic to table view too.

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Jérémy M
2024-10-24 15:38:52 +02:00
committed by GitHub
parent 68a060a046
commit e8d96cfd10
61 changed files with 1408 additions and 508 deletions

View File

@ -31,16 +31,21 @@ const StyledContainer = styled.div`
const StyledColumnContainer = styled.div`
display: flex;
& > *:not(:first-child) {
border-left: 1px solid ${({ theme }) => theme.border.color.light};
}
`;
const StyledContainerContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
`;
const StyledBoardContentContainer = styled.div`
display: flex;
flex-direction: column;
height: calc(100% - 48px);
`;
const RecordBoardScrollRestoreEffect = () => {
@ -137,6 +142,12 @@ export const RecordBoard = () => {
],
);
// FixMe: Check if we really need this as it depends on the times it takes to update the view groups
// if (isPersistingViewGroups) {
// // TODO: Add skeleton state
// return null;
// }
return (
<RecordBoardScope
recordBoardScopeId={getScopeIdFromComponentId(recordBoardId)}

View File

@ -17,6 +17,10 @@ const StyledHeaderContainer = styled.div`
position: sticky;
top: 0;
}
& > *:not(:first-child) {
border-left: 1px solid ${({ theme }) => theme.border.color.light};
}
`;
export const RecordBoardHeader = () => {

View File

@ -1,6 +1,4 @@
import { RecordBoardScopeInternalContext } from '@/object-record/record-board/scopes/scope-internal-context/RecordBoardScopeInternalContext';
import { isFirstRecordBoardColumnComponentFamilyState } from '@/object-record/record-board/states/isFirstRecordBoardColumnComponentFamilyState';
import { isLastRecordBoardColumnComponentFamilyState } from '@/object-record/record-board/states/isLastRecordBoardColumnComponentFamilyState';
import { isRecordBoardCardSelectedComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardSelectedComponentFamilyState';
import { isRecordBoardCompactModeActiveComponentState } from '@/object-record/record-board/states/isRecordBoardCompactModeActiveComponentState';
import { isRecordBoardFetchingRecordsByColumnFamilyState } from '@/object-record/record-board/states/isRecordBoardFetchingRecordsByColumnFamilyState';
@ -51,14 +49,6 @@ export const useRecordBoardStates = (recordBoardId?: string) => {
recordBoardColumnIdsComponentState,
scopeId,
),
isFirstColumnFamilyState: extractComponentFamilyState(
isFirstRecordBoardColumnComponentFamilyState,
scopeId,
),
isLastColumnFamilyState: extractComponentFamilyState(
isLastRecordBoardColumnComponentFamilyState,
scopeId,
),
columnsFamilySelector: extractComponentFamilyState(
recordBoardColumnsComponentFamilySelector,
scopeId,

View File

@ -1,8 +1,8 @@
import { useRecoilCallback } from 'recoil';
import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates';
import { RecordBoardColumnDefinition } from '@/object-record/record-board/types/RecordBoardColumnDefinition';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition';
export const useSetRecordBoardColumns = (recordBoardId?: string) => {
const { scopeId, columnIdsState, columnsFamilySelector } =
@ -10,21 +10,20 @@ export const useSetRecordBoardColumns = (recordBoardId?: string) => {
const setColumns = useRecoilCallback(
({ set, snapshot }) =>
(columns: RecordBoardColumnDefinition[]) => {
(columns: RecordGroupDefinition[]) => {
const currentColumnsIds = snapshot
.getLoadable(columnIdsState)
.getValue();
const columnIds = columns.map(({ id }) => id);
const columnIds = columns
.filter(({ isVisible }) => isVisible)
.map(({ id }) => id);
if (isDeeplyEqual(currentColumnsIds, columnIds)) {
return;
}
set(
columnIdsState,
columns.map((column) => column.id),
);
set(columnIdsState, columnIds);
columns.forEach((column) => {
const currentColumn = snapshot

View File

@ -6,11 +6,8 @@ import { useRecordBoardStates } from '@/object-record/record-board/hooks/interna
import { RecordBoardColumnCardsContainer } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnCardsContainer';
import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext';
const StyledColumn = styled.div<{ isFirstColumn: boolean }>`
const StyledColumn = styled.div`
background-color: ${({ theme }) => theme.background.primary};
border-left: 1px solid
${({ theme, isFirstColumn }) =>
isFirstColumn ? 'none' : theme.border.color.light};
display: flex;
flex-direction: column;
max-width: 200px;
@ -32,24 +29,12 @@ type RecordBoardColumnProps = {
export const RecordBoardColumn = ({
recordBoardColumnId,
}: RecordBoardColumnProps) => {
const {
isFirstColumnFamilyState,
isLastColumnFamilyState,
columnsFamilySelector,
recordIdsByColumnIdFamilyState,
} = useRecordBoardStates();
const { columnsFamilySelector, recordIdsByColumnIdFamilyState } =
useRecordBoardStates();
const columnDefinition = useRecoilValue(
columnsFamilySelector(recordBoardColumnId),
);
const isFirstColumn = useRecoilValue(
isFirstColumnFamilyState(recordBoardColumnId),
);
const isLastColumn = useRecoilValue(
isLastColumnFamilyState(recordBoardColumnId),
);
const recordIds = useRecoilValue(
recordIdsByColumnIdFamilyState(recordBoardColumnId),
);
@ -62,8 +47,6 @@ export const RecordBoardColumn = ({
<RecordBoardColumnContext.Provider
value={{
columnDefinition: columnDefinition,
isFirstColumn: isFirstColumn,
isLastColumn: isLastColumn,
recordCount: recordIds.length,
columnId: recordBoardColumnId,
recordIds,
@ -71,7 +54,7 @@ export const RecordBoardColumn = ({
>
<Droppable droppableId={recordBoardColumnId}>
{(droppableProvided) => (
<StyledColumn isFirstColumn={isFirstColumn}>
<StyledColumn>
<RecordBoardColumnCardsContainer
droppableProvided={droppableProvided}
recordIds={recordIds}

View File

@ -1,7 +1,7 @@
import styled from '@emotion/styled';
import { useCallback, useContext, useRef } from 'react';
import { useCallback, useRef } from 'react';
import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext';
import { useRecordGroupActions } from '@/object-record/record-group/hooks/useRecordGroupActions';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
@ -25,6 +25,8 @@ export const RecordBoardColumnDropdownMenu = ({
}: RecordBoardColumnDropdownMenuProps) => {
const boardColumnMenuRef = useRef<HTMLDivElement>(null);
const recordGroupActions = useRecordGroupActions();
const closeMenu = useCallback(() => {
onClose();
}, [onClose]);
@ -34,13 +36,11 @@ export const RecordBoardColumnDropdownMenu = ({
callback: closeMenu,
});
const { columnDefinition } = useContext(RecordBoardColumnContext);
return (
<StyledMenuContainer ref={boardColumnMenuRef}>
<DropdownMenu data-select-disable>
<DropdownMenuItemsContainer>
{columnDefinition.actions.map((action) => (
{recordGroupActions.map((action) => (
<MenuItem
key={action.id}
onClick={() => {

View File

@ -10,7 +10,7 @@ import { RecordBoardColumnContext } from '@/object-record/record-board/record-bo
import { useColumnNewCardActions } from '@/object-record/record-board/record-board-column/hooks/useColumnNewCardActions';
import { useIsOpportunitiesCompanyFieldDisabled } from '@/object-record/record-board/record-board-column/hooks/useIsOpportunitiesCompanyFieldDisabled';
import { RecordBoardColumnHotkeyScope } from '@/object-record/record-board/types/BoardColumnHotkeyScope';
import { RecordBoardColumnDefinitionType } from '@/object-record/record-board/types/RecordBoardColumnDefinition';
import { RecordGroupDefinitionType } from '@/object-record/record-group/types/RecordGroupDefinition';
import { SingleEntitySelect } from '@/object-record/relation-picker/components/SingleEntitySelect';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
@ -59,11 +59,8 @@ const StyledRightContainer = styled.div`
display: flex;
`;
const StyledColumn = styled.div<{ isFirstColumn: boolean }>`
const StyledColumn = styled.div`
background-color: ${({ theme }) => theme.background.primary};
border-left: 1px solid
${({ theme, isFirstColumn }) =>
isFirstColumn ? 'none' : theme.border.color.light};
display: flex;
flex-direction: column;
max-width: 200px;
@ -75,7 +72,7 @@ const StyledColumn = styled.div<{ isFirstColumn: boolean }>`
`;
export const RecordBoardColumnHeader = () => {
const { columnDefinition, isFirstColumn, recordCount } = useContext(
const { columnDefinition, recordCount } = useContext(
RecordBoardColumnContext,
);
const [isBoardColumnMenuOpen, setIsBoardColumnMenuOpen] = useState(false);
@ -120,7 +117,7 @@ export const RecordBoardColumnHeader = () => {
!isOpportunitiesCompanyFieldDisabled;
return (
<StyledColumn isFirstColumn={isFirstColumn}>
<StyledColumn>
<StyledHeader
onMouseEnter={() => setIsHeaderHovered(true)}
onMouseLeave={() => setIsHeaderHovered(false)}
@ -130,18 +127,18 @@ export const RecordBoardColumnHeader = () => {
<Tag
onClick={handleBoardColumnMenuOpen}
variant={
columnDefinition.type === RecordBoardColumnDefinitionType.Value
columnDefinition.type === RecordGroupDefinitionType.Value
? 'solid'
: 'outline'
}
color={
columnDefinition.type === RecordBoardColumnDefinitionType.Value
columnDefinition.type === RecordGroupDefinitionType.Value
? columnDefinition.color
: 'transparent'
}
text={columnDefinition.title}
weight={
columnDefinition.type === RecordBoardColumnDefinitionType.Value
columnDefinition.type === RecordGroupDefinitionType.Value
? 'regular'
: 'medium'
}
@ -154,13 +151,11 @@ export const RecordBoardColumnHeader = () => {
<StyledRightContainer>
{isHeaderHovered && (
<StyledHeaderActions>
{columnDefinition.actions.length > 0 && (
<LightIconButton
accent="tertiary"
Icon={IconDotsVertical}
onClick={handleBoardColumnMenuOpen}
/>
)}
<LightIconButton
accent="tertiary"
Icon={IconDotsVertical}
onClick={handleBoardColumnMenuOpen}
/>
<LightIconButton
accent="tertiary"
@ -172,7 +167,7 @@ export const RecordBoardColumnHeader = () => {
</StyledRightContainer>
</StyledHeaderContainer>
</StyledHeader>
{isBoardColumnMenuOpen && columnDefinition.actions.length > 0 && (
{isBoardColumnMenuOpen && (
<RecordBoardColumnDropdownMenu
onClose={handleBoardColumnMenuClose}
stageId={columnDefinition.id}

View File

@ -12,19 +12,11 @@ type RecordBoardColumnHeaderWrapperProps = {
export const RecordBoardColumnHeaderWrapper = ({
columnId,
}: RecordBoardColumnHeaderWrapperProps) => {
const {
isFirstColumnFamilyState,
isLastColumnFamilyState,
columnsFamilySelector,
recordIdsByColumnIdFamilyState,
} = useRecordBoardStates();
const { columnsFamilySelector, recordIdsByColumnIdFamilyState } =
useRecordBoardStates();
const columnDefinition = useRecoilValue(columnsFamilySelector(columnId));
const isFirstColumn = useRecoilValue(isFirstColumnFamilyState(columnId));
const isLastColumn = useRecoilValue(isLastColumnFamilyState(columnId));
const recordIds = useRecoilValue(recordIdsByColumnIdFamilyState(columnId));
if (!isDefined(columnDefinition)) {
@ -36,8 +28,6 @@ export const RecordBoardColumnHeaderWrapper = ({
value={{
columnId,
columnDefinition: columnDefinition,
isFirstColumn: isFirstColumn,
isLastColumn: isLastColumn,
recordCount: recordIds.length,
recordIds,
}}

View File

@ -1,11 +1,9 @@
import { createContext } from 'react';
import { RecordBoardColumnDefinition } from '@/object-record/record-board/types/RecordBoardColumnDefinition';
import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition';
type RecordBoardColumnContextProps = {
columnDefinition: RecordBoardColumnDefinition;
isFirstColumn: boolean;
isLastColumn: boolean;
columnDefinition: RecordGroupDefinition;
recordCount: number;
columnId: string;
recordIds: string[];

View File

@ -1,15 +1,15 @@
import { ReactNode } from 'react';
import { RecordBoardScopeInternalContext } from '@/object-record/record-board/scopes/scope-internal-context/RecordBoardScopeInternalContext';
import { RecordBoardColumnDefinition } from '@/object-record/record-board/types/RecordBoardColumnDefinition';
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition';
type RecordBoardScopeProps = {
children: ReactNode;
recordBoardScopeId: string;
onFieldsChange: (fields: FieldDefinition<FieldMetadata>[]) => void;
onColumnsChange: (column: RecordBoardColumnDefinition[]) => void;
onColumnsChange: (column: RecordGroupDefinition[]) => void;
};
/** @deprecated */

View File

@ -1,12 +1,12 @@
import { RecordBoardColumnDefinition } from '@/object-record/record-board/types/RecordBoardColumnDefinition';
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition';
import { createScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext';
import { RecoilComponentStateKey } from '@/ui/utilities/state/component-state/types/RecoilComponentStateKey';
type RecordBoardScopeInternalContextProps = RecoilComponentStateKey & {
onFieldsChange: (fields: FieldDefinition<FieldMetadata>[]) => void;
onColumnsChange: (column: RecordBoardColumnDefinition[]) => void;
onColumnsChange: (column: RecordGroupDefinition[]) => void;
};
export const RecordBoardScopeInternalContext =

View File

@ -1,7 +0,0 @@
import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState';
export const isFirstRecordBoardColumnComponentFamilyState =
createComponentFamilyState<boolean, string>({
key: 'isFirstRecordBoardColumnComponentFamilyState',
defaultValue: false,
});

View File

@ -1,7 +0,0 @@
import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState';
export const isLastRecordBoardColumnComponentFamilyState =
createComponentFamilyState<boolean, string>({
key: 'isLastRecordBoardColumnComponentFamilyState',
defaultValue: false,
});

View File

@ -1,8 +1,8 @@
import { RecordBoardColumnDefinition } from '@/object-record/record-board/types/RecordBoardColumnDefinition';
import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition';
import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState';
export const recordBoardColumnsComponentFamilyState =
createComponentFamilyState<RecordBoardColumnDefinition | undefined, string>({
createComponentFamilyState<RecordGroupDefinition | undefined, string>({
key: 'recordBoardColumnsComponentFamilyState',
defaultValue: undefined,
});

View File

@ -1,19 +1,9 @@
import { isUndefined } from '@sniptt/guards';
import { isFirstRecordBoardColumnComponentFamilyState } from '@/object-record/record-board/states/isFirstRecordBoardColumnComponentFamilyState';
import { isLastRecordBoardColumnComponentFamilyState } from '@/object-record/record-board/states/isLastRecordBoardColumnComponentFamilyState';
import { recordBoardColumnIdsComponentState } from '@/object-record/record-board/states/recordBoardColumnIdsComponentState';
import { recordBoardColumnsComponentFamilyState } from '@/object-record/record-board/states/recordBoardColumnsComponentFamilyState';
import { RecordBoardColumnDefinition } from '@/object-record/record-board/types/RecordBoardColumnDefinition';
import { guardRecoilDefaultValue } from '@/ui/utilities/recoil-scope/utils/guardRecoilDefaultValue';
import { createComponentFamilySelector } from '@/ui/utilities/state/component-state/utils/createComponentFamilySelector';
import { isDefined } from '~/utils/isDefined';
import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition';
export const recordBoardColumnsComponentFamilySelector =
createComponentFamilySelector<
RecordBoardColumnDefinition | undefined,
string
>({
createComponentFamilySelector<RecordGroupDefinition | undefined, string>({
key: 'recordBoardColumnsComponentFamilySelector',
get:
({
@ -39,7 +29,7 @@ export const recordBoardColumnsComponentFamilySelector =
scopeId: string;
familyKey: string;
}) =>
({ set, get }, newColumn) => {
({ set }, newColumn) => {
set(
recordBoardColumnsComponentFamilyState({
scopeId,
@ -47,72 +37,5 @@ export const recordBoardColumnsComponentFamilySelector =
}),
newColumn,
);
if (guardRecoilDefaultValue(newColumn)) return;
const columnIds = get(recordBoardColumnIdsComponentState({ scopeId }));
const columns = columnIds
.map((columnId) => {
return get(
recordBoardColumnsComponentFamilyState({
scopeId,
familyKey: columnId,
}),
);
})
.filter(isDefined);
const lastColumn = [...columns].sort(
(a, b) => b.position - a.position,
)[0];
const firstColumn = [...columns].sort(
(a, b) => a.position - b.position,
)[0];
if (!newColumn) {
return;
}
if (!lastColumn || newColumn.position > lastColumn.position) {
set(
isLastRecordBoardColumnComponentFamilyState({
scopeId,
familyKey: columnId,
}),
true,
);
if (!isUndefined(lastColumn)) {
set(
isLastRecordBoardColumnComponentFamilyState({
scopeId,
familyKey: lastColumn.id,
}),
false,
);
}
}
if (!firstColumn || newColumn.position < firstColumn.position) {
set(
isFirstRecordBoardColumnComponentFamilyState({
scopeId,
familyKey: columnId,
}),
true,
);
if (!isUndefined(firstColumn)) {
set(
isFirstRecordBoardColumnComponentFamilyState({
scopeId,
familyKey: firstColumn.id,
}),
false,
);
}
}
},
});

View File

@ -1,31 +0,0 @@
import { ThemeColor } from 'twenty-ui';
import { RecordBoardColumnAction } from '@/object-record/record-board/types/RecordBoardColumnAction';
export const enum RecordBoardColumnDefinitionType {
Value = 'value',
NoValue = 'no-value',
}
export type RecordBoardColumnDefinitionNoValue = {
id: 'no-value';
type: RecordBoardColumnDefinitionType.NoValue;
title: 'No Value';
position: number;
value: null;
actions: RecordBoardColumnAction[];
};
export type RecordBoardColumnDefinitionValue = {
id: string;
type: RecordBoardColumnDefinitionType.Value;
title: string;
value: string;
color: ThemeColor;
position: number;
actions: RecordBoardColumnAction[];
};
export type RecordBoardColumnDefinition =
| RecordBoardColumnDefinitionValue
| RecordBoardColumnDefinitionNoValue;

View File

@ -0,0 +1,96 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { getFieldSlug } from '@/object-metadata/utils/getFieldSlug';
import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug';
import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext';
import { useRecordGroups } from '@/object-record/record-group/hooks/useRecordGroups';
import { useRecordGroupVisibility } from '@/object-record/record-group/hooks/useRecordGroupVisibility';
import { RecordGroupAction } from '@/object-record/record-group/types/RecordGroupActions';
import { RecordGroupDefinitionType } from '@/object-record/record-group/types/RecordGroupDefinition';
import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext';
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
import { useCallback, useContext, useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useSetRecoilState } from 'recoil';
import { IconEyeOff, IconSettings, isDefined } from 'twenty-ui';
export const useRecordGroupActions = () => {
const navigate = useNavigate();
const location = useLocation();
const { objectNameSingular, recordIndexId } = useContext(
RecordIndexRootPropsContext,
);
const { columnDefinition: recordGroupDefinition } = useContext(
RecordBoardColumnContext,
);
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { viewGroupFieldMetadataItem } = useRecordGroups({
objectNameSingular,
});
const { handleVisibilityChange: handleRecordGroupVisibilityChange } =
useRecordGroupVisibility({
viewBarId: recordIndexId,
});
const setNavigationMemorizedUrl = useSetRecoilState(
navigationMemorizedUrlState,
);
const navigateToSelectSettings = useCallback(() => {
setNavigationMemorizedUrl(location.pathname + location.search);
if (!isDefined(viewGroupFieldMetadataItem)) {
throw new Error('viewGroupFieldMetadataItem is not a non-empty string');
}
const settingsPath = `/settings/objects/${getObjectSlug(objectMetadataItem)}/${getFieldSlug(viewGroupFieldMetadataItem)}`;
navigate(settingsPath);
}, [
setNavigationMemorizedUrl,
location.pathname,
location.search,
navigate,
objectMetadataItem,
viewGroupFieldMetadataItem,
]);
const recordGroupActions: RecordGroupAction[] = useMemo(
() =>
[
{
id: 'edit',
label: 'Edit',
icon: IconSettings,
position: 0,
callback: () => {
navigateToSelectSettings();
},
},
recordGroupDefinition.type !== RecordGroupDefinitionType.NoValue
? {
id: 'hide',
label: 'Hide',
icon: IconEyeOff,
position: 1,
callback: () => {
handleRecordGroupVisibilityChange(recordGroupDefinition);
},
}
: undefined,
].filter(isDefined),
[
handleRecordGroupVisibilityChange,
navigateToSelectSettings,
recordGroupDefinition,
],
);
return recordGroupActions;
};

View File

@ -0,0 +1,59 @@
import { OnDragEndResponder } from '@hello-pangea/dnd';
import { useCallback } from 'react';
import { useRecordGroups } from '@/object-record/record-group/hooks/useRecordGroups';
import { recordGroupDefinitionsComponentState } from '@/object-record/record-group/states/recordGroupDefinitionsComponentState';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useSaveCurrentViewGroups } from '@/views/hooks/useSaveCurrentViewGroups';
import { mapRecordGroupDefinitionsToViewGroups } from '@/views/utils/mapRecordGroupDefinitionsToViewGroups';
import { moveArrayItem } from '~/utils/array/moveArrayItem';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
type UseRecordGroupHandlersParams = {
objectNameSingular: string;
viewBarId: string;
};
export const useRecordGroupReorder = ({
objectNameSingular,
viewBarId,
}: UseRecordGroupHandlersParams) => {
const setRecordGroupDefinitions = useSetRecoilComponentStateV2(
recordGroupDefinitionsComponentState,
);
const { visibleRecordGroups } = useRecordGroups({
objectNameSingular,
});
const { saveViewGroups } = useSaveCurrentViewGroups(viewBarId);
const handleOrderChange: OnDragEndResponder = useCallback(
(result) => {
if (!result.destination) {
return;
}
const reorderedVisibleBoardGroups = moveArrayItem(visibleRecordGroups, {
fromIndex: result.source.index - 1,
toIndex: result.destination.index - 1,
});
if (isDeeplyEqual(visibleRecordGroups, reorderedVisibleBoardGroups))
return;
const updatedGroups = [...reorderedVisibleBoardGroups].map(
(group, index) => ({ ...group, position: index }),
);
setRecordGroupDefinitions(updatedGroups);
saveViewGroups(mapRecordGroupDefinitionsToViewGroups(updatedGroups));
},
[saveViewGroups, setRecordGroupDefinitions, visibleRecordGroups],
);
return {
visibleRecordGroups,
handleOrderChange,
};
};

View File

@ -0,0 +1,45 @@
import { useCallback } from 'react';
import { recordGroupDefinitionsComponentState } from '@/object-record/record-group/states/recordGroupDefinitionsComponentState';
import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { useSaveCurrentViewGroups } from '@/views/hooks/useSaveCurrentViewGroups';
import { mapRecordGroupDefinitionsToViewGroups } from '@/views/utils/mapRecordGroupDefinitionsToViewGroups';
type UseRecordGroupVisibilityParams = {
viewBarId: string;
};
export const useRecordGroupVisibility = ({
viewBarId,
}: UseRecordGroupVisibilityParams) => {
const [recordGroupDefinitions, setRecordGroupDefinitions] =
useRecoilComponentStateV2(recordGroupDefinitionsComponentState);
const { saveViewGroups } = useSaveCurrentViewGroups(viewBarId);
const handleVisibilityChange = useCallback(
async (updatedRecordGroupDefinition: RecordGroupDefinition) => {
const updatedRecordGroupDefinitions = recordGroupDefinitions.map(
(groupDefinition) =>
groupDefinition.id === updatedRecordGroupDefinition.id
? {
...groupDefinition,
isVisible: !groupDefinition.isVisible,
}
: groupDefinition,
);
setRecordGroupDefinitions(updatedRecordGroupDefinitions);
saveViewGroups(
mapRecordGroupDefinitionsToViewGroups(updatedRecordGroupDefinitions),
);
},
[recordGroupDefinitions, setRecordGroupDefinitions, saveViewGroups],
);
return {
handleVisibilityChange,
};
};

View File

@ -0,0 +1,58 @@
import { useMemo } from 'react';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { recordGroupDefinitionsComponentState } from '@/object-record/record-group/states/recordGroupDefinitionsComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
type UseRecordGroupsParams = {
objectNameSingular: string;
};
export const useRecordGroups = ({
objectNameSingular,
}: UseRecordGroupsParams) => {
const recordGroupDefinitions = useRecoilComponentValueV2(
recordGroupDefinitionsComponentState,
);
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const viewGroupFieldMetadataItem = useMemo(() => {
if (recordGroupDefinitions.length === 0) return null;
// We're assuming that all groups have the same fieldMetadataId for now
const fieldMetadataId =
'fieldMetadataId' in recordGroupDefinitions[0]
? recordGroupDefinitions[0].fieldMetadataId
: null;
if (!fieldMetadataId) return null;
return objectMetadataItem.fields.find(
(field) => field.id === fieldMetadataId,
);
}, [objectMetadataItem, recordGroupDefinitions]);
const visibleRecordGroups = useMemo(
() =>
recordGroupDefinitions
.filter((boardGroup) => boardGroup.isVisible)
.sort(
(boardGroupA, boardGroupB) =>
boardGroupA.position - boardGroupB.position,
),
[recordGroupDefinitions],
);
const hiddenRecordGroups = useMemo(
() => recordGroupDefinitions.filter((boardGroup) => !boardGroup.isVisible),
[recordGroupDefinitions],
);
return {
hiddenRecordGroups,
visibleRecordGroups,
viewGroupFieldMetadataItem,
};
};

View File

@ -0,0 +1,11 @@
import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
export const recordGroupDefinitionsComponentState = createComponentStateV2<
RecordGroupDefinition[]
>({
key: 'recordGroupDefinitionsComponentState',
defaultValue: [],
componentInstanceContext: ViewComponentInstanceContext,
});

View File

@ -1,6 +1,6 @@
import { IconComponent } from 'twenty-ui';
export type RecordBoardColumnAction = {
export type RecordGroupAction = {
id: string;
label: string;
icon: IconComponent;

View File

@ -0,0 +1,17 @@
import { ThemeColor } from 'twenty-ui';
export const enum RecordGroupDefinitionType {
Value = 'value',
NoValue = 'no-value',
}
export type RecordGroupDefinition = {
id: string;
fieldMetadataId: string;
type: RecordGroupDefinitionType;
title: string;
value: string | null;
color: ThemeColor | 'transparent';
position: number;
isVisible: boolean;
};

View File

@ -9,14 +9,12 @@ import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/get
export const RecordIndexBoardColumnLoaderEffect = ({
objectNameSingular,
boardFieldSelectValue,
boardFieldMetadataId,
recordBoardId,
columnId,
}: {
recordBoardId: string;
objectNameSingular: string;
boardFieldSelectValue: string | null;
boardFieldMetadataId: string | null;
columnId: string;
}) => {
@ -40,7 +38,6 @@ export const RecordIndexBoardColumnLoaderEffect = ({
objectNameSingular,
recordBoardId,
boardFieldMetadataId,
columnFieldSelectValue: boardFieldSelectValue,
columnId,
});
@ -70,7 +67,6 @@ export const RecordIndexBoardColumnLoaderEffect = ({
fetchMoreRecords,
loading,
shouldFetchMore,
boardFieldSelectValue,
setLoadingRecordsForThisColumn,
loadingRecordsForThisColumn,

View File

@ -26,23 +26,18 @@ export const RecordIndexBoardDataLoader = ({
(field) => field.id === recordIndexKanbanFieldMetadataId,
);
const possibleKanbanSelectFieldValues =
recordIndexKanbanFieldMetadataItem?.options ?? [];
const { columnIdsState } = useRecordBoardStates(recordBoardId);
// TODO: we should make sure there's no way to have a mismatch between columnIds and possibleKanbanSelectFieldValues order
const columnIds = useRecoilValue(columnIdsState);
return (
<>
{possibleKanbanSelectFieldValues.map((option, index) => (
{columnIds.map((columnId, index) => (
<RecordIndexBoardColumnLoaderEffect
objectNameSingular={objectNameSingular}
boardFieldMetadataId={recordIndexKanbanFieldMetadataId}
boardFieldSelectValue={option.value}
recordBoardId={recordBoardId}
columnId={columnIds[index]}
columnId={columnId}
key={index}
/>
))}
@ -50,7 +45,6 @@ export const RecordIndexBoardDataLoader = ({
<RecordIndexBoardColumnLoaderEffect
objectNameSingular={objectNameSingular}
boardFieldMetadataId={recordIndexKanbanFieldMetadataId}
boardFieldSelectValue={null}
recordBoardId={recordBoardId}
columnId={'no-value'}
/>

View File

@ -1,16 +1,14 @@
import { useCallback, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useEffect } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug';
import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard';
import { recordGroupDefinitionsComponentState } from '@/object-record/record-group/states/recordGroupDefinitionsComponentState';
import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState';
import { recordIndexIsCompactModeActiveState } from '@/object-record/record-index/states/recordIndexIsCompactModeActiveState';
import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState';
import { computeRecordBoardColumnDefinitionsFromObjectMetadata } from '@/object-record/utils/computeRecordBoardColumnDefinitionsFromObjectMetadata';
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined';
@ -32,6 +30,10 @@ export const RecordIndexBoardDataLoaderEffect = ({
recordIndexFieldDefinitionsState,
);
const recordIndexGroupDefinitions = useRecoilComponentValueV2(
recordGroupDefinitionsComponentState,
);
const recordIndexKanbanFieldMetadataId = useRecoilValue(
recordIndexKanbanFieldMetadataIdState,
);
@ -60,43 +62,17 @@ export const RecordIndexBoardDataLoaderEffect = ({
setFieldDefinitions(recordIndexFieldDefinitions);
}, [recordIndexFieldDefinitions, setFieldDefinitions]);
const navigate = useNavigate();
const location = useLocation();
const setNavigationMemorizedUrl = useSetRecoilState(
navigationMemorizedUrlState,
);
const navigateToSelectSettings = useCallback(() => {
setNavigationMemorizedUrl(location.pathname + location.search);
navigate(`/settings/objects/${getObjectSlug(objectMetadataItem)}`);
}, [
navigate,
objectMetadataItem,
location.pathname,
location.search,
setNavigationMemorizedUrl,
]);
useEffect(() => {
setObjectSingularName(objectNameSingular);
}, [objectNameSingular, setObjectSingularName]);
useEffect(() => {
setColumns(
computeRecordBoardColumnDefinitionsFromObjectMetadata(
objectMetadataItem,
recordIndexKanbanFieldMetadataId ?? '',
navigateToSelectSettings,
),
);
}, [
navigateToSelectSettings,
objectMetadataItem,
objectNameSingular,
recordIndexKanbanFieldMetadataId,
setColumns,
]);
setColumns(recordIndexGroupDefinitions);
}, [recordIndexGroupDefinitions, setColumns]);
// TODO: Remove this duplicate useEffect by ensuring it's not here because
// We want it to be triggered by a change of objectMetadataItem, which would be an anti-pattern
// As it is an unnecessary dependency
useEffect(() => {
setFieldDefinitions(recordIndexFieldDefinitions);
}, [objectMetadataItem, setFieldDefinitions, recordIndexFieldDefinitions]);

View File

@ -24,13 +24,17 @@ import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/compone
import { RecordIndexActionMenu } from '@/action-menu/components/RecordIndexActionMenu';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard';
import { recordGroupDefinitionsComponentState } from '@/object-record/record-group/states/recordGroupDefinitionsComponentState';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { ViewBar } from '@/views/components/ViewBar';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
import { ViewField } from '@/views/types/ViewField';
import { ViewGroup } from '@/views/types/ViewGroup';
import { ViewType } from '@/views/types/ViewType';
import { mapViewFieldsToColumnDefinitions } from '@/views/utils/mapViewFieldsToColumnDefinitions';
import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters';
import { mapViewGroupsToRecordGroupDefinitions } from '@/views/utils/mapViewGroupsToRecordGroupDefinitions';
import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts';
import { useContext } from 'react';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
@ -61,6 +65,10 @@ export const RecordIndexContainer = () => {
objectNameSingular,
} = useContext(RecordIndexRootPropsContext);
const recordGroupDefinitionsCallbackState = useRecoilComponentCallbackStateV2(
recordGroupDefinitionsComponentState,
);
const { columnDefinitions, filterDefinitions, sortDefinitions } =
useColumnDefinitionsFromFieldMetadata(objectMetadataItem);
@ -77,6 +85,8 @@ export const RecordIndexContainer = () => {
recordTableId: recordIndexId,
});
const { setColumns } = useRecordBoard(recordIndexId);
const onViewFieldsChange = useRecoilCallback(
({ set, snapshot }) =>
(viewFields: ViewField[]) => {
@ -103,6 +113,32 @@ export const RecordIndexContainer = () => {
[columnDefinitions, setTableColumns],
);
const onViewGroupsChange = useRecoilCallback(
({ set, snapshot }) =>
(viewGroups: ViewGroup[]) => {
const newGroupDefinitions = mapViewGroupsToRecordGroupDefinitions({
objectMetadataItem,
viewGroups,
});
setColumns(newGroupDefinitions);
const existingRecordIndexGroupDefinitions = snapshot
.getLoadable(recordGroupDefinitionsCallbackState)
.getValue();
if (
!isDeeplyEqual(
existingRecordIndexGroupDefinitions,
newGroupDefinitions,
)
) {
set(recordGroupDefinitionsCallbackState, newGroupDefinitions);
}
},
[objectMetadataItem, recordGroupDefinitionsCallbackState, setColumns],
);
const setContextStoreTargetedRecordsRule = useSetRecoilComponentStateV2(
contextStoreTargetedRecordsRuleComponentState,
);
@ -110,86 +146,83 @@ export const RecordIndexContainer = () => {
return (
<StyledContainer>
<InformationBannerWrapper />
<ViewComponentInstanceContext.Provider
value={{ instanceId: recordIndexId }}
>
<RecordFieldValueSelectorContextProvider>
<SpreadsheetImportProvider>
<ViewBar
viewBarId={recordIndexId}
optionsDropdownButton={
<RecordIndexOptionsDropdown
recordIndexId={recordIndexId}
objectMetadataItem={objectMetadataItem}
viewType={recordIndexViewType ?? ViewType.Table}
/>
<RecordFieldValueSelectorContextProvider>
<SpreadsheetImportProvider>
<ViewBar
viewBarId={recordIndexId}
optionsDropdownButton={
<RecordIndexOptionsDropdown
recordIndexId={recordIndexId}
objectMetadataItem={objectMetadataItem}
viewType={recordIndexViewType ?? ViewType.Table}
/>
}
onCurrentViewChange={(view) => {
if (!view) {
return;
}
onCurrentViewChange={(view) => {
if (!view) {
return;
}
onViewFieldsChange(view.viewFields);
setTableFilters(
mapViewFiltersToFilters(view.viewFilters, filterDefinitions),
);
setRecordIndexFilters(
mapViewFiltersToFilters(view.viewFilters, filterDefinitions),
);
setContextStoreTargetedRecordsRule((prev) => ({
...prev,
filters: mapViewFiltersToFilters(
view.viewFilters,
filterDefinitions,
),
}));
setTableSorts(
mapViewSortsToSorts(view.viewSorts, sortDefinitions),
);
setRecordIndexSorts(
mapViewSortsToSorts(view.viewSorts, sortDefinitions),
);
setRecordIndexViewType(view.type);
setRecordIndexViewKanbanFieldMetadataIdState(
view.kanbanFieldMetadataId,
);
setRecordIndexIsCompactModeActive(view.isCompact);
}}
/>
<RecordIndexViewBarEffect
objectNamePlural={objectNamePlural}
onViewFieldsChange(view.viewFields);
onViewGroupsChange(view.viewGroups);
setTableFilters(
mapViewFiltersToFilters(view.viewFilters, filterDefinitions),
);
setRecordIndexFilters(
mapViewFiltersToFilters(view.viewFilters, filterDefinitions),
);
setContextStoreTargetedRecordsRule((prev) => ({
...prev,
filters: mapViewFiltersToFilters(
view.viewFilters,
filterDefinitions,
),
}));
setTableSorts(
mapViewSortsToSorts(view.viewSorts, sortDefinitions),
);
setRecordIndexSorts(
mapViewSortsToSorts(view.viewSorts, sortDefinitions),
);
setRecordIndexViewType(view.type);
setRecordIndexViewKanbanFieldMetadataIdState(
view.kanbanFieldMetadataId,
);
setRecordIndexIsCompactModeActive(view.isCompact);
}}
/>
<RecordIndexViewBarEffect
objectNamePlural={objectNamePlural}
viewBarId={recordIndexId}
/>
</SpreadsheetImportProvider>
{recordIndexViewType === ViewType.Table && (
<>
<RecordIndexTableContainer
recordTableId={recordIndexId}
viewBarId={recordIndexId}
/>
</SpreadsheetImportProvider>
{recordIndexViewType === ViewType.Table && (
<>
<RecordIndexTableContainer
recordTableId={recordIndexId}
viewBarId={recordIndexId}
/>
<RecordIndexTableContainerEffect />
</>
)}
{recordIndexViewType === ViewType.Kanban && (
<StyledContainerWithPadding>
<RecordIndexBoardContainer
recordBoardId={recordIndexId}
viewBarId={recordIndexId}
objectNameSingular={objectNameSingular}
/>
<RecordIndexBoardDataLoader
objectNameSingular={objectNameSingular}
recordBoardId={recordIndexId}
/>
<RecordIndexBoardDataLoaderEffect
objectNameSingular={objectNameSingular}
recordBoardId={recordIndexId}
/>
</StyledContainerWithPadding>
)}
<RecordIndexActionMenu actionMenuId={recordIndexId} />
</RecordFieldValueSelectorContextProvider>
</ViewComponentInstanceContext.Provider>
<RecordIndexTableContainerEffect />
</>
)}
{recordIndexViewType === ViewType.Kanban && (
<StyledContainerWithPadding>
<RecordIndexBoardContainer
recordBoardId={recordIndexId}
viewBarId={recordIndexId}
objectNameSingular={objectNameSingular}
/>
<RecordIndexBoardDataLoader
objectNameSingular={objectNameSingular}
recordBoardId={recordIndexId}
/>
<RecordIndexBoardDataLoaderEffect
objectNameSingular={objectNameSingular}
recordBoardId={recordIndexId}
/>
</StyledContainerWithPadding>
)}
<RecordIndexActionMenu actionMenuId={recordIndexId} />
</RecordFieldValueSelectorContextProvider>
</StyledContainer>
);
};

View File

@ -3,7 +3,7 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates';
import { useAddNewCard } from '@/object-record/record-board/record-board-column/hooks/useAddNewCard';
import { useIsOpportunitiesCompanyFieldDisabled } from '@/object-record/record-board/record-board-column/hooks/useIsOpportunitiesCompanyFieldDisabled';
import { RecordBoardColumnDefinition } from '@/object-record/record-board/types/RecordBoardColumnDefinition';
import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition';
import { RecordIndexPageKanbanAddMenuItem } from '@/object-record/record-index/components/RecordIndexPageKanbanAddMenuItem';
import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext';
import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState';
@ -58,7 +58,7 @@ export const RecordIndexPageKanbanAddButton = () => {
const { handleAddNewCardClick } = useAddNewCard();
const handleItemClick = useCallback(
(columnDefinition: RecordBoardColumnDefinition) => {
(columnDefinition: RecordGroupDefinition) => {
const isOpportunityEnabled =
isOpportunity && !isOpportunitiesCompanyFieldDisabled;
handleAddNewCardClick(

View File

@ -1,4 +1,4 @@
import { RecordBoardColumnDefinitionType } from '@/object-record/record-board/types/RecordBoardColumnDefinition';
import { RecordGroupDefinitionType } from '@/object-record/record-group/types/RecordGroupDefinition';
import { useRecordIndexPageKanbanAddMenuItem } from '@/object-record/record-index/hooks/useRecordIndexPageKanbanAddMenuItem';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import styled from '@emotion/styled';
@ -32,18 +32,18 @@ export const RecordIndexPageKanbanAddMenuItem = ({
text={
<Tag
variant={
columnDefinition.type === RecordBoardColumnDefinitionType.Value
columnDefinition.type === RecordGroupDefinitionType.Value
? 'solid'
: 'outline'
}
color={
columnDefinition.type === RecordBoardColumnDefinitionType.Value
columnDefinition.type === RecordGroupDefinitionType.Value
? columnDefinition.color
: 'transparent'
}
text={columnDefinition.title}
weight={
columnDefinition.type === RecordBoardColumnDefinitionType.Value
columnDefinition.type === RecordGroupDefinitionType.Value
? 'regular'
: 'medium'
}

View File

@ -6,12 +6,14 @@ 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 { turnFiltersIntoQueryFilter } from '@/object-record/record-filter/utils/turnFiltersIntoQueryFilter';
import { recordGroupDefinitionsComponentState } from '@/object-record/record-group/states/recordGroupDefinitionsComponentState';
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';
import { recordIndexIsCompactModeActiveState } from '@/object-record/record-index/states/recordIndexIsCompactModeActiveState';
import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecordCountInCurrentView } from '@/views/hooks/useSetRecordCountInCurrentView';
type UseLoadRecordIndexBoardProps = {
@ -31,6 +33,7 @@ export const useLoadRecordIndexBoard = ({
const {
setRecordIds: setRecordIdsInBoard,
setFieldDefinitions,
setColumns,
isCompactModeActiveState,
} = useRecordBoard(recordBoardId);
const { upsertRecords: upsertRecordsInStore } = useUpsertRecordsInStore();
@ -42,6 +45,13 @@ export const useLoadRecordIndexBoard = ({
setFieldDefinitions(recordIndexFieldDefinitions);
}, [recordIndexFieldDefinitions, setFieldDefinitions]);
const recordIndexGroupDefinitions = useRecoilComponentValueV2(
recordGroupDefinitionsComponentState,
);
useEffect(() => {
setColumns(recordIndexGroupDefinitions);
}, [recordIndexGroupDefinitions, setColumns]);
const recordIndexFilters = useRecoilValue(recordIndexFiltersState);
const recordIndexSorts = useRecoilValue(recordIndexSortsState);
const requestFilters = turnFiltersIntoQueryFilter(

View File

@ -11,12 +11,12 @@ import { recordIndexFiltersState } from '@/object-record/record-index/states/rec
import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { isDefined } from '~/utils/isDefined';
import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates';
type UseLoadRecordIndexBoardProps = {
objectNameSingular: string;
boardFieldMetadataId: string | null;
recordBoardId: string;
columnFieldSelectValue: string | null;
columnId: string;
};
@ -24,17 +24,18 @@ export const useLoadRecordIndexBoardColumn = ({
objectNameSingular,
boardFieldMetadataId,
recordBoardId,
columnFieldSelectValue,
columnId,
}: UseLoadRecordIndexBoardProps) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { setRecordIdsForColumn } = useRecordBoard(recordBoardId);
const { columnsFamilySelector } = useRecordBoardStates(recordBoardId);
const { upsertRecords: upsertRecordsInStore } = useUpsertRecordsInStore();
const recordIndexFilters = useRecoilValue(recordIndexFiltersState);
const recordIndexSorts = useRecoilValue(recordIndexSortsState);
const columnDefinition = useRecoilValue(columnsFamilySelector(columnId));
const requestFilters = turnFiltersIntoQueryFilter(
recordIndexFilters,
objectMetadataItem?.fields ?? [],
@ -53,9 +54,9 @@ export const useLoadRecordIndexBoardColumn = ({
const filter = {
...requestFilters,
[recordIndexKanbanFieldMetadataItem?.name ?? '']: isDefined(
columnFieldSelectValue,
columnDefinition?.value,
)
? { in: [columnFieldSelectValue] }
? { in: [columnDefinition?.value] }
: { is: 'NULL' },
};

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Key } from 'ts-key-enum';
import {
IconBaselineDensitySmall,
@ -10,6 +10,7 @@ import {
IconSettings,
IconTag,
UndecoratedLink,
useIcons,
} from 'twenty-ui';
import { useObjectNamePluralFromSingular } from '@/object-metadata/hooks/useObjectNamePluralFromSingular';
@ -21,6 +22,9 @@ import {
useExportRecordData,
} from '@/action-menu/hooks/useExportRecordData';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useRecordGroupReorder } from '@/object-record/record-group/hooks/useRecordGroupReorder';
import { useRecordGroups } from '@/object-record/record-group/hooks/useRecordGroups';
import { useRecordGroupVisibility } from '@/object-record/record-group/hooks/useRecordGroupVisibility';
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';
@ -37,12 +41,17 @@ import { MenuItemToggle } from '@/ui/navigation/menu-item/components/MenuItemTog
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { ViewFieldsVisibilityDropdownSection } from '@/views/components/ViewFieldsVisibilityDropdownSection';
import { ViewGroupsVisibilityDropdownSection } from '@/views/components/ViewGroupsVisibilityDropdownSection';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { ViewType } from '@/views/types/ViewType';
import { useLocation } from 'react-router-dom';
import { useSetRecoilState } from 'recoil';
type RecordIndexOptionsMenu = 'fields' | 'hiddenFields';
type RecordIndexOptionsMenu =
| 'viewGroups'
| 'hiddenViewGroups'
| 'fields'
| 'hiddenFields';
type RecordIndexOptionsDropdownContentProps = {
recordIndexId: string;
@ -50,6 +59,7 @@ type RecordIndexOptionsDropdownContentProps = {
viewType: ViewType;
};
// TODO: Break this component down
export const RecordIndexOptionsDropdownContent = ({
viewType,
recordIndexId,
@ -57,6 +67,8 @@ export const RecordIndexOptionsDropdownContent = ({
}: RecordIndexOptionsDropdownContentProps) => {
const { currentViewWithCombinedFiltersAndSorts } = useGetCurrentView();
const { getIcon } = useIcons();
const { closeDropdown } = useDropdown(RECORD_INDEX_OPTIONS_DROPDOWN_ID);
const [currentMenu, setCurrentMenu] = useState<
@ -111,6 +123,28 @@ export const RecordIndexOptionsDropdownContent = ({
viewBarId: recordIndexId,
});
const {
hiddenRecordGroups,
visibleRecordGroups,
viewGroupFieldMetadataItem,
} = useRecordGroups({
objectNameSingular: objectMetadataItem.nameSingular,
});
const { handleVisibilityChange: handleRecordGroupVisibilityChange } =
useRecordGroupVisibility({
viewBarId: recordIndexId,
});
const { handleOrderChange: handleRecordGroupOrderChange } =
useRecordGroupReorder({
objectNameSingular: objectMetadataItem.nameSingular,
viewBarId: recordIndexId,
});
const viewGroupSettingsUrl = getSettingsPagePath(SettingsPath.ObjectDetail, {
id: viewGroupFieldMetadataItem?.name,
objectSlug: objectNamePlural,
});
const visibleRecordFields =
viewType === ViewType.Kanban ? visibleBoardFields : visibleTableColumns;
@ -143,10 +177,28 @@ export const RecordIndexOptionsDropdownContent = ({
navigationMemorizedUrlState,
);
const isViewGroupMenuItemVisible =
viewGroupFieldMetadataItem &&
(visibleRecordGroups.length > 0 || hiddenRecordGroups.length > 0);
useEffect(() => {
if (currentMenu === 'hiddenViewGroups' && hiddenRecordGroups.length === 0) {
setCurrentMenu('viewGroups');
}
}, [hiddenRecordGroups, currentMenu]);
return (
<>
{!currentMenu && (
<DropdownMenuItemsContainer>
{isViewGroupMenuItemVisible && (
<MenuItem
onClick={() => handleSelectMenu('viewGroups')}
LeftIcon={getIcon(currentViewWithCombinedFiltersAndSorts?.icon)}
text={viewGroupFieldMetadataItem.label}
hasSubMenu
/>
)}
<MenuItem
onClick={() => handleSelectMenu('fields')}
LeftIcon={IconTag}
@ -174,6 +226,34 @@ export const RecordIndexOptionsDropdownContent = ({
/>
</DropdownMenuItemsContainer>
)}
{currentMenu === 'viewGroups' && (
<>
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={resetMenu}>
{viewGroupFieldMetadataItem?.label}
</DropdownMenuHeader>
<ViewGroupsVisibilityDropdownSection
title={viewGroupFieldMetadataItem?.label ?? ''}
viewGroups={visibleRecordGroups}
onDragEnd={handleRecordGroupOrderChange}
onVisibilityChange={handleRecordGroupVisibilityChange}
isDraggable
showSubheader={false}
showDragGrip={true}
/>
{hiddenRecordGroups.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<MenuItemNavigate
onClick={() => handleSelectMenu('hiddenViewGroups')}
LeftIcon={IconEyeOff}
text={`Hidden ${viewGroupFieldMetadataItem?.label ?? ''}`}
/>
</DropdownMenuItemsContainer>
</>
)}
</>
)}
{currentMenu === 'fields' && (
<>
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={resetMenu}>
@ -198,6 +278,36 @@ export const RecordIndexOptionsDropdownContent = ({
</DropdownMenuItemsContainer>
</>
)}
{currentMenu === 'hiddenViewGroups' && (
<>
<DropdownMenuHeader
StartIcon={IconChevronLeft}
onClick={() => setCurrentMenu('viewGroups')}
>
Hidden {viewGroupFieldMetadataItem?.label}
</DropdownMenuHeader>
<ViewGroupsVisibilityDropdownSection
title={`Hidden ${viewGroupFieldMetadataItem?.label}`}
viewGroups={hiddenRecordGroups}
onVisibilityChange={handleRecordGroupVisibilityChange}
isDraggable={false}
showSubheader={false}
showDragGrip={false}
/>
<DropdownMenuSeparator />
<UndecoratedLink
to={viewGroupSettingsUrl}
onClick={() => {
setNavigationMemorizedUrl(location.pathname + location.search);
closeDropdown();
}}
>
<DropdownMenuItemsContainer>
<MenuItem LeftIcon={IconSettings} text="Edit field values" />
</DropdownMenuItemsContainer>
</UndecoratedLink>
</>
)}
{currentMenu === 'hiddenFields' && (
<>
<DropdownMenuHeader

View File

@ -1,27 +0,0 @@
import { expect } from '@storybook/test';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { computeRecordBoardColumnDefinitionsFromObjectMetadata } from '../computeRecordBoardColumnDefinitionsFromObjectMetadata';
describe('computeRecordBoardColumnDefinitionsFromObjectMetadata', () => {
it('should correctly compute', () => {
const objectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'opportunity',
);
const stageField = objectMetadataItem?.fields.find(
(field) => field.name === 'stage',
);
if (!objectMetadataItem) {
throw new Error('Object metadata item not found');
}
const res = computeRecordBoardColumnDefinitionsFromObjectMetadata(
objectMetadataItem,
stageField?.id,
() => null,
);
expect(res.length).toEqual(stageField?.options?.length);
});
});

View File

@ -1,71 +0,0 @@
import { IconSettings } from 'twenty-ui';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
RecordBoardColumnDefinition,
RecordBoardColumnDefinitionNoValue,
RecordBoardColumnDefinitionType,
RecordBoardColumnDefinitionValue,
} from '@/object-record/record-board/types/RecordBoardColumnDefinition';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const computeRecordBoardColumnDefinitionsFromObjectMetadata = (
objectMetadataItem: ObjectMetadataItem,
kanbanFieldMetadataId: string,
navigateToSelectSettings: () => void,
): RecordBoardColumnDefinition[] => {
const selectFieldMetadataItem = objectMetadataItem.fields.find(
(field) =>
field.id === kanbanFieldMetadataId &&
field.type === FieldMetadataType.Select,
);
if (!selectFieldMetadataItem) {
return [];
}
if (!selectFieldMetadataItem.options) {
throw new Error(
`Select Field ${objectMetadataItem.nameSingular} has no options`,
);
}
const valueColumns = selectFieldMetadataItem.options.map(
(selectOption) =>
({
id: selectOption.id,
type: RecordBoardColumnDefinitionType.Value,
title: selectOption.label,
value: selectOption.value,
color: selectOption.color,
position: selectOption.position,
actions: [
{
id: 'edit',
label: 'Edit from Settings',
icon: IconSettings,
position: 0,
callback: navigateToSelectSettings,
},
],
}) satisfies RecordBoardColumnDefinitionValue,
);
const noValueColumn = {
id: 'no-value',
title: 'No Value',
type: RecordBoardColumnDefinitionType.NoValue,
value: null,
actions: [],
position:
selectFieldMetadataItem.options
.map((option) => option.position)
.reduce((a, b) => Math.max(a, b), 0) + 1,
} satisfies RecordBoardColumnDefinitionNoValue;
if (selectFieldMetadataItem.isNullable === true) {
return [...valueColumns, noValueColumn];
}
return valueColumns;
};