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

@ -0,0 +1,192 @@
import {
DropResult,
OnDragEndResponder,
ResponderProvided,
} from '@hello-pangea/dnd';
import { useRef } from 'react';
import { IconEye, IconEyeOff, Tag } from 'twenty-ui';
import {
RecordGroupDefinition,
RecordGroupDefinitionType,
} from '@/object-record/record-group/types/RecordGroupDefinition';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { StyledDropdownMenuSubheader } from '@/ui/layout/dropdown/components/StyledDropdownMenuSubheader';
import { MenuItemDraggable } from '@/ui/navigation/menu-item/components/MenuItemDraggable';
import { isDefined } from '~/utils/isDefined';
type ViewGroupsVisibilityDropdownSectionProps = {
viewGroups: RecordGroupDefinition[];
isDraggable: boolean;
onDragEnd?: OnDragEndResponder;
onVisibilityChange: (viewGroup: RecordGroupDefinition) => void;
title: string;
showSubheader: boolean;
showDragGrip: boolean;
};
export const ViewGroupsVisibilityDropdownSection = ({
viewGroups,
isDraggable,
onDragEnd,
onVisibilityChange,
title,
showSubheader = true,
showDragGrip,
}: ViewGroupsVisibilityDropdownSectionProps) => {
const handleOnDrag = (result: DropResult, provided: ResponderProvided) => {
onDragEnd?.(result, provided);
};
const getIconButtons = (index: number, viewGroup: RecordGroupDefinition) => {
const iconButtons = [
{
Icon: viewGroup.isVisible ? IconEyeOff : IconEye,
onClick: () => onVisibilityChange(viewGroup),
},
].filter(isDefined);
return iconButtons.length ? iconButtons : undefined;
};
const noValueViewGroups =
viewGroups.filter(
(viewGroup) => viewGroup.type === RecordGroupDefinitionType.NoValue,
) ?? [];
const viewGroupsWithoutNoValueGroups = viewGroups.filter(
(viewGroup) => viewGroup.type !== RecordGroupDefinitionType.NoValue,
);
const ref = useRef<HTMLDivElement>(null);
return (
<div ref={ref}>
{showSubheader && (
<StyledDropdownMenuSubheader>{title}</StyledDropdownMenuSubheader>
)}
<DropdownMenuItemsContainer>
{!!viewGroups.length && (
<>
{!isDraggable ? (
viewGroupsWithoutNoValueGroups.map(
(viewGroup, viewGroupIndex) => (
<MenuItemDraggable
key={viewGroup.id}
text={
<Tag
variant={
viewGroup.type !== RecordGroupDefinitionType.NoValue
? 'solid'
: 'outline'
}
color={
viewGroup.type !== RecordGroupDefinitionType.NoValue
? viewGroup.color
: 'transparent'
}
text={viewGroup.title}
weight={
viewGroup.type !== RecordGroupDefinitionType.NoValue
? 'regular'
: 'medium'
}
/>
}
iconButtons={getIconButtons(viewGroupIndex, viewGroup)}
accent={showDragGrip ? 'placeholder' : 'default'}
showGrip={showDragGrip}
isDragDisabled={!isDraggable}
/>
),
)
) : (
<DraggableList
onDragEnd={handleOnDrag}
draggableItems={
<>
{viewGroupsWithoutNoValueGroups.map(
(viewGroup, viewGroupIndex) => (
<DraggableItem
key={viewGroup.id}
draggableId={viewGroup.id}
index={viewGroupIndex + 1}
itemComponent={
<MenuItemDraggable
key={viewGroup.id}
text={
<Tag
variant={
viewGroup.type !==
RecordGroupDefinitionType.NoValue
? 'solid'
: 'outline'
}
color={
viewGroup.type !==
RecordGroupDefinitionType.NoValue
? viewGroup.color
: 'transparent'
}
text={viewGroup.title}
weight={
viewGroup.type !==
RecordGroupDefinitionType.NoValue
? 'regular'
: 'medium'
}
/>
}
iconButtons={getIconButtons(
viewGroupIndex,
viewGroup,
)}
accent={showDragGrip ? 'placeholder' : 'default'}
showGrip={showDragGrip}
isDragDisabled={!isDraggable}
/>
}
/>
),
)}
</>
}
/>
)}
{noValueViewGroups.map((viewGroup) => (
<MenuItemDraggable
key={viewGroup.id}
text={
<Tag
variant={
viewGroup.type !== RecordGroupDefinitionType.NoValue
? 'solid'
: 'outline'
}
color={
viewGroup.type !== RecordGroupDefinitionType.NoValue
? viewGroup.color
: 'transparent'
}
text={viewGroup.title}
weight={
viewGroup.type !== RecordGroupDefinitionType.NoValue
? 'regular'
: 'medium'
}
/>
}
accent={showDragGrip ? 'placeholder' : 'default'}
showGrip={true}
isDragDisabled={true}
isHoverDisabled
/>
))}
</>
)}
</DropdownMenuItemsContainer>
</div>
);
};

