From 377fd23c901da609ff3c4da69755f62fc14576f6 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Thu, 25 Jan 2024 18:21:15 +0100 Subject: [PATCH] Display columns on Record Board (#3626) * Display columns on Record board * Fix * Fix according to review * Fix --- .../__tests__/useObjectRecordTable.test.tsx | 2 +- .../record-board/components/RecordBoard.tsx | 16 ++- .../contexts/RecordBoardColumnContext.ts | 11 +- .../hooks/internal/useRecordBoardStates.ts | 33 +++++ .../internal/useSetRecordBoardColumns.ts | 48 ++++++++ .../record-board/hooks/useRecordBoard.ts | 16 +++ .../components/RecordBoardColumn.tsx | 31 ++++- .../RecordBoardColumnDropdownMenu.tsx | 58 +++++++++ .../components/RecordBoardColumnHeader.tsx | 105 ++++++++++++++++ ...rstRecordBoardColumnFamilyStateScopeMap.ts | 7 ++ ...astRecordBoardColumnFamilyStateScopeMap.ts | 7 ++ .../recordBoardColumnIdsStateScopeMap.ts | 6 + .../recordBoardColumnsFamilyStateScopeMap.ts | 10 ++ ...ecordBoardColumnsFamilySelectorScopeMap.ts | 115 ++++++++++++++++++ .../types/RecordBoardColumnAction.ts | 9 ++ .../types/RecordBoardColumnDefinition.ts | 4 +- .../RecordIndexBoardContainerEffect.tsx | 45 ++++++- .../components/RecordIndexContainer.tsx | 5 +- .../hooks/internal/useRecordTableStates.ts | 4 +- ...oardColumnDefinitionsFromObjectMetadata.ts | 39 ++++++ .../ui/layout/dropdown/hooks/useDropdown.ts | 4 +- .../hooks/useClickOustideListenerStates.ts | 3 +- .../utils/getScopeIdFromComponentId.ts | 4 +- .../getScopeIdOrUndefinedFromComponentId.ts | 4 + .../utils/guardRecoilDefaultValue.ts | 8 ++ .../typeorm-seeds/workspace/opportunity.ts | 5 + .../opportunity.object-metadata.ts | 21 ++++ 27 files changed, 591 insertions(+), 29 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useRecordBoardStates.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useSetRecordBoardColumns.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoard.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnDropdownMenu.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-board/states/isFirstRecordBoardColumnFamilyStateScopeMap.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/states/isLastRecordBoardColumnFamilyStateScopeMap.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/states/recordBoardColumnIdsStateScopeMap.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/states/recordBoardColumnsFamilyStateScopeMap.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/states/selectors/recordBoardColumnsFamilySelectorScopeMap.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardColumnAction.ts create mode 100644 packages/twenty-front/src/modules/object-record/utils/computeRecordBoardColumnDefinitionsFromObjectMetadata.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/recoil-scope/utils/getScopeIdOrUndefinedFromComponentId.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/recoil-scope/utils/guardRecoilDefaultValue.ts diff --git a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useObjectRecordTable.test.tsx b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useObjectRecordTable.test.tsx index 0824b33ee..e50f36d50 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useObjectRecordTable.test.tsx +++ b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useObjectRecordTable.test.tsx @@ -22,7 +22,7 @@ const Wrapper = ({ children }: { children: ReactNode }) => { diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx index a8aaaa97b..27e78a85c 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx @@ -1,10 +1,13 @@ import { useRef } from 'react'; import styled from '@emotion/styled'; import { DragDropContext } from '@hello-pangea/dnd'; // Atlassian dnd does not support StrictMode from RN 18, so we use a fork @hello-pangea/dnd https://github.com/atlassian/react-beautiful-dnd/issues/2350 +import { useRecoilValue } from 'recoil'; +import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard'; import { RecordBoardColumn } from '@/object-record/record-board/record-board-column/components/RecordBoardColumn'; import { RecordBoardScope } from '@/object-record/record-board/scopes/RecordBoardScope'; import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect'; +import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId'; import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; export type RecordBoardProps = { @@ -37,9 +40,13 @@ const StyledBoardHeader = styled.div` export const RecordBoard = ({ recordBoardId }: RecordBoardProps) => { const boardRef = useRef(null); + const { getColumnIdsState } = useRecordBoard(recordBoardId); + + const columnIds = useRecoilValue(getColumnIdsState()); + return ( {}} onFieldsChange={() => {}} > @@ -48,11 +55,10 @@ export const RecordBoard = ({ recordBoardId }: RecordBoardProps) => { {}}> - {[].map((column) => ( + {columnIds.map((columnId) => ( ))} diff --git a/packages/twenty-front/src/modules/object-record/record-board/contexts/RecordBoardColumnContext.ts b/packages/twenty-front/src/modules/object-record/record-board/contexts/RecordBoardColumnContext.ts index bd19cd033..7016dcce8 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/contexts/RecordBoardColumnContext.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/contexts/RecordBoardColumnContext.ts @@ -1,11 +1,14 @@ import { createContext } from 'react'; -import { BoardColumnDefinition } from '@/object-record/record-board-deprecated/types/BoardColumnDefinition'; +import { RecordBoardColumnDefinition } from '@/object-record/record-board/types/RecordBoardColumnDefinition'; type RecordBoardColumnContextProps = { - id: string; - columnDefinition: BoardColumnDefinition; + columnDefinition: RecordBoardColumnDefinition; + isFirstColumn: boolean; + isLastColumn: boolean; }; export const RecordBoardColumnContext = - createContext(null); + createContext( + {} as RecordBoardColumnContextProps, + ); diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useRecordBoardStates.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useRecordBoardStates.ts new file mode 100644 index 000000000..fe2f8ed61 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useRecordBoardStates.ts @@ -0,0 +1,33 @@ +import { RecordBoardScopeInternalContext } from '@/object-record/record-board/scopes/scope-internal-context/RecordBoardScopeInternalContext'; +import { isFirstRecordBoardColumnFamilyStateScopeMap } from '@/object-record/record-board/states/isFirstRecordBoardColumnFamilyStateScopeMap'; +import { isLastRecordBoardColumnFamilyStateScopeMap } from '@/object-record/record-board/states/isLastRecordBoardColumnFamilyStateScopeMap'; +import { recordBoardColumnIdsStateScopeMap } from '@/object-record/record-board/states/recordBoardColumnIdsStateScopeMap'; +import { recordBoardColumnsFamilySelectorScopeMap } from '@/object-record/record-board/states/selectors/recordBoardColumnsFamilySelectorScopeMap'; +import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; +import { getFamilyState } from '@/ui/utilities/recoil-scope/utils/getFamilyState'; +import { getScopeIdOrUndefinedFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdOrUndefinedFromComponentId'; +import { getState } from '@/ui/utilities/recoil-scope/utils/getState'; + +export const useRecordBoardStates = (recordBoardId?: string) => { + const scopeId = useAvailableScopeIdOrThrow( + RecordBoardScopeInternalContext, + getScopeIdOrUndefinedFromComponentId(recordBoardId), + ); + + return { + scopeId, + getColumnIdsState: getState(recordBoardColumnIdsStateScopeMap, scopeId), + isFirstColumnFamilyState: getFamilyState( + isFirstRecordBoardColumnFamilyStateScopeMap, + scopeId, + ), + isLastColumnFamilyState: getFamilyState( + isLastRecordBoardColumnFamilyStateScopeMap, + scopeId, + ), + columnsFamilySelector: getFamilyState( + recordBoardColumnsFamilySelectorScopeMap, + scopeId, + ), + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useSetRecordBoardColumns.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useSetRecordBoardColumns.ts new file mode 100644 index 000000000..393f0250b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useSetRecordBoardColumns.ts @@ -0,0 +1,48 @@ +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'; + +export const useSetRecordBoardColumns = (recordBoardId?: string) => { + const { scopeId, getColumnIdsState, columnsFamilySelector } = + useRecordBoardStates(recordBoardId); + + const setRecordBoardColumns = useRecoilCallback( + ({ set, snapshot }) => + (columns: RecordBoardColumnDefinition[]) => { + const currentColumnsIds = snapshot + .getLoadable(getColumnIdsState()) + .getValue(); + + const columnIds = columns.map(({ id }) => id); + + if (isDeeplyEqual(currentColumnsIds, columnIds)) { + return; + } + + set( + getColumnIdsState(), + columns.map((column) => column.id), + ); + + columns.forEach((column) => { + const currentColumn = snapshot + .getLoadable(columnsFamilySelector(column.id)) + .getValue(); + + if (isDeeplyEqual(currentColumn, column)) { + return; + } + + set(columnsFamilySelector(column.id), column); + }); + }, + [columnsFamilySelector, getColumnIdsState], + ); + + return { + scopeId, + setRecordBoardColumns, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoard.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoard.ts new file mode 100644 index 000000000..2ddfcfc04 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoard.ts @@ -0,0 +1,16 @@ +import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; +import { useSetRecordBoardColumns } from '@/object-record/record-board/hooks/internal/useSetRecordBoardColumns'; + +export const useRecordBoard = (recordBoardId?: string) => { + const { scopeId, getColumnIdsState, columnsFamilySelector } = + useRecordBoardStates(recordBoardId); + + const { setRecordBoardColumns } = useSetRecordBoardColumns(recordBoardId); + + return { + scopeId, + getColumnIdsState, + columnsFamilySelector, + setRecordBoardColumns, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx index e7f037b7d..97b14a715 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx @@ -1,10 +1,12 @@ import styled from '@emotion/styled'; import { Droppable } from '@hello-pangea/dnd'; +import { useRecoilValue } from 'recoil'; import { RecordBoardColumnContext } from '@/object-record/record-board/contexts/RecordBoardColumnContext'; +import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; import { RecordBoardColumnCardsContainer } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnCardsContainer'; +import { RecordBoardColumnHeader } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeader'; import { BoardCardIdContext } from '@/object-record/record-board-deprecated/contexts/BoardCardIdContext'; -import { BoardColumnDefinition } from '@/object-record/record-board-deprecated/types/BoardColumnDefinition'; const StyledColumn = styled.div<{ isFirstColumn: boolean }>` background-color: ${({ theme }) => theme.background.primary}; @@ -22,25 +24,44 @@ const StyledColumn = styled.div<{ isFirstColumn: boolean }>` type RecordBoardColumnProps = { recordBoardColumnId: string; - columnDefinition: BoardColumnDefinition; }; export const RecordBoardColumn = ({ recordBoardColumnId, - columnDefinition, }: RecordBoardColumnProps) => { - const isFirstColumn = columnDefinition.position === 0; + const { + isFirstColumnFamilyState, + isLastColumnFamilyState, + columnsFamilySelector, + } = useRecordBoardStates(); + const columnDefinition = useRecoilValue( + columnsFamilySelector(recordBoardColumnId), + ); + + const isFirstColumn = useRecoilValue( + isFirstColumnFamilyState(recordBoardColumnId), + ); + + const isLastColumn = useRecoilValue( + isLastColumnFamilyState(recordBoardColumnId), + ); + + if (!columnDefinition) { + return null; + } return ( {(droppableProvided) => ( + diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnDropdownMenu.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnDropdownMenu.tsx new file mode 100644 index 000000000..e6bd40310 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnDropdownMenu.tsx @@ -0,0 +1,58 @@ +import { useCallback, useContext, useRef } from 'react'; +import styled from '@emotion/styled'; +import { MenuItem } from 'tsup.ui.index'; + +import { RecordBoardColumnContext } from '@/object-record/record-board/contexts/RecordBoardColumnContext'; +import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; + +const StyledMenuContainer = styled.div` + position: absolute; + top: ${({ theme }) => theme.spacing(10)}; + width: 200px; + z-index: 1; +`; + +type RecordBoardColumnDropdownMenuProps = { + onClose: () => void; + onDelete?: (id: string) => void; + stageId: string; +}; + +export const RecordBoardColumnDropdownMenu = ({ + onClose, +}: RecordBoardColumnDropdownMenuProps) => { + const boardColumnMenuRef = useRef(null); + + const closeMenu = useCallback(() => { + onClose(); + }, [onClose]); + + useListenClickOutside({ + refs: [boardColumnMenuRef], + callback: closeMenu, + }); + + const { columnDefinition } = useContext(RecordBoardColumnContext); + + return ( + + + + {columnDefinition.actions.map((action) => ( + { + action.callback(); + closeMenu(); + }} + LeftIcon={action.icon} + text={action.label} + /> + ))} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx new file mode 100644 index 000000000..da1d5999e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx @@ -0,0 +1,105 @@ +import React, { useContext, useState } from 'react'; +import styled from '@emotion/styled'; + +import { RecordBoardColumnContext } from '@/object-record/record-board/contexts/RecordBoardColumnContext'; +import { RecordBoardColumnDropdownMenu } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnDropdownMenu'; +import { BoardColumnHotkeyScope } from '@/object-record/record-board-deprecated/types/BoardColumnHotkeyScope'; +import { IconDotsVertical } from '@/ui/display/icon'; +import { Tag } from '@/ui/display/tag/components/Tag'; +import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; +import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; + +const StyledHeader = styled.div` + align-items: center; + cursor: pointer; + display: flex; + flex-direction: row; + height: 24px; + justify-content: left; + margin-bottom: ${({ theme }) => theme.spacing(2)}; + width: 100%; +`; + +const StyledAmount = styled.div` + color: ${({ theme }) => theme.font.color.tertiary}; + margin-left: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledNumChildren = styled.div` + align-items: center; + background-color: ${({ theme }) => theme.background.tertiary}; + border-radius: ${({ theme }) => theme.border.radius.rounded}; + color: ${({ theme }) => theme.font.color.tertiary}; + display: flex; + height: 20px; + justify-content: center; + line-height: ${({ theme }) => theme.text.lineHeight.lg}; + margin-left: auto; + width: 16px; +`; + +const StyledHeaderActions = styled.div` + display: flex; + margin-left: auto; +`; + +export const RecordBoardColumnHeader = () => { + const [isBoardColumnMenuOpen, setIsBoardColumnMenuOpen] = useState(false); + const [isHeaderHovered, setIsHeaderHovered] = useState(false); + + const { columnDefinition } = useContext(RecordBoardColumnContext); + + const { + setHotkeyScopeAndMemorizePreviousScope, + goBackToPreviousHotkeyScope, + } = usePreviousHotkeyScope(); + + const handleBoardColumnMenuOpen = () => { + setIsBoardColumnMenuOpen(true); + setHotkeyScopeAndMemorizePreviousScope(BoardColumnHotkeyScope.BoardColumn, { + goto: false, + }); + }; + + const handleBoardColumnMenuClose = () => { + goBackToPreviousHotkeyScope(); + setIsBoardColumnMenuOpen(false); + }; + + const boardColumnTotal = 0; + const cardIds = []; + + return ( + <> + setIsHeaderHovered(true)} + onMouseLeave={() => setIsHeaderHovered(false)} + > + + {!!boardColumnTotal && ${boardColumnTotal}} + {!isHeaderHovered && ( + {cardIds.length} + )} + {isHeaderHovered && ( + + + + )} + + {isBoardColumnMenuOpen && ( + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/isFirstRecordBoardColumnFamilyStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-board/states/isFirstRecordBoardColumnFamilyStateScopeMap.ts new file mode 100644 index 000000000..989f3277b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/isFirstRecordBoardColumnFamilyStateScopeMap.ts @@ -0,0 +1,7 @@ +import { createFamilyStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createFamilyStateScopeMap'; + +export const isFirstRecordBoardColumnFamilyStateScopeMap = + createFamilyStateScopeMap({ + key: 'isFirstRecordBoardColumnFamilyStateScopeMap', + defaultValue: false, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/isLastRecordBoardColumnFamilyStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-board/states/isLastRecordBoardColumnFamilyStateScopeMap.ts new file mode 100644 index 000000000..b29e9ecae --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/isLastRecordBoardColumnFamilyStateScopeMap.ts @@ -0,0 +1,7 @@ +import { createFamilyStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createFamilyStateScopeMap'; + +export const isLastRecordBoardColumnFamilyStateScopeMap = + createFamilyStateScopeMap({ + key: 'isLastRecordBoardColumnFamilyStateScopeMap', + defaultValue: false, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardColumnIdsStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardColumnIdsStateScopeMap.ts new file mode 100644 index 000000000..82118caf3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardColumnIdsStateScopeMap.ts @@ -0,0 +1,6 @@ +import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; + +export const recordBoardColumnIdsStateScopeMap = createStateScopeMap({ + key: 'recordBoardColumnIdsStateScopeMap', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardColumnsFamilyStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardColumnsFamilyStateScopeMap.ts new file mode 100644 index 000000000..aca09ce52 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardColumnsFamilyStateScopeMap.ts @@ -0,0 +1,10 @@ +import { RecordBoardColumnDefinition } from '@/object-record/record-board/types/RecordBoardColumnDefinition'; +import { createFamilyStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createFamilyStateScopeMap'; + +export const recordBoardColumnsFamilyStateScopeMap = createFamilyStateScopeMap< + RecordBoardColumnDefinition | undefined, + string +>({ + key: 'recordBoardColumnsFamilyStateScopeMap', + defaultValue: undefined, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/selectors/recordBoardColumnsFamilySelectorScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-board/states/selectors/recordBoardColumnsFamilySelectorScopeMap.ts new file mode 100644 index 000000000..46d5c6123 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/selectors/recordBoardColumnsFamilySelectorScopeMap.ts @@ -0,0 +1,115 @@ +import { isFirstRecordBoardColumnFamilyStateScopeMap } from '@/object-record/record-board/states/isFirstRecordBoardColumnFamilyStateScopeMap'; +import { isLastRecordBoardColumnFamilyStateScopeMap } from '@/object-record/record-board/states/isLastRecordBoardColumnFamilyStateScopeMap'; +import { recordBoardColumnIdsStateScopeMap } from '@/object-record/record-board/states/recordBoardColumnIdsStateScopeMap'; +import { recordBoardColumnsFamilyStateScopeMap } from '@/object-record/record-board/states/recordBoardColumnsFamilyStateScopeMap'; +import { RecordBoardColumnDefinition } from '@/object-record/record-board/types/RecordBoardColumnDefinition'; +import { createFamilySelectorScopeMap } from '@/ui/utilities/recoil-scope/utils/createFamilySelectorScopeMap'; +import { guardRecoilDefaultValue } from '@/ui/utilities/recoil-scope/utils/guardRecoilDefaultValue'; +import { assertNotNull } from '~/utils/assert'; + +export const recordBoardColumnsFamilySelectorScopeMap = + createFamilySelectorScopeMap( + { + key: 'recordBoardColumnsFamilySelectorScopeMap', + get: + ({ + scopeId, + familyKey: columnId, + }: { + scopeId: string; + familyKey: string; + }) => + ({ get }) => { + return get( + recordBoardColumnsFamilyStateScopeMap({ + scopeId, + familyKey: columnId, + }), + ); + }, + set: + ({ + scopeId, + familyKey: columnId, + }: { + scopeId: string; + familyKey: string; + }) => + ({ set, get }, newColumn) => { + set( + recordBoardColumnsFamilyStateScopeMap({ + scopeId, + familyKey: columnId, + }), + newColumn, + ); + + if (guardRecoilDefaultValue(newColumn)) return; + + const columnIds = get(recordBoardColumnIdsStateScopeMap({ scopeId })); + + const columns = columnIds + .map((columnId) => { + return get( + recordBoardColumnsFamilyStateScopeMap({ + scopeId, + familyKey: columnId, + }), + ); + }) + .filter(assertNotNull); + + 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( + isLastRecordBoardColumnFamilyStateScopeMap({ + scopeId, + familyKey: columnId, + }), + true, + ); + + if (lastColumn) { + set( + isLastRecordBoardColumnFamilyStateScopeMap({ + scopeId, + familyKey: lastColumn.id, + }), + false, + ); + } + } + + if (!firstColumn || newColumn.position < firstColumn.position) { + set( + isFirstRecordBoardColumnFamilyStateScopeMap({ + scopeId, + familyKey: columnId, + }), + true, + ); + + if (firstColumn) { + set( + isFirstRecordBoardColumnFamilyStateScopeMap({ + scopeId, + familyKey: firstColumn.id, + }), + false, + ); + } + } + }, + }, + ); diff --git a/packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardColumnAction.ts b/packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardColumnAction.ts new file mode 100644 index 000000000..f2a5713cb --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardColumnAction.ts @@ -0,0 +1,9 @@ +import { IconComponent } from '@/ui/display/icon/types/IconComponent'; + +export type RecordBoardColumnAction = { + id: string; + label: string; + icon: IconComponent; + position: number; + callback: () => void; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardColumnDefinition.ts b/packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardColumnDefinition.ts index a2ce4a32f..cafdb56e7 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardColumnDefinition.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardColumnDefinition.ts @@ -1,8 +1,10 @@ +import { RecordBoardColumnAction } from '@/object-record/record-board/types/RecordBoardColumnAction'; import { ThemeColor } from '@/ui/theme/constants/colors'; export type RecordBoardColumnDefinition = { id: string; title: string; position: number; - colorCode?: ThemeColor; + color: ThemeColor; + actions: RecordBoardColumnAction[]; }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainerEffect.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainerEffect.tsx index 95dac6191..e6a18ce09 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainerEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainerEffect.tsx @@ -1,11 +1,50 @@ +import { useCallback, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural'; +import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard'; +import { computeRecordBoardColumnDefinitionsFromObjectMetadata } from '@/object-record/utils/computeRecordBoardColumnDefinitionsFromObjectMetadata'; + type RecordIndexBoardContainerEffectProps = { objectNamePlural: string; recordBoardId: string; viewBarId: string; }; -export const RecordIndexBoardContainerEffect = ( - _props: RecordIndexBoardContainerEffectProps, -) => { +export const RecordIndexBoardContainerEffect = ({ + objectNamePlural, + recordBoardId, +}: RecordIndexBoardContainerEffectProps) => { + const { objectNameSingular } = useObjectNameSingularFromPlural({ + objectNamePlural, + }); + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const navigate = useNavigate(); + + const navigateToSelectSettings = useCallback(() => { + navigate(`/settings/objects/${objectNamePlural}`); + }, [navigate, objectNamePlural]); + + const { setRecordBoardColumns } = useRecordBoard(recordBoardId); + + useEffect(() => { + setRecordBoardColumns( + computeRecordBoardColumnDefinitionsFromObjectMetadata( + objectMetadataItem, + navigateToSelectSettings, + ), + ); + }, [ + navigateToSelectSettings, + objectMetadataItem, + objectNameSingular, + setRecordBoardColumns, + ]); + return <>; }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx index 7b4c99b92..02a522033 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx @@ -6,6 +6,7 @@ import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/u import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural'; import { RecordIndexBoardContainer } from '@/object-record/record-index/components/RecordIndexBoardContainer'; +import { RecordIndexBoardContainerEffect } from '@/object-record/record-index/components/RecordIndexBoardContainerEffect'; import { RecordIndexTableContainer } from '@/object-record/record-index/components/RecordIndexTableContainer'; import { RecordIndexTableContainerEffect } from '@/object-record/record-index/components/RecordIndexTableContainerEffect'; import { RecordIndexViewBarEffect } from '@/object-record/record-index/components/RecordIndexViewBarEffect'; @@ -129,9 +130,9 @@ export const RecordIndexContainer = ({ objectNamePlural={objectNamePlural} createRecord={createRecord} /> - diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useRecordTableStates.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useRecordTableStates.ts index 717a66b51..5067ab540 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useRecordTableStates.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useRecordTableStates.ts @@ -25,14 +25,14 @@ import { tableRowIdsStateScopeMap } from '@/object-record/record-table/states/ta import { tableSortsStateScopeMap } from '@/object-record/record-table/states/tableSortsStateScopeMap'; import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; import { getFamilyState } from '@/ui/utilities/recoil-scope/utils/getFamilyState'; -import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId'; +import { getScopeIdOrUndefinedFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdOrUndefinedFromComponentId'; import { getSelector } from '@/ui/utilities/recoil-scope/utils/getSelector'; import { getState } from '@/ui/utilities/recoil-scope/utils/getState'; export const useRecordTableStates = (recordTableId?: string) => { const scopeId = useAvailableScopeIdOrThrow( RecordTableScopeInternalContext, - getScopeIdFromComponentId(recordTableId), + getScopeIdOrUndefinedFromComponentId(recordTableId), ); return { diff --git a/packages/twenty-front/src/modules/object-record/utils/computeRecordBoardColumnDefinitionsFromObjectMetadata.ts b/packages/twenty-front/src/modules/object-record/utils/computeRecordBoardColumnDefinitionsFromObjectMetadata.ts new file mode 100644 index 000000000..d06f01dc2 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/computeRecordBoardColumnDefinitionsFromObjectMetadata.ts @@ -0,0 +1,39 @@ +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { RecordBoardColumnDefinition } from '@/object-record/record-board/types/RecordBoardColumnDefinition'; +import { IconPencil } from '@/ui/display/icon'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +export const computeRecordBoardColumnDefinitionsFromObjectMetadata = ( + objectMetadataItem: ObjectMetadataItem, + navigateToSelectSettings: () => void, +): RecordBoardColumnDefinition[] => { + const selectFieldMetadataItem = objectMetadataItem.fields.find( + (field) => field.type === FieldMetadataType.Select, + ); + + if (!selectFieldMetadataItem) { + return []; + } + + if (!selectFieldMetadataItem.options) { + throw new Error( + `Select Field ${objectMetadataItem.nameSingular} has no options`, + ); + } + + return selectFieldMetadataItem.options.map((selectOption) => ({ + id: selectOption.id, + title: selectOption.label, + color: selectOption.color, + position: selectOption.position, + actions: [ + { + id: 'edit', + label: 'Edit from settings', + icon: IconPencil, + position: 0, + callback: navigateToSelectSettings, + }, + ], + })); +}; diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useDropdown.ts b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useDropdown.ts index a8f2f4630..00f16c174 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useDropdown.ts +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useDropdown.ts @@ -2,7 +2,7 @@ import { useRecoilState } from 'recoil'; import { useDropdownStates } from '@/ui/layout/dropdown/hooks/internal/useDropdownStates'; import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; -import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId'; +import { getScopeIdOrUndefinedFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdOrUndefinedFromComponentId'; export const useDropdown = (dropdownId?: string) => { const { @@ -11,7 +11,7 @@ export const useDropdown = (dropdownId?: string) => { dropdownWidthState, isDropdownOpenState, } = useDropdownStates({ - dropdownScopeId: getScopeIdFromComponentId(dropdownId), + dropdownScopeId: getScopeIdOrUndefinedFromComponentId(dropdownId), }); const { diff --git a/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useClickOustideListenerStates.ts b/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useClickOustideListenerStates.ts index 238df6465..50c598ea0 100644 --- a/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useClickOustideListenerStates.ts +++ b/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useClickOustideListenerStates.ts @@ -5,8 +5,7 @@ import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/get import { getState } from '@/ui/utilities/recoil-scope/utils/getState'; export const useClickOustideListenerStates = (componentId: string) => { - // TODO: improve typing - const scopeId = getScopeIdFromComponentId(componentId) ?? ''; + const scopeId = getScopeIdFromComponentId(componentId); return { scopeId, diff --git a/packages/twenty-front/src/modules/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId.ts b/packages/twenty-front/src/modules/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId.ts index 44f75b863..feb67c86d 100644 --- a/packages/twenty-front/src/modules/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId.ts +++ b/packages/twenty-front/src/modules/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId.ts @@ -1,2 +1,2 @@ -export const getScopeIdFromComponentId = (componentId?: string) => - componentId ? `${componentId}-scope` : undefined; +export const getScopeIdFromComponentId = (componentId: string) => + `${componentId}-scope`; diff --git a/packages/twenty-front/src/modules/ui/utilities/recoil-scope/utils/getScopeIdOrUndefinedFromComponentId.ts b/packages/twenty-front/src/modules/ui/utilities/recoil-scope/utils/getScopeIdOrUndefinedFromComponentId.ts new file mode 100644 index 000000000..3892e6afd --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/recoil-scope/utils/getScopeIdOrUndefinedFromComponentId.ts @@ -0,0 +1,4 @@ +import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId'; + +export const getScopeIdOrUndefinedFromComponentId = (componentId?: string) => + componentId ? getScopeIdFromComponentId(componentId) : undefined; diff --git a/packages/twenty-front/src/modules/ui/utilities/recoil-scope/utils/guardRecoilDefaultValue.ts b/packages/twenty-front/src/modules/ui/utilities/recoil-scope/utils/guardRecoilDefaultValue.ts new file mode 100644 index 000000000..843496011 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/recoil-scope/utils/guardRecoilDefaultValue.ts @@ -0,0 +1,8 @@ +import { DefaultValue } from 'recoil'; + +export const guardRecoilDefaultValue = ( + candidate: any, +): candidate is DefaultValue => { + if (candidate instanceof DefaultValue) return true; + return false; +}; diff --git a/packages/twenty-server/src/database/typeorm-seeds/workspace/opportunity.ts b/packages/twenty-server/src/database/typeorm-seeds/workspace/opportunity.ts index c6ec05117..6a3626e01 100644 --- a/packages/twenty-server/src/database/typeorm-seeds/workspace/opportunity.ts +++ b/packages/twenty-server/src/database/typeorm-seeds/workspace/opportunity.ts @@ -16,6 +16,7 @@ export const seedOpportunity = async ( 'amountCurrencyCode', 'closeDate', 'probability', + 'stage', 'pipelineStepId', 'pointOfContactId', 'companyId', @@ -29,6 +30,7 @@ export const seedOpportunity = async ( amountCurrencyCode: 'USD', closeDate: new Date(), probability: 0.5, + stage: 'new', pipelineStepId: '6edf4ead-006a-46e1-9c6d-228f1d0143c9', pointOfContactId: '86083141-1c0e-494c-a1b6-85b1c6fefaa5', companyId: 'fe256b39-3ec3-4fe3-8997-b76aa0bfa408', @@ -40,6 +42,7 @@ export const seedOpportunity = async ( amountCurrencyCode: 'USD', closeDate: new Date(), probability: 0.5, + stage: 'meeting', pipelineStepId: 'd8361722-03fb-4e65-bd4f-ec9e52e5ec0a', pointOfContactId: '93c72d2e-f517-42fd-80ae-14173b3b70ae', companyId: '118995f3-5d81-46d6-bf83-f7fd33ea6102', @@ -51,6 +54,7 @@ export const seedOpportunity = async ( amountCurrencyCode: 'USD', closeDate: new Date(), probability: 0.5, + stage: 'proposal', pipelineStepId: '30b14887-d592-427d-bd97-6e670158db02', pointOfContactId: '9b324a88-6784-4449-afdf-dc62cb8702f2', companyId: '460b6fb1-ed89-413a-b31a-962986e67bb4', @@ -62,6 +66,7 @@ export const seedOpportunity = async ( amountCurrencyCode: 'USD', closeDate: new Date(), probability: 0.5, + stage: 'proposal', pipelineStepId: '30b14887-d592-427d-bd97-6e670158db02', pointOfContactId: '98406e26-80f1-4dff-b570-a74942528de3', companyId: '460b6fb1-ed89-413a-b31a-962986e67bb4', diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/standard-objects/opportunity.object-metadata.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/standard-objects/opportunity.object-metadata.ts index b2ea3bbc9..db36e98e6 100644 --- a/packages/twenty-server/src/workspace/workspace-sync-metadata/standard-objects/opportunity.object-metadata.ts +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/standard-objects/opportunity.object-metadata.ts @@ -55,6 +55,27 @@ export class OpportunityObjectMetadata extends BaseObjectMetadata { }) probability: string; + @FieldMetadata({ + type: FieldMetadataType.SELECT, + label: 'Stage', + description: 'Opportunity stage', + icon: 'IconProgressCheck', + options: [ + { value: 'new', label: 'New', position: 0, color: 'red' }, + { value: 'screening', label: 'Screening', position: 1, color: 'purple' }, + { value: 'meeting', label: 'Meeting', position: 2, color: 'sky' }, + { + value: 'proposal', + label: 'Proposal', + position: 3, + color: 'turquoise', + }, + { value: 'customer', label: 'Customer', position: 4, color: 'yellow' }, + ], + defaultValue: { value: 'new' }, + }) + stage: string; + // Relations @FieldMetadata({ type: FieldMetadataType.RELATION,