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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 1408 additions and 508 deletions

View File

@ -57,5 +57,6 @@ const config: StorybookConfig = {
},
});
},
logLevel: 'error',
};
export default config;

View File

@ -29,6 +29,7 @@ initialize({
with payload ${JSON.stringify(requestBody)}\n
This request should be mocked with MSW`);
},
quiet: true,
});
const preview: Preview = {

View File

@ -27,7 +27,7 @@ const jestConfig: JestConfigWithTsJest = {
global: {
statements: 59,
lines: 55,
functions: 49,
functions: 48,
},
},
collectCoverageFrom: ['<rootDir>/src/**/*.ts'],

View File

@ -25,6 +25,7 @@ export enum CoreObjectNameSingular {
ViewField = 'viewField',
ViewFilter = 'viewFilter',
ViewSort = 'viewSort',
ViewGroup = 'viewGroup',
Webhook = 'webhook',
WorkspaceMember = 'workspaceMember',
MessageThreadSubscriber = 'messageThreadSubscriber',

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;
};

View File

@ -20,5 +20,6 @@ export const findAllViewsOperationSignatureFactory: RecordGqlOperationSignatureF
viewFilters: true,
viewSorts: true,
viewFields: true,
viewGroups: true,
},
});

View File

@ -5,14 +5,15 @@ import { StyledHoverableMenuItemBase } from '../internals/components/StyledMenuI
import { MenuItemAccent } from '../types/MenuItemAccent';
import { MenuItemIconButton } from './MenuItem';
import { ReactNode } from 'react';
export type MenuItemDraggableProps = {
LeftIcon: IconComponent | undefined;
LeftIcon?: IconComponent | undefined;
accent?: MenuItemAccent;
iconButtons?: MenuItemIconButton[];
isTooltipOpen?: boolean;
onClick?: () => void;
text: string;
text: ReactNode;
className?: string;
isIconDisplayedOnHoverOnly?: boolean;
showGrip?: boolean;

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;
};

View File

@ -16,6 +16,7 @@ import { useCreateNewTableRecord } from '@/object-record/record-table/hooks/useC
import { PageBody } from '@/ui/layout/page/components/PageBody';
import { PageContainer } from '@/ui/layout/page/components/PageContainer';
import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
import { useRecoilCallback } from 'recoil';
import { capitalize } from '~/utils/string/capitalize';
@ -71,22 +72,26 @@ export const RecordIndexPage = () => {
onCreateRecord: handleCreateRecord,
}}
>
<PageTitle title={`${capitalize(objectNamePlural)}`} />
<RecordIndexPageHeader />
<PageBody>
<StyledIndexContainer>
<ContextStoreComponentInstanceContext.Provider
value={{
instanceId: 'record-index',
}}
>
<RecordIndexContainerContextStoreObjectMetadataEffect />
<RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect />
<SetMainContextStoreComponentInstanceIdEffect />
<RecordIndexContainer />
</ContextStoreComponentInstanceContext.Provider>
</StyledIndexContainer>
</PageBody>
<ViewComponentInstanceContext.Provider
value={{ instanceId: recordIndexId }}
>
<PageTitle title={`${capitalize(objectNamePlural)}`} />
<RecordIndexPageHeader />
<PageBody>
<StyledIndexContainer>
<ContextStoreComponentInstanceContext.Provider
value={{
instanceId: 'record-index',
}}
>
<RecordIndexContainerContextStoreObjectMetadataEffect />
<RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect />
<SetMainContextStoreComponentInstanceIdEffect />
<RecordIndexContainer />
</ContextStoreComponentInstanceContext.Provider>
</StyledIndexContainer>
</PageBody>
</ViewComponentInstanceContext.Provider>
</RecordIndexRootPropsContext.Provider>
</PageContainer>
);

View File

@ -32,7 +32,7 @@ export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('People', undefined, { timeout: 3000 });
await canvas.findByText('People', undefined, { timeout: 10000 });
await canvas.findByText('Linkedin');
},
};

View File

@ -29,7 +29,7 @@ export const WithStandardSelected: Story = {
play: async () => {
const canvas = within(document.body);
await canvas.findByText('New Object');
await canvas.findByText('New Object', undefined, { timeout: 2000 });
const listingInput = await canvas.findByPlaceholderText('Listing');
const pluralInput = await canvas.findByPlaceholderText('Listings');

View File

@ -126,6 +126,33 @@ export const viewPrefillData = async (
)
.execute();
}
if (
'groups' in viewDefinition &&
viewDefinition.groups &&
viewDefinition.groups.length > 0
) {
await entityManager
.createQueryBuilder()
.insert()
.into(`${schemaName}.viewGroup`, [
'fieldMetadataId',
'isVisible',
'fieldValue',
'position',
'viewId',
])
.values(
viewDefinition.groups.map((group: any) => ({
fieldMetadataId: group.fieldMetadataId,
isVisible: group.isVisible,
fieldValue: group.fieldValue,
position: group.position,
viewId: viewDefinition.id,
})),
)
.execute();
}
}
return viewDefinitionsWithId;

View File

@ -73,5 +73,52 @@ export const opportunitiesByStageView = (
size: 150,
},
],
groups: [
{
fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.opportunity].fields[
OPPORTUNITY_STANDARD_FIELD_IDS.stage
],
isVisible: true,
fieldValue: 'NEW',
position: 0,
},
{
fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.opportunity].fields[
OPPORTUNITY_STANDARD_FIELD_IDS.stage
],
isVisible: true,
fieldValue: 'SCREENING',
position: 1,
},
{
fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.opportunity].fields[
OPPORTUNITY_STANDARD_FIELD_IDS.stage
],
isVisible: true,
fieldValue: 'MEETING',
position: 2,
},
{
fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.opportunity].fields[
OPPORTUNITY_STANDARD_FIELD_IDS.stage
],
isVisible: true,
fieldValue: 'PROPOSAL',
position: 3,
},
{
fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.opportunity].fields[
OPPORTUNITY_STANDARD_FIELD_IDS.stage
],
isVisible: true,
fieldValue: 'CUSTOMER',
position: 4,
},
],
};
};

View File

@ -89,5 +89,34 @@ export const tasksByStatusView = (
},
*/
],
groups: [
{
fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.task].fields[
TASK_STANDARD_FIELD_IDS.status
],
isVisible: true,
fieldValue: 'TODO',
position: 0,
},
{
fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.task].fields[
TASK_STANDARD_FIELD_IDS.status
],
isVisible: true,
fieldValue: 'IN_PROGESS',
position: 1,
},
{
fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.task].fields[
TASK_STANDARD_FIELD_IDS.status
],
isVisible: true,
fieldValue: 'DONE',
position: 2,
},
],
};
};

View File

@ -368,6 +368,14 @@ export const VIEW_FIELD_STANDARD_FIELD_IDS = {
view: '20202020-e8da-4521-afab-d6d231f9fa18',
};
export const VIEW_GROUP_STANDARD_FIELD_IDS = {
fieldMetadataId: '20202020-8f26-46ae-afed-fdacd7778682',
fieldValue: '20202020-175e-4596-b7a4-1cd9d14e5a30',
isVisible: '20202020-0fed-4b44-88fd-a064c4fcfce4',
position: '20202020-748e-4645-8f32-84aae7726c04',
view: '20202020-5bc7-4110-b23f-fb851fb133b4',
};
export const VIEW_FILTER_STANDARD_FIELD_IDS = {
fieldMetadataId: '20202020-c9aa-4c94-8d0e-9592f5008fb0',
operand: '20202020-bd23-48c4-9fab-29d1ffb80310',
@ -392,6 +400,7 @@ export const VIEW_STANDARD_FIELD_IDS = {
position: '20202020-e9db-4303-b271-e8250c450172',
isCompact: '20202020-674e-4314-994d-05754ea7b22b',
viewFields: '20202020-542b-4bdc-b177-b63175d48edf',
viewGroups: '20202020-e1a1-419f-ac81-1986a5ea59a8',
viewFilters: '20202020-ff23-4154-b63c-21fb36cd0967',
viewSorts: '20202020-891b-45c3-9fe1-80a75b4aa043',
favorites: '20202020-c818-4a86-8284-9ec0ef0a59a5',

View File

@ -35,6 +35,7 @@ export const STANDARD_OBJECT_IDS = {
taskTarget: '20202020-5a9a-44e8-95df-771cd06d0fb1',
timelineActivity: '20202020-6736-4337-b5c4-8b39fae325a5',
viewField: '20202020-4d19-4655-95bf-b2a04cf206d4',
viewGroup: '20202020-725f-47a4-8008-4255f9519f70',
viewFilter: '20202020-6fb6-4631-aded-b7d67e952ec8',
viewSort: '20202020-e46a-47a8-939a-e5d911f83531',
view: '20202020-722e-4739-8e2c-0c372d661f49',

View File

@ -28,6 +28,7 @@ import { BehavioralEventWorkspaceEntity } from 'src/modules/timeline/standard-ob
import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity';
import { ViewFieldWorkspaceEntity } from 'src/modules/view/standard-objects/view-field.workspace-entity';
import { ViewFilterWorkspaceEntity } from 'src/modules/view/standard-objects/view-filter.workspace-entity';
import { ViewGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-group.workspace-entity';
import { ViewSortWorkspaceEntity } from 'src/modules/view/standard-objects/view-sort.workspace-entity';
import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity';
import { WebhookWorkspaceEntity } from 'src/modules/webhook/standard-objects/webhook.workspace-entity';
@ -56,6 +57,7 @@ export const standardObjectMetadataDefinitions = [
FavoriteWorkspaceEntity,
TimelineActivityWorkspaceEntity,
ViewFieldWorkspaceEntity,
ViewGroupWorkspaceEntity,
ViewFilterWorkspaceEntity,
ViewSortWorkspaceEntity,
ViewWorkspaceEntity,

View File

@ -0,0 +1,77 @@
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { RelationMetadataType } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
import { WorkspaceIsNotAuditLogged } from 'src/engine/twenty-orm/decorators/workspace-is-not-audit-logged.decorator';
import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator';
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator';
import { VIEW_GROUP_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity';
import { WorkspaceJoinColumn } from 'src/engine/twenty-orm/decorators/workspace-join-column.decorator';
@WorkspaceEntity({
standardId: STANDARD_OBJECT_IDS.viewGroup,
namePlural: 'viewGroups',
labelSingular: 'View Group',
labelPlural: 'View Groups',
description: '(System) View Groups',
icon: 'IconTag',
})
@WorkspaceIsNotAuditLogged()
@WorkspaceIsSystem()
export class ViewGroupWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceField({
standardId: VIEW_GROUP_STANDARD_FIELD_IDS.fieldMetadataId,
type: FieldMetadataType.UUID,
label: 'Field Metadata Id',
description: 'View Group target field',
icon: 'IconTag',
})
fieldMetadataId: string;
@WorkspaceField({
standardId: VIEW_GROUP_STANDARD_FIELD_IDS.isVisible,
type: FieldMetadataType.BOOLEAN,
label: 'Visible',
description: 'View Group visibility',
icon: 'IconEye',
defaultValue: true,
})
isVisible: boolean;
@WorkspaceField({
standardId: VIEW_GROUP_STANDARD_FIELD_IDS.fieldValue,
type: FieldMetadataType.TEXT,
label: 'Field Value',
description: 'Group by this field value',
})
fieldValue: string;
@WorkspaceField({
standardId: VIEW_GROUP_STANDARD_FIELD_IDS.position,
type: FieldMetadataType.NUMBER,
label: 'Position',
description: 'View Field position',
icon: 'IconList',
defaultValue: 0,
})
position: number;
@WorkspaceRelation({
standardId: VIEW_GROUP_STANDARD_FIELD_IDS.view,
type: RelationMetadataType.MANY_TO_ONE,
label: 'View',
description: 'View Group related view',
icon: 'IconLayoutCollage',
inverseSideTarget: () => ViewWorkspaceEntity,
inverseSideFieldKey: 'viewGroups',
})
@WorkspaceIsNullable()
view?: ViewWorkspaceEntity | null;
@WorkspaceJoinColumn('view')
viewId: string | null;
}

View File

@ -18,6 +18,7 @@ import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/f
import { ViewFieldWorkspaceEntity } from 'src/modules/view/standard-objects/view-field.workspace-entity';
import { ViewFilterWorkspaceEntity } from 'src/modules/view/standard-objects/view-filter.workspace-entity';
import { ViewSortWorkspaceEntity } from 'src/modules/view/standard-objects/view-sort.workspace-entity';
import { ViewGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-group.workspace-entity';
@WorkspaceEntity({
standardId: STANDARD_OBJECT_IDS.view,
@ -113,6 +114,18 @@ export class ViewWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceIsNullable()
viewFields: Relation<ViewFieldWorkspaceEntity[]>;
@WorkspaceRelation({
standardId: VIEW_STANDARD_FIELD_IDS.viewGroups,
type: RelationMetadataType.ONE_TO_MANY,
label: 'View Groups',
description: 'View Groups',
icon: 'IconTag',
inverseSideTarget: () => ViewGroupWorkspaceEntity,
onDelete: RelationOnDeleteAction.SET_NULL,
})
@WorkspaceIsNullable()
viewGroups: Relation<ViewGroupWorkspaceEntity[]>;
@WorkspaceRelation({
standardId: VIEW_STANDARD_FIELD_IDS.viewFilters,
type: RelationMetadataType.ONE_TO_MANY,