View File

@ -0,0 +1,118 @@
import { useApolloClient } from '@apollo/client';
import { useCallback } from 'react';
import { v4 } from 'uuid';
import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateOneRecordMutation } from '@/object-record/hooks/useCreateOneRecordMutation';
import { useUpdateOneRecordMutation } from '@/object-record/hooks/useUpdateOneRecordMutation';
import { GraphQLView } from '@/views/types/GraphQLView';
import { ViewGroup } from '@/views/types/ViewGroup';
export const usePersistViewGroupRecords = () => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.ViewGroup,
});
const { createOneRecordMutation } = useCreateOneRecordMutation({
objectNameSingular: CoreObjectNameSingular.ViewGroup,
});
const { updateOneRecordMutation } = useUpdateOneRecordMutation({
objectNameSingular: CoreObjectNameSingular.ViewGroup,
});
const { objectMetadataItems } = useObjectMetadataItems();
const apolloClient = useApolloClient();
const createViewGroupRecords = useCallback(
(viewGroupsToCreate: ViewGroup[], view: GraphQLView) => {
if (!viewGroupsToCreate.length) return;
return Promise.all(
viewGroupsToCreate.map((viewGroup) =>
apolloClient.mutate({
mutation: createOneRecordMutation,
variables: {
input: {
fieldMetadataId: viewGroup.fieldMetadataId,
viewId: view.id,
isVisible: viewGroup.isVisible,
position: viewGroup.position,
id: v4(),
fieldValue: viewGroup.fieldValue,
},
},
update: (cache, { data }) => {
const record = data?.['createViewGroup'];
if (!record) return;
triggerCreateRecordsOptimisticEffect({
cache,
objectMetadataItem,
recordsToCreate: [record],
objectMetadataItems,
});
},
}),
),
);
},
[
apolloClient,
createOneRecordMutation,
objectMetadataItem,
objectMetadataItems,
],
);
const updateViewGroupRecords = useCallback(
async (viewGroupsToUpdate: ViewGroup[]) => {
if (!viewGroupsToUpdate.length) return;
const mutationPromises = viewGroupsToUpdate.map((viewGroup) =>
apolloClient.mutate<{ updateViewGroup: ViewGroup }>({
mutation: updateOneRecordMutation,
variables: {
idToUpdate: viewGroup.id,
input: {
isVisible: viewGroup.isVisible,
position: viewGroup.position,
},
},
// Avoid cache being updated with stale data
fetchPolicy: 'no-cache',
}),
);
const mutationResults = await Promise.all(mutationPromises);
// FixMe: Using triggerCreateRecordsOptimisticEffect is actaully causing multiple records to be created
mutationResults.forEach(({ data }) => {
const record = data?.['updateViewGroup'];
if (!record) return;
apolloClient.cache.modify({
id: apolloClient.cache.identify({
__typename: 'ViewGroup',
id: record.id,
}),
fields: {
isVisible: () => record.isVisible,
position: () => record.position,
},
});
});
},
[apolloClient, updateOneRecordMutation],
);
return {
createViewGroupRecords,
updateViewGroupRecords,
};
};

View File

