mirror of
				https://github.com/lingble/twenty.git
				synced 2025-10-30 20:27:55 +00:00 
			
		
		
		
	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:
		| @@ -57,5 +57,6 @@ const config: StorybookConfig = { | |||||||
|       }, |       }, | ||||||
|     }); |     }); | ||||||
|   }, |   }, | ||||||
|  |   logLevel: 'error', | ||||||
| }; | }; | ||||||
| export default config; | export default config; | ||||||
|   | |||||||
| @@ -29,6 +29,7 @@ initialize({ | |||||||
|       with payload ${JSON.stringify(requestBody)}\n |       with payload ${JSON.stringify(requestBody)}\n | ||||||
|       This request should be mocked with MSW`); |       This request should be mocked with MSW`); | ||||||
|   }, |   }, | ||||||
|  |   quiet: true, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const preview: Preview = { | const preview: Preview = { | ||||||
|   | |||||||
| @@ -27,7 +27,7 @@ const jestConfig: JestConfigWithTsJest = { | |||||||
|     global: { |     global: { | ||||||
|       statements: 59, |       statements: 59, | ||||||
|       lines: 55, |       lines: 55, | ||||||
|       functions: 49, |       functions: 48, | ||||||
|     }, |     }, | ||||||
|   }, |   }, | ||||||
|   collectCoverageFrom: ['<rootDir>/src/**/*.ts'], |   collectCoverageFrom: ['<rootDir>/src/**/*.ts'], | ||||||
|   | |||||||
| @@ -25,6 +25,7 @@ export enum CoreObjectNameSingular { | |||||||
|   ViewField = 'viewField', |   ViewField = 'viewField', | ||||||
|   ViewFilter = 'viewFilter', |   ViewFilter = 'viewFilter', | ||||||
|   ViewSort = 'viewSort', |   ViewSort = 'viewSort', | ||||||
|  |   ViewGroup = 'viewGroup', | ||||||
|   Webhook = 'webhook', |   Webhook = 'webhook', | ||||||
|   WorkspaceMember = 'workspaceMember', |   WorkspaceMember = 'workspaceMember', | ||||||
|   MessageThreadSubscriber = 'messageThreadSubscriber', |   MessageThreadSubscriber = 'messageThreadSubscriber', | ||||||
|   | |||||||
| @@ -31,16 +31,21 @@ const StyledContainer = styled.div` | |||||||
|  |  | ||||||
| const StyledColumnContainer = styled.div` | const StyledColumnContainer = styled.div` | ||||||
|   display: flex; |   display: flex; | ||||||
|  |   & > *:not(:first-child) { | ||||||
|  |     border-left: 1px solid ${({ theme }) => theme.border.color.light}; | ||||||
|  |   } | ||||||
| `; | `; | ||||||
|  |  | ||||||
| const StyledContainerContainer = styled.div` | const StyledContainerContainer = styled.div` | ||||||
|   display: flex; |   display: flex; | ||||||
|   flex-direction: column; |   flex-direction: column; | ||||||
|  |   height: 100%; | ||||||
| `; | `; | ||||||
|  |  | ||||||
| const StyledBoardContentContainer = styled.div` | const StyledBoardContentContainer = styled.div` | ||||||
|   display: flex; |   display: flex; | ||||||
|   flex-direction: column; |   flex-direction: column; | ||||||
|  |   height: calc(100% - 48px); | ||||||
| `; | `; | ||||||
|  |  | ||||||
| const RecordBoardScrollRestoreEffect = () => { | 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 ( |   return ( | ||||||
|     <RecordBoardScope |     <RecordBoardScope | ||||||
|       recordBoardScopeId={getScopeIdFromComponentId(recordBoardId)} |       recordBoardScopeId={getScopeIdFromComponentId(recordBoardId)} | ||||||
|   | |||||||
| @@ -17,6 +17,10 @@ const StyledHeaderContainer = styled.div` | |||||||
|     position: sticky; |     position: sticky; | ||||||
|     top: 0; |     top: 0; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   & > *:not(:first-child) { | ||||||
|  |     border-left: 1px solid ${({ theme }) => theme.border.color.light}; | ||||||
|  |   } | ||||||
| `; | `; | ||||||
|  |  | ||||||
| export const RecordBoardHeader = () => { | export const RecordBoardHeader = () => { | ||||||
|   | |||||||
| @@ -1,6 +1,4 @@ | |||||||
| import { RecordBoardScopeInternalContext } from '@/object-record/record-board/scopes/scope-internal-context/RecordBoardScopeInternalContext'; | 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 { isRecordBoardCardSelectedComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardSelectedComponentFamilyState'; | ||||||
| import { isRecordBoardCompactModeActiveComponentState } from '@/object-record/record-board/states/isRecordBoardCompactModeActiveComponentState'; | import { isRecordBoardCompactModeActiveComponentState } from '@/object-record/record-board/states/isRecordBoardCompactModeActiveComponentState'; | ||||||
| import { isRecordBoardFetchingRecordsByColumnFamilyState } from '@/object-record/record-board/states/isRecordBoardFetchingRecordsByColumnFamilyState'; | import { isRecordBoardFetchingRecordsByColumnFamilyState } from '@/object-record/record-board/states/isRecordBoardFetchingRecordsByColumnFamilyState'; | ||||||
| @@ -51,14 +49,6 @@ export const useRecordBoardStates = (recordBoardId?: string) => { | |||||||
|       recordBoardColumnIdsComponentState, |       recordBoardColumnIdsComponentState, | ||||||
|       scopeId, |       scopeId, | ||||||
|     ), |     ), | ||||||
|     isFirstColumnFamilyState: extractComponentFamilyState( |  | ||||||
|       isFirstRecordBoardColumnComponentFamilyState, |  | ||||||
|       scopeId, |  | ||||||
|     ), |  | ||||||
|     isLastColumnFamilyState: extractComponentFamilyState( |  | ||||||
|       isLastRecordBoardColumnComponentFamilyState, |  | ||||||
|       scopeId, |  | ||||||
|     ), |  | ||||||
|     columnsFamilySelector: extractComponentFamilyState( |     columnsFamilySelector: extractComponentFamilyState( | ||||||
|       recordBoardColumnsComponentFamilySelector, |       recordBoardColumnsComponentFamilySelector, | ||||||
|       scopeId, |       scopeId, | ||||||
|   | |||||||
| @@ -1,8 +1,8 @@ | |||||||
| import { useRecoilCallback } from 'recoil'; | import { useRecoilCallback } from 'recoil'; | ||||||
|  |  | ||||||
| import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; | 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 { isDeeplyEqual } from '~/utils/isDeeplyEqual'; | ||||||
|  | import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition'; | ||||||
|  |  | ||||||
| export const useSetRecordBoardColumns = (recordBoardId?: string) => { | export const useSetRecordBoardColumns = (recordBoardId?: string) => { | ||||||
|   const { scopeId, columnIdsState, columnsFamilySelector } = |   const { scopeId, columnIdsState, columnsFamilySelector } = | ||||||
| @@ -10,21 +10,20 @@ export const useSetRecordBoardColumns = (recordBoardId?: string) => { | |||||||
|  |  | ||||||
|   const setColumns = useRecoilCallback( |   const setColumns = useRecoilCallback( | ||||||
|     ({ set, snapshot }) => |     ({ set, snapshot }) => | ||||||
|       (columns: RecordBoardColumnDefinition[]) => { |       (columns: RecordGroupDefinition[]) => { | ||||||
|         const currentColumnsIds = snapshot |         const currentColumnsIds = snapshot | ||||||
|           .getLoadable(columnIdsState) |           .getLoadable(columnIdsState) | ||||||
|           .getValue(); |           .getValue(); | ||||||
|  |  | ||||||
|         const columnIds = columns.map(({ id }) => id); |         const columnIds = columns | ||||||
|  |           .filter(({ isVisible }) => isVisible) | ||||||
|  |           .map(({ id }) => id); | ||||||
|  |  | ||||||
|         if (isDeeplyEqual(currentColumnsIds, columnIds)) { |         if (isDeeplyEqual(currentColumnsIds, columnIds)) { | ||||||
|           return; |           return; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         set( |         set(columnIdsState, columnIds); | ||||||
|           columnIdsState, |  | ||||||
|           columns.map((column) => column.id), |  | ||||||
|         ); |  | ||||||
|  |  | ||||||
|         columns.forEach((column) => { |         columns.forEach((column) => { | ||||||
|           const currentColumn = snapshot |           const currentColumn = snapshot | ||||||
|   | |||||||
| @@ -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 { RecordBoardColumnCardsContainer } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnCardsContainer'; | ||||||
| import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext'; | 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}; |   background-color: ${({ theme }) => theme.background.primary}; | ||||||
|   border-left: 1px solid |  | ||||||
|     ${({ theme, isFirstColumn }) => |  | ||||||
|       isFirstColumn ? 'none' : theme.border.color.light}; |  | ||||||
|   display: flex; |   display: flex; | ||||||
|   flex-direction: column; |   flex-direction: column; | ||||||
|   max-width: 200px; |   max-width: 200px; | ||||||
| @@ -32,24 +29,12 @@ type RecordBoardColumnProps = { | |||||||
| export const RecordBoardColumn = ({ | export const RecordBoardColumn = ({ | ||||||
|   recordBoardColumnId, |   recordBoardColumnId, | ||||||
| }: RecordBoardColumnProps) => { | }: RecordBoardColumnProps) => { | ||||||
|   const { |   const { columnsFamilySelector, recordIdsByColumnIdFamilyState } = | ||||||
|     isFirstColumnFamilyState, |     useRecordBoardStates(); | ||||||
|     isLastColumnFamilyState, |  | ||||||
|     columnsFamilySelector, |  | ||||||
|     recordIdsByColumnIdFamilyState, |  | ||||||
|   } = useRecordBoardStates(); |  | ||||||
|   const columnDefinition = useRecoilValue( |   const columnDefinition = useRecoilValue( | ||||||
|     columnsFamilySelector(recordBoardColumnId), |     columnsFamilySelector(recordBoardColumnId), | ||||||
|   ); |   ); | ||||||
|  |  | ||||||
|   const isFirstColumn = useRecoilValue( |  | ||||||
|     isFirstColumnFamilyState(recordBoardColumnId), |  | ||||||
|   ); |  | ||||||
|  |  | ||||||
|   const isLastColumn = useRecoilValue( |  | ||||||
|     isLastColumnFamilyState(recordBoardColumnId), |  | ||||||
|   ); |  | ||||||
|  |  | ||||||
|   const recordIds = useRecoilValue( |   const recordIds = useRecoilValue( | ||||||
|     recordIdsByColumnIdFamilyState(recordBoardColumnId), |     recordIdsByColumnIdFamilyState(recordBoardColumnId), | ||||||
|   ); |   ); | ||||||
| @@ -62,8 +47,6 @@ export const RecordBoardColumn = ({ | |||||||
|     <RecordBoardColumnContext.Provider |     <RecordBoardColumnContext.Provider | ||||||
|       value={{ |       value={{ | ||||||
|         columnDefinition: columnDefinition, |         columnDefinition: columnDefinition, | ||||||
|         isFirstColumn: isFirstColumn, |  | ||||||
|         isLastColumn: isLastColumn, |  | ||||||
|         recordCount: recordIds.length, |         recordCount: recordIds.length, | ||||||
|         columnId: recordBoardColumnId, |         columnId: recordBoardColumnId, | ||||||
|         recordIds, |         recordIds, | ||||||
| @@ -71,7 +54,7 @@ export const RecordBoardColumn = ({ | |||||||
|     > |     > | ||||||
|       <Droppable droppableId={recordBoardColumnId}> |       <Droppable droppableId={recordBoardColumnId}> | ||||||
|         {(droppableProvided) => ( |         {(droppableProvided) => ( | ||||||
|           <StyledColumn isFirstColumn={isFirstColumn}> |           <StyledColumn> | ||||||
|             <RecordBoardColumnCardsContainer |             <RecordBoardColumnCardsContainer | ||||||
|               droppableProvided={droppableProvided} |               droppableProvided={droppableProvided} | ||||||
|               recordIds={recordIds} |               recordIds={recordIds} | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| import styled from '@emotion/styled'; | 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 { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; | ||||||
| import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; | import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; | ||||||
| import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; | import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; | ||||||
| @@ -25,6 +25,8 @@ export const RecordBoardColumnDropdownMenu = ({ | |||||||
| }: RecordBoardColumnDropdownMenuProps) => { | }: RecordBoardColumnDropdownMenuProps) => { | ||||||
|   const boardColumnMenuRef = useRef<HTMLDivElement>(null); |   const boardColumnMenuRef = useRef<HTMLDivElement>(null); | ||||||
|  |  | ||||||
|  |   const recordGroupActions = useRecordGroupActions(); | ||||||
|  |  | ||||||
|   const closeMenu = useCallback(() => { |   const closeMenu = useCallback(() => { | ||||||
|     onClose(); |     onClose(); | ||||||
|   }, [onClose]); |   }, [onClose]); | ||||||
| @@ -34,13 +36,11 @@ export const RecordBoardColumnDropdownMenu = ({ | |||||||
|     callback: closeMenu, |     callback: closeMenu, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   const { columnDefinition } = useContext(RecordBoardColumnContext); |  | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <StyledMenuContainer ref={boardColumnMenuRef}> |     <StyledMenuContainer ref={boardColumnMenuRef}> | ||||||
|       <DropdownMenu data-select-disable> |       <DropdownMenu data-select-disable> | ||||||
|         <DropdownMenuItemsContainer> |         <DropdownMenuItemsContainer> | ||||||
|           {columnDefinition.actions.map((action) => ( |           {recordGroupActions.map((action) => ( | ||||||
|             <MenuItem |             <MenuItem | ||||||
|               key={action.id} |               key={action.id} | ||||||
|               onClick={() => { |               onClick={() => { | ||||||
|   | |||||||
| @@ -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 { useColumnNewCardActions } from '@/object-record/record-board/record-board-column/hooks/useColumnNewCardActions'; | ||||||
| import { useIsOpportunitiesCompanyFieldDisabled } from '@/object-record/record-board/record-board-column/hooks/useIsOpportunitiesCompanyFieldDisabled'; | import { useIsOpportunitiesCompanyFieldDisabled } from '@/object-record/record-board/record-board-column/hooks/useIsOpportunitiesCompanyFieldDisabled'; | ||||||
| import { RecordBoardColumnHotkeyScope } from '@/object-record/record-board/types/BoardColumnHotkeyScope'; | 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 { SingleEntitySelect } from '@/object-record/relation-picker/components/SingleEntitySelect'; | ||||||
| import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; | import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; | ||||||
|  |  | ||||||
| @@ -59,11 +59,8 @@ const StyledRightContainer = styled.div` | |||||||
|   display: flex; |   display: flex; | ||||||
| `; | `; | ||||||
|  |  | ||||||
| const StyledColumn = styled.div<{ isFirstColumn: boolean }>` | const StyledColumn = styled.div` | ||||||
|   background-color: ${({ theme }) => theme.background.primary}; |   background-color: ${({ theme }) => theme.background.primary}; | ||||||
|   border-left: 1px solid |  | ||||||
|     ${({ theme, isFirstColumn }) => |  | ||||||
|       isFirstColumn ? 'none' : theme.border.color.light}; |  | ||||||
|   display: flex; |   display: flex; | ||||||
|   flex-direction: column; |   flex-direction: column; | ||||||
|   max-width: 200px; |   max-width: 200px; | ||||||
| @@ -75,7 +72,7 @@ const StyledColumn = styled.div<{ isFirstColumn: boolean }>` | |||||||
| `; | `; | ||||||
|  |  | ||||||
| export const RecordBoardColumnHeader = () => { | export const RecordBoardColumnHeader = () => { | ||||||
|   const { columnDefinition, isFirstColumn, recordCount } = useContext( |   const { columnDefinition, recordCount } = useContext( | ||||||
|     RecordBoardColumnContext, |     RecordBoardColumnContext, | ||||||
|   ); |   ); | ||||||
|   const [isBoardColumnMenuOpen, setIsBoardColumnMenuOpen] = useState(false); |   const [isBoardColumnMenuOpen, setIsBoardColumnMenuOpen] = useState(false); | ||||||
| @@ -120,7 +117,7 @@ export const RecordBoardColumnHeader = () => { | |||||||
|     !isOpportunitiesCompanyFieldDisabled; |     !isOpportunitiesCompanyFieldDisabled; | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <StyledColumn isFirstColumn={isFirstColumn}> |     <StyledColumn> | ||||||
|       <StyledHeader |       <StyledHeader | ||||||
|         onMouseEnter={() => setIsHeaderHovered(true)} |         onMouseEnter={() => setIsHeaderHovered(true)} | ||||||
|         onMouseLeave={() => setIsHeaderHovered(false)} |         onMouseLeave={() => setIsHeaderHovered(false)} | ||||||
| @@ -130,18 +127,18 @@ export const RecordBoardColumnHeader = () => { | |||||||
|             <Tag |             <Tag | ||||||
|               onClick={handleBoardColumnMenuOpen} |               onClick={handleBoardColumnMenuOpen} | ||||||
|               variant={ |               variant={ | ||||||
|                 columnDefinition.type === RecordBoardColumnDefinitionType.Value |                 columnDefinition.type === RecordGroupDefinitionType.Value | ||||||
|                   ? 'solid' |                   ? 'solid' | ||||||
|                   : 'outline' |                   : 'outline' | ||||||
|               } |               } | ||||||
|               color={ |               color={ | ||||||
|                 columnDefinition.type === RecordBoardColumnDefinitionType.Value |                 columnDefinition.type === RecordGroupDefinitionType.Value | ||||||
|                   ? columnDefinition.color |                   ? columnDefinition.color | ||||||
|                   : 'transparent' |                   : 'transparent' | ||||||
|               } |               } | ||||||
|               text={columnDefinition.title} |               text={columnDefinition.title} | ||||||
|               weight={ |               weight={ | ||||||
|                 columnDefinition.type === RecordBoardColumnDefinitionType.Value |                 columnDefinition.type === RecordGroupDefinitionType.Value | ||||||
|                   ? 'regular' |                   ? 'regular' | ||||||
|                   : 'medium' |                   : 'medium' | ||||||
|               } |               } | ||||||
| @@ -154,13 +151,11 @@ export const RecordBoardColumnHeader = () => { | |||||||
|           <StyledRightContainer> |           <StyledRightContainer> | ||||||
|             {isHeaderHovered && ( |             {isHeaderHovered && ( | ||||||
|               <StyledHeaderActions> |               <StyledHeaderActions> | ||||||
|                 {columnDefinition.actions.length > 0 && ( |  | ||||||
|                 <LightIconButton |                 <LightIconButton | ||||||
|                   accent="tertiary" |                   accent="tertiary" | ||||||
|                   Icon={IconDotsVertical} |                   Icon={IconDotsVertical} | ||||||
|                   onClick={handleBoardColumnMenuOpen} |                   onClick={handleBoardColumnMenuOpen} | ||||||
|                 /> |                 /> | ||||||
|                 )} |  | ||||||
|  |  | ||||||
|                 <LightIconButton |                 <LightIconButton | ||||||
|                   accent="tertiary" |                   accent="tertiary" | ||||||
| @@ -172,7 +167,7 @@ export const RecordBoardColumnHeader = () => { | |||||||
|           </StyledRightContainer> |           </StyledRightContainer> | ||||||
|         </StyledHeaderContainer> |         </StyledHeaderContainer> | ||||||
|       </StyledHeader> |       </StyledHeader> | ||||||
|       {isBoardColumnMenuOpen && columnDefinition.actions.length > 0 && ( |       {isBoardColumnMenuOpen && ( | ||||||
|         <RecordBoardColumnDropdownMenu |         <RecordBoardColumnDropdownMenu | ||||||
|           onClose={handleBoardColumnMenuClose} |           onClose={handleBoardColumnMenuClose} | ||||||
|           stageId={columnDefinition.id} |           stageId={columnDefinition.id} | ||||||
|   | |||||||
| @@ -12,19 +12,11 @@ type RecordBoardColumnHeaderWrapperProps = { | |||||||
| export const RecordBoardColumnHeaderWrapper = ({ | export const RecordBoardColumnHeaderWrapper = ({ | ||||||
|   columnId, |   columnId, | ||||||
| }: RecordBoardColumnHeaderWrapperProps) => { | }: RecordBoardColumnHeaderWrapperProps) => { | ||||||
|   const { |   const { columnsFamilySelector, recordIdsByColumnIdFamilyState } = | ||||||
|     isFirstColumnFamilyState, |     useRecordBoardStates(); | ||||||
|     isLastColumnFamilyState, |  | ||||||
|     columnsFamilySelector, |  | ||||||
|     recordIdsByColumnIdFamilyState, |  | ||||||
|   } = useRecordBoardStates(); |  | ||||||
|  |  | ||||||
|   const columnDefinition = useRecoilValue(columnsFamilySelector(columnId)); |   const columnDefinition = useRecoilValue(columnsFamilySelector(columnId)); | ||||||
|  |  | ||||||
|   const isFirstColumn = useRecoilValue(isFirstColumnFamilyState(columnId)); |  | ||||||
|  |  | ||||||
|   const isLastColumn = useRecoilValue(isLastColumnFamilyState(columnId)); |  | ||||||
|  |  | ||||||
|   const recordIds = useRecoilValue(recordIdsByColumnIdFamilyState(columnId)); |   const recordIds = useRecoilValue(recordIdsByColumnIdFamilyState(columnId)); | ||||||
|  |  | ||||||
|   if (!isDefined(columnDefinition)) { |   if (!isDefined(columnDefinition)) { | ||||||
| @@ -36,8 +28,6 @@ export const RecordBoardColumnHeaderWrapper = ({ | |||||||
|       value={{ |       value={{ | ||||||
|         columnId, |         columnId, | ||||||
|         columnDefinition: columnDefinition, |         columnDefinition: columnDefinition, | ||||||
|         isFirstColumn: isFirstColumn, |  | ||||||
|         isLastColumn: isLastColumn, |  | ||||||
|         recordCount: recordIds.length, |         recordCount: recordIds.length, | ||||||
|         recordIds, |         recordIds, | ||||||
|       }} |       }} | ||||||
|   | |||||||
| @@ -1,11 +1,9 @@ | |||||||
| import { createContext } from 'react'; | import { createContext } from 'react'; | ||||||
|  |  | ||||||
| import { RecordBoardColumnDefinition } from '@/object-record/record-board/types/RecordBoardColumnDefinition'; | import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition'; | ||||||
|  |  | ||||||
| type RecordBoardColumnContextProps = { | type RecordBoardColumnContextProps = { | ||||||
|   columnDefinition: RecordBoardColumnDefinition; |   columnDefinition: RecordGroupDefinition; | ||||||
|   isFirstColumn: boolean; |  | ||||||
|   isLastColumn: boolean; |  | ||||||
|   recordCount: number; |   recordCount: number; | ||||||
|   columnId: string; |   columnId: string; | ||||||
|   recordIds: string[]; |   recordIds: string[]; | ||||||
|   | |||||||
| @@ -1,15 +1,15 @@ | |||||||
| import { ReactNode } from 'react'; | import { ReactNode } from 'react'; | ||||||
|  |  | ||||||
| import { RecordBoardScopeInternalContext } from '@/object-record/record-board/scopes/scope-internal-context/RecordBoardScopeInternalContext'; | 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 { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; | ||||||
| import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; | import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; | ||||||
|  | import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition'; | ||||||
|  |  | ||||||
| type RecordBoardScopeProps = { | type RecordBoardScopeProps = { | ||||||
|   children: ReactNode; |   children: ReactNode; | ||||||
|   recordBoardScopeId: string; |   recordBoardScopeId: string; | ||||||
|   onFieldsChange: (fields: FieldDefinition<FieldMetadata>[]) => void; |   onFieldsChange: (fields: FieldDefinition<FieldMetadata>[]) => void; | ||||||
|   onColumnsChange: (column: RecordBoardColumnDefinition[]) => void; |   onColumnsChange: (column: RecordGroupDefinition[]) => void; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| /** @deprecated  */ | /** @deprecated  */ | ||||||
|   | |||||||
| @@ -1,12 +1,12 @@ | |||||||
| import { RecordBoardColumnDefinition } from '@/object-record/record-board/types/RecordBoardColumnDefinition'; |  | ||||||
| import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; | import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; | ||||||
| import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; | 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 { createScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext'; | ||||||
| import { RecoilComponentStateKey } from '@/ui/utilities/state/component-state/types/RecoilComponentStateKey'; | import { RecoilComponentStateKey } from '@/ui/utilities/state/component-state/types/RecoilComponentStateKey'; | ||||||
|  |  | ||||||
| type RecordBoardScopeInternalContextProps = RecoilComponentStateKey & { | type RecordBoardScopeInternalContextProps = RecoilComponentStateKey & { | ||||||
|   onFieldsChange: (fields: FieldDefinition<FieldMetadata>[]) => void; |   onFieldsChange: (fields: FieldDefinition<FieldMetadata>[]) => void; | ||||||
|   onColumnsChange: (column: RecordBoardColumnDefinition[]) => void; |   onColumnsChange: (column: RecordGroupDefinition[]) => void; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export const RecordBoardScopeInternalContext = | export const RecordBoardScopeInternalContext = | ||||||
|   | |||||||
| @@ -1,7 +0,0 @@ | |||||||
| import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState'; |  | ||||||
|  |  | ||||||
| export const isFirstRecordBoardColumnComponentFamilyState = |  | ||||||
|   createComponentFamilyState<boolean, string>({ |  | ||||||
|     key: 'isFirstRecordBoardColumnComponentFamilyState', |  | ||||||
|     defaultValue: false, |  | ||||||
|   }); |  | ||||||
| @@ -1,7 +0,0 @@ | |||||||
| import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState'; |  | ||||||
|  |  | ||||||
| export const isLastRecordBoardColumnComponentFamilyState = |  | ||||||
|   createComponentFamilyState<boolean, string>({ |  | ||||||
|     key: 'isLastRecordBoardColumnComponentFamilyState', |  | ||||||
|     defaultValue: false, |  | ||||||
|   }); |  | ||||||
| @@ -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'; | import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState'; | ||||||
|  |  | ||||||
| export const recordBoardColumnsComponentFamilyState = | export const recordBoardColumnsComponentFamilyState = | ||||||
|   createComponentFamilyState<RecordBoardColumnDefinition | undefined, string>({ |   createComponentFamilyState<RecordGroupDefinition | undefined, string>({ | ||||||
|     key: 'recordBoardColumnsComponentFamilyState', |     key: 'recordBoardColumnsComponentFamilyState', | ||||||
|     defaultValue: undefined, |     defaultValue: undefined, | ||||||
|   }); |   }); | ||||||
|   | |||||||
| @@ -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 { 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 { 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 = | export const recordBoardColumnsComponentFamilySelector = | ||||||
|   createComponentFamilySelector< |   createComponentFamilySelector<RecordGroupDefinition | undefined, string>({ | ||||||
|     RecordBoardColumnDefinition | undefined, |  | ||||||
|     string |  | ||||||
|   >({ |  | ||||||
|     key: 'recordBoardColumnsComponentFamilySelector', |     key: 'recordBoardColumnsComponentFamilySelector', | ||||||
|     get: |     get: | ||||||
|       ({ |       ({ | ||||||
| @@ -39,7 +29,7 @@ export const recordBoardColumnsComponentFamilySelector = | |||||||
|         scopeId: string; |         scopeId: string; | ||||||
|         familyKey: string; |         familyKey: string; | ||||||
|       }) => |       }) => | ||||||
|       ({ set, get }, newColumn) => { |       ({ set }, newColumn) => { | ||||||
|         set( |         set( | ||||||
|           recordBoardColumnsComponentFamilyState({ |           recordBoardColumnsComponentFamilyState({ | ||||||
|             scopeId, |             scopeId, | ||||||
| @@ -47,72 +37,5 @@ export const recordBoardColumnsComponentFamilySelector = | |||||||
|           }), |           }), | ||||||
|           newColumn, |           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, |  | ||||||
|             ); |  | ||||||
|           } |  | ||||||
|         } |  | ||||||
|       }, |       }, | ||||||
|   }); |   }); | ||||||
|   | |||||||
| @@ -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; |  | ||||||
| @@ -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; | ||||||
|  | }; | ||||||
| @@ -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, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
| @@ -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, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
| @@ -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, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
| @@ -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, | ||||||
|  | }); | ||||||
| @@ -1,6 +1,6 @@ | |||||||
| import { IconComponent } from 'twenty-ui'; | import { IconComponent } from 'twenty-ui'; | ||||||
| 
 | 
 | ||||||
| export type RecordBoardColumnAction = { | export type RecordGroupAction = { | ||||||
|   id: string; |   id: string; | ||||||
|   label: string; |   label: string; | ||||||
|   icon: IconComponent; |   icon: IconComponent; | ||||||
| @@ -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; | ||||||
|  | }; | ||||||
| @@ -9,14 +9,12 @@ import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/get | |||||||
|  |  | ||||||
| export const RecordIndexBoardColumnLoaderEffect = ({ | export const RecordIndexBoardColumnLoaderEffect = ({ | ||||||
|   objectNameSingular, |   objectNameSingular, | ||||||
|   boardFieldSelectValue, |  | ||||||
|   boardFieldMetadataId, |   boardFieldMetadataId, | ||||||
|   recordBoardId, |   recordBoardId, | ||||||
|   columnId, |   columnId, | ||||||
| }: { | }: { | ||||||
|   recordBoardId: string; |   recordBoardId: string; | ||||||
|   objectNameSingular: string; |   objectNameSingular: string; | ||||||
|   boardFieldSelectValue: string | null; |  | ||||||
|   boardFieldMetadataId: string | null; |   boardFieldMetadataId: string | null; | ||||||
|   columnId: string; |   columnId: string; | ||||||
| }) => { | }) => { | ||||||
| @@ -40,7 +38,6 @@ export const RecordIndexBoardColumnLoaderEffect = ({ | |||||||
|       objectNameSingular, |       objectNameSingular, | ||||||
|       recordBoardId, |       recordBoardId, | ||||||
|       boardFieldMetadataId, |       boardFieldMetadataId, | ||||||
|       columnFieldSelectValue: boardFieldSelectValue, |  | ||||||
|       columnId, |       columnId, | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
| @@ -70,7 +67,6 @@ export const RecordIndexBoardColumnLoaderEffect = ({ | |||||||
|     fetchMoreRecords, |     fetchMoreRecords, | ||||||
|     loading, |     loading, | ||||||
|     shouldFetchMore, |     shouldFetchMore, | ||||||
|     boardFieldSelectValue, |  | ||||||
|     setLoadingRecordsForThisColumn, |     setLoadingRecordsForThisColumn, | ||||||
|     loadingRecordsForThisColumn, |     loadingRecordsForThisColumn, | ||||||
|  |  | ||||||
|   | |||||||
| @@ -26,23 +26,18 @@ export const RecordIndexBoardDataLoader = ({ | |||||||
|     (field) => field.id === recordIndexKanbanFieldMetadataId, |     (field) => field.id === recordIndexKanbanFieldMetadataId, | ||||||
|   ); |   ); | ||||||
|  |  | ||||||
|   const possibleKanbanSelectFieldValues = |  | ||||||
|     recordIndexKanbanFieldMetadataItem?.options ?? []; |  | ||||||
|  |  | ||||||
|   const { columnIdsState } = useRecordBoardStates(recordBoardId); |   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); |   const columnIds = useRecoilValue(columnIdsState); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <> |     <> | ||||||
|       {possibleKanbanSelectFieldValues.map((option, index) => ( |       {columnIds.map((columnId, index) => ( | ||||||
|         <RecordIndexBoardColumnLoaderEffect |         <RecordIndexBoardColumnLoaderEffect | ||||||
|           objectNameSingular={objectNameSingular} |           objectNameSingular={objectNameSingular} | ||||||
|           boardFieldMetadataId={recordIndexKanbanFieldMetadataId} |           boardFieldMetadataId={recordIndexKanbanFieldMetadataId} | ||||||
|           boardFieldSelectValue={option.value} |  | ||||||
|           recordBoardId={recordBoardId} |           recordBoardId={recordBoardId} | ||||||
|           columnId={columnIds[index]} |           columnId={columnId} | ||||||
|           key={index} |           key={index} | ||||||
|         /> |         /> | ||||||
|       ))} |       ))} | ||||||
| @@ -50,7 +45,6 @@ export const RecordIndexBoardDataLoader = ({ | |||||||
|         <RecordIndexBoardColumnLoaderEffect |         <RecordIndexBoardColumnLoaderEffect | ||||||
|           objectNameSingular={objectNameSingular} |           objectNameSingular={objectNameSingular} | ||||||
|           boardFieldMetadataId={recordIndexKanbanFieldMetadataId} |           boardFieldMetadataId={recordIndexKanbanFieldMetadataId} | ||||||
|           boardFieldSelectValue={null} |  | ||||||
|           recordBoardId={recordBoardId} |           recordBoardId={recordBoardId} | ||||||
|           columnId={'no-value'} |           columnId={'no-value'} | ||||||
|         /> |         /> | ||||||
|   | |||||||
| @@ -1,16 +1,14 @@ | |||||||
| import { useCallback, useEffect } from 'react'; | import { useEffect } from 'react'; | ||||||
| import { useLocation, useNavigate } from 'react-router-dom'; |  | ||||||
| import { useRecoilValue, useSetRecoilState } from 'recoil'; | import { useRecoilValue, useSetRecoilState } from 'recoil'; | ||||||
|  |  | ||||||
| import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; | import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; | ||||||
| import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; | import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; | ||||||
| import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug'; |  | ||||||
| import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard'; | 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 { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState'; | ||||||
| import { recordIndexIsCompactModeActiveState } from '@/object-record/record-index/states/recordIndexIsCompactModeActiveState'; | import { recordIndexIsCompactModeActiveState } from '@/object-record/record-index/states/recordIndexIsCompactModeActiveState'; | ||||||
| import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState'; | import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState'; | ||||||
| import { computeRecordBoardColumnDefinitionsFromObjectMetadata } from '@/object-record/utils/computeRecordBoardColumnDefinitionsFromObjectMetadata'; | import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; | ||||||
| import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState'; |  | ||||||
| import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; | import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; | ||||||
| import { FieldMetadataType } from '~/generated-metadata/graphql'; | import { FieldMetadataType } from '~/generated-metadata/graphql'; | ||||||
| import { isDefined } from '~/utils/isDefined'; | import { isDefined } from '~/utils/isDefined'; | ||||||
| @@ -32,6 +30,10 @@ export const RecordIndexBoardDataLoaderEffect = ({ | |||||||
|     recordIndexFieldDefinitionsState, |     recordIndexFieldDefinitionsState, | ||||||
|   ); |   ); | ||||||
|  |  | ||||||
|  |   const recordIndexGroupDefinitions = useRecoilComponentValueV2( | ||||||
|  |     recordGroupDefinitionsComponentState, | ||||||
|  |   ); | ||||||
|  |  | ||||||
|   const recordIndexKanbanFieldMetadataId = useRecoilValue( |   const recordIndexKanbanFieldMetadataId = useRecoilValue( | ||||||
|     recordIndexKanbanFieldMetadataIdState, |     recordIndexKanbanFieldMetadataIdState, | ||||||
|   ); |   ); | ||||||
| @@ -60,43 +62,17 @@ export const RecordIndexBoardDataLoaderEffect = ({ | |||||||
|     setFieldDefinitions(recordIndexFieldDefinitions); |     setFieldDefinitions(recordIndexFieldDefinitions); | ||||||
|   }, [recordIndexFieldDefinitions, setFieldDefinitions]); |   }, [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(() => { |   useEffect(() => { | ||||||
|     setObjectSingularName(objectNameSingular); |     setObjectSingularName(objectNameSingular); | ||||||
|   }, [objectNameSingular, setObjectSingularName]); |   }, [objectNameSingular, setObjectSingularName]); | ||||||
|  |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     setColumns( |     setColumns(recordIndexGroupDefinitions); | ||||||
|       computeRecordBoardColumnDefinitionsFromObjectMetadata( |   }, [recordIndexGroupDefinitions, setColumns]); | ||||||
|         objectMetadataItem, |  | ||||||
|         recordIndexKanbanFieldMetadataId ?? '', |  | ||||||
|         navigateToSelectSettings, |  | ||||||
|       ), |  | ||||||
|     ); |  | ||||||
|   }, [ |  | ||||||
|     navigateToSelectSettings, |  | ||||||
|     objectMetadataItem, |  | ||||||
|     objectNameSingular, |  | ||||||
|     recordIndexKanbanFieldMetadataId, |  | ||||||
|     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(() => { |   useEffect(() => { | ||||||
|     setFieldDefinitions(recordIndexFieldDefinitions); |     setFieldDefinitions(recordIndexFieldDefinitions); | ||||||
|   }, [objectMetadataItem, setFieldDefinitions, recordIndexFieldDefinitions]); |   }, [objectMetadataItem, setFieldDefinitions, recordIndexFieldDefinitions]); | ||||||
|   | |||||||
| @@ -24,13 +24,17 @@ import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/compone | |||||||
|  |  | ||||||
| import { RecordIndexActionMenu } from '@/action-menu/components/RecordIndexActionMenu'; | import { RecordIndexActionMenu } from '@/action-menu/components/RecordIndexActionMenu'; | ||||||
| import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; | 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 { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; | ||||||
| import { ViewBar } from '@/views/components/ViewBar'; | import { ViewBar } from '@/views/components/ViewBar'; | ||||||
| import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; |  | ||||||
| import { ViewField } from '@/views/types/ViewField'; | import { ViewField } from '@/views/types/ViewField'; | ||||||
|  | import { ViewGroup } from '@/views/types/ViewGroup'; | ||||||
| import { ViewType } from '@/views/types/ViewType'; | import { ViewType } from '@/views/types/ViewType'; | ||||||
| import { mapViewFieldsToColumnDefinitions } from '@/views/utils/mapViewFieldsToColumnDefinitions'; | import { mapViewFieldsToColumnDefinitions } from '@/views/utils/mapViewFieldsToColumnDefinitions'; | ||||||
| import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters'; | import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters'; | ||||||
|  | import { mapViewGroupsToRecordGroupDefinitions } from '@/views/utils/mapViewGroupsToRecordGroupDefinitions'; | ||||||
| import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts'; | import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts'; | ||||||
| import { useContext } from 'react'; | import { useContext } from 'react'; | ||||||
| import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; | import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; | ||||||
| @@ -61,6 +65,10 @@ export const RecordIndexContainer = () => { | |||||||
|     objectNameSingular, |     objectNameSingular, | ||||||
|   } = useContext(RecordIndexRootPropsContext); |   } = useContext(RecordIndexRootPropsContext); | ||||||
|  |  | ||||||
|  |   const recordGroupDefinitionsCallbackState = useRecoilComponentCallbackStateV2( | ||||||
|  |     recordGroupDefinitionsComponentState, | ||||||
|  |   ); | ||||||
|  |  | ||||||
|   const { columnDefinitions, filterDefinitions, sortDefinitions } = |   const { columnDefinitions, filterDefinitions, sortDefinitions } = | ||||||
|     useColumnDefinitionsFromFieldMetadata(objectMetadataItem); |     useColumnDefinitionsFromFieldMetadata(objectMetadataItem); | ||||||
|  |  | ||||||
| @@ -77,6 +85,8 @@ export const RecordIndexContainer = () => { | |||||||
|     recordTableId: recordIndexId, |     recordTableId: recordIndexId, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|  |   const { setColumns } = useRecordBoard(recordIndexId); | ||||||
|  |  | ||||||
|   const onViewFieldsChange = useRecoilCallback( |   const onViewFieldsChange = useRecoilCallback( | ||||||
|     ({ set, snapshot }) => |     ({ set, snapshot }) => | ||||||
|       (viewFields: ViewField[]) => { |       (viewFields: ViewField[]) => { | ||||||
| @@ -103,6 +113,32 @@ export const RecordIndexContainer = () => { | |||||||
|     [columnDefinitions, setTableColumns], |     [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( |   const setContextStoreTargetedRecordsRule = useSetRecoilComponentStateV2( | ||||||
|     contextStoreTargetedRecordsRuleComponentState, |     contextStoreTargetedRecordsRuleComponentState, | ||||||
|   ); |   ); | ||||||
| @@ -110,9 +146,6 @@ export const RecordIndexContainer = () => { | |||||||
|   return ( |   return ( | ||||||
|     <StyledContainer> |     <StyledContainer> | ||||||
|       <InformationBannerWrapper /> |       <InformationBannerWrapper /> | ||||||
|       <ViewComponentInstanceContext.Provider |  | ||||||
|         value={{ instanceId: recordIndexId }} |  | ||||||
|       > |  | ||||||
|       <RecordFieldValueSelectorContextProvider> |       <RecordFieldValueSelectorContextProvider> | ||||||
|         <SpreadsheetImportProvider> |         <SpreadsheetImportProvider> | ||||||
|           <ViewBar |           <ViewBar | ||||||
| @@ -130,6 +163,7 @@ export const RecordIndexContainer = () => { | |||||||
|               } |               } | ||||||
|  |  | ||||||
|               onViewFieldsChange(view.viewFields); |               onViewFieldsChange(view.viewFields); | ||||||
|  |               onViewGroupsChange(view.viewGroups); | ||||||
|               setTableFilters( |               setTableFilters( | ||||||
|                 mapViewFiltersToFilters(view.viewFilters, filterDefinitions), |                 mapViewFiltersToFilters(view.viewFilters, filterDefinitions), | ||||||
|               ); |               ); | ||||||
| @@ -189,7 +223,6 @@ export const RecordIndexContainer = () => { | |||||||
|         )} |         )} | ||||||
|         <RecordIndexActionMenu actionMenuId={recordIndexId} /> |         <RecordIndexActionMenu actionMenuId={recordIndexId} /> | ||||||
|       </RecordFieldValueSelectorContextProvider> |       </RecordFieldValueSelectorContextProvider> | ||||||
|       </ViewComponentInstanceContext.Provider> |  | ||||||
|     </StyledContainer> |     </StyledContainer> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -3,7 +3,7 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi | |||||||
| import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; | import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; | ||||||
| import { useAddNewCard } from '@/object-record/record-board/record-board-column/hooks/useAddNewCard'; | 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 { 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 { RecordIndexPageKanbanAddMenuItem } from '@/object-record/record-index/components/RecordIndexPageKanbanAddMenuItem'; | ||||||
| import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext'; | import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext'; | ||||||
| import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState'; | import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState'; | ||||||
| @@ -58,7 +58,7 @@ export const RecordIndexPageKanbanAddButton = () => { | |||||||
|   const { handleAddNewCardClick } = useAddNewCard(); |   const { handleAddNewCardClick } = useAddNewCard(); | ||||||
|  |  | ||||||
|   const handleItemClick = useCallback( |   const handleItemClick = useCallback( | ||||||
|     (columnDefinition: RecordBoardColumnDefinition) => { |     (columnDefinition: RecordGroupDefinition) => { | ||||||
|       const isOpportunityEnabled = |       const isOpportunityEnabled = | ||||||
|         isOpportunity && !isOpportunitiesCompanyFieldDisabled; |         isOpportunity && !isOpportunitiesCompanyFieldDisabled; | ||||||
|       handleAddNewCardClick( |       handleAddNewCardClick( | ||||||
|   | |||||||
| @@ -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 { useRecordIndexPageKanbanAddMenuItem } from '@/object-record/record-index/hooks/useRecordIndexPageKanbanAddMenuItem'; | ||||||
| import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; | import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; | ||||||
| import styled from '@emotion/styled'; | import styled from '@emotion/styled'; | ||||||
| @@ -32,18 +32,18 @@ export const RecordIndexPageKanbanAddMenuItem = ({ | |||||||
|       text={ |       text={ | ||||||
|         <Tag |         <Tag | ||||||
|           variant={ |           variant={ | ||||||
|             columnDefinition.type === RecordBoardColumnDefinitionType.Value |             columnDefinition.type === RecordGroupDefinitionType.Value | ||||||
|               ? 'solid' |               ? 'solid' | ||||||
|               : 'outline' |               : 'outline' | ||||||
|           } |           } | ||||||
|           color={ |           color={ | ||||||
|             columnDefinition.type === RecordBoardColumnDefinitionType.Value |             columnDefinition.type === RecordGroupDefinitionType.Value | ||||||
|               ? columnDefinition.color |               ? columnDefinition.color | ||||||
|               : 'transparent' |               : 'transparent' | ||||||
|           } |           } | ||||||
|           text={columnDefinition.title} |           text={columnDefinition.title} | ||||||
|           weight={ |           weight={ | ||||||
|             columnDefinition.type === RecordBoardColumnDefinitionType.Value |             columnDefinition.type === RecordGroupDefinitionType.Value | ||||||
|               ? 'regular' |               ? 'regular' | ||||||
|               : 'medium' |               : 'medium' | ||||||
|           } |           } | ||||||
|   | |||||||
| @@ -6,12 +6,14 @@ import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; | |||||||
| import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy'; | import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy'; | ||||||
| import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard'; | import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard'; | ||||||
| import { turnFiltersIntoQueryFilter } from '@/object-record/record-filter/utils/turnFiltersIntoQueryFilter'; | 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 { useRecordBoardRecordGqlFields } from '@/object-record/record-index/hooks/useRecordBoardRecordGqlFields'; | ||||||
| import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState'; | import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState'; | ||||||
| import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState'; | import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState'; | ||||||
| import { recordIndexIsCompactModeActiveState } from '@/object-record/record-index/states/recordIndexIsCompactModeActiveState'; | import { recordIndexIsCompactModeActiveState } from '@/object-record/record-index/states/recordIndexIsCompactModeActiveState'; | ||||||
| import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState'; | import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState'; | ||||||
| import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; | 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'; | import { useSetRecordCountInCurrentView } from '@/views/hooks/useSetRecordCountInCurrentView'; | ||||||
|  |  | ||||||
| type UseLoadRecordIndexBoardProps = { | type UseLoadRecordIndexBoardProps = { | ||||||
| @@ -31,6 +33,7 @@ export const useLoadRecordIndexBoard = ({ | |||||||
|   const { |   const { | ||||||
|     setRecordIds: setRecordIdsInBoard, |     setRecordIds: setRecordIdsInBoard, | ||||||
|     setFieldDefinitions, |     setFieldDefinitions, | ||||||
|  |     setColumns, | ||||||
|     isCompactModeActiveState, |     isCompactModeActiveState, | ||||||
|   } = useRecordBoard(recordBoardId); |   } = useRecordBoard(recordBoardId); | ||||||
|   const { upsertRecords: upsertRecordsInStore } = useUpsertRecordsInStore(); |   const { upsertRecords: upsertRecordsInStore } = useUpsertRecordsInStore(); | ||||||
| @@ -42,6 +45,13 @@ export const useLoadRecordIndexBoard = ({ | |||||||
|     setFieldDefinitions(recordIndexFieldDefinitions); |     setFieldDefinitions(recordIndexFieldDefinitions); | ||||||
|   }, [recordIndexFieldDefinitions, setFieldDefinitions]); |   }, [recordIndexFieldDefinitions, setFieldDefinitions]); | ||||||
|  |  | ||||||
|  |   const recordIndexGroupDefinitions = useRecoilComponentValueV2( | ||||||
|  |     recordGroupDefinitionsComponentState, | ||||||
|  |   ); | ||||||
|  |   useEffect(() => { | ||||||
|  |     setColumns(recordIndexGroupDefinitions); | ||||||
|  |   }, [recordIndexGroupDefinitions, setColumns]); | ||||||
|  |  | ||||||
|   const recordIndexFilters = useRecoilValue(recordIndexFiltersState); |   const recordIndexFilters = useRecoilValue(recordIndexFiltersState); | ||||||
|   const recordIndexSorts = useRecoilValue(recordIndexSortsState); |   const recordIndexSorts = useRecoilValue(recordIndexSortsState); | ||||||
|   const requestFilters = turnFiltersIntoQueryFilter( |   const requestFilters = turnFiltersIntoQueryFilter( | ||||||
|   | |||||||
| @@ -11,12 +11,12 @@ import { recordIndexFiltersState } from '@/object-record/record-index/states/rec | |||||||
| import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState'; | import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState'; | ||||||
| import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; | import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; | ||||||
| import { isDefined } from '~/utils/isDefined'; | import { isDefined } from '~/utils/isDefined'; | ||||||
|  | import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; | ||||||
|  |  | ||||||
| type UseLoadRecordIndexBoardProps = { | type UseLoadRecordIndexBoardProps = { | ||||||
|   objectNameSingular: string; |   objectNameSingular: string; | ||||||
|   boardFieldMetadataId: string | null; |   boardFieldMetadataId: string | null; | ||||||
|   recordBoardId: string; |   recordBoardId: string; | ||||||
|   columnFieldSelectValue: string | null; |  | ||||||
|   columnId: string; |   columnId: string; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| @@ -24,17 +24,18 @@ export const useLoadRecordIndexBoardColumn = ({ | |||||||
|   objectNameSingular, |   objectNameSingular, | ||||||
|   boardFieldMetadataId, |   boardFieldMetadataId, | ||||||
|   recordBoardId, |   recordBoardId, | ||||||
|   columnFieldSelectValue, |  | ||||||
|   columnId, |   columnId, | ||||||
| }: UseLoadRecordIndexBoardProps) => { | }: UseLoadRecordIndexBoardProps) => { | ||||||
|   const { objectMetadataItem } = useObjectMetadataItem({ |   const { objectMetadataItem } = useObjectMetadataItem({ | ||||||
|     objectNameSingular, |     objectNameSingular, | ||||||
|   }); |   }); | ||||||
|   const { setRecordIdsForColumn } = useRecordBoard(recordBoardId); |   const { setRecordIdsForColumn } = useRecordBoard(recordBoardId); | ||||||
|  |   const { columnsFamilySelector } = useRecordBoardStates(recordBoardId); | ||||||
|   const { upsertRecords: upsertRecordsInStore } = useUpsertRecordsInStore(); |   const { upsertRecords: upsertRecordsInStore } = useUpsertRecordsInStore(); | ||||||
|  |  | ||||||
|   const recordIndexFilters = useRecoilValue(recordIndexFiltersState); |   const recordIndexFilters = useRecoilValue(recordIndexFiltersState); | ||||||
|   const recordIndexSorts = useRecoilValue(recordIndexSortsState); |   const recordIndexSorts = useRecoilValue(recordIndexSortsState); | ||||||
|  |   const columnDefinition = useRecoilValue(columnsFamilySelector(columnId)); | ||||||
|   const requestFilters = turnFiltersIntoQueryFilter( |   const requestFilters = turnFiltersIntoQueryFilter( | ||||||
|     recordIndexFilters, |     recordIndexFilters, | ||||||
|     objectMetadataItem?.fields ?? [], |     objectMetadataItem?.fields ?? [], | ||||||
| @@ -53,9 +54,9 @@ export const useLoadRecordIndexBoardColumn = ({ | |||||||
|   const filter = { |   const filter = { | ||||||
|     ...requestFilters, |     ...requestFilters, | ||||||
|     [recordIndexKanbanFieldMetadataItem?.name ?? '']: isDefined( |     [recordIndexKanbanFieldMetadataItem?.name ?? '']: isDefined( | ||||||
|       columnFieldSelectValue, |       columnDefinition?.value, | ||||||
|     ) |     ) | ||||||
|       ? { in: [columnFieldSelectValue] } |       ? { in: [columnDefinition?.value] } | ||||||
|       : { is: 'NULL' }, |       : { is: 'NULL' }, | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| import { useState } from 'react'; | import { useEffect, useState } from 'react'; | ||||||
| import { Key } from 'ts-key-enum'; | import { Key } from 'ts-key-enum'; | ||||||
| import { | import { | ||||||
|   IconBaselineDensitySmall, |   IconBaselineDensitySmall, | ||||||
| @@ -10,6 +10,7 @@ import { | |||||||
|   IconSettings, |   IconSettings, | ||||||
|   IconTag, |   IconTag, | ||||||
|   UndecoratedLink, |   UndecoratedLink, | ||||||
|  |   useIcons, | ||||||
| } from 'twenty-ui'; | } from 'twenty-ui'; | ||||||
|  |  | ||||||
| import { useObjectNamePluralFromSingular } from '@/object-metadata/hooks/useObjectNamePluralFromSingular'; | import { useObjectNamePluralFromSingular } from '@/object-metadata/hooks/useObjectNamePluralFromSingular'; | ||||||
| @@ -21,6 +22,9 @@ import { | |||||||
|   useExportRecordData, |   useExportRecordData, | ||||||
| } from '@/action-menu/hooks/useExportRecordData'; | } from '@/action-menu/hooks/useExportRecordData'; | ||||||
| import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; | 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 { useRecordIndexOptionsForBoard } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard'; | ||||||
| import { useRecordIndexOptionsForTable } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForTable'; | import { useRecordIndexOptionsForTable } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForTable'; | ||||||
| import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope'; | 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 { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState'; | ||||||
| import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; | import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; | ||||||
| import { ViewFieldsVisibilityDropdownSection } from '@/views/components/ViewFieldsVisibilityDropdownSection'; | import { ViewFieldsVisibilityDropdownSection } from '@/views/components/ViewFieldsVisibilityDropdownSection'; | ||||||
|  | import { ViewGroupsVisibilityDropdownSection } from '@/views/components/ViewGroupsVisibilityDropdownSection'; | ||||||
| import { useGetCurrentView } from '@/views/hooks/useGetCurrentView'; | import { useGetCurrentView } from '@/views/hooks/useGetCurrentView'; | ||||||
| import { ViewType } from '@/views/types/ViewType'; | import { ViewType } from '@/views/types/ViewType'; | ||||||
| import { useLocation } from 'react-router-dom'; | import { useLocation } from 'react-router-dom'; | ||||||
| import { useSetRecoilState } from 'recoil'; | import { useSetRecoilState } from 'recoil'; | ||||||
|  |  | ||||||
| type RecordIndexOptionsMenu = 'fields' | 'hiddenFields'; | type RecordIndexOptionsMenu = | ||||||
|  |   | 'viewGroups' | ||||||
|  |   | 'hiddenViewGroups' | ||||||
|  |   | 'fields' | ||||||
|  |   | 'hiddenFields'; | ||||||
|  |  | ||||||
| type RecordIndexOptionsDropdownContentProps = { | type RecordIndexOptionsDropdownContentProps = { | ||||||
|   recordIndexId: string; |   recordIndexId: string; | ||||||
| @@ -50,6 +59,7 @@ type RecordIndexOptionsDropdownContentProps = { | |||||||
|   viewType: ViewType; |   viewType: ViewType; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | // TODO: Break this component down | ||||||
| export const RecordIndexOptionsDropdownContent = ({ | export const RecordIndexOptionsDropdownContent = ({ | ||||||
|   viewType, |   viewType, | ||||||
|   recordIndexId, |   recordIndexId, | ||||||
| @@ -57,6 +67,8 @@ export const RecordIndexOptionsDropdownContent = ({ | |||||||
| }: RecordIndexOptionsDropdownContentProps) => { | }: RecordIndexOptionsDropdownContentProps) => { | ||||||
|   const { currentViewWithCombinedFiltersAndSorts } = useGetCurrentView(); |   const { currentViewWithCombinedFiltersAndSorts } = useGetCurrentView(); | ||||||
|  |  | ||||||
|  |   const { getIcon } = useIcons(); | ||||||
|  |  | ||||||
|   const { closeDropdown } = useDropdown(RECORD_INDEX_OPTIONS_DROPDOWN_ID); |   const { closeDropdown } = useDropdown(RECORD_INDEX_OPTIONS_DROPDOWN_ID); | ||||||
|  |  | ||||||
|   const [currentMenu, setCurrentMenu] = useState< |   const [currentMenu, setCurrentMenu] = useState< | ||||||
| @@ -111,6 +123,28 @@ export const RecordIndexOptionsDropdownContent = ({ | |||||||
|     viewBarId: recordIndexId, |     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 = |   const visibleRecordFields = | ||||||
|     viewType === ViewType.Kanban ? visibleBoardFields : visibleTableColumns; |     viewType === ViewType.Kanban ? visibleBoardFields : visibleTableColumns; | ||||||
|  |  | ||||||
| @@ -143,10 +177,28 @@ export const RecordIndexOptionsDropdownContent = ({ | |||||||
|     navigationMemorizedUrlState, |     navigationMemorizedUrlState, | ||||||
|   ); |   ); | ||||||
|  |  | ||||||
|  |   const isViewGroupMenuItemVisible = | ||||||
|  |     viewGroupFieldMetadataItem && | ||||||
|  |     (visibleRecordGroups.length > 0 || hiddenRecordGroups.length > 0); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (currentMenu === 'hiddenViewGroups' && hiddenRecordGroups.length === 0) { | ||||||
|  |       setCurrentMenu('viewGroups'); | ||||||
|  |     } | ||||||
|  |   }, [hiddenRecordGroups, currentMenu]); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <> |     <> | ||||||
|       {!currentMenu && ( |       {!currentMenu && ( | ||||||
|         <DropdownMenuItemsContainer> |         <DropdownMenuItemsContainer> | ||||||
|  |           {isViewGroupMenuItemVisible && ( | ||||||
|  |             <MenuItem | ||||||
|  |               onClick={() => handleSelectMenu('viewGroups')} | ||||||
|  |               LeftIcon={getIcon(currentViewWithCombinedFiltersAndSorts?.icon)} | ||||||
|  |               text={viewGroupFieldMetadataItem.label} | ||||||
|  |               hasSubMenu | ||||||
|  |             /> | ||||||
|  |           )} | ||||||
|           <MenuItem |           <MenuItem | ||||||
|             onClick={() => handleSelectMenu('fields')} |             onClick={() => handleSelectMenu('fields')} | ||||||
|             LeftIcon={IconTag} |             LeftIcon={IconTag} | ||||||
| @@ -174,6 +226,34 @@ export const RecordIndexOptionsDropdownContent = ({ | |||||||
|           /> |           /> | ||||||
|         </DropdownMenuItemsContainer> |         </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' && ( |       {currentMenu === 'fields' && ( | ||||||
|         <> |         <> | ||||||
|           <DropdownMenuHeader StartIcon={IconChevronLeft} onClick={resetMenu}> |           <DropdownMenuHeader StartIcon={IconChevronLeft} onClick={resetMenu}> | ||||||
| @@ -198,6 +278,36 @@ export const RecordIndexOptionsDropdownContent = ({ | |||||||
|           </DropdownMenuItemsContainer> |           </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' && ( |       {currentMenu === 'hiddenFields' && ( | ||||||
|         <> |         <> | ||||||
|           <DropdownMenuHeader |           <DropdownMenuHeader | ||||||
|   | |||||||
| @@ -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); |  | ||||||
|   }); |  | ||||||
| }); |  | ||||||
| @@ -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; |  | ||||||
| }; |  | ||||||
| @@ -20,5 +20,6 @@ export const findAllViewsOperationSignatureFactory: RecordGqlOperationSignatureF | |||||||
|       viewFilters: true, |       viewFilters: true, | ||||||
|       viewSorts: true, |       viewSorts: true, | ||||||
|       viewFields: true, |       viewFields: true, | ||||||
|  |       viewGroups: true, | ||||||
|     }, |     }, | ||||||
|   }); |   }); | ||||||
|   | |||||||
| @@ -5,14 +5,15 @@ import { StyledHoverableMenuItemBase } from '../internals/components/StyledMenuI | |||||||
| import { MenuItemAccent } from '../types/MenuItemAccent'; | import { MenuItemAccent } from '../types/MenuItemAccent'; | ||||||
|  |  | ||||||
| import { MenuItemIconButton } from './MenuItem'; | import { MenuItemIconButton } from './MenuItem'; | ||||||
|  | import { ReactNode } from 'react'; | ||||||
|  |  | ||||||
| export type MenuItemDraggableProps = { | export type MenuItemDraggableProps = { | ||||||
|   LeftIcon: IconComponent | undefined; |   LeftIcon?: IconComponent | undefined; | ||||||
|   accent?: MenuItemAccent; |   accent?: MenuItemAccent; | ||||||
|   iconButtons?: MenuItemIconButton[]; |   iconButtons?: MenuItemIconButton[]; | ||||||
|   isTooltipOpen?: boolean; |   isTooltipOpen?: boolean; | ||||||
|   onClick?: () => void; |   onClick?: () => void; | ||||||
|   text: string; |   text: ReactNode; | ||||||
|   className?: string; |   className?: string; | ||||||
|   isIconDisplayedOnHoverOnly?: boolean; |   isIconDisplayedOnHoverOnly?: boolean; | ||||||
|   showGrip?: boolean; |   showGrip?: boolean; | ||||||
|   | |||||||
| @@ -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> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -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, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
| @@ -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, |  | ||||||
|   }; |  | ||||||
| }; |  | ||||||
| @@ -1,9 +1,12 @@ | |||||||
| import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; | import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; | ||||||
| import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; | 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 { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue'; | ||||||
| import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; | import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; | ||||||
| import { usePersistViewFieldRecords } from '@/views/hooks/internal/usePersistViewFieldRecords'; | 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 { useGetViewFiltersCombined } from '@/views/hooks/useGetCombinedViewFilters'; | ||||||
| import { useGetViewSortsCombined } from '@/views/hooks/useGetCombinedViewSorts'; | import { useGetViewSortsCombined } from '@/views/hooks/useGetCombinedViewSorts'; | ||||||
| import { useGetViewFromCache } from '@/views/hooks/useGetViewFromCache'; | import { useGetViewFromCache } from '@/views/hooks/useGetViewFromCache'; | ||||||
| @@ -11,6 +14,10 @@ import { currentViewIdComponentState } from '@/views/states/currentViewIdCompone | |||||||
| import { isPersistingViewFieldsComponentState } from '@/views/states/isPersistingViewFieldsComponentState'; | import { isPersistingViewFieldsComponentState } from '@/views/states/isPersistingViewFieldsComponentState'; | ||||||
| import { GraphQLView } from '@/views/types/GraphQLView'; | import { GraphQLView } from '@/views/types/GraphQLView'; | ||||||
| import { View } from '@/views/types/View'; | 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 { useRecoilCallback } from 'recoil'; | ||||||
| import { isDefined } from 'twenty-ui'; | import { isDefined } from 'twenty-ui'; | ||||||
| import { v4 } from 'uuid'; | import { v4 } from 'uuid'; | ||||||
| @@ -35,12 +42,18 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => { | |||||||
|  |  | ||||||
|   const { createViewFieldRecords } = usePersistViewFieldRecords(); |   const { createViewFieldRecords } = usePersistViewFieldRecords(); | ||||||
|  |  | ||||||
|   const { createViewFiltersAndSorts } = useCreateViewFiltersAndSorts(); |  | ||||||
|  |  | ||||||
|   const { getViewSortsCombined } = useGetViewSortsCombined(viewBarComponentId); |   const { getViewSortsCombined } = useGetViewSortsCombined(viewBarComponentId); | ||||||
|   const { getViewFiltersCombined } = |   const { getViewFiltersCombined } = | ||||||
|     useGetViewFiltersCombined(viewBarComponentId); |     useGetViewFiltersCombined(viewBarComponentId); | ||||||
|  |  | ||||||
|  |   const { createViewSortRecords } = usePersistViewSortRecords(); | ||||||
|  |  | ||||||
|  |   const { createViewGroupRecords } = usePersistViewGroupRecords(); | ||||||
|  |  | ||||||
|  |   const { createViewFilterRecords } = usePersistViewFilterRecords(); | ||||||
|  |  | ||||||
|  |   const { objectMetadataItem } = useContext(RecordIndexRootPropsContext); | ||||||
|  |  | ||||||
|   const createViewFromCurrentView = useRecoilCallback( |   const createViewFromCurrentView = useRecoilCallback( | ||||||
|     ({ snapshot, set }) => |     ({ snapshot, set }) => | ||||||
|       async ( |       async ( | ||||||
| @@ -93,20 +106,56 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => { | |||||||
|  |  | ||||||
|         await createViewFieldRecords(view.viewFields, newView); |         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) { |         if (shouldCopyFiltersAndSorts === true) { | ||||||
|           const sourceViewCombinedFilters = getViewFiltersCombined(view.id); |           const sourceViewCombinedFilters = getViewFiltersCombined(view.id); | ||||||
|           const sourceViewCombinedSorts = getViewSortsCombined(view.id); |           const sourceViewCombinedSorts = getViewSortsCombined(view.id); | ||||||
|  |  | ||||||
|           await createViewFiltersAndSorts( |           await createViewSortRecords(sourceViewCombinedSorts, view); | ||||||
|             newView.id, |           await createViewFilterRecords(sourceViewCombinedFilters, view); | ||||||
|             sourceViewCombinedFilters, |  | ||||||
|             sourceViewCombinedSorts, |  | ||||||
|           ); |  | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         set(isPersistingViewFieldsCallbackState, false); |         set(isPersistingViewFieldsCallbackState, false); | ||||||
|       }, |       }, | ||||||
|     [ |     [ | ||||||
|  |       objectMetadataItem, | ||||||
|  |       createViewSortRecords, | ||||||
|  |       createViewFilterRecords, | ||||||
|       createOneRecord, |       createOneRecord, | ||||||
|       createViewFieldRecords, |       createViewFieldRecords, | ||||||
|       getViewSortsCombined, |       getViewSortsCombined, | ||||||
| @@ -114,7 +163,7 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => { | |||||||
|       currentViewIdCallbackState, |       currentViewIdCallbackState, | ||||||
|       getViewFromCache, |       getViewFromCache, | ||||||
|       isPersistingViewFieldsCallbackState, |       isPersistingViewFieldsCallbackState, | ||||||
|       createViewFiltersAndSorts, |       createViewGroupRecords, | ||||||
|     ], |     ], | ||||||
|   ); |   ); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
| @@ -1,5 +1,6 @@ | |||||||
| import { ViewField } from '@/views/types/ViewField'; | import { ViewField } from '@/views/types/ViewField'; | ||||||
| import { ViewFilter } from '@/views/types/ViewFilter'; | import { ViewFilter } from '@/views/types/ViewFilter'; | ||||||
|  | import { ViewGroup } from '@/views/types/ViewGroup'; | ||||||
| import { ViewKey } from '@/views/types/ViewKey'; | import { ViewKey } from '@/views/types/ViewKey'; | ||||||
| import { ViewSort } from '@/views/types/ViewSort'; | import { ViewSort } from '@/views/types/ViewSort'; | ||||||
| import { ViewType } from '@/views/types/ViewType'; | import { ViewType } from '@/views/types/ViewType'; | ||||||
| @@ -15,6 +16,7 @@ export type GraphQLView = { | |||||||
|   viewFields: ViewField[]; |   viewFields: ViewField[]; | ||||||
|   viewFilters: ViewFilter[]; |   viewFilters: ViewFilter[]; | ||||||
|   viewSorts: ViewSort[]; |   viewSorts: ViewSort[]; | ||||||
|  |   viewGroups: ViewGroup[]; | ||||||
|   position: number; |   position: number; | ||||||
|   icon: string; |   icon: string; | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| import { ViewField } from '@/views/types/ViewField'; | import { ViewField } from '@/views/types/ViewField'; | ||||||
| import { ViewFilter } from '@/views/types/ViewFilter'; | import { ViewFilter } from '@/views/types/ViewFilter'; | ||||||
|  | import { ViewGroup } from '@/views/types/ViewGroup'; | ||||||
| import { ViewKey } from '@/views/types/ViewKey'; | import { ViewKey } from '@/views/types/ViewKey'; | ||||||
| import { ViewSort } from '@/views/types/ViewSort'; | import { ViewSort } from '@/views/types/ViewSort'; | ||||||
| import { ViewType } from '@/views/types/ViewType'; | import { ViewType } from '@/views/types/ViewType'; | ||||||
| @@ -12,6 +13,7 @@ export type View = { | |||||||
|   objectMetadataId: string; |   objectMetadataId: string; | ||||||
|   isCompact: boolean; |   isCompact: boolean; | ||||||
|   viewFields: ViewField[]; |   viewFields: ViewField[]; | ||||||
|  |   viewGroups: ViewGroup[]; | ||||||
|   viewFilters: ViewFilter[]; |   viewFilters: ViewFilter[]; | ||||||
|   viewSorts: ViewSort[]; |   viewSorts: ViewSort[]; | ||||||
|   kanbanFieldMetadataId: string; |   kanbanFieldMetadataId: string; | ||||||
|   | |||||||
| @@ -0,0 +1,8 @@ | |||||||
|  | export type ViewGroup = { | ||||||
|  |   __typename: 'ViewGroup'; | ||||||
|  |   id: string; | ||||||
|  |   fieldMetadataId: string; | ||||||
|  |   isVisible: boolean; | ||||||
|  |   fieldValue: string; | ||||||
|  |   position: number; | ||||||
|  | }; | ||||||
| @@ -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 ?? '', | ||||||
|  |     }), | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -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; | ||||||
|  | }; | ||||||
| @@ -16,6 +16,7 @@ import { useCreateNewTableRecord } from '@/object-record/record-table/hooks/useC | |||||||
| import { PageBody } from '@/ui/layout/page/components/PageBody'; | import { PageBody } from '@/ui/layout/page/components/PageBody'; | ||||||
| import { PageContainer } from '@/ui/layout/page/components/PageContainer'; | import { PageContainer } from '@/ui/layout/page/components/PageContainer'; | ||||||
| import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle'; | import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle'; | ||||||
|  | import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; | ||||||
| import { useRecoilCallback } from 'recoil'; | import { useRecoilCallback } from 'recoil'; | ||||||
| import { capitalize } from '~/utils/string/capitalize'; | import { capitalize } from '~/utils/string/capitalize'; | ||||||
|  |  | ||||||
| @@ -70,6 +71,9 @@ export const RecordIndexPage = () => { | |||||||
|           indexIdentifierUrl, |           indexIdentifierUrl, | ||||||
|           onCreateRecord: handleCreateRecord, |           onCreateRecord: handleCreateRecord, | ||||||
|         }} |         }} | ||||||
|  |       > | ||||||
|  |         <ViewComponentInstanceContext.Provider | ||||||
|  |           value={{ instanceId: recordIndexId }} | ||||||
|         > |         > | ||||||
|           <PageTitle title={`${capitalize(objectNamePlural)}`} /> |           <PageTitle title={`${capitalize(objectNamePlural)}`} /> | ||||||
|           <RecordIndexPageHeader /> |           <RecordIndexPageHeader /> | ||||||
| @@ -87,6 +91,7 @@ export const RecordIndexPage = () => { | |||||||
|               </ContextStoreComponentInstanceContext.Provider> |               </ContextStoreComponentInstanceContext.Provider> | ||||||
|             </StyledIndexContainer> |             </StyledIndexContainer> | ||||||
|           </PageBody> |           </PageBody> | ||||||
|  |         </ViewComponentInstanceContext.Provider> | ||||||
|       </RecordIndexRootPropsContext.Provider> |       </RecordIndexRootPropsContext.Provider> | ||||||
|     </PageContainer> |     </PageContainer> | ||||||
|   ); |   ); | ||||||
|   | |||||||
| @@ -32,7 +32,7 @@ export const Default: Story = { | |||||||
|   play: async ({ canvasElement }) => { |   play: async ({ canvasElement }) => { | ||||||
|     const canvas = within(canvasElement); |     const canvas = within(canvasElement); | ||||||
|  |  | ||||||
|     await canvas.findByText('People', undefined, { timeout: 3000 }); |     await canvas.findByText('People', undefined, { timeout: 10000 }); | ||||||
|     await canvas.findByText('Linkedin'); |     await canvas.findByText('Linkedin'); | ||||||
|   }, |   }, | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -29,7 +29,7 @@ export const WithStandardSelected: Story = { | |||||||
|   play: async () => { |   play: async () => { | ||||||
|     const canvas = within(document.body); |     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 listingInput = await canvas.findByPlaceholderText('Listing'); | ||||||
|     const pluralInput = await canvas.findByPlaceholderText('Listings'); |     const pluralInput = await canvas.findByPlaceholderText('Listings'); | ||||||
|   | |||||||
| @@ -126,6 +126,33 @@ export const viewPrefillData = async ( | |||||||
|         ) |         ) | ||||||
|         .execute(); |         .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; |   return viewDefinitionsWithId; | ||||||
|   | |||||||
| @@ -73,5 +73,52 @@ export const opportunitiesByStageView = ( | |||||||
|         size: 150, |         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, | ||||||
|  |       }, | ||||||
|  |     ], | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -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, | ||||||
|  |       }, | ||||||
|  |     ], | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -368,6 +368,14 @@ export const VIEW_FIELD_STANDARD_FIELD_IDS = { | |||||||
|   view: '20202020-e8da-4521-afab-d6d231f9fa18', |   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 = { | export const VIEW_FILTER_STANDARD_FIELD_IDS = { | ||||||
|   fieldMetadataId: '20202020-c9aa-4c94-8d0e-9592f5008fb0', |   fieldMetadataId: '20202020-c9aa-4c94-8d0e-9592f5008fb0', | ||||||
|   operand: '20202020-bd23-48c4-9fab-29d1ffb80310', |   operand: '20202020-bd23-48c4-9fab-29d1ffb80310', | ||||||
| @@ -392,6 +400,7 @@ export const VIEW_STANDARD_FIELD_IDS = { | |||||||
|   position: '20202020-e9db-4303-b271-e8250c450172', |   position: '20202020-e9db-4303-b271-e8250c450172', | ||||||
|   isCompact: '20202020-674e-4314-994d-05754ea7b22b', |   isCompact: '20202020-674e-4314-994d-05754ea7b22b', | ||||||
|   viewFields: '20202020-542b-4bdc-b177-b63175d48edf', |   viewFields: '20202020-542b-4bdc-b177-b63175d48edf', | ||||||
|  |   viewGroups: '20202020-e1a1-419f-ac81-1986a5ea59a8', | ||||||
|   viewFilters: '20202020-ff23-4154-b63c-21fb36cd0967', |   viewFilters: '20202020-ff23-4154-b63c-21fb36cd0967', | ||||||
|   viewSorts: '20202020-891b-45c3-9fe1-80a75b4aa043', |   viewSorts: '20202020-891b-45c3-9fe1-80a75b4aa043', | ||||||
|   favorites: '20202020-c818-4a86-8284-9ec0ef0a59a5', |   favorites: '20202020-c818-4a86-8284-9ec0ef0a59a5', | ||||||
|   | |||||||
| @@ -35,6 +35,7 @@ export const STANDARD_OBJECT_IDS = { | |||||||
|   taskTarget: '20202020-5a9a-44e8-95df-771cd06d0fb1', |   taskTarget: '20202020-5a9a-44e8-95df-771cd06d0fb1', | ||||||
|   timelineActivity: '20202020-6736-4337-b5c4-8b39fae325a5', |   timelineActivity: '20202020-6736-4337-b5c4-8b39fae325a5', | ||||||
|   viewField: '20202020-4d19-4655-95bf-b2a04cf206d4', |   viewField: '20202020-4d19-4655-95bf-b2a04cf206d4', | ||||||
|  |   viewGroup: '20202020-725f-47a4-8008-4255f9519f70', | ||||||
|   viewFilter: '20202020-6fb6-4631-aded-b7d67e952ec8', |   viewFilter: '20202020-6fb6-4631-aded-b7d67e952ec8', | ||||||
|   viewSort: '20202020-e46a-47a8-939a-e5d911f83531', |   viewSort: '20202020-e46a-47a8-939a-e5d911f83531', | ||||||
|   view: '20202020-722e-4739-8e2c-0c372d661f49', |   view: '20202020-722e-4739-8e2c-0c372d661f49', | ||||||
|   | |||||||
| @@ -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 { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity'; | ||||||
| import { ViewFieldWorkspaceEntity } from 'src/modules/view/standard-objects/view-field.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 { 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 { ViewSortWorkspaceEntity } from 'src/modules/view/standard-objects/view-sort.workspace-entity'; | ||||||
| import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity'; | import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity'; | ||||||
| import { WebhookWorkspaceEntity } from 'src/modules/webhook/standard-objects/webhook.workspace-entity'; | import { WebhookWorkspaceEntity } from 'src/modules/webhook/standard-objects/webhook.workspace-entity'; | ||||||
| @@ -56,6 +57,7 @@ export const standardObjectMetadataDefinitions = [ | |||||||
|   FavoriteWorkspaceEntity, |   FavoriteWorkspaceEntity, | ||||||
|   TimelineActivityWorkspaceEntity, |   TimelineActivityWorkspaceEntity, | ||||||
|   ViewFieldWorkspaceEntity, |   ViewFieldWorkspaceEntity, | ||||||
|  |   ViewGroupWorkspaceEntity, | ||||||
|   ViewFilterWorkspaceEntity, |   ViewFilterWorkspaceEntity, | ||||||
|   ViewSortWorkspaceEntity, |   ViewSortWorkspaceEntity, | ||||||
|   ViewWorkspaceEntity, |   ViewWorkspaceEntity, | ||||||
|   | |||||||
| @@ -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; | ||||||
|  | } | ||||||
| @@ -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 { ViewFieldWorkspaceEntity } from 'src/modules/view/standard-objects/view-field.workspace-entity'; | ||||||
| import { ViewFilterWorkspaceEntity } from 'src/modules/view/standard-objects/view-filter.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 { ViewSortWorkspaceEntity } from 'src/modules/view/standard-objects/view-sort.workspace-entity'; | ||||||
|  | import { ViewGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-group.workspace-entity'; | ||||||
|  |  | ||||||
| @WorkspaceEntity({ | @WorkspaceEntity({ | ||||||
|   standardId: STANDARD_OBJECT_IDS.view, |   standardId: STANDARD_OBJECT_IDS.view, | ||||||
| @@ -113,6 +114,18 @@ export class ViewWorkspaceEntity extends BaseWorkspaceEntity { | |||||||
|   @WorkspaceIsNullable() |   @WorkspaceIsNullable() | ||||||
|   viewFields: Relation<ViewFieldWorkspaceEntity[]>; |   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({ |   @WorkspaceRelation({ | ||||||
|     standardId: VIEW_STANDARD_FIELD_IDS.viewFilters, |     standardId: VIEW_STANDARD_FIELD_IDS.viewFilters, | ||||||
|     type: RelationMetadataType.ONE_TO_MANY, |     type: RelationMetadataType.ONE_TO_MANY, | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Jérémy M
					Jérémy M