diff --git a/packages/twenty-front/.storybook/main.ts b/packages/twenty-front/.storybook/main.ts index 04dd231aa..6d34593ab 100644 --- a/packages/twenty-front/.storybook/main.ts +++ b/packages/twenty-front/.storybook/main.ts @@ -57,5 +57,6 @@ const config: StorybookConfig = { }, }); }, + logLevel: 'error', }; export default config; diff --git a/packages/twenty-front/.storybook/preview.tsx b/packages/twenty-front/.storybook/preview.tsx index 1d67634e2..d35b87e85 100644 --- a/packages/twenty-front/.storybook/preview.tsx +++ b/packages/twenty-front/.storybook/preview.tsx @@ -29,6 +29,7 @@ initialize({ with payload ${JSON.stringify(requestBody)}\n This request should be mocked with MSW`); }, + quiet: true, }); const preview: Preview = { diff --git a/packages/twenty-front/jest.config.ts b/packages/twenty-front/jest.config.ts index c3e8ab414..5cb6b789c 100644 --- a/packages/twenty-front/jest.config.ts +++ b/packages/twenty-front/jest.config.ts @@ -27,7 +27,7 @@ const jestConfig: JestConfigWithTsJest = { global: { statements: 59, lines: 55, - functions: 49, + functions: 48, }, }, collectCoverageFrom: ['/src/**/*.ts'], diff --git a/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts b/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts index 8ac533f76..03164e28a 100644 --- a/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts +++ b/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts @@ -25,6 +25,7 @@ export enum CoreObjectNameSingular { ViewField = 'viewField', ViewFilter = 'viewFilter', ViewSort = 'viewSort', + ViewGroup = 'viewGroup', Webhook = 'webhook', WorkspaceMember = 'workspaceMember', MessageThreadSubscriber = 'messageThreadSubscriber', 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 592f2d7b4..b08fe9e75 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 @@ -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 ( *:not(:first-child) { + border-left: 1px solid ${({ theme }) => theme.border.color.light}; + } `; export const RecordBoardHeader = () => { 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 index aece94d48..65428a019 100644 --- 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 @@ -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, 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 index d330a476d..712fc2571 100644 --- 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 @@ -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 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 8fe978244..489798e16 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 @@ -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 = ({ {(droppableProvided) => ( - + { const boardColumnMenuRef = useRef(null); + const recordGroupActions = useRecordGroupActions(); + const closeMenu = useCallback(() => { onClose(); }, [onClose]); @@ -34,13 +36,11 @@ export const RecordBoardColumnDropdownMenu = ({ callback: closeMenu, }); - const { columnDefinition } = useContext(RecordBoardColumnContext); - return ( - {columnDefinition.actions.map((action) => ( + {recordGroupActions.map((action) => ( { 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 index 1f25864dd..1d91fc540 100644 --- 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 @@ -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 ( - + setIsHeaderHovered(true)} onMouseLeave={() => setIsHeaderHovered(false)} @@ -130,18 +127,18 @@ export const RecordBoardColumnHeader = () => { { {isHeaderHovered && ( - {columnDefinition.actions.length > 0 && ( - - )} + { - {isBoardColumnMenuOpen && columnDefinition.actions.length > 0 && ( + {isBoardColumnMenuOpen && ( { - 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, }} diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext.ts index f37c5c5cb..1d000084f 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext.ts @@ -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[]; diff --git a/packages/twenty-front/src/modules/object-record/record-board/scopes/RecordBoardScope.tsx b/packages/twenty-front/src/modules/object-record/record-board/scopes/RecordBoardScope.tsx index 37c605185..2f61769ed 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/scopes/RecordBoardScope.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/scopes/RecordBoardScope.tsx @@ -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[]) => void; - onColumnsChange: (column: RecordBoardColumnDefinition[]) => void; + onColumnsChange: (column: RecordGroupDefinition[]) => void; }; /** @deprecated */ diff --git a/packages/twenty-front/src/modules/object-record/record-board/scopes/scope-internal-context/RecordBoardScopeInternalContext.ts b/packages/twenty-front/src/modules/object-record/record-board/scopes/scope-internal-context/RecordBoardScopeInternalContext.ts index 44ac1e08e..330ff18ab 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/scopes/scope-internal-context/RecordBoardScopeInternalContext.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/scopes/scope-internal-context/RecordBoardScopeInternalContext.ts @@ -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[]) => void; - onColumnsChange: (column: RecordBoardColumnDefinition[]) => void; + onColumnsChange: (column: RecordGroupDefinition[]) => void; }; export const RecordBoardScopeInternalContext = diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/isFirstRecordBoardColumnComponentFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/isFirstRecordBoardColumnComponentFamilyState.ts deleted file mode 100644 index bef421970..000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/states/isFirstRecordBoardColumnComponentFamilyState.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState'; - -export const isFirstRecordBoardColumnComponentFamilyState = - createComponentFamilyState({ - key: 'isFirstRecordBoardColumnComponentFamilyState', - defaultValue: false, - }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/isLastRecordBoardColumnComponentFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/isLastRecordBoardColumnComponentFamilyState.ts deleted file mode 100644 index 9174fba1c..000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/states/isLastRecordBoardColumnComponentFamilyState.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState'; - -export const isLastRecordBoardColumnComponentFamilyState = - createComponentFamilyState({ - key: 'isLastRecordBoardColumnComponentFamilyState', - defaultValue: false, - }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardColumnsComponentFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardColumnsComponentFamilyState.ts index c2b6cc1cf..1530820d8 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardColumnsComponentFamilyState.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardColumnsComponentFamilyState.ts @@ -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({ + createComponentFamilyState({ key: 'recordBoardColumnsComponentFamilyState', defaultValue: undefined, }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/selectors/recordBoardColumnsComponentFamilySelector.ts b/packages/twenty-front/src/modules/object-record/record-board/states/selectors/recordBoardColumnsComponentFamilySelector.ts index 22dd7aa8d..fefff8451 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/states/selectors/recordBoardColumnsComponentFamilySelector.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/states/selectors/recordBoardColumnsComponentFamilySelector.ts @@ -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({ 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, - ); - } - } }, }); 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 deleted file mode 100644 index b5e443b0f..000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardColumnDefinition.ts +++ /dev/null @@ -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; diff --git a/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupActions.ts b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupActions.ts new file mode 100644 index 000000000..2fa75b470 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupActions.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupReorder.ts b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupReorder.ts new file mode 100644 index 000000000..97151a583 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupReorder.ts @@ -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, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupVisibility.ts b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupVisibility.ts new file mode 100644 index 000000000..c9ecbd776 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupVisibility.ts @@ -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, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroups.ts b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroups.ts new file mode 100644 index 000000000..8dcea64d7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroups.ts @@ -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, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-group/states/recordGroupDefinitionsComponentState.ts b/packages/twenty-front/src/modules/object-record/record-group/states/recordGroupDefinitionsComponentState.ts new file mode 100644 index 000000000..56ec80fcc --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-group/states/recordGroupDefinitionsComponentState.ts @@ -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, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardColumnAction.ts b/packages/twenty-front/src/modules/object-record/record-group/types/RecordGroupActions.ts similarity index 78% rename from packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardColumnAction.ts rename to packages/twenty-front/src/modules/object-record/record-group/types/RecordGroupActions.ts index 46e880ff8..7fd2d731b 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardColumnAction.ts +++ b/packages/twenty-front/src/modules/object-record/record-group/types/RecordGroupActions.ts @@ -1,6 +1,6 @@ import { IconComponent } from 'twenty-ui'; -export type RecordBoardColumnAction = { +export type RecordGroupAction = { id: string; label: string; icon: IconComponent; diff --git a/packages/twenty-front/src/modules/object-record/record-group/types/RecordGroupDefinition.ts b/packages/twenty-front/src/modules/object-record/record-group/types/RecordGroupDefinition.ts new file mode 100644 index 000000000..2c6884ce1 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-group/types/RecordGroupDefinition.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardColumnLoaderEffect.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardColumnLoaderEffect.tsx index e9c909b72..6f6a2e2be 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardColumnLoaderEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardColumnLoaderEffect.tsx @@ -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, diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoader.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoader.tsx index 8bacfe035..194580587 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoader.tsx @@ -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) => ( ))} @@ -50,7 +45,6 @@ export const RecordIndexBoardDataLoader = ({ diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoaderEffect.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoaderEffect.tsx index e03473030..d1ef9eef4 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoaderEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoaderEffect.tsx @@ -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]); 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 1bd29294b..78a046cb9 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 @@ -24,13 +24,17 @@ import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/compone import { RecordIndexActionMenu } from '@/action-menu/components/RecordIndexActionMenu'; import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; +import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard'; +import { recordGroupDefinitionsComponentState } from '@/object-record/record-group/states/recordGroupDefinitionsComponentState'; +import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; import { ViewBar } from '@/views/components/ViewBar'; -import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; import { ViewField } from '@/views/types/ViewField'; +import { ViewGroup } from '@/views/types/ViewGroup'; import { ViewType } from '@/views/types/ViewType'; import { mapViewFieldsToColumnDefinitions } from '@/views/utils/mapViewFieldsToColumnDefinitions'; import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters'; +import { mapViewGroupsToRecordGroupDefinitions } from '@/views/utils/mapViewGroupsToRecordGroupDefinitions'; import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts'; import { useContext } from 'react'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; @@ -61,6 +65,10 @@ export const RecordIndexContainer = () => { objectNameSingular, } = useContext(RecordIndexRootPropsContext); + const recordGroupDefinitionsCallbackState = useRecoilComponentCallbackStateV2( + recordGroupDefinitionsComponentState, + ); + const { columnDefinitions, filterDefinitions, sortDefinitions } = useColumnDefinitionsFromFieldMetadata(objectMetadataItem); @@ -77,6 +85,8 @@ export const RecordIndexContainer = () => { recordTableId: recordIndexId, }); + const { setColumns } = useRecordBoard(recordIndexId); + const onViewFieldsChange = useRecoilCallback( ({ set, snapshot }) => (viewFields: ViewField[]) => { @@ -103,6 +113,32 @@ export const RecordIndexContainer = () => { [columnDefinitions, setTableColumns], ); + const onViewGroupsChange = useRecoilCallback( + ({ set, snapshot }) => + (viewGroups: ViewGroup[]) => { + const newGroupDefinitions = mapViewGroupsToRecordGroupDefinitions({ + objectMetadataItem, + viewGroups, + }); + + setColumns(newGroupDefinitions); + + const existingRecordIndexGroupDefinitions = snapshot + .getLoadable(recordGroupDefinitionsCallbackState) + .getValue(); + + if ( + !isDeeplyEqual( + existingRecordIndexGroupDefinitions, + newGroupDefinitions, + ) + ) { + set(recordGroupDefinitionsCallbackState, newGroupDefinitions); + } + }, + [objectMetadataItem, recordGroupDefinitionsCallbackState, setColumns], + ); + const setContextStoreTargetedRecordsRule = useSetRecoilComponentStateV2( contextStoreTargetedRecordsRuleComponentState, ); @@ -110,86 +146,83 @@ export const RecordIndexContainer = () => { return ( - - - - + + + + } + onCurrentViewChange={(view) => { + if (!view) { + return; } - onCurrentViewChange={(view) => { - if (!view) { - return; - } - onViewFieldsChange(view.viewFields); - setTableFilters( - mapViewFiltersToFilters(view.viewFilters, filterDefinitions), - ); - setRecordIndexFilters( - mapViewFiltersToFilters(view.viewFilters, filterDefinitions), - ); - setContextStoreTargetedRecordsRule((prev) => ({ - ...prev, - filters: mapViewFiltersToFilters( - view.viewFilters, - filterDefinitions, - ), - })); - setTableSorts( - mapViewSortsToSorts(view.viewSorts, sortDefinitions), - ); - setRecordIndexSorts( - mapViewSortsToSorts(view.viewSorts, sortDefinitions), - ); - setRecordIndexViewType(view.type); - setRecordIndexViewKanbanFieldMetadataIdState( - view.kanbanFieldMetadataId, - ); - setRecordIndexIsCompactModeActive(view.isCompact); - }} - /> - ({ + ...prev, + filters: mapViewFiltersToFilters( + view.viewFilters, + filterDefinitions, + ), + })); + setTableSorts( + mapViewSortsToSorts(view.viewSorts, sortDefinitions), + ); + setRecordIndexSorts( + mapViewSortsToSorts(view.viewSorts, sortDefinitions), + ); + setRecordIndexViewType(view.type); + setRecordIndexViewKanbanFieldMetadataIdState( + view.kanbanFieldMetadataId, + ); + setRecordIndexIsCompactModeActive(view.isCompact); + }} + /> + + + {recordIndexViewType === ViewType.Table && ( + <> + - - {recordIndexViewType === ViewType.Table && ( - <> - - - - )} - {recordIndexViewType === ViewType.Kanban && ( - - - - - - )} - - - + + + )} + {recordIndexViewType === ViewType.Kanban && ( + + + + + + )} + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageKanbanAddButton.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageKanbanAddButton.tsx index 3b8079909..65c8b1303 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageKanbanAddButton.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageKanbanAddButton.tsx @@ -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( diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageKanbanAddMenuItem.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageKanbanAddMenuItem.tsx index f7b288191..facb36608 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageKanbanAddMenuItem.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageKanbanAddMenuItem.tsx @@ -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={ { + setColumns(recordIndexGroupDefinitions); + }, [recordIndexGroupDefinitions, setColumns]); + const recordIndexFilters = useRecoilValue(recordIndexFiltersState); const recordIndexSorts = useRecoilValue(recordIndexSortsState); const requestFilters = turnFiltersIntoQueryFilter( diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoardColumn.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoardColumn.ts index e77545fdc..15c84578d 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoardColumn.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoardColumn.ts @@ -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' }, }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx index 5f036cc54..0375ab340 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx @@ -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 && ( + {isViewGroupMenuItemVisible && ( + handleSelectMenu('viewGroups')} + LeftIcon={getIcon(currentViewWithCombinedFiltersAndSorts?.icon)} + text={viewGroupFieldMetadataItem.label} + hasSubMenu + /> + )} handleSelectMenu('fields')} LeftIcon={IconTag} @@ -174,6 +226,34 @@ export const RecordIndexOptionsDropdownContent = ({ /> )} + {currentMenu === 'viewGroups' && ( + <> + + {viewGroupFieldMetadataItem?.label} + + + {hiddenRecordGroups.length > 0 && ( + <> + + + handleSelectMenu('hiddenViewGroups')} + LeftIcon={IconEyeOff} + text={`Hidden ${viewGroupFieldMetadataItem?.label ?? ''}`} + /> + + + )} + + )} {currentMenu === 'fields' && ( <> @@ -198,6 +278,36 @@ export const RecordIndexOptionsDropdownContent = ({ )} + {currentMenu === 'hiddenViewGroups' && ( + <> + setCurrentMenu('viewGroups')} + > + Hidden {viewGroupFieldMetadataItem?.label} + + + + { + setNavigationMemorizedUrl(location.pathname + location.search); + closeDropdown(); + }} + > + + + + + + )} {currentMenu === 'hiddenFields' && ( <> { - 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); - }); -}); diff --git a/packages/twenty-front/src/modules/object-record/utils/computeRecordBoardColumnDefinitionsFromObjectMetadata.ts b/packages/twenty-front/src/modules/object-record/utils/computeRecordBoardColumnDefinitionsFromObjectMetadata.ts deleted file mode 100644 index 51b3fa1d3..000000000 --- a/packages/twenty-front/src/modules/object-record/utils/computeRecordBoardColumnDefinitionsFromObjectMetadata.ts +++ /dev/null @@ -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; -}; diff --git a/packages/twenty-front/src/modules/prefetch/graphql/operation-signatures/factories/findAllViewsOperationSignatureFactory.ts b/packages/twenty-front/src/modules/prefetch/graphql/operation-signatures/factories/findAllViewsOperationSignatureFactory.ts index 3a4430a2c..0e9aa76a0 100644 --- a/packages/twenty-front/src/modules/prefetch/graphql/operation-signatures/factories/findAllViewsOperationSignatureFactory.ts +++ b/packages/twenty-front/src/modules/prefetch/graphql/operation-signatures/factories/findAllViewsOperationSignatureFactory.ts @@ -20,5 +20,6 @@ export const findAllViewsOperationSignatureFactory: RecordGqlOperationSignatureF viewFilters: true, viewSorts: true, viewFields: true, + viewGroups: true, }, }); diff --git a/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemDraggable.tsx b/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemDraggable.tsx index c324e8ec4..430d45973 100644 --- a/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemDraggable.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemDraggable.tsx @@ -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; diff --git a/packages/twenty-front/src/modules/views/components/ViewGroupsVisibilityDropdownSection.tsx b/packages/twenty-front/src/modules/views/components/ViewGroupsVisibilityDropdownSection.tsx new file mode 100644 index 000000000..c7040f9ff --- /dev/null +++ b/packages/twenty-front/src/modules/views/components/ViewGroupsVisibilityDropdownSection.tsx @@ -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(null); + + return ( +
+ {showSubheader && ( + {title} + )} + + {!!viewGroups.length && ( + <> + {!isDraggable ? ( + viewGroupsWithoutNoValueGroups.map( + (viewGroup, viewGroupIndex) => ( + + } + iconButtons={getIconButtons(viewGroupIndex, viewGroup)} + accent={showDragGrip ? 'placeholder' : 'default'} + showGrip={showDragGrip} + isDragDisabled={!isDraggable} + /> + ), + ) + ) : ( + + {viewGroupsWithoutNoValueGroups.map( + (viewGroup, viewGroupIndex) => ( + + } + iconButtons={getIconButtons( + viewGroupIndex, + viewGroup, + )} + accent={showDragGrip ? 'placeholder' : 'default'} + showGrip={showDragGrip} + isDragDisabled={!isDraggable} + /> + } + /> + ), + )} + + } + /> + )} + {noValueViewGroups.map((viewGroup) => ( + + } + accent={showDragGrip ? 'placeholder' : 'default'} + showGrip={true} + isDragDisabled={true} + isHoverDisabled + /> + ))} + + )} + +
+ ); +}; diff --git a/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewGroupRecords.ts b/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewGroupRecords.ts new file mode 100644 index 000000000..5582c771f --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewGroupRecords.ts @@ -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, + }; +}; diff --git a/packages/twenty-front/src/modules/views/hooks/useCreateViewFiltersAndSorts.ts b/packages/twenty-front/src/modules/views/hooks/useCreateViewFiltersAndSorts.ts deleted file mode 100644 index 4f760b173..000000000 --- a/packages/twenty-front/src/modules/views/hooks/useCreateViewFiltersAndSorts.ts +++ /dev/null @@ -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, - }; -}; diff --git a/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts b/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts index fcf8a6c81..82f79e62a 100644 --- a/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts +++ b/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts @@ -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, ], ); diff --git a/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewGroups.ts b/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewGroups.ts new file mode 100644 index 000000000..384b26284 --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewGroups.ts @@ -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, + }; +}; diff --git a/packages/twenty-front/src/modules/views/types/GraphQLView.ts b/packages/twenty-front/src/modules/views/types/GraphQLView.ts index c657f83b5..fa3caa2f2 100644 --- a/packages/twenty-front/src/modules/views/types/GraphQLView.ts +++ b/packages/twenty-front/src/modules/views/types/GraphQLView.ts @@ -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; }; diff --git a/packages/twenty-front/src/modules/views/types/View.ts b/packages/twenty-front/src/modules/views/types/View.ts index a3c9cac58..03af1756f 100644 --- a/packages/twenty-front/src/modules/views/types/View.ts +++ b/packages/twenty-front/src/modules/views/types/View.ts @@ -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; diff --git a/packages/twenty-front/src/modules/views/types/ViewGroup.ts b/packages/twenty-front/src/modules/views/types/ViewGroup.ts new file mode 100644 index 000000000..9f0cd3822 --- /dev/null +++ b/packages/twenty-front/src/modules/views/types/ViewGroup.ts @@ -0,0 +1,8 @@ +export type ViewGroup = { + __typename: 'ViewGroup'; + id: string; + fieldMetadataId: string; + isVisible: boolean; + fieldValue: string; + position: number; +}; diff --git a/packages/twenty-front/src/modules/views/utils/mapRecordGroupDefinitionsToViewGroups.ts b/packages/twenty-front/src/modules/views/utils/mapRecordGroupDefinitionsToViewGroups.ts new file mode 100644 index 000000000..b92519451 --- /dev/null +++ b/packages/twenty-front/src/modules/views/utils/mapRecordGroupDefinitionsToViewGroups.ts @@ -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 ?? '', + }), + ); +}; diff --git a/packages/twenty-front/src/modules/views/utils/mapViewGroupsToRecordGroupDefinitions.ts b/packages/twenty-front/src/modules/views/utils/mapViewGroupsToRecordGroupDefinitions.ts new file mode 100644 index 000000000..3767abe6a --- /dev/null +++ b/packages/twenty-front/src/modules/views/utils/mapViewGroupsToRecordGroupDefinitions.ts @@ -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; +}; diff --git a/packages/twenty-front/src/pages/object-record/RecordIndexPage.tsx b/packages/twenty-front/src/pages/object-record/RecordIndexPage.tsx index c15536d8c..6e7a2fb68 100644 --- a/packages/twenty-front/src/pages/object-record/RecordIndexPage.tsx +++ b/packages/twenty-front/src/pages/object-record/RecordIndexPage.tsx @@ -16,6 +16,7 @@ import { useCreateNewTableRecord } from '@/object-record/record-table/hooks/useC import { PageBody } from '@/ui/layout/page/components/PageBody'; import { PageContainer } from '@/ui/layout/page/components/PageContainer'; import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle'; +import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; import { useRecoilCallback } from 'recoil'; import { capitalize } from '~/utils/string/capitalize'; @@ -71,22 +72,26 @@ export const RecordIndexPage = () => { onCreateRecord: handleCreateRecord, }} > - - - - - - - - - - - - + + + + + + + + + + + + + + ); diff --git a/packages/twenty-front/src/pages/object-record/__stories__/RecordIndexPage.stories.tsx b/packages/twenty-front/src/pages/object-record/__stories__/RecordIndexPage.stories.tsx index 76b559961..5101e66f2 100644 --- a/packages/twenty-front/src/pages/object-record/__stories__/RecordIndexPage.stories.tsx +++ b/packages/twenty-front/src/pages/object-record/__stories__/RecordIndexPage.stories.tsx @@ -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'); }, }; diff --git a/packages/twenty-front/src/pages/settings/data-model/__stories__/SettingsNewObject.stories.tsx b/packages/twenty-front/src/pages/settings/data-model/__stories__/SettingsNewObject.stories.tsx index 8815ff456..a90e609b3 100644 --- a/packages/twenty-front/src/pages/settings/data-model/__stories__/SettingsNewObject.stories.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/__stories__/SettingsNewObject.stories.tsx @@ -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'); diff --git a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/view.ts b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/view.ts index 3b7e47b80..1d059f886 100644 --- a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/view.ts +++ b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/view.ts @@ -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; diff --git a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/opportunity-by-stage.view.ts b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/opportunity-by-stage.view.ts index 82d3b4791..2ac1b1279 100644 --- a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/opportunity-by-stage.view.ts +++ b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/opportunity-by-stage.view.ts @@ -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, + }, + ], }; }; diff --git a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/tasks-by-status.view.ts b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/tasks-by-status.view.ts index 304b3ed01..e5e666565 100644 --- a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/tasks-by-status.view.ts +++ b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/tasks-by-status.view.ts @@ -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, + }, + ], }; }; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts index ac9d92131..85a56e532 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts @@ -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', diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids.ts index a13e78836..98473a173 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids.ts @@ -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', diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts index 914ee491e..98b5a062e 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts @@ -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, diff --git a/packages/twenty-server/src/modules/view/standard-objects/view-group.workspace-entity.ts b/packages/twenty-server/src/modules/view/standard-objects/view-group.workspace-entity.ts new file mode 100644 index 000000000..93ff49b30 --- /dev/null +++ b/packages/twenty-server/src/modules/view/standard-objects/view-group.workspace-entity.ts @@ -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; +} diff --git a/packages/twenty-server/src/modules/view/standard-objects/view.workspace-entity.ts b/packages/twenty-server/src/modules/view/standard-objects/view.workspace-entity.ts index 8c8431c00..c673a1c1c 100644 --- a/packages/twenty-server/src/modules/view/standard-objects/view.workspace-entity.ts +++ b/packages/twenty-server/src/modules/view/standard-objects/view.workspace-entity.ts @@ -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; + @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; + @WorkspaceRelation({ standardId: VIEW_STANDARD_FIELD_IDS.viewFilters, type: RelationMetadataType.ONE_TO_MANY,