@ -1,34 +0,0 @@
import { usePersistViewFilterRecords } from '@/views/hooks/internal/usePersistViewFilterRecords';
import { usePersistViewSortRecords } from '@/views/hooks/internal/usePersistViewSortRecords';
import { useGetViewFromCache } from '@/views/hooks/useGetViewFromCache';
import { ViewFilter } from '@/views/types/ViewFilter';
import { ViewSort } from '@/views/types/ViewSort';
import { isDefined } from '~/utils/isDefined';
export const useCreateViewFiltersAndSorts = () => {
const { getViewFromCache } = useGetViewFromCache();
const { createViewSortRecords } = usePersistViewSortRecords();
const { createViewFilterRecords } = usePersistViewFilterRecords();
const createViewFiltersAndSorts = async (
viewIdToCreateOn: string,
filtersToCreate: ViewFilter[],
sortsToCreate: ViewSort[],
) => {
const view = await getViewFromCache(viewIdToCreateOn);
if (!isDefined(view)) {
return;
}
await createViewSortRecords(sortsToCreate, view);
await createViewFilterRecords(filtersToCreate, view);
};
return {
createViewFiltersAndSorts,
};
};

View File

@ -1,9 +1,12 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { usePersistViewFieldRecords } from '@/views/hooks/internal/usePersistViewFieldRecords';
import { useCreateViewFiltersAndSorts } from '@/views/hooks/useCreateViewFiltersAndSorts';
import { usePersistViewFilterRecords } from '@/views/hooks/internal/usePersistViewFilterRecords';
import { usePersistViewGroupRecords } from '@/views/hooks/internal/usePersistViewGroupRecords';
import { usePersistViewSortRecords } from '@/views/hooks/internal/usePersistViewSortRecords';
import { useGetViewFiltersCombined } from '@/views/hooks/useGetCombinedViewFilters';
import { useGetViewSortsCombined } from '@/views/hooks/useGetCombinedViewSorts';
import { useGetViewFromCache } from '@/views/hooks/useGetViewFromCache';
@ -11,6 +14,10 @@ import { currentViewIdComponentState } from '@/views/states/currentViewIdCompone
import { isPersistingViewFieldsComponentState } from '@/views/states/isPersistingViewFieldsComponentState';
import { GraphQLView } from '@/views/types/GraphQLView';
import { View } from '@/views/types/View';
import { ViewGroup } from '@/views/types/ViewGroup';
import { ViewType } from '@/views/types/ViewType';
import { isNonEmptyArray } from '@sniptt/guards';
import { useContext } from 'react';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-ui';
import { v4 } from 'uuid';
@ -35,12 +42,18 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
const { createViewFieldRecords } = usePersistViewFieldRecords();
const { createViewFiltersAndSorts } = useCreateViewFiltersAndSorts();
const { getViewSortsCombined } = useGetViewSortsCombined(viewBarComponentId);
const { getViewFiltersCombined } =
useGetViewFiltersCombined(viewBarComponentId);
const { createViewSortRecords } = usePersistViewSortRecords();
const { createViewGroupRecords } = usePersistViewGroupRecords();
const { createViewFilterRecords } = usePersistViewFilterRecords();
const { objectMetadataItem } = useContext(RecordIndexRootPropsContext);
const createViewFromCurrentView = useRecoilCallback(
({ snapshot, set }) =>
async (
@ -93,20 +106,56 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
await createViewFieldRecords(view.viewFields, newView);
if (type === ViewType.Kanban) {
if (!isNonEmptyArray(view.viewGroups)) {
if (!isDefined(kanbanFieldMetadataId)) {
throw new Error('Kanban view must have a kanban field');
}
const viewGroupsToCreate =
objectMetadataItem?.fields
?.find((field) => field.id === kanbanFieldMetadataId)
?.options?.map(
(option, index) =>
({
id: v4(),
__typename: 'ViewGroup',
fieldMetadataId: kanbanFieldMetadataId,
fieldValue: option.value,
isVisible: true,
position: index,
}) satisfies ViewGroup,
) ?? [];
viewGroupsToCreate.push({
__typename: 'ViewGroup',
id: v4(),
fieldValue: '',
position: viewGroupsToCreate.length,
isVisible: true,
fieldMetadataId: kanbanFieldMetadataId,
} satisfies ViewGroup);
await createViewGroupRecords(viewGroupsToCreate, newView);
} else {
await createViewGroupRecords(view.viewGroups, newView);
}
}
if (shouldCopyFiltersAndSorts === true) {
const sourceViewCombinedFilters = getViewFiltersCombined(view.id);
const sourceViewCombinedSorts = getViewSortsCombined(view.id);
await createViewFiltersAndSorts(
newView.id,
sourceViewCombinedFilters,
sourceViewCombinedSorts,
);
await createViewSortRecords(sourceViewCombinedSorts, view);
await createViewFilterRecords(sourceViewCombinedFilters, view);
}
set(isPersistingViewFieldsCallbackState, false);
},
[
objectMetadataItem,
createViewSortRecords,
createViewFilterRecords,
createOneRecord,
createViewFieldRecords,
getViewSortsCombined,
@ -114,7 +163,7 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
currentViewIdCallbackState,
getViewFromCache,
isPersistingViewFieldsCallbackState,
createViewFiltersAndSorts,
createViewGroupRecords,
],
);

