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