feat: add Opportunities Views dropdown (#1503)

* feat: add Opportunities Views dropdown

Closes #1454

* feat: persist Opportunities view filters and sorts

Closes #1456

* feat: create/edit/delete Opportunities views

Closes #1455, Closes #1457

* fix: add missing Opportunities view mock

---------

Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
This commit is contained in:
Thaïs
2023-09-11 04:07:14 +02:00
committed by GitHub
parent 8ea4e6a51c
commit 88c6d0da2a
14 changed files with 408 additions and 225 deletions

View File

@ -0,0 +1,40 @@
import {
EntityBoard,
type EntityBoardProps,
} from '@/ui/board/components/EntityBoard';
import { EntityBoardActionBar } from '@/ui/board/components/EntityBoardActionBar';
import { EntityBoardContextMenu } from '@/ui/board/components/EntityBoardContextMenu';
import { useBoardViews } from '@/views/hooks/useBoardViews';
import { HooksCompanyBoard } from '../../components/HooksCompanyBoard';
import { CompanyBoardRecoilScopeContext } from '../../states/recoil-scope-contexts/CompanyBoardRecoilScopeContext';
type OwnProps = Pick<
EntityBoardProps,
'boardOptions' | 'onColumnAdd' | 'onColumnDelete' | 'onEditColumnTitle'
>;
export const CompanyBoard = ({ boardOptions, ...props }: OwnProps) => {
const { handleViewsChange, handleViewSubmit } = useBoardViews({
availableFilters: boardOptions.filters,
availableSorts: boardOptions.sorts,
objectId: 'company',
scopeContext: CompanyBoardRecoilScopeContext,
});
return (
<>
<HooksCompanyBoard />
<EntityBoard
boardOptions={boardOptions}
defaultViewName="All opportunities"
onViewsChange={handleViewsChange}
onViewSubmit={handleViewSubmit}
scopeContext={CompanyBoardRecoilScopeContext}
{...props}
/>
<EntityBoardActionBar />
<EntityBoardContextMenu />
</>
);
};

View File

@ -1,50 +1,52 @@
import type { ComponentProps, Context, ReactNode } from 'react';
import styled from '@emotion/styled';
import { type ComponentProps, useCallback } from 'react';
import { DropdownRecoilScopeContext } from '@/ui/dropdown/states/recoil-scope-contexts/DropdownRecoilScopeContext';
import { TopBar } from '@/ui/top-bar/TopBar';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { FilterDropdownButton } from '@/ui/view-bar/components/FilterDropdownButton';
import { SortDropdownButton } from '@/ui/view-bar/components/SortDropdownButton';
import ViewBarDetails from '@/ui/view-bar/components/ViewBarDetails';
import { FiltersHotkeyScope } from '@/ui/view-bar/types/FiltersHotkeyScope';
import { SortType } from '@/ui/view-bar/types/interface';
import { ViewBar, type ViewBarProps } from '@/ui/view-bar/components/ViewBar';
import type { BoardColumnDefinition } from '../types/BoardColumnDefinition';
import { BoardOptionsDropdownKey } from '../types/BoardOptionsDropdownKey';
import { BoardOptionsHotkeyScope } from '../types/BoardOptionsHotkeyScope';
import { BoardOptionsDropdown } from './BoardOptionsDropdown';
type OwnProps<SortField> = ComponentProps<'div'> & {
viewName: string;
viewIcon?: ReactNode;
availableSorts?: Array<SortType<SortField>>;
export type BoardHeaderProps<SortField> = ComponentProps<'div'> & {
onStageAdd?: (boardColumn: BoardColumnDefinition) => void;
context: Context<string | null>;
};
const StyledIcon = styled.div`
display: flex;
margin-left: ${({ theme }) => theme.spacing(1)};
margin-right: ${({ theme }) => theme.spacing(2)};
& > svg {
font-size: ${({ theme }) => theme.icon.size.sm};
}
`;
} & Pick<
ViewBarProps<SortField>,
| 'availableSorts'
| 'defaultViewName'
| 'onViewsChange'
| 'onViewSubmit'
| 'scopeContext'
>;
export function BoardHeader<SortField>({
viewName,
viewIcon,
availableSorts,
onStageAdd,
context,
onViewsChange,
scopeContext,
...props
}: OwnProps<SortField>) {
}: BoardHeaderProps<SortField>) {
const OptionsDropdownButton = useCallback(
() => (
<BoardOptionsDropdown
customHotkeyScope={{ scope: BoardOptionsHotkeyScope.Dropdown }}
onStageAdd={onStageAdd}
onViewsChange={onViewsChange}
scopeContext={scopeContext}
/>
),
[onStageAdd, onViewsChange, scopeContext],
);
return (
<RecoilScope SpecificContext={DropdownRecoilScopeContext}>
<TopBar
<ViewBar
{...props}
onViewsChange={onViewsChange}
optionsDropdownKey={BoardOptionsDropdownKey}
OptionsDropdownButton={OptionsDropdownButton}
scopeContext={scopeContext}
displayBottomBorder={false}
leftComponent={
<>

View File

@ -1,28 +1,29 @@
import { DropdownButton } from '@/ui/dropdown/components/DropdownButton';
import type { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { BoardColumnDefinition } from '../types/BoardColumnDefinition';
import { BoardOptionsDropdownKey } from '../types/BoardOptionsDropdownKey';
import { BoardOptionsDropdownButton } from './BoardOptionsDropdownButton';
import { BoardOptionsDropdownContent } from './BoardOptionsDropdownContent';
import {
BoardOptionsDropdownContent,
type BoardOptionsDropdownContentProps,
} from './BoardOptionsDropdownContent';
type BoardOptionsDropdownProps = {
customHotkeyScope: HotkeyScope;
onStageAdd?: (boardColumn: BoardColumnDefinition) => void;
};
type BoardOptionsDropdownProps = Pick<
BoardOptionsDropdownContentProps,
'customHotkeyScope' | 'onStageAdd' | 'onViewsChange' | 'scopeContext'
>;
export function BoardOptionsDropdown({
customHotkeyScope,
onStageAdd,
...props
}: BoardOptionsDropdownProps) {
return (
<DropdownButton
buttonComponents={<BoardOptionsDropdownButton />}
dropdownComponents={
<BoardOptionsDropdownContent
{...props}
customHotkeyScope={customHotkeyScope}
onStageAdd={onStageAdd}
/>
}
dropdownHotkeyScope={customHotkeyScope}

View File

@ -1,7 +1,7 @@
import { useRef, useState } from 'react';
import { type Context, useRef, useState } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { useRecoilState, useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { v4 } from 'uuid';
@ -22,14 +22,21 @@ import { MenuItemNavigate } from '@/ui/menu-item/components/MenuItemNavigate';
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 { 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 { boardColumnsState } from '../states/boardColumnsState';
import type { BoardColumnDefinition } from '../types/BoardColumnDefinition';
import { BoardOptionsDropdownKey } from '../types/BoardOptionsDropdownKey';
type BoardOptionsDropdownContentProps = {
export type BoardOptionsDropdownContentProps = {
customHotkeyScope: HotkeyScope;
onStageAdd?: (boardColumn: BoardColumnDefinition) => void;
onViewsChange?: (views: View[]) => void | Promise<void>;
scopeContext: Context<string | null>;
};
const StyledIconSettings = styled(IconSettings)`
@ -51,10 +58,13 @@ type ColumnForCreate = {
export function BoardOptionsDropdownContent({
customHotkeyScope,
onStageAdd,
onViewsChange,
scopeContext,
}: BoardOptionsDropdownContentProps) {
const theme = useTheme();
const stageInputRef = useRef<HTMLInputElement>(null);
const viewEditInputRef = useRef<HTMLInputElement>(null);
const [currentMenu, setCurrentMenu] = useState<
BoardOptionsMenu | undefined
@ -62,7 +72,8 @@ export function BoardOptionsDropdownContent({
const [boardColumns, setBoardColumns] = useRecoilState(boardColumnsState);
const resetMenu = () => setCurrentMenu(undefined);
const viewsById = useRecoilScopedValue(viewsByIdScopedSelector, scopeContext);
const viewEditMode = useRecoilValue(viewEditModeState);
const handleStageSubmit = () => {
if (
@ -85,6 +96,23 @@ export function BoardOptionsDropdownContent({
onStageAdd?.(columnToCreate);
};
const { upsertView } = useUpsertView({
onViewsChange,
scopeContext,
});
const handleViewNameSubmit = async () => {
const name = viewEditInputRef.current?.value;
await upsertView(name);
};
const resetMenu = () => setCurrentMenu(undefined);
const handleMenuNavigate = (menu: BoardOptionsMenu) => {
handleViewNameSubmit();
setCurrentMenu(menu);
};
const { closeDropdownButton } = useDropdownButton({
key: BoardOptionsDropdownKey,
});
@ -101,6 +129,7 @@ export function BoardOptionsDropdownContent({
Key.Enter,
() => {
handleStageSubmit();
handleViewNameSubmit();
closeDropdownButton();
},
customHotkeyScope.scope,
@ -110,14 +139,29 @@ export function BoardOptionsDropdownContent({
<StyledDropdownMenu>
{!currentMenu && (
<>
<DropdownMenuHeader>
<StyledIconSettings size={theme.icon.size.md} />
Settings
</DropdownMenuHeader>
{!!viewEditMode.mode ? (
<DropdownMenuInput
ref={viewEditInputRef}
autoFocus
placeholder={
viewEditMode.mode === 'create' ? 'New view' : 'View name'
}
defaultValue={
viewEditMode.viewId
? viewsById[viewEditMode.viewId]?.name
: undefined
}
/>
) : (
<DropdownMenuHeader>
<StyledIconSettings size={theme.icon.size.md} />
Settings
</DropdownMenuHeader>
)}
<StyledDropdownMenuSeparator />
<StyledDropdownMenuItemsContainer>
<MenuItemNavigate
onClick={() => setCurrentMenu(BoardOptionsMenu.Stages)}
onClick={() => handleMenuNavigate(BoardOptionsMenu.Stages)}
LeftIcon={IconLayoutKanban}
text="Stages"
/>

View File

@ -1,17 +1,17 @@
import { useCallback, useRef } from 'react';
import { type Context, useCallback, useRef } from 'react';
import { getOperationName } from '@apollo/client/utilities';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { DragDropContext, OnDragEndResponder } from '@hello-pangea/dnd'; // Atlassian dnd does not support StrictMode from RN 18, so we use a fork @hello-pangea/dnd https://github.com/atlassian/react-beautiful-dnd/issues/2350
import { useRecoilState } from 'recoil';
import { CompanyBoardRecoilScopeContext } from '@/companies/states/recoil-scope-contexts/CompanyBoardRecoilScopeContext';
import { GET_PIPELINE_PROGRESS } from '@/pipeline/graphql/queries/getPipelineProgress';
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { BoardHeader } from '@/ui/board/components/BoardHeader';
import {
BoardHeader,
type BoardHeaderProps,
} from '@/ui/board/components/BoardHeader';
import { StyledBoard } from '@/ui/board/components/StyledBoard';
import { BoardColumnIdContext } from '@/ui/board/contexts/BoardColumnIdContext';
import { IconList } from '@/ui/icon';
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutsideByClassName } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
@ -22,6 +22,7 @@ import {
PipelineStage,
useUpdateOnePipelineProgressStageMutation,
} from '~/generated/graphql';
import { PipelineProgressOrderByWithRelationInput as PipelineProgresses_Order_By } from '~/generated/graphql';
import { useCurrentCardSelected } from '../hooks/useCurrentCardSelected';
import { useSetCardSelected } from '../hooks/useSetCardSelected';
@ -33,6 +34,17 @@ import { BoardOptions } from '../types/BoardOptions';
import { EntityBoardColumn } from './EntityBoardColumn';
export type EntityBoardProps = {
boardOptions: BoardOptions;
onColumnAdd?: (boardColumn: BoardColumnDefinition) => void;
onColumnDelete?: (boardColumnId: string) => void;
onEditColumnTitle: (columnId: string, title: string, color: string) => void;
scopeContext: Context<string | null>;
} & Pick<
BoardHeaderProps<PipelineProgresses_Order_By>,
'defaultViewName' | 'onViewsChange' | 'onViewSubmit'
>;
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
@ -46,19 +58,17 @@ const StyledBoardHeader = styled(BoardHeader)`
export function EntityBoard({
boardOptions,
defaultViewName,
onColumnAdd,
onColumnDelete,
onEditColumnTitle,
}: {
boardOptions: BoardOptions;
onColumnAdd?: (boardColumn: BoardColumnDefinition) => void;
onColumnDelete?: (boardColumnId: string) => void;
onEditColumnTitle: (columnId: string, title: string, color: string) => void;
}) {
onViewsChange,
onViewSubmit,
scopeContext,
}: EntityBoardProps) {
const [boardColumns] = useRecoilState(boardColumnsState);
const setCardSelected = useSetCardSelected();
const theme = useTheme();
const [updatePipelineProgressStage] =
useUpdateOnePipelineProgressStageMutation();
@ -131,11 +141,12 @@ export function EntityBoard({
return (boardColumns?.length ?? 0) > 0 ? (
<StyledWrapper>
<StyledBoardHeader
viewName="All opportunities"
viewIcon={<IconList size={theme.icon.size.md} />}
defaultViewName={defaultViewName}
availableSorts={boardOptions.sorts}
onStageAdd={onColumnAdd}
context={CompanyBoardRecoilScopeContext}
onViewsChange={onViewsChange}
onViewSubmit={onViewSubmit}
scopeContext={scopeContext}
/>
<ScrollWrapper>
<StyledBoard ref={boardRef}>

View File

@ -1,7 +1,6 @@
import { type FormEvent, useCallback, useRef, useState } from 'react';
import { useRecoilCallback, useRecoilState } from 'recoil';
import { useRef, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { v4 } from 'uuid';
import { DropdownMenuHeader } from '@/ui/dropdown/components/DropdownMenuHeader';
import { DropdownMenuInput } from '@/ui/dropdown/components/DropdownMenuInput';
@ -11,22 +10,14 @@ import { StyledDropdownMenuSeparator } from '@/ui/dropdown/components/StyledDrop
import { useDropdownButton } from '@/ui/dropdown/hooks/useDropdownButton';
import { IconChevronLeft, IconFileImport, IconTag } from '@/ui/icon';
import { MenuItem } from '@/ui/menu-item/components/MenuItem';
import { tableColumnsScopedState } from '@/ui/table/states/tableColumnsScopedState';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useContextScopeId } from '@/ui/utilities/recoil-scope/hooks/useContextScopeId';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
import { currentViewIdScopedState } from '@/ui/view-bar/states/currentViewIdScopedState';
import { filtersScopedState } from '@/ui/view-bar/states/filtersScopedState';
import { savedFiltersFamilyState } from '@/ui/view-bar/states/savedFiltersFamilyState';
import { savedSortsFamilyState } from '@/ui/view-bar/states/savedSortsFamilyState';
import { useUpsertView } from '@/ui/view-bar/hooks/useUpsertView';
import { viewsByIdScopedSelector } from '@/ui/view-bar/states/selectors/viewsByIdScopedSelector';
import { sortsScopedState } from '@/ui/view-bar/states/sortsScopedState';
import { viewEditModeState } from '@/ui/view-bar/states/viewEditModeState';
import { viewsScopedState } from '@/ui/view-bar/states/viewsScopedState';
import type { View } from '@/ui/view-bar/types/View';
import { TableRecoilScopeContext } from '../../states/recoil-scope-contexts/TableRecoilScopeContext';
import { savedTableColumnsFamilyState } from '../../states/savedTableColumnsFamilyState';
import { hiddenTableColumnsScopedSelector } from '../../states/selectors/hiddenTableColumnsScopedSelector';
import { visibleTableColumnsScopedSelector } from '../../states/selectors/visibleTableColumnsScopedSelector';
import { TableOptionsDropdownKey } from '../../types/TableOptionsDropdownKey';
@ -35,31 +26,27 @@ import { TableOptionsHotkeyScope } from '../../types/TableOptionsHotkeyScope';
import { TableOptionsDropdownColumnVisibility } from './TableOptionsDropdownSection';
type TableOptionsDropdownButtonProps = {
onViewsChange?: (views: View[]) => void;
onViewsChange?: (views: View[]) => void | Promise<void>;
onImport?: () => void;
};
enum Option {
Properties = 'Properties',
}
type TableOptionsMenu = 'properties';
export function TableOptionsDropdownContent({
onViewsChange,
onImport,
}: TableOptionsDropdownButtonProps) {
const tableScopeId = useContextScopeId(TableRecoilScopeContext);
const { closeDropdownButton } = useDropdownButton({
key: TableOptionsDropdownKey,
});
const [selectedOption, setSelectedOption] = useState<Option | undefined>(
undefined,
);
const [selectedMenu, setSelectedMenu] = useState<
TableOptionsMenu | undefined
>(undefined);
const viewEditInputRef = useRef<HTMLInputElement>(null);
const [viewEditMode, setViewEditMode] = useRecoilState(viewEditModeState);
const viewEditMode = useRecoilValue(viewEditModeState);
const visibleTableColumns = useRecoilScopedValue(
visibleTableColumnsScopedSelector,
TableRecoilScopeContext,
@ -73,83 +60,22 @@ export function TableOptionsDropdownContent({
TableRecoilScopeContext,
);
const resetViewEditMode = useCallback(() => {
setViewEditMode({ mode: undefined, viewId: undefined });
const { upsertView } = useUpsertView({
onViewsChange,
scopeContext: TableRecoilScopeContext,
});
if (viewEditInputRef.current) {
viewEditInputRef.current.value = '';
}
}, [setViewEditMode]);
const handleViewNameSubmit = async () => {
const name = viewEditInputRef.current?.value;
await upsertView(name);
};
const handleViewNameSubmit = useRecoilCallback(
({ set, snapshot }) =>
async (event?: FormEvent) => {
event?.preventDefault();
const handleSelectMenu = (option: TableOptionsMenu) => {
handleViewNameSubmit();
setSelectedMenu(option);
};
const name = viewEditInputRef.current?.value;
if (!viewEditMode.mode || !name) {
return resetViewEditMode();
}
const views = await snapshot.getPromise(viewsScopedState(tableScopeId));
if (viewEditMode.mode === 'create') {
const viewToCreate = { id: v4(), name };
const nextViews = [...views, viewToCreate];
const currentColumns = await snapshot.getPromise(
tableColumnsScopedState(tableScopeId),
);
set(savedTableColumnsFamilyState(viewToCreate.id), currentColumns);
const selectedFilters = await snapshot.getPromise(
filtersScopedState(tableScopeId),
);
set(savedFiltersFamilyState(viewToCreate.id), selectedFilters);
const selectedSorts = await snapshot.getPromise(
sortsScopedState(tableScopeId),
);
set(savedSortsFamilyState(viewToCreate.id), selectedSorts);
set(viewsScopedState(tableScopeId), nextViews);
await Promise.resolve(onViewsChange?.(nextViews));
set(currentViewIdScopedState(tableScopeId), viewToCreate.id);
}
if (viewEditMode.mode === 'edit') {
const nextViews = views.map((view) =>
view.id === viewEditMode.viewId ? { ...view, name } : view,
);
set(viewsScopedState(tableScopeId), nextViews);
await Promise.resolve(onViewsChange?.(nextViews));
}
return resetViewEditMode();
},
[
onViewsChange,
resetViewEditMode,
tableScopeId,
viewEditMode.mode,
viewEditMode.viewId,
],
);
const handleSelectOption = useCallback(
(option: Option) => {
handleViewNameSubmit();
setSelectedOption(option);
},
[handleViewNameSubmit],
);
const resetSelectedOption = useCallback(() => {
setSelectedOption(undefined);
}, []);
const resetMenu = () => setSelectedMenu(undefined);
useScopedHotkeys(
Key.Escape,
@ -163,7 +89,7 @@ export function TableOptionsDropdownContent({
Key.Enter,
() => {
handleViewNameSubmit();
resetSelectedOption();
resetMenu();
closeDropdownButton();
},
TableOptionsHotkeyScope.Dropdown,
@ -171,7 +97,7 @@ export function TableOptionsDropdownContent({
return (
<StyledDropdownMenu>
{!selectedOption && (
{!selectedMenu && (
<>
{!!viewEditMode.mode ? (
<DropdownMenuInput
@ -192,7 +118,7 @@ export function TableOptionsDropdownContent({
<StyledDropdownMenuSeparator />
<StyledDropdownMenuItemsContainer>
<MenuItem
onClick={() => handleSelectOption(Option.Properties)}
onClick={() => handleSelectMenu('properties')}
LeftIcon={IconTag}
text="Properties"
/>
@ -206,12 +132,9 @@ export function TableOptionsDropdownContent({
</StyledDropdownMenuItemsContainer>
</>
)}
{selectedOption === Option.Properties && (
{selectedMenu === 'properties' && (
<>
<DropdownMenuHeader
StartIcon={IconChevronLeft}
onClick={resetSelectedOption}
>
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={resetMenu}>
Properties
</DropdownMenuHeader>
<StyledDropdownMenuSeparator />

View File

@ -0,0 +1,85 @@
import { Context, useCallback } from 'react';
import { useRecoilCallback, useRecoilState } from 'recoil';
import { v4 } from 'uuid';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
import { currentViewIdScopedState } from '../states/currentViewIdScopedState';
import { filtersScopedState } from '../states/filtersScopedState';
import { savedFiltersFamilyState } from '../states/savedFiltersFamilyState';
import { savedSortsFamilyState } from '../states/savedSortsFamilyState';
import { sortsScopedState } from '../states/sortsScopedState';
import { viewEditModeState } from '../states/viewEditModeState';
import { viewsScopedState } from '../states/viewsScopedState';
import type { View } from '../types/View';
export const useUpsertView = ({
onViewsChange,
scopeContext,
}: {
onViewsChange?: (views: View[]) => void | Promise<void>;
scopeContext: Context<string | null>;
}) => {
const filters = useRecoilScopedValue(filtersScopedState, scopeContext);
const sorts = useRecoilScopedValue(sortsScopedState, scopeContext);
const [, setCurrentViewId] = useRecoilScopedState(
currentViewIdScopedState,
scopeContext,
);
const [views, setViews] = useRecoilScopedState(
viewsScopedState,
scopeContext,
);
const [viewEditMode, setViewEditMode] = useRecoilState(viewEditModeState);
const resetViewEditMode = useCallback(
() => setViewEditMode({ mode: undefined, viewId: undefined }),
[setViewEditMode],
);
const upsertView = useRecoilCallback(
({ set }) =>
async (name?: string) => {
if (!viewEditMode.mode || !name) return resetViewEditMode();
if (viewEditMode.mode === 'create') {
const viewToCreate = { id: v4(), name };
const nextViews = [...views, viewToCreate];
set(savedFiltersFamilyState(viewToCreate.id), filters);
set(savedSortsFamilyState(viewToCreate.id), sorts);
setViews(nextViews);
await onViewsChange?.(nextViews);
setCurrentViewId(viewToCreate.id);
}
if (viewEditMode.mode === 'edit') {
const nextViews = views.map((view) =>
view.id === viewEditMode.viewId ? { ...view, name } : view,
);
setViews(nextViews);
await onViewsChange?.(nextViews);
}
return resetViewEditMode();
},
[
filters,
onViewsChange,
resetViewEditMode,
setCurrentViewId,
setViews,
sorts,
viewEditMode.mode,
viewEditMode.viewId,
views,
],
);
return { upsertView };
};

View File

@ -0,0 +1,56 @@
import { type Context } from 'react';
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';
import type { FilterDefinitionByEntity } from '@/ui/view-bar/types/FilterDefinitionByEntity';
import type { SortType } from '@/ui/view-bar/types/interface';
import { ViewType } from '~/generated/graphql';
import { useViewFilters } from './useViewFilters';
import { useViews } from './useViews';
import { useViewSorts } from './useViewSorts';
export const useBoardViews = <Entity, SortField>({
availableFilters,
availableSorts,
objectId,
scopeContext,
}: {
availableFilters: FilterDefinitionByEntity<Entity>[];
availableSorts: SortType<SortField>[];
objectId: 'company';
scopeContext: Context<string | null>;
}) => {
const filters = useRecoilScopedValue(filtersScopedState, scopeContext);
const sorts = useRecoilScopedValue(sortsScopedState, scopeContext);
const { handleViewsChange, isFetchingViews } = useViews({
objectId,
onViewCreate: handleViewCreate,
type: ViewType.Pipeline,
scopeContext,
});
const { createViewFilters, persistFilters } = useViewFilters({
availableFilters,
scopeContext,
skipFetch: isFetchingViews,
});
const { createViewSorts, persistSorts } = useViewSorts({
availableSorts,
scopeContext,
skipFetch: isFetchingViews,
});
async function handleViewCreate(viewId: string) {
await createViewFilters(filters, viewId);
await createViewSorts(sorts, viewId);
}
const handleViewSubmit = async () => {
await persistFilters();
await persistSorts();
};
return { handleViewsChange, handleViewSubmit };
};

View File

@ -1,5 +1,3 @@
import { useCallback } from 'react';
import type { ViewFieldMetadata } from '@/ui/editable-field/types/ViewField';
import { TableRecoilScopeContext } from '@/ui/table/states/recoil-scope-contexts/TableRecoilScopeContext';
import { tableColumnsScopedState } from '@/ui/table/states/tableColumnsScopedState';
@ -41,6 +39,7 @@ export const useTableViews = <Entity, SortField>({
objectId,
onViewCreate: handleViewCreate,
type: ViewType.Table,
scopeContext: TableRecoilScopeContext,
});
const { createViewFields, persistColumns } = useTableViewFields({
objectId,
@ -64,11 +63,11 @@ export const useTableViews = <Entity, SortField>({
await createViewSorts(sorts, viewId);
}
const handleViewSubmit = useCallback(async () => {
const handleViewSubmit = async () => {
await persistColumns();
await persistFilters();
await persistSorts();
}, [persistColumns, persistFilters, persistSorts]);
};
return { handleViewsChange, handleViewSubmit };
};

View File

@ -1,6 +1,6 @@
import type { Context } from 'react';
import { useRecoilCallback } from 'recoil';
import { TableRecoilScopeContext } from '@/ui/table/states/recoil-scope-contexts/TableRecoilScopeContext';
import { savedTableColumnsFamilyState } from '@/ui/table/states/savedTableColumnsFamilyState';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
@ -9,7 +9,7 @@ import { savedFiltersFamilyState } from '@/ui/view-bar/states/savedFiltersFamily
import { savedSortsFamilyState } from '@/ui/view-bar/states/savedSortsFamilyState';
import { viewsByIdScopedSelector } from '@/ui/view-bar/states/selectors/viewsByIdScopedSelector';
import { viewsScopedState } from '@/ui/view-bar/states/viewsScopedState';
import { View } from '@/ui/view-bar/types/View';
import type { View } from '@/ui/view-bar/types/View';
import {
useCreateViewMutation,
useDeleteViewMutation,
@ -22,24 +22,23 @@ import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const useViews = ({
objectId,
onViewCreate,
scopeContext,
type,
}: {
objectId: 'company' | 'person';
onViewCreate: (viewId: string) => Promise<void>;
onViewCreate?: (viewId: string) => Promise<void>;
scopeContext: Context<string | null>;
type: ViewType;
}) => {
const [currentViewId, setCurrentViewId] = useRecoilScopedState(
currentViewIdScopedState,
TableRecoilScopeContext,
scopeContext,
);
const [views, setViews] = useRecoilScopedState(
viewsScopedState,
TableRecoilScopeContext,
);
const viewsById = useRecoilScopedValue(
viewsByIdScopedSelector,
TableRecoilScopeContext,
scopeContext,
);
const viewsById = useRecoilScopedValue(viewsByIdScopedSelector, scopeContext);
const [createViewMutation] = useCreateViewMutation();
const [updateViewMutation] = useUpdateViewMutation();
@ -51,12 +50,12 @@ export const useViews = ({
data: {
...view,
objectId,
type: ViewType.Table,
type,
},
},
});
if (data?.view) await onViewCreate(data.view.id);
if (data?.view) await onViewCreate?.(data.view.id);
};
const updateView = (view: View) =>
@ -97,15 +96,22 @@ export const useViews = ({
if (!isDeeplyEqual(views, nextViews)) setViews(nextViews);
// If there is no current view selected,
// or if the current view cannot be found in the views list (user switched workspaces)
if (
nextViews.length &&
(!currentViewId || !nextViews.some((view) => view.id === currentViewId))
) {
setCurrentViewId(nextViews[0].id);
handleResetSavedViews();
}
if (!nextViews.length) return;
if (!currentViewId) return setCurrentViewId(nextViews[0].id);
const currentViewExists = nextViews.some(
(view) => view.id === currentViewId,
);
if (currentViewExists) return;
// currentView does not exist in the list = the user has switched workspaces
// and currentViewId is outdated.
// Select the first view in the list.
setCurrentViewId(nextViews[0].id);
// Reset outdated view recoil states.
handleResetSavedViews();
},
});

View File

@ -1,12 +1,9 @@
import styled from '@emotion/styled';
import { HooksCompanyBoard } from '@/companies/components/HooksCompanyBoard';
import { CompanyBoard } from '@/companies/board/components/CompanyBoard';
import { CompanyBoardRecoilScopeContext } from '@/companies/states/recoil-scope-contexts/CompanyBoardRecoilScopeContext';
import { PipelineAddButton } from '@/pipeline/components/PipelineAddButton';
import { usePipelineStages } from '@/pipeline/hooks/usePipelineStages';
import { EntityBoard } from '@/ui/board/components/EntityBoard';
import { EntityBoardActionBar } from '@/ui/board/components/EntityBoardActionBar';
import { EntityBoardContextMenu } from '@/ui/board/components/EntityBoardContextMenu';
import { BoardOptionsContext } from '@/ui/board/contexts/BoardOptionsContext';
import { DropdownRecoilScopeContext } from '@/ui/dropdown/states/recoil-scope-contexts/DropdownRecoilScopeContext';
import { IconTargetArrow } from '@/ui/icon';
@ -60,16 +57,16 @@ export function Opportunities() {
</StyledPageHeader>
<PageBody>
<BoardOptionsContext.Provider value={opportunitiesBoardOptions}>
<RecoilScope SpecificContext={CompanyBoardRecoilScopeContext}>
<HooksCompanyBoard />
<EntityBoard
<RecoilScope
scopeId="opportunities"
SpecificContext={CompanyBoardRecoilScopeContext}
>
<CompanyBoard
boardOptions={opportunitiesBoardOptions}
onEditColumnTitle={handleEditColumnTitle}
onColumnAdd={handlePipelineStageAdd}
onColumnDelete={handlePipelineStageDelete}
onEditColumnTitle={handleEditColumnTitle}
/>
<EntityBoardActionBar />
<EntityBoardContextMenu />
</RecoilScope>
</BoardOptionsContext.Provider>
</PageBody>

View File

@ -26,18 +26,20 @@ import {
SearchCompanyQuery,
SearchPeopleQuery,
SearchUserQuery,
ViewType,
} from '~/generated/graphql';
import { mockedActivities, mockedTasks } from './mock-data/activities';
import {
mockedCompaniesData,
mockedCompanyViewFields,
mockedCompanyViews,
mockedCompanyBoardViews,
mockedCompanyTableColumns,
mockedCompanyTableViews,
} from './mock-data/companies';
import {
mockedPeopleData,
mockedPersonViewFields,
mockedPersonViews,
mockedPersonTableColumns,
mockedPersonTableViews,
} from './mock-data/people';
import { mockedPipelineProgressData } from './mock-data/pipeline-progress';
import { mockedPipelinesData } from './mock-data/pipelines';
@ -237,12 +239,18 @@ export const graphqlMocks = [
const {
where: {
objectId: { equals: objectId },
type: { equals: type },
},
} = req.variables;
return res(
ctx.data({
views: objectId === 'company' ? mockedCompanyViews : mockedPersonViews,
views:
objectId === 'person'
? mockedPersonTableViews
: type === ViewType.Table
? mockedCompanyTableViews
: mockedCompanyBoardViews,
}),
);
}),
@ -256,9 +264,9 @@ export const graphqlMocks = [
return res(
ctx.data({
viewFields:
viewId === mockedCompanyViews[0].id
? mockedCompanyViewFields
: mockedPersonViewFields,
viewId === mockedCompanyTableViews[0].id
? mockedCompanyTableColumns
: mockedPersonTableColumns,
}),
);
}),

View File

@ -144,7 +144,17 @@ export const mockedCompaniesData: Array<MockedCompany> = [
},
];
export const mockedCompanyViews: View[] = [
export const mockedCompanyBoardViews: View[] = [
{
__typename: 'View',
id: '1e8f93e6-ae0e-43ba-8121-a7a763286351',
name: 'All opportunities',
objectId: 'company',
type: ViewType.Pipeline,
},
];
export const mockedCompanyTableViews: View[] = [
{
__typename: 'View',
id: 'e6a2232d-ca6c-42df-b78e-ca0343f545a9',
@ -154,15 +164,16 @@ export const mockedCompanyViews: View[] = [
},
];
export const mockedCompanyViewFields = companiesAvailableColumnDefinitions.map<
Omit<ViewField, 'view'>
>((viewFieldDefinition) => ({
__typename: 'ViewField',
name: viewFieldDefinition.name,
index: viewFieldDefinition.index,
isVisible: true,
key: viewFieldDefinition.key,
objectId: 'company',
size: viewFieldDefinition.size,
viewId: 'e6a2232d-ca6c-42df-b78e-ca0343f545a9',
}));
export const mockedCompanyTableColumns =
companiesAvailableColumnDefinitions.map<Omit<ViewField, 'view'>>(
(viewFieldDefinition) => ({
__typename: 'ViewField',
name: viewFieldDefinition.name,
index: viewFieldDefinition.index,
isVisible: true,
key: viewFieldDefinition.key,
objectId: 'company',
size: viewFieldDefinition.size,
viewId: mockedCompanyTableViews[0].id,
}),
);

View File

@ -129,7 +129,7 @@ export const mockedPeopleData: MockedPerson[] = [
},
];
export const mockedPersonViews: View[] = [
export const mockedPersonTableViews: View[] = [
{
__typename: 'View',
id: 'afd7737a-bf1d-41a3-8863-c277b56a657b',
@ -146,7 +146,7 @@ export const mockedPersonViews: View[] = [
},
];
export const mockedPersonViewFields = peopleAvailableColumnDefinitions.map<
export const mockedPersonTableColumns = peopleAvailableColumnDefinitions.map<
Omit<ViewField, 'view'>
>((viewFieldDefinition) => ({
__typename: 'ViewField',
@ -156,5 +156,5 @@ export const mockedPersonViewFields = peopleAvailableColumnDefinitions.map<
key: viewFieldDefinition.key,
objectId: 'person',
size: viewFieldDefinition.size,
viewId: 'afd7737a-bf1d-41a3-8863-c277b56a657b',
viewId: mockedPersonTableViews[0].id,
}));