View File

@ -0,0 +1,96 @@
import { useRecoilCallback } from 'recoil';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { usePersistViewGroupRecords } from '@/views/hooks/internal/usePersistViewGroupRecords';
import { useGetViewFromCache } from '@/views/hooks/useGetViewFromCache';
import { currentViewIdComponentState } from '@/views/states/currentViewIdComponentState';
import { ViewGroup } from '@/views/types/ViewGroup';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { isDefined } from '~/utils/isDefined';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const useSaveCurrentViewGroups = (viewBarComponentId?: string) => {
const { createViewGroupRecords, updateViewGroupRecords } =
usePersistViewGroupRecords();
const { getViewFromCache } = useGetViewFromCache();
const currentViewIdCallbackState = useRecoilComponentCallbackStateV2(
currentViewIdComponentState,
viewBarComponentId,
);
const saveViewGroups = useRecoilCallback(
({ snapshot }) =>
async (viewGroupsToSave: ViewGroup[]) => {
const currentViewId = snapshot
.getLoadable(currentViewIdCallbackState)
.getValue();
if (!currentViewId) {
return;
}
const view = await getViewFromCache(currentViewId);
if (isUndefinedOrNull(view)) {
return;
}
const currentViewGroups = view.viewGroups;
const viewGroupsToUpdate = viewGroupsToSave
.map((viewGroupToSave) => {
const existingField = currentViewGroups.find(
(currentViewGroup) =>
currentViewGroup.fieldValue === viewGroupToSave.fieldValue,
);
if (isUndefinedOrNull(existingField)) {
return undefined;
}
if (
isDeeplyEqual(
{
position: existingField.position,
isVisible: existingField.isVisible,
},
{
position: viewGroupToSave.position,
isVisible: viewGroupToSave.isVisible,
},
)
) {
return undefined;
}
return { ...viewGroupToSave, id: existingField.id };
})
.filter(isDefined);
const viewGroupsToCreate = viewGroupsToSave.filter(
(viewFieldToSave) =>
!currentViewGroups.some(
(currentViewGroup) =>
currentViewGroup.fieldValue === viewFieldToSave.fieldValue,
),
);
await Promise.all([
createViewGroupRecords(viewGroupsToCreate, view),
updateViewGroupRecords(viewGroupsToUpdate),
]);
},
[
createViewGroupRecords,
currentViewIdCallbackState,
getViewFromCache,
updateViewGroupRecords,
],
);
return {
saveViewGroups,
};
};

View File

@ -1,5 +1,6 @@
import { ViewField } from '@/views/types/ViewField';
import { ViewFilter } from '@/views/types/ViewFilter';
import { ViewGroup } from '@/views/types/ViewGroup';
import { ViewKey } from '@/views/types/ViewKey';
import { ViewSort } from '@/views/types/ViewSort';
import { ViewType } from '@/views/types/ViewType';
@ -15,6 +16,7 @@ export type GraphQLView = {
viewFields: ViewField[];
viewFilters: ViewFilter[];
viewSorts: ViewSort[];
viewGroups: ViewGroup[];
position: number;
icon: string;
};

