From 28e12d492c7f9f4340f0f7e8c3bdbd5a26419521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tha=C3=AFs?= Date: Wed, 13 Sep 2023 11:58:52 +0200 Subject: [PATCH] feat: toggle board field visibilities (#1547) Closes #1537, Closes #1539 --- .../__stories__/CompanyBoardCard.stories.tsx | 48 ++++++-- .../board/components/CompanyBoard.tsx | 2 + .../companies/components/CompanyBoardCard.tsx | 50 ++++---- .../components/HooksCompanyBoard.tsx | 8 +- .../pipelineAvailableFieldDefinitions.tsx | 4 + .../ui/board/components/BoardHeader.tsx | 23 ++-- .../BoardOptionsDropdownContent.tsx | 66 ++++++++-- .../ui/board/components/EntityBoard.tsx | 3 +- .../ui/board/components/EntityBoardCard.tsx | 5 +- .../ui/board/components/EntityBoardColumn.tsx | 9 +- .../ui/board/hooks/useBoardCardFields.ts | 42 +++++++ .../availableBoardCardFieldsScopedState.ts | 14 +++ .../states/boardCardFieldsScopedState.ts | 14 +++ .../boardCardFieldsByKeyScopedSelector.ts | 18 +++ .../hiddenBoardCardFieldsScopedSelector.ts | 22 ++++ .../visibleBoardCardFieldsScopedSelector.ts | 13 ++ .../states/viewFieldsDefinitionsState.ts | 13 -- .../modules/ui/board/types/BoardOptions.ts | 4 +- .../components/FloatingIconButtonGroup.tsx | 1 + .../ui/editable-field/types/ViewField.ts | 5 +- .../TableOptionsDropdownContent.tsx | 38 +++--- .../TableOptionsDropdownSection.tsx | 41 ------- .../table-header/components/TableHeader.tsx | 20 ++-- .../ui/table/types/ColumnDefinition.ts | 1 - .../ui/view-bar/components/ViewBar.tsx | 8 +- .../ViewFieldsVisibilityDropdownSection.tsx | 39 ++++++ .../modules/views/hooks/useBoardViewFields.ts | 113 ++++++++++++++++++ .../src/modules/views/hooks/useBoardViews.ts | 15 ++- .../opportunitiesBoardOptions.tsx | 2 +- front/src/testing/graphqlMocks.ts | 5 +- front/src/testing/mock-data/companies.ts | 14 +++ 31 files changed, 492 insertions(+), 168 deletions(-) create mode 100644 front/src/modules/ui/board/hooks/useBoardCardFields.ts create mode 100644 front/src/modules/ui/board/states/availableBoardCardFieldsScopedState.ts create mode 100644 front/src/modules/ui/board/states/boardCardFieldsScopedState.ts create mode 100644 front/src/modules/ui/board/states/selectors/boardCardFieldsByKeyScopedSelector.ts create mode 100644 front/src/modules/ui/board/states/selectors/hiddenBoardCardFieldsScopedSelector.ts create mode 100644 front/src/modules/ui/board/states/selectors/visibleBoardCardFieldsScopedSelector.ts delete mode 100644 front/src/modules/ui/board/states/viewFieldsDefinitionsState.ts delete mode 100644 front/src/modules/ui/table/options/components/TableOptionsDropdownSection.tsx create mode 100644 front/src/modules/ui/view-bar/components/ViewFieldsVisibilityDropdownSection.tsx create mode 100644 front/src/modules/views/hooks/useBoardViewFields.ts diff --git a/front/src/modules/companies/__stories__/CompanyBoardCard.stories.tsx b/front/src/modules/companies/__stories__/CompanyBoardCard.stories.tsx index 5e4406b74..6fc1c72e6 100644 --- a/front/src/modules/companies/__stories__/CompanyBoardCard.stories.tsx +++ b/front/src/modules/companies/__stories__/CompanyBoardCard.stories.tsx @@ -1,11 +1,16 @@ +import { useEffect } from 'react'; import { MemoryRouter } from 'react-router-dom'; import { Meta, StoryObj } from '@storybook/react'; import { CompanyBoardCard } from '@/companies/components/CompanyBoardCard'; +import { pipelineAvailableFieldDefinitions } from '@/pipeline/constants/pipelineAvailableFieldDefinitions'; import { BoardCardIdContext } from '@/ui/board/contexts/BoardCardIdContext'; +import { boardCardFieldsScopedState } from '@/ui/board/states/boardCardFieldsScopedState'; import { BoardColumnRecoilScopeContext } from '@/ui/board/states/recoil-scope-contexts/BoardColumnRecoilScopeContext'; import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope'; +import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState'; import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator'; +import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator'; import { graphqlMocks } from '~/testing/graphqlMocks'; import { mockedPipelineProgressData } from '~/testing/mock-data/pipeline-progress'; @@ -16,26 +21,43 @@ const meta: Meta = { title: 'Modules/Companies/CompanyBoardCard', component: CompanyBoardCard, decorators: [ - (Story) => ( - - - - - - - - - - - ), + (Story, context) => { + const [, setBoardCardFields] = useRecoilScopedState( + boardCardFieldsScopedState, + context.parameters.recoilScopeContext, + ); + + useEffect(() => { + setBoardCardFields(pipelineAvailableFieldDefinitions); + }, [setBoardCardFields]); + + return ( + <> + + + + + + + + + + ); + }, + ComponentWithRecoilScopeDecorator, ComponentDecorator, ], + args: { scopeContext: CompanyBoardRecoilScopeContext }, + argTypes: { scopeContext: { control: false } }, parameters: { msw: graphqlMocks, + recoilScopeContext: CompanyBoardRecoilScopeContext, }, }; export default meta; type Story = StoryObj; -export const CompanyCompanyBoardCard: Story = {}; +export const Default: Story = {}; diff --git a/front/src/modules/companies/board/components/CompanyBoard.tsx b/front/src/modules/companies/board/components/CompanyBoard.tsx index 6b97ebeb1..acad1ecb7 100644 --- a/front/src/modules/companies/board/components/CompanyBoard.tsx +++ b/front/src/modules/companies/board/components/CompanyBoard.tsx @@ -1,3 +1,4 @@ +import { pipelineAvailableFieldDefinitions } from '@/pipeline/constants/pipelineAvailableFieldDefinitions'; import { EntityBoard, type EntityBoardProps, @@ -21,6 +22,7 @@ export const CompanyBoard = ({ ...props }: CompanyBoardProps) => { availableSorts: opportunitiesBoardOptions.sorts, objectId: 'company', scopeContext: CompanyBoardRecoilScopeContext, + fieldDefinitions: pipelineAvailableFieldDefinitions, }); return ( diff --git a/front/src/modules/companies/components/CompanyBoardCard.tsx b/front/src/modules/companies/components/CompanyBoardCard.tsx index 590ac89d4..f8082015d 100644 --- a/front/src/modules/companies/components/CompanyBoardCard.tsx +++ b/front/src/modules/companies/components/CompanyBoardCard.tsx @@ -1,16 +1,17 @@ -import { ReactNode, useContext } from 'react'; +import { type Context, type ReactNode, useContext } from 'react'; import styled from '@emotion/styled'; -import { useRecoilState, useRecoilValue } from 'recoil'; +import { useRecoilState } from 'recoil'; import { BoardCardIdContext } from '@/ui/board/contexts/BoardCardIdContext'; import { useCurrentCardSelected } from '@/ui/board/hooks/useCurrentCardSelected'; -import { viewFieldsDefinitionsState } from '@/ui/board/states/viewFieldsDefinitionsState'; +import { visibleBoardCardFieldsScopedSelector } from '@/ui/board/states/selectors/visibleBoardCardFieldsScopedSelector'; import { EntityChipVariant } from '@/ui/chip/components/EntityChip'; import { GenericEditableField } from '@/ui/editable-field/components/GenericEditableField'; import { EditableFieldDefinitionContext } from '@/ui/editable-field/contexts/EditableFieldDefinitionContext'; import { EditableFieldEntityIdContext } from '@/ui/editable-field/contexts/EditableFieldEntityIdContext'; import { EditableFieldMutationContext } from '@/ui/editable-field/contexts/EditableFieldMutationContext'; import { Checkbox, CheckboxVariant } from '@/ui/input/components/Checkbox'; +import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue'; import { useUpdateOnePipelineProgressMutation } from '~/generated/graphql'; import { getLogoUrlFromDomainName } from '~/utils'; @@ -18,6 +19,10 @@ import { companyProgressesFamilyState } from '../states/companyProgressesFamilyS import { CompanyChip } from './CompanyChip'; +type OwnProps = { + scopeContext: Context; +}; + const StyledBoardCard = styled.div<{ selected: boolean }>` background-color: ${({ theme, selected }) => selected ? theme.accent.quaternary : theme.background.secondary}; @@ -98,7 +103,7 @@ const StyledFieldContainer = styled.div` width: 100%; `; -export function CompanyBoardCard() { +export function CompanyBoardCard({ scopeContext }: OwnProps) { const { currentCardSelected, setCurrentCardSelected } = useCurrentCardSelected(); const boardCardId = useContext(BoardCardIdContext); @@ -108,7 +113,10 @@ export function CompanyBoardCard() { ); const { pipelineProgress, company } = companyProgress ?? {}; - const viewFieldsDefinitions = useRecoilValue(viewFieldsDefinitionsState); + const visibleBoardCardFields = useRecoilScopedValue( + visibleBoardCardFieldsScopedSelector, + scopeContext, + ); // boardCardId check can be moved to a wrapper to avoid unnecessary logic above if (!company || !pipelineProgress || !boardCardId) { @@ -157,23 +165,21 @@ export function CompanyBoardCard() { value={useUpdateOnePipelineProgressMutation} > - {viewFieldsDefinitions.map((viewField) => { - return ( - - - - - - ); - })} + {visibleBoardCardFields.map((viewField) => ( + + + + + + ))} diff --git a/front/src/modules/companies/components/HooksCompanyBoard.tsx b/front/src/modules/companies/components/HooksCompanyBoard.tsx index 5fcf55285..dad044062 100644 --- a/front/src/modules/companies/components/HooksCompanyBoard.tsx +++ b/front/src/modules/companies/components/HooksCompanyBoard.tsx @@ -1,11 +1,9 @@ import { useEffect, useMemo } from 'react'; -import { useRecoilState, useSetRecoilState } from 'recoil'; +import { useRecoilState } from 'recoil'; -import { pipelineAvailableFieldDefinitions } from '@/pipeline/constants/pipelineAvailableFieldDefinitions'; import { useBoardActionBarEntries } from '@/ui/board/hooks/useBoardActionBarEntries'; import { useBoardContextMenuEntries } from '@/ui/board/hooks/useBoardContextMenuEntries'; import { isBoardLoadedState } from '@/ui/board/states/isBoardLoadedState'; -import { viewFieldsDefinitionsState } from '@/ui/board/states/viewFieldsDefinitionsState'; import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState'; import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue'; import { availableFiltersScopedState } from '@/ui/view-bar/states/availableFiltersScopedState'; @@ -26,9 +24,6 @@ import { useUpdateCompanyBoard } from '../hooks/useUpdateCompanyBoardColumns'; import { CompanyBoardRecoilScopeContext } from '../states/recoil-scope-contexts/CompanyBoardRecoilScopeContext'; export function HooksCompanyBoard() { - const setFieldsDefinitionsState = useSetRecoilState( - viewFieldsDefinitionsState, - ); const [, setAvailableFilters] = useRecoilScopedState( availableFiltersScopedState, CompanyBoardRecoilScopeContext, @@ -36,7 +31,6 @@ export function HooksCompanyBoard() { useEffect(() => { setAvailableFilters(opportunitiesBoardOptions.filters); - setFieldsDefinitionsState(pipelineAvailableFieldDefinitions); }); const [, setIsBoardLoaded] = useRecoilState(isBoardLoadedState); diff --git a/front/src/modules/pipeline/constants/pipelineAvailableFieldDefinitions.tsx b/front/src/modules/pipeline/constants/pipelineAvailableFieldDefinitions.tsx index 0e1cb67c4..22a40615d 100644 --- a/front/src/modules/pipeline/constants/pipelineAvailableFieldDefinitions.tsx +++ b/front/src/modules/pipeline/constants/pipelineAvailableFieldDefinitions.tsx @@ -20,6 +20,7 @@ export const pipelineAvailableFieldDefinitions: ViewFieldDefinition({ availableSorts, defaultViewName, }: BoardHeaderProps) { - const OptionsDropdownButton = useCallback( - () => ( - - ), - [onStageAdd, onViewsChange, scopeContext], - ); - return ( ({ defaultViewName={defaultViewName} onViewsChange={onViewsChange} onViewSubmit={onViewSubmit} + optionsDropdownButton={ + + } optionsDropdownKey={BoardOptionsDropdownKey} - OptionsDropdownButton={OptionsDropdownButton} scopeContext={scopeContext} /> diff --git a/front/src/modules/ui/board/components/BoardOptionsDropdownContent.tsx b/front/src/modules/ui/board/components/BoardOptionsDropdownContent.tsx index cc4e3de00..6bb25ce3d 100644 --- a/front/src/modules/ui/board/components/BoardOptionsDropdownContent.tsx +++ b/front/src/modules/ui/board/components/BoardOptionsDropdownContent.tsx @@ -16,6 +16,7 @@ import { IconLayoutKanban, IconPlus, IconSettings, + IconTag, } from '@/ui/icon'; import { MenuItem } from '@/ui/menu-item/components/MenuItem'; import { MenuItemNavigate } from '@/ui/menu-item/components/MenuItemNavigate'; @@ -23,12 +24,16 @@ import { ThemeColor } from '@/ui/theme/constants/colors'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue'; +import { ViewFieldsVisibilityDropdownSection } from '@/ui/view-bar/components/ViewFieldsVisibilityDropdownSection'; import { useUpsertView } from '@/ui/view-bar/hooks/useUpsertView'; import { viewsByIdScopedSelector } from '@/ui/view-bar/states/selectors/viewsByIdScopedSelector'; import { viewEditModeState } from '@/ui/view-bar/states/viewEditModeState'; import type { View } from '@/ui/view-bar/types/View'; +import { useBoardCardFields } from '../hooks/useBoardCardFields'; import { boardColumnsState } from '../states/boardColumnsState'; +import { hiddenBoardCardFieldsScopedSelector } from '../states/selectors/hiddenBoardCardFieldsScopedSelector'; +import { visibleBoardCardFieldsScopedSelector } from '../states/selectors/visibleBoardCardFieldsScopedSelector'; import type { BoardColumnDefinition } from '../types/BoardColumnDefinition'; import { BoardOptionsDropdownKey } from '../types/BoardOptionsDropdownKey'; @@ -43,10 +48,7 @@ const StyledIconSettings = styled(IconSettings)` margin-right: ${({ theme }) => theme.spacing(1)}; `; -enum BoardOptionsMenu { - StageCreation = 'StageCreation', - Stages = 'Stages', -} +type BoardOptionsMenu = 'fields' | 'stage-creation' | 'stages'; type ColumnForCreate = { id: string; @@ -72,14 +74,22 @@ export function BoardOptionsDropdownContent({ const [boardColumns, setBoardColumns] = useRecoilState(boardColumnsState); + const hiddenBoardCardFields = useRecoilScopedValue( + hiddenBoardCardFieldsScopedSelector, + scopeContext, + ); + const hasHiddenFields = hiddenBoardCardFields.length > 0; + const visibleBoardCardFields = useRecoilScopedValue( + visibleBoardCardFieldsScopedSelector, + scopeContext, + ); + const hasVisibleFields = visibleBoardCardFields.length > 0; + const viewsById = useRecoilScopedValue(viewsByIdScopedSelector, scopeContext); const viewEditMode = useRecoilValue(viewEditModeState); const handleStageSubmit = () => { - if ( - currentMenu !== BoardOptionsMenu.StageCreation || - !stageInputRef?.current?.value - ) + if (currentMenu !== 'stage-creation' || !stageInputRef?.current?.value) return; const columnToCreate: ColumnForCreate = { @@ -113,6 +123,8 @@ export function BoardOptionsDropdownContent({ setCurrentMenu(menu); }; + const { handleFieldVisibilityChange } = useBoardCardFields({ scopeContext }); + const { closeDropdownButton } = useDropdownButton({ key: BoardOptionsDropdownKey, }); @@ -161,14 +173,19 @@ export function BoardOptionsDropdownContent({ handleMenuNavigate(BoardOptionsMenu.Stages)} + onClick={() => handleMenuNavigate('stages')} LeftIcon={IconLayoutKanban} text="Stages" /> + handleMenuNavigate('fields')} + LeftIcon={IconTag} + text="Fields" + /> )} - {currentMenu === BoardOptionsMenu.Stages && ( + {currentMenu === 'stages' && ( <> Stages @@ -176,20 +193,45 @@ export function BoardOptionsDropdownContent({ setCurrentMenu(BoardOptionsMenu.StageCreation)} + onClick={() => setCurrentMenu('stage-creation')} LeftIcon={IconPlus} text="Add stage" /> )} - {currentMenu === BoardOptionsMenu.StageCreation && ( + {currentMenu === 'stage-creation' && ( )} + {currentMenu === 'fields' && ( + <> + + Fields + + + {hasVisibleFields && ( + + )} + {hasVisibleFields && hasHiddenFields && ( + + )} + {hasHiddenFields && ( + + )} + + )} ); } diff --git a/front/src/modules/ui/board/components/EntityBoard.tsx b/front/src/modules/ui/board/components/EntityBoard.tsx index c60cf2c2a..686e05f8b 100644 --- a/front/src/modules/ui/board/components/EntityBoard.tsx +++ b/front/src/modules/ui/board/components/EntityBoard.tsx @@ -160,8 +160,9 @@ export function EntityBoard({ diff --git a/front/src/modules/ui/board/components/EntityBoardCard.tsx b/front/src/modules/ui/board/components/EntityBoardCard.tsx index de07f5490..5d8bd732c 100644 --- a/front/src/modules/ui/board/components/EntityBoardCard.tsx +++ b/front/src/modules/ui/board/components/EntityBoardCard.tsx @@ -1,3 +1,4 @@ +import type { Context } from 'react'; import { Draggable } from '@hello-pangea/dnd'; import { useSetRecoilState } from 'recoil'; @@ -11,10 +12,12 @@ export function EntityBoardCard({ boardOptions, cardId, index, + scopeContext, }: { boardOptions: BoardOptions; cardId: string; index: number; + scopeContext: Context; }) { const setContextMenuPosition = useSetRecoilState(contextMenuPositionState); const setContextMenuOpenState = useSetRecoilState(contextMenuIsOpenState); @@ -43,7 +46,7 @@ export function EntityBoardCard({ data-select-disable onContextMenu={handleContextMenu} > - {boardOptions.cardComponent} + {} )} diff --git a/front/src/modules/ui/board/components/EntityBoardColumn.tsx b/front/src/modules/ui/board/components/EntityBoardColumn.tsx index e13b7e3a3..818e3ff45 100644 --- a/front/src/modules/ui/board/components/EntityBoardColumn.tsx +++ b/front/src/modules/ui/board/components/EntityBoardColumn.tsx @@ -1,4 +1,4 @@ -import { useContext } from 'react'; +import { type Context, useContext } from 'react'; import styled from '@emotion/styled'; import { Draggable, Droppable, DroppableProvided } from '@hello-pangea/dnd'; import { useRecoilValue } from 'recoil'; @@ -48,15 +48,17 @@ const BoardColumnCardsContainer = ({ }; export function EntityBoardColumn({ - column, boardOptions, + column, onDelete, onTitleEdit, + scopeContext, }: { - column: BoardColumnDefinition; boardOptions: BoardOptions; + column: BoardColumnDefinition; onDelete?: (columnId: string) => void; onTitleEdit: (columnId: string, title: string, color: string) => void; + scopeContext: Context; }) { const boardColumnId = useContext(BoardColumnIdContext) ?? ''; @@ -92,6 +94,7 @@ export function EntityBoardColumn({ index={index} cardId={cardId} boardOptions={boardOptions} + scopeContext={scopeContext} /> ))} diff --git a/front/src/modules/ui/board/hooks/useBoardCardFields.ts b/front/src/modules/ui/board/hooks/useBoardCardFields.ts new file mode 100644 index 000000000..bf9c0e8ac --- /dev/null +++ b/front/src/modules/ui/board/hooks/useBoardCardFields.ts @@ -0,0 +1,42 @@ +import type { Context } from 'react'; + +import type { + ViewFieldDefinition, + ViewFieldMetadata, +} from '@/ui/editable-field/types/ViewField'; +import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState'; +import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue'; + +import { boardCardFieldsScopedState } from '../states/boardCardFieldsScopedState'; +import { boardCardFieldsByKeyScopedSelector } from '../states/selectors/boardCardFieldsByKeyScopedSelector'; + +export const useBoardCardFields = ({ + scopeContext, +}: { + scopeContext: Context; +}) => { + const [boardCardFields, setBoardCardFields] = useRecoilScopedState( + boardCardFieldsScopedState, + scopeContext, + ); + const boardCardFieldsByKey = useRecoilScopedValue( + boardCardFieldsByKeyScopedSelector, + scopeContext, + ); + + const handleFieldVisibilityChange = ( + field: ViewFieldDefinition, + ) => { + const nextFields = boardCardFieldsByKey[field.key] + ? boardCardFields.map((previousField) => + previousField.key === field.key + ? { ...previousField, isVisible: !field.isVisible } + : previousField, + ) + : [...boardCardFields, { ...field, isVisible: true }]; + + setBoardCardFields(nextFields); + }; + + return { handleFieldVisibilityChange }; +}; diff --git a/front/src/modules/ui/board/states/availableBoardCardFieldsScopedState.ts b/front/src/modules/ui/board/states/availableBoardCardFieldsScopedState.ts new file mode 100644 index 000000000..bed69d670 --- /dev/null +++ b/front/src/modules/ui/board/states/availableBoardCardFieldsScopedState.ts @@ -0,0 +1,14 @@ +import { atomFamily } from 'recoil'; + +import type { + ViewFieldDefinition, + ViewFieldMetadata, +} from '@/ui/editable-field/types/ViewField'; + +export const availableBoardCardFieldsScopedState = atomFamily< + ViewFieldDefinition[], + string +>({ + key: 'availableBoardCardFieldsScopedState', + default: [], +}); diff --git a/front/src/modules/ui/board/states/boardCardFieldsScopedState.ts b/front/src/modules/ui/board/states/boardCardFieldsScopedState.ts new file mode 100644 index 000000000..22ad621c3 --- /dev/null +++ b/front/src/modules/ui/board/states/boardCardFieldsScopedState.ts @@ -0,0 +1,14 @@ +import { atomFamily } from 'recoil'; + +import type { + ViewFieldDefinition, + ViewFieldMetadata, +} from '@/ui/editable-field/types/ViewField'; + +export const boardCardFieldsScopedState = atomFamily< + ViewFieldDefinition[], + string +>({ + key: 'boardCardFieldsScopedState', + default: [], +}); diff --git a/front/src/modules/ui/board/states/selectors/boardCardFieldsByKeyScopedSelector.ts b/front/src/modules/ui/board/states/selectors/boardCardFieldsByKeyScopedSelector.ts new file mode 100644 index 000000000..eadb8b623 --- /dev/null +++ b/front/src/modules/ui/board/states/selectors/boardCardFieldsByKeyScopedSelector.ts @@ -0,0 +1,18 @@ +import { selectorFamily } from 'recoil'; + +import type { + ViewFieldDefinition, + ViewFieldMetadata, +} from '@/ui/editable-field/types/ViewField'; + +import { boardCardFieldsScopedState } from '../boardCardFieldsScopedState'; + +export const boardCardFieldsByKeyScopedSelector = selectorFamily({ + key: 'boardCardFieldsByKeyScopedSelector', + get: + (scopeId: string) => + ({ get }) => + get(boardCardFieldsScopedState(scopeId)).reduce< + Record> + >((result, field) => ({ ...result, [field.key]: field }), {}), +}); diff --git a/front/src/modules/ui/board/states/selectors/hiddenBoardCardFieldsScopedSelector.ts b/front/src/modules/ui/board/states/selectors/hiddenBoardCardFieldsScopedSelector.ts new file mode 100644 index 000000000..6bc81b9b1 --- /dev/null +++ b/front/src/modules/ui/board/states/selectors/hiddenBoardCardFieldsScopedSelector.ts @@ -0,0 +1,22 @@ +import { selectorFamily } from 'recoil'; + +import { availableBoardCardFieldsScopedState } from '../availableBoardCardFieldsScopedState'; +import { boardCardFieldsScopedState } from '../boardCardFieldsScopedState'; + +export const hiddenBoardCardFieldsScopedSelector = selectorFamily({ + key: 'hiddenBoardCardFieldsScopedSelector', + get: + (scopeId: string) => + ({ get }) => { + const fields = get(boardCardFieldsScopedState(scopeId)); + const fieldKeys = fields.map(({ key }) => key); + const otherAvailableKeys = get( + availableBoardCardFieldsScopedState(scopeId), + ).filter(({ key }) => !fieldKeys.includes(key)); + + return [ + ...fields.filter((field) => !field.isVisible), + ...otherAvailableKeys, + ]; + }, +}); diff --git a/front/src/modules/ui/board/states/selectors/visibleBoardCardFieldsScopedSelector.ts b/front/src/modules/ui/board/states/selectors/visibleBoardCardFieldsScopedSelector.ts new file mode 100644 index 000000000..e4ce74ea3 --- /dev/null +++ b/front/src/modules/ui/board/states/selectors/visibleBoardCardFieldsScopedSelector.ts @@ -0,0 +1,13 @@ +import { selectorFamily } from 'recoil'; + +import { boardCardFieldsScopedState } from '../boardCardFieldsScopedState'; + +export const visibleBoardCardFieldsScopedSelector = selectorFamily({ + key: 'visibleBoardCardFieldsScopedSelector', + get: + (scopeId: string) => + ({ get }) => + get(boardCardFieldsScopedState(scopeId)).filter( + (field) => field.isVisible, + ), +}); diff --git a/front/src/modules/ui/board/states/viewFieldsDefinitionsState.ts b/front/src/modules/ui/board/states/viewFieldsDefinitionsState.ts deleted file mode 100644 index c3f40224a..000000000 --- a/front/src/modules/ui/board/states/viewFieldsDefinitionsState.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { atom } from 'recoil'; - -import type { - ViewFieldDefinition, - ViewFieldMetadata, -} from '../../editable-field/types/ViewField'; - -export const viewFieldsDefinitionsState = atom< - ViewFieldDefinition[] ->({ - key: 'viewFieldsDefinitionState', - default: [], -}); diff --git a/front/src/modules/ui/board/types/BoardOptions.ts b/front/src/modules/ui/board/types/BoardOptions.ts index c7147d29b..27607c197 100644 --- a/front/src/modules/ui/board/types/BoardOptions.ts +++ b/front/src/modules/ui/board/types/BoardOptions.ts @@ -1,3 +1,5 @@ +import type { ComponentType, Context } from 'react'; + import { FilterDefinitionByEntity } from '@/ui/view-bar/types/FilterDefinitionByEntity'; import { SortType } from '@/ui/view-bar/types/interface'; import { PipelineProgress } from '~/generated/graphql'; @@ -5,7 +7,7 @@ import { PipelineProgressOrderByWithRelationInput as PipelineProgresses_Order_By export type BoardOptions = { newCardComponent: React.ReactNode; - cardComponent: React.ReactNode; + CardComponent: ComponentType<{ scopeContext: Context }>; filters: FilterDefinitionByEntity[]; sorts: Array>; }; diff --git a/front/src/modules/ui/button/components/FloatingIconButtonGroup.tsx b/front/src/modules/ui/button/components/FloatingIconButtonGroup.tsx index 9a0d08656..b8e3cbbb4 100644 --- a/front/src/modules/ui/button/components/FloatingIconButtonGroup.tsx +++ b/front/src/modules/ui/button/components/FloatingIconButtonGroup.tsx @@ -49,6 +49,7 @@ export function FloatingIconButtonGroup({ applyBlur={false} applyShadow={false} Icon={Icon} + key={index} onClick={onClick} position={position} size={size} diff --git a/front/src/modules/ui/editable-field/types/ViewField.ts b/front/src/modules/ui/editable-field/types/ViewField.ts index 8c29e830f..77c022770 100644 --- a/front/src/modules/ui/editable-field/types/ViewField.ts +++ b/front/src/modules/ui/editable-field/types/ViewField.ts @@ -117,11 +117,12 @@ export type ViewFieldMetadata = { type: ViewFieldType } & ( ); export type ViewFieldDefinition = { - key: string; - name: string; Icon?: IconComponent; + index: number; isVisible?: boolean; + key: string; metadata: T; + name: string; }; export type ViewFieldTextValue = string; diff --git a/front/src/modules/ui/table/options/components/TableOptionsDropdownContent.tsx b/front/src/modules/ui/table/options/components/TableOptionsDropdownContent.tsx index 663c6e531..ac9290261 100644 --- a/front/src/modules/ui/table/options/components/TableOptionsDropdownContent.tsx +++ b/front/src/modules/ui/table/options/components/TableOptionsDropdownContent.tsx @@ -12,25 +12,25 @@ import { IconChevronLeft, IconFileImport, IconTag } from '@/ui/icon'; import { MenuItem } from '@/ui/menu-item/components/MenuItem'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue'; +import { ViewFieldsVisibilityDropdownSection } from '@/ui/view-bar/components/ViewFieldsVisibilityDropdownSection'; import { useUpsertView } from '@/ui/view-bar/hooks/useUpsertView'; import { viewsByIdScopedSelector } from '@/ui/view-bar/states/selectors/viewsByIdScopedSelector'; import { viewEditModeState } from '@/ui/view-bar/states/viewEditModeState'; import type { View } from '@/ui/view-bar/types/View'; +import { useTableColumns } from '../../hooks/useTableColumns'; import { TableRecoilScopeContext } from '../../states/recoil-scope-contexts/TableRecoilScopeContext'; import { hiddenTableColumnsScopedSelector } from '../../states/selectors/hiddenTableColumnsScopedSelector'; import { visibleTableColumnsScopedSelector } from '../../states/selectors/visibleTableColumnsScopedSelector'; import { TableOptionsDropdownKey } from '../../types/TableOptionsDropdownKey'; import { TableOptionsHotkeyScope } from '../../types/TableOptionsHotkeyScope'; -import { TableOptionsDropdownColumnVisibility } from './TableOptionsDropdownSection'; - type TableOptionsDropdownButtonProps = { onViewsChange?: (views: View[]) => void | Promise; onImport?: () => void; }; -type TableOptionsMenu = 'properties'; +type TableOptionsMenu = 'fields'; export function TableOptionsDropdownContent({ onViewsChange, @@ -40,9 +40,9 @@ export function TableOptionsDropdownContent({ key: TableOptionsDropdownKey, }); - const [selectedMenu, setSelectedMenu] = useState< - TableOptionsMenu | undefined - >(undefined); + const [currentMenu, setCurrentMenu] = useState( + undefined, + ); const viewEditInputRef = useRef(null); @@ -65,6 +65,8 @@ export function TableOptionsDropdownContent({ scopeContext: TableRecoilScopeContext, }); + const { handleColumnVisibilityChange } = useTableColumns(); + const handleViewNameSubmit = async () => { const name = viewEditInputRef.current?.value; await upsertView(name); @@ -72,10 +74,10 @@ export function TableOptionsDropdownContent({ const handleSelectMenu = (option: TableOptionsMenu) => { handleViewNameSubmit(); - setSelectedMenu(option); + setCurrentMenu(option); }; - const resetMenu = () => setSelectedMenu(undefined); + const resetMenu = () => setCurrentMenu(undefined); useScopedHotkeys( Key.Escape, @@ -97,7 +99,7 @@ export function TableOptionsDropdownContent({ return ( - {!selectedMenu && ( + {!currentMenu && ( <> {!!viewEditMode.mode ? ( handleSelectMenu('properties')} + onClick={() => handleSelectMenu('fields')} LeftIcon={IconTag} - text="Properties" + text="Fields" /> {onImport && ( )} - {selectedMenu === 'properties' && ( + {currentMenu === 'fields' && ( <> - Properties + Fields - {hiddenTableColumns.length > 0 && ( <> - )} diff --git a/front/src/modules/ui/table/options/components/TableOptionsDropdownSection.tsx b/front/src/modules/ui/table/options/components/TableOptionsDropdownSection.tsx deleted file mode 100644 index ed9095f48..000000000 --- a/front/src/modules/ui/table/options/components/TableOptionsDropdownSection.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { StyledDropdownMenuItemsContainer } from '@/ui/dropdown/components/StyledDropdownMenuItemsContainer'; -import { StyledDropdownMenuSubheader } from '@/ui/dropdown/components/StyledDropdownMenuSubheader'; -import type { ViewFieldMetadata } from '@/ui/editable-field/types/ViewField'; -import { IconMinus, IconPlus } from '@/ui/icon'; -import { MenuItem } from '@/ui/menu-item/components/MenuItem'; - -import { useTableColumns } from '../../hooks/useTableColumns'; -import type { ColumnDefinition } from '../../types/ColumnDefinition'; - -type OwnProps = { - title: string; - columns: ColumnDefinition[]; -}; - -export function TableOptionsDropdownColumnVisibility({ - title, - columns, -}: OwnProps) { - const { handleColumnVisibilityChange } = useTableColumns(); - - return ( - <> - {title} - - {columns.map((column) => ( - handleColumnVisibilityChange(column), - }, - ]} - text={column.name} - /> - ))} - - - ); -} diff --git a/front/src/modules/ui/table/table-header/components/TableHeader.tsx b/front/src/modules/ui/table/table-header/components/TableHeader.tsx index be287c364..aad2b1ac9 100644 --- a/front/src/modules/ui/table/table-header/components/TableHeader.tsx +++ b/front/src/modules/ui/table/table-header/components/TableHeader.tsx @@ -1,4 +1,3 @@ -import { useCallback } from 'react'; import { useRecoilCallback, useRecoilState, useRecoilValue } from 'recoil'; import { DropdownRecoilScopeContext } from '@/ui/dropdown/states/recoil-scope-contexts/DropdownRecoilScopeContext'; @@ -70,17 +69,6 @@ export function TableHeader({ await onViewSubmit?.(); } - const OptionsDropdownButton = useCallback( - () => ( - - ), - [onImport, onViewsChange], - ); - return ( ({ onReset={handleViewBarReset} onViewSelect={handleViewSelect} onViewSubmit={handleViewSubmit} - OptionsDropdownButton={OptionsDropdownButton} + optionsDropdownButton={ + + } optionsDropdownKey={TableOptionsDropdownKey} scopeContext={TableRecoilScopeContext} /> diff --git a/front/src/modules/ui/table/types/ColumnDefinition.ts b/front/src/modules/ui/table/types/ColumnDefinition.ts index 93f931ff6..f3eef4c97 100644 --- a/front/src/modules/ui/table/types/ColumnDefinition.ts +++ b/front/src/modules/ui/table/types/ColumnDefinition.ts @@ -6,5 +6,4 @@ import type { export type ColumnDefinition = ViewFieldDefinition & { size: number; - index: number; }; diff --git a/front/src/modules/ui/view-bar/components/ViewBar.tsx b/front/src/modules/ui/view-bar/components/ViewBar.tsx index e8cc97f2c..6eb047283 100644 --- a/front/src/modules/ui/view-bar/components/ViewBar.tsx +++ b/front/src/modules/ui/view-bar/components/ViewBar.tsx @@ -1,4 +1,4 @@ -import type { ComponentProps, ComponentType, Context } from 'react'; +import type { ComponentProps, Context, ReactNode } from 'react'; import { useDropdownButton } from '@/ui/dropdown/hooks/useDropdownButton'; import { TopBar } from '@/ui/top-bar/TopBar'; @@ -22,7 +22,7 @@ import { } from './ViewsDropdownButton'; export type ViewBarProps = ComponentProps<'div'> & { - OptionsDropdownButton: ComponentType; + optionsDropdownButton: ReactNode; optionsDropdownKey: string; scopeContext: Context; } & Pick< @@ -41,7 +41,7 @@ export const ViewBar = ({ onViewsChange, onViewSelect, onViewSubmit, - OptionsDropdownButton, + optionsDropdownButton, optionsDropdownKey, scopeContext, ...props @@ -76,7 +76,7 @@ export const ViewBar = ({ hotkeyScope={FiltersHotkeyScope.FilterDropdownButton} isPrimaryButton /> - + {optionsDropdownButton} } bottomComponent={ diff --git a/front/src/modules/ui/view-bar/components/ViewFieldsVisibilityDropdownSection.tsx b/front/src/modules/ui/view-bar/components/ViewFieldsVisibilityDropdownSection.tsx new file mode 100644 index 000000000..8b8683cdc --- /dev/null +++ b/front/src/modules/ui/view-bar/components/ViewFieldsVisibilityDropdownSection.tsx @@ -0,0 +1,39 @@ +import { StyledDropdownMenuItemsContainer } from '@/ui/dropdown/components/StyledDropdownMenuItemsContainer'; +import { StyledDropdownMenuSubheader } from '@/ui/dropdown/components/StyledDropdownMenuSubheader'; +import type { + ViewFieldDefinition, + ViewFieldMetadata, +} from '@/ui/editable-field/types/ViewField'; +import { IconMinus, IconPlus } from '@/ui/icon'; +import { MenuItem } from '@/ui/menu-item/components/MenuItem'; + +type OwnProps = { + fields: Field[]; + onVisibilityChange: (field: Field) => void; + title: string; +}; + +export function ViewFieldsVisibilityDropdownSection< + Field extends ViewFieldDefinition, +>({ fields, onVisibilityChange, title }: OwnProps) { + return ( + <> + {title} + + {fields.map((field) => ( + onVisibilityChange(field), + }, + ]} + text={field.name} + /> + ))} + + + ); +} diff --git a/front/src/modules/views/hooks/useBoardViewFields.ts b/front/src/modules/views/hooks/useBoardViewFields.ts new file mode 100644 index 000000000..1742279f5 --- /dev/null +++ b/front/src/modules/views/hooks/useBoardViewFields.ts @@ -0,0 +1,113 @@ +import { type Context } from 'react'; + +import { availableBoardCardFieldsScopedState } from '@/ui/board/states/availableBoardCardFieldsScopedState'; +import { boardCardFieldsScopedState } from '@/ui/board/states/boardCardFieldsScopedState'; +import type { + ViewFieldDefinition, + ViewFieldMetadata, +} from '@/ui/editable-field/types/ViewField'; +import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState'; +import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue'; +import { currentViewIdScopedState } from '@/ui/view-bar/states/currentViewIdScopedState'; +import { + SortOrder, + useCreateViewFieldsMutation, + useGetViewFieldsQuery, +} from '~/generated/graphql'; +import { assertNotNull } from '~/utils/assert'; +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; + +const toViewFieldInput = ( + objectId: 'company' | 'person', + fieldDefinition: ViewFieldDefinition, +) => ({ + key: fieldDefinition.key, + name: fieldDefinition.name, + index: fieldDefinition.index, + isVisible: fieldDefinition.isVisible ?? true, + objectId, +}); + +export const useBoardViewFields = ({ + objectId, + fieldDefinitions, + scopeContext, + skipFetch, +}: { + objectId: 'company' | 'person'; + fieldDefinitions: ViewFieldDefinition[]; + scopeContext: Context; + skipFetch?: boolean; +}) => { + const currentViewId = useRecoilScopedValue( + currentViewIdScopedState, + scopeContext, + ); + const [availableBoardCardFields, setAvailableBoardCardFields] = + useRecoilScopedState(availableBoardCardFieldsScopedState, scopeContext); + const [boardCardFields, setBoardCardFields] = useRecoilScopedState( + boardCardFieldsScopedState, + scopeContext, + ); + + const [createViewFieldsMutation] = useCreateViewFieldsMutation(); + + const createViewFields = ( + fields: ViewFieldDefinition[], + viewId = currentViewId, + ) => { + if (!viewId || !fields.length) return; + + return createViewFieldsMutation({ + variables: { + data: fields.map((field) => ({ + ...toViewFieldInput(objectId, field), + viewId, + })), + }, + }); + }; + + const { refetch } = useGetViewFieldsQuery({ + skip: !currentViewId || skipFetch, + variables: { + orderBy: { index: SortOrder.Asc }, + where: { + viewId: { equals: currentViewId }, + }, + }, + onCompleted: async (data) => { + if (!data.viewFields.length) { + // Populate if empty + await createViewFields(fieldDefinitions); + return refetch(); + } + + const nextFields = data.viewFields + .map | null>((viewField) => { + const fieldDefinition = fieldDefinitions.find( + ({ key }) => viewField.key === key, + ); + + return fieldDefinition + ? { + ...fieldDefinition, + key: viewField.key, + name: viewField.name, + index: viewField.index, + isVisible: viewField.isVisible, + } + : null; + }) + .filter>(assertNotNull); + + if (!isDeeplyEqual(boardCardFields, nextFields)) { + setBoardCardFields(nextFields); + } + + if (!availableBoardCardFields.length) { + setAvailableBoardCardFields(fieldDefinitions); + } + }, + }); +}; diff --git a/front/src/modules/views/hooks/useBoardViews.ts b/front/src/modules/views/hooks/useBoardViews.ts index c56d16b8d..db808165c 100644 --- a/front/src/modules/views/hooks/useBoardViews.ts +++ b/front/src/modules/views/hooks/useBoardViews.ts @@ -1,5 +1,9 @@ -import { type Context } from 'react'; +import type { Context } from 'react'; +import type { + ViewFieldDefinition, + ViewFieldMetadata, +} from '@/ui/editable-field/types/ViewField'; import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue'; import { filtersScopedState } from '@/ui/view-bar/states/filtersScopedState'; import { sortsScopedState } from '@/ui/view-bar/states/sortsScopedState'; @@ -7,6 +11,7 @@ import type { FilterDefinitionByEntity } from '@/ui/view-bar/types/FilterDefinit import type { SortType } from '@/ui/view-bar/types/interface'; import { ViewType } from '~/generated/graphql'; +import { useBoardViewFields } from './useBoardViewFields'; import { useViewFilters } from './useViewFilters'; import { useViews } from './useViews'; import { useViewSorts } from './useViewSorts'; @@ -14,11 +19,13 @@ import { useViewSorts } from './useViewSorts'; export const useBoardViews = ({ availableFilters, availableSorts, + fieldDefinitions, objectId, scopeContext, }: { availableFilters: FilterDefinitionByEntity[]; availableSorts: SortType[]; + fieldDefinitions: ViewFieldDefinition[]; objectId: 'company'; scopeContext: Context; }) => { @@ -31,6 +38,12 @@ export const useBoardViews = ({ type: ViewType.Pipeline, scopeContext, }); + useBoardViewFields({ + objectId, + fieldDefinitions, + scopeContext, + skipFetch: isFetchingViews, + }); const { createViewFilters, persistFilters } = useViewFilters({ availableFilters, scopeContext, diff --git a/front/src/pages/opportunities/opportunitiesBoardOptions.tsx b/front/src/pages/opportunities/opportunitiesBoardOptions.tsx index 3685f8b12..00955dc4e 100644 --- a/front/src/pages/opportunities/opportunitiesBoardOptions.tsx +++ b/front/src/pages/opportunities/opportunitiesBoardOptions.tsx @@ -7,7 +7,7 @@ import { opportunitiesSorts } from './opportunities-sorts'; export const opportunitiesBoardOptions: BoardOptions = { newCardComponent: , - cardComponent: , + CardComponent: CompanyBoardCard, filters: opportunitiesFilters, sorts: opportunitiesSorts, }; diff --git a/front/src/testing/graphqlMocks.ts b/front/src/testing/graphqlMocks.ts index 4037fd6fc..7f896952c 100644 --- a/front/src/testing/graphqlMocks.ts +++ b/front/src/testing/graphqlMocks.ts @@ -32,6 +32,7 @@ import { import { mockedActivities, mockedTasks } from './mock-data/activities'; import { mockedCompaniesData, + mockedCompanyBoardCardFields, mockedCompanyBoardViews, mockedCompanyTableColumns, mockedCompanyTableViews, @@ -264,7 +265,9 @@ export const graphqlMocks = [ return res( ctx.data({ viewFields: - viewId === mockedCompanyTableViews[0].id + viewId === mockedCompanyBoardViews[0].id + ? mockedCompanyBoardCardFields + : viewId === mockedCompanyTableViews[0].id ? mockedCompanyTableColumns : mockedPersonTableColumns, }), diff --git a/front/src/testing/mock-data/companies.ts b/front/src/testing/mock-data/companies.ts index a95384d9b..17380d7b9 100644 --- a/front/src/testing/mock-data/companies.ts +++ b/front/src/testing/mock-data/companies.ts @@ -1,4 +1,5 @@ import { companiesAvailableColumnDefinitions } from '@/companies/constants/companiesAvailableColumnDefinitions'; +import { pipelineAvailableFieldDefinitions } from '@/pipeline/constants/pipelineAvailableFieldDefinitions'; import { Company, Favorite, @@ -168,6 +169,19 @@ export const mockedCompanyBoardViews: View[] = [ }, ]; +export const mockedCompanyBoardCardFields = + pipelineAvailableFieldDefinitions.map>( + (viewFieldDefinition) => ({ + __typename: 'ViewField', + name: viewFieldDefinition.name, + index: viewFieldDefinition.index, + isVisible: true, + key: viewFieldDefinition.key, + objectId: 'company', + viewId: mockedCompanyBoardViews[0].id, + }), + ); + export const mockedCompanyTableViews: View[] = [ { __typename: 'View',