View File

@ -1,5 +1,6 @@
import { ViewField } from '@/views/types/ViewField';
import { ViewFilter } from '@/views/types/ViewFilter';
import { ViewGroup } from '@/views/types/ViewGroup';
import { ViewKey } from '@/views/types/ViewKey';
import { ViewSort } from '@/views/types/ViewSort';
import { ViewType } from '@/views/types/ViewType';
@ -12,6 +13,7 @@ export type View = {
objectMetadataId: string;
isCompact: boolean;
viewFields: ViewField[];
viewGroups: ViewGroup[];
viewFilters: ViewFilter[];
viewSorts: ViewSort[];
kanbanFieldMetadataId: string;

View File

@ -0,0 +1,8 @@
export type ViewGroup = {
__typename: 'ViewGroup';
id: string;
fieldMetadataId: string;
isVisible: boolean;
fieldValue: string;
position: number;
};

View File

@ -0,0 +1,17 @@
import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition';
import { ViewGroup } from '@/views/types/ViewGroup';
export const mapRecordGroupDefinitionsToViewGroups = (
groupDefinitions: RecordGroupDefinition[],
): ViewGroup[] => {
return groupDefinitions.map(
(groupDefinition): ViewGroup => ({
__typename: 'ViewGroup',
id: groupDefinition.id,
fieldMetadataId: groupDefinition.fieldMetadataId,
position: groupDefinition.position,
isVisible: groupDefinition.isVisible ?? true,
fieldValue: groupDefinition.value ?? '',
}),
);
};

View File

@ -0,0 +1,79 @@
import { isDefined } from '~/utils/isDefined';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
RecordGroupDefinition,
RecordGroupDefinitionType,
} from '@/object-record/record-group/types/RecordGroupDefinition';
import { ViewGroup } from '@/views/types/ViewGroup';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const mapViewGroupsToRecordGroupDefinitions = ({
objectMetadataItem,
viewGroups,
}: {
objectMetadataItem: ObjectMetadataItem;
viewGroups: ViewGroup[];
}): RecordGroupDefinition[] => {
if (viewGroups?.length === 0) {
return [];
}
const fieldMetadataId = viewGroups?.[0]?.fieldMetadataId;
const selectFieldMetadataItem = objectMetadataItem.fields.find(
(field) =>
field.id === fieldMetadataId && field.type === FieldMetadataType.Select,
);
if (!selectFieldMetadataItem) {
return [];
}
if (!selectFieldMetadataItem.options) {
throw new Error(
`Select Field ${objectMetadataItem.nameSingular} has no options`,
);
}
const recordGroupDefinitionsFromViewGroups = viewGroups
.map((viewGroup) => {
const selectedOption = selectFieldMetadataItem.options?.find(
(option) => option.value === viewGroup.fieldValue,
);
if (!selectedOption) return null;
return {
id: viewGroup.id,
fieldMetadataId: viewGroup.fieldMetadataId,
type: RecordGroupDefinitionType.Value,
title: selectedOption.label,
value: selectedOption.value,
color: selectedOption.color,
position: viewGroup.position,
isVisible: viewGroup.isVisible,
} as RecordGroupDefinition;
})
.filter(isDefined)
.sort((a, b) => a.position - b.position);
if (selectFieldMetadataItem.isNullable === true) {
const noValueColumn = {
id: 'no-value',
title: 'No Value',
type: RecordGroupDefinitionType.NoValue,
value: null,
position:
recordGroupDefinitionsFromViewGroups
.map((option) => option.position)
.reduce((a, b) => Math.max(a, b), 0) + 1,
isVisible: true,
fieldMetadataId: selectFieldMetadataItem.id,
color: 'transparent',
} satisfies RecordGroupDefinition;
return [...recordGroupDefinitionsFromViewGroups, noValueColumn];
}
return recordGroupDefinitionsFromViewGroups;
};