Board V2 - Part 1 (#2619)

* improve useComputeDefinitionsFromFieldMetadata to prevent infinit loops

* fix viewFields

* improve initial seeding

* fix height 100%

* fix filters and sorts

* allow filter on currency

* remove probability from filter

* fix opportunities count

* fix persist filters and sorts
This commit is contained in:
bosiraphael
2023-11-21 18:01:30 +01:00
committed by GitHub
parent 9912f7a336
commit ad8331aa89
12 changed files with 152 additions and 95 deletions

View File

@ -1,4 +1,5 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useSetRecoilState } from 'recoil';
import { BoardContext } from '@/companies/states/contexts/BoardContext'; import { BoardContext } from '@/companies/states/contexts/BoardContext';
import { BoardOptionsDropdown } from '@/ui/layout/board/components/BoardOptionsDropdown'; import { BoardOptionsDropdown } from '@/ui/layout/board/components/BoardOptionsDropdown';
@ -10,6 +11,7 @@ import {
import { EntityBoardActionBar } from '@/ui/layout/board/components/EntityBoardActionBar'; import { EntityBoardActionBar } from '@/ui/layout/board/components/EntityBoardActionBar';
import { EntityBoardContextMenu } from '@/ui/layout/board/components/EntityBoardContextMenu'; import { EntityBoardContextMenu } from '@/ui/layout/board/components/EntityBoardContextMenu';
import { ViewBar } from '@/views/components/ViewBar'; import { ViewBar } from '@/views/components/ViewBar';
import { useViewScopedStates } from '@/views/hooks/internal/useViewScopedStates';
import { ViewScope } from '@/views/scopes/ViewScope'; import { ViewScope } from '@/views/scopes/ViewScope';
import { opportunitiesBoardOptions } from '~/pages/opportunities/opportunitiesBoardOptions'; import { opportunitiesBoardOptions } from '~/pages/opportunities/opportunitiesBoardOptions';
@ -35,8 +37,32 @@ export const CompanyBoard = ({
onEditColumnTitle, onEditColumnTitle,
}: CompanyBoardProps) => { }: CompanyBoardProps) => {
const viewScopeId = 'company-board-view'; const viewScopeId = 'company-board-view';
const {
currentViewFieldsState,
currentViewFiltersState,
currentViewSortsState,
} = useViewScopedStates({
customViewScopeId: viewScopeId,
});
const setCurrentViewFields = useSetRecoilState(currentViewFieldsState);
const setCurrentViewFilters = useSetRecoilState(currentViewFiltersState);
const setCurrentViewSorts = useSetRecoilState(currentViewSortsState);
return ( return (
<ViewScope viewScopeId={viewScopeId}> <ViewScope
viewScopeId={viewScopeId}
onViewFieldsChange={(viewFields) => {
setCurrentViewFields(viewFields);
}}
onViewFiltersChange={(viewFilters) => {
setCurrentViewFilters(viewFilters);
}}
onViewSortsChange={(viewSorts) => {
setCurrentViewSorts(viewSorts);
}}
>
<StyledContainer> <StyledContainer>
<BoardContext.Provider <BoardContext.Provider
value={{ value={{

View File

@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useRecoilState, useRecoilValue } from 'recoil';
import { useRecoilCallback, useRecoilState, useRecoilValue } from 'recoil';
import { Company } from '@/companies/types/Company'; import { Company } from '@/companies/types/Company';
import { useComputeDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useComputeDefinitionsFromFieldMetadata'; import { useComputeDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useComputeDefinitionsFromFieldMetadata';
@ -16,13 +15,16 @@ import { useBoardContextMenuEntries } from '@/ui/layout/board/hooks/useBoardCont
import { availableBoardCardFieldsScopedState } from '@/ui/layout/board/states/availableBoardCardFieldsScopedState'; import { availableBoardCardFieldsScopedState } from '@/ui/layout/board/states/availableBoardCardFieldsScopedState';
import { boardCardFieldsScopedState } from '@/ui/layout/board/states/boardCardFieldsScopedState'; import { boardCardFieldsScopedState } from '@/ui/layout/board/states/boardCardFieldsScopedState';
import { isBoardLoadedState } from '@/ui/layout/board/states/isBoardLoadedState'; import { isBoardLoadedState } from '@/ui/layout/board/states/isBoardLoadedState';
import { turnFiltersIntoWhereClauseV2 } from '@/ui/object/object-filter-dropdown/utils/turnFiltersIntoWhereClauseV2';
import { turnSortsIntoOrderByV2 } from '@/ui/object/object-sort-dropdown/utils/turnSortsIntoOrderByV2';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState'; import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useRecoilScopedStateV2 } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedStateV2'; import { useSetRecoilScopedStateV2 } from '@/ui/utilities/recoil-scope/hooks/useSetRecoilScopedStateV2';
import { useViewScopedStates } from '@/views/hooks/internal/useViewScopedStates'; import { useViewScopedStates } from '@/views/hooks/internal/useViewScopedStates';
import { useView } from '@/views/hooks/useView'; import { useView } from '@/views/hooks/useView';
import { ViewType } from '@/views/types/ViewType'; import { ViewType } from '@/views/types/ViewType';
import { mapViewFieldsToBoardFieldDefinitions } from '@/views/utils/mapViewFieldsToBoardFieldDefinitions'; import { mapViewFieldsToBoardFieldDefinitions } from '@/views/utils/mapViewFieldsToBoardFieldDefinitions';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters';
import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts';
import { useUpdateCompanyBoardCardIds } from '../hooks/useUpdateBoardCardIds'; import { useUpdateCompanyBoardCardIds } from '../hooks/useUpdateBoardCardIds';
import { useUpdateCompanyBoard } from '../hooks/useUpdateCompanyBoardColumns'; import { useUpdateCompanyBoard } from '../hooks/useUpdateCompanyBoardColumns';
@ -37,13 +39,19 @@ export const HooksCompanyBoardEffect = () => {
setViewType, setViewType,
} = useView(); } = useView();
const { currentViewFieldsState } = useViewScopedStates(); const {
currentViewFieldsState,
currentViewFiltersState,
currentViewSortsState,
} = useViewScopedStates();
const [pipelineSteps, setPipelineSteps] = useState<PipelineStep[]>([]); const [pipelineSteps, setPipelineSteps] = useState<PipelineStep[]>([]);
const [opportunities, setOpportunities] = useState<Opportunity[]>([]); const [opportunities, setOpportunities] = useState<Opportunity[]>([]);
const [companies, setCompanies] = useState<Company[]>([]); const [companies, setCompanies] = useState<Company[]>([]);
const currentViewFields = useRecoilValue(currentViewFieldsState); const currentViewFields = useRecoilValue(currentViewFieldsState);
const currentViewFilters = useRecoilValue(currentViewFiltersState);
const currentViewSorts = useRecoilValue(currentViewSortsState);
const { objectMetadataItem } = useObjectMetadataItem({ const { objectMetadataItem } = useObjectMetadataItem({
objectNamePlural: 'opportunities', objectNamePlural: 'opportunities',
@ -64,6 +72,11 @@ export const HooksCompanyBoardEffect = () => {
const updateCompanyBoardCardIds = useUpdateCompanyBoardCardIds(); const updateCompanyBoardCardIds = useUpdateCompanyBoardCardIds();
const updateCompanyBoard = useUpdateCompanyBoard(); const updateCompanyBoard = useUpdateCompanyBoard();
const setAvailableBoardCardFields = useSetRecoilScopedStateV2(
availableBoardCardFieldsScopedState,
'company-board-view',
);
useFindManyObjectRecords({ useFindManyObjectRecords({
objectNamePlural: 'pipelineSteps', objectNamePlural: 'pipelineSteps',
filter: {}, filter: {},
@ -75,23 +88,21 @@ export const HooksCompanyBoardEffect = () => {
), ),
}); });
const whereFilters = useMemo(() => { const filter = turnFiltersIntoWhereClauseV2(
return { mapViewFiltersToFilters(currentViewFilters),
and: [ objectMetadataItem?.fields ?? [],
{ );
pipelineStepId: {
in: pipelineSteps.map((pipelineStep) => pipelineStep.id), const orderBy = turnSortsIntoOrderByV2(
}, mapViewSortsToSorts(currentViewSorts),
}, objectMetadataItem?.fields ?? [],
...[], );
],
};
}, [pipelineSteps]) as any;
useFindManyObjectRecords({ useFindManyObjectRecords({
skip: !pipelineSteps.length, skip: !pipelineSteps.length,
objectNamePlural: 'opportunities', objectNamePlural: 'opportunities',
filter: whereFilters, filter: filter,
orderBy: orderBy,
onCompleted: useCallback( onCompleted: useCallback(
(data: PaginatedObjectTypeResults<Opportunity>) => { (data: PaginatedObjectTypeResults<Opportunity>) => {
const pipelineProgresses: Array<Opportunity> = data.edges.map( const pipelineProgresses: Array<Opportunity> = data.edges.map(
@ -137,39 +148,6 @@ export const HooksCompanyBoardEffect = () => {
sortDefinitions, sortDefinitions,
]); ]);
const setAvailableBoardCardFields = useRecoilCallback(
({ snapshot, set }) =>
(availableBoardCardFields: any) => {
const availableBoardCardFieldsFromState = snapshot
.getLoadable(
availableBoardCardFieldsScopedState({
scopeId: 'company-board-view',
}),
)
.getValue();
if (
!isDeeplyEqual(
availableBoardCardFieldsFromState,
availableBoardCardFields,
)
) {
set(
availableBoardCardFieldsScopedState({
scopeId: 'company-board-view',
}),
availableBoardCardFields,
);
}
},
[],
);
useRecoilScopedStateV2(
availableBoardCardFieldsScopedState,
'company-board-view',
);
useEffect(() => { useEffect(() => {
const availableTableColumns = columnDefinitions.filter( const availableTableColumns = columnDefinitions.filter(
filterAvailableTableColumns, filterAvailableTableColumns,
@ -179,11 +157,12 @@ export const HooksCompanyBoardEffect = () => {
}, [columnDefinitions, setAvailableBoardCardFields]); }, [columnDefinitions, setAvailableBoardCardFields]);
useEffect(() => { useEffect(() => {
setViewObjectMetadataId?.('company'); if (!objectMetadataItem) {
return;
}
setViewObjectMetadataId?.(objectMetadataItem.id);
setViewType?.(ViewType.Kanban); setViewType?.(ViewType.Kanban);
}, [setViewObjectMetadataId, setViewType]); }, [objectMetadataItem, setViewObjectMetadataId, setViewType]);
const [searchParams] = useSearchParams();
const loading = !companies; const loading = !companies;
@ -194,9 +173,8 @@ export const HooksCompanyBoardEffect = () => {
if (!loading && opportunities && companies) { if (!loading && opportunities && companies) {
setActionBarEntries(); setActionBarEntries();
setContextMenuEntries(); setContextMenuEntries();
updateCompanyBoard(pipelineSteps, opportunities, companies); updateCompanyBoard(pipelineSteps, opportunities, companies);
setEntityCountInCurrentView(companies.length); setEntityCountInCurrentView(opportunities.length);
} }
}, [ }, [
companies, companies,
@ -212,10 +190,13 @@ export const HooksCompanyBoardEffect = () => {
useEffect(() => { useEffect(() => {
if (currentViewFields) { if (currentViewFields) {
setBoardCardFields( setBoardCardFields(
mapViewFieldsToBoardFieldDefinitions(currentViewFields, []), mapViewFieldsToBoardFieldDefinitions(
currentViewFields,
columnDefinitions,
),
); );
} }
}, [currentViewFields, setBoardCardFields]); }, [columnDefinitions, currentViewFields, setBoardCardFields]);
return <></>; return <></>;
}; };

View File

@ -1,3 +1,5 @@
import { useMemo } from 'react';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { FieldMetadata } from '@/ui/object/field/types/FieldMetadata'; import { FieldMetadata } from '@/ui/object/field/types/FieldMetadata';
import { ColumnDefinition } from '@/ui/object/record-table/types/ColumnDefinition'; import { ColumnDefinition } from '@/ui/object/record-table/types/ColumnDefinition';
@ -10,25 +12,24 @@ import { formatFieldMetadataItemsAsSortDefinitions } from '../utils/formatFieldM
export const useComputeDefinitionsFromFieldMetadata = ( export const useComputeDefinitionsFromFieldMetadata = (
objectMetadataItem?: Nullable<ObjectMetadataItem>, objectMetadataItem?: Nullable<ObjectMetadataItem>,
) => { ) => {
if (!objectMetadataItem) { const activeFieldMetadataItems = useMemo(
return { () =>
columnDefinitions: [], objectMetadataItem
filterDefinitions: [], ? objectMetadataItem.fields.filter(({ isActive }) => isActive)
sortDefinitions: [], : [],
}; [objectMetadataItem],
}
const activeFieldMetadataItems = objectMetadataItem.fields.filter(
({ isActive }) => isActive,
); );
const columnDefinitions: ColumnDefinition<FieldMetadata>[] = const columnDefinitions: ColumnDefinition<FieldMetadata>[] = useMemo(
activeFieldMetadataItems.map((field, index) => () =>
formatFieldMetadataItemAsColumnDefinition({ activeFieldMetadataItems.map((field, index) =>
position: index, formatFieldMetadataItemAsColumnDefinition({
field, position: index,
}), field,
); }),
),
[activeFieldMetadataItems],
);
const filterDefinitions = formatFieldMetadataItemsAsFilterDefinitions({ const filterDefinitions = formatFieldMetadataItemsAsFilterDefinitions({
fields: activeFieldMetadataItems, fields: activeFieldMetadataItems,

View File

@ -13,8 +13,10 @@ export const formatFieldMetadataItemsAsFilterDefinitions = ({
![ ![
FieldMetadataType.DateTime, FieldMetadataType.DateTime,
FieldMetadataType.Number, FieldMetadataType.Number,
FieldMetadataType.Currency,
FieldMetadataType.Text, FieldMetadataType.Text,
].includes(field.type) ].includes(field.type) ||
field.name === 'probability'
) { ) {
return acc; return acc;
} }
@ -34,5 +36,7 @@ const formatFieldMetadataItemAsFilterDefinition = ({
? 'DATE_TIME' ? 'DATE_TIME'
: field.type === FieldMetadataType.Number : field.type === FieldMetadataType.Number
? 'NUMBER' ? 'NUMBER'
: field.type === FieldMetadataType.Currency
? 'CURRENCY'
: 'TEXT', : 'TEXT',
}); });

View File

@ -1,9 +0,0 @@
export type MetadataFieldDataType =
| 'BOOLEAN'
| 'DATE_TIME'
| 'ENUM'
| 'MONEY'
| 'NUMBER'
| 'RELATION'
| 'TEXT'
| 'URL';

View File

@ -9,9 +9,6 @@ export type FieldType =
| 'DOUBLE_TEXT' | 'DOUBLE_TEXT'
| 'EMAIL' | 'EMAIL'
| 'ENUM' | 'ENUM'
| 'MONEY_AMOUNT_'
| 'MONEY_AMOUNT'
| 'MONEY'
| 'NUMBER' | 'NUMBER'
| 'PHONE' | 'PHONE'
| 'PROBABILITY' | 'PROBABILITY'

View File

@ -33,9 +33,9 @@ export const MultipleFiltersDropdownContent = () => {
{filterDefinitionUsedInDropdown.type === 'TEXT' && ( {filterDefinitionUsedInDropdown.type === 'TEXT' && (
<ObjectFilterDropdownTextSearchInput /> <ObjectFilterDropdownTextSearchInput />
)} )}
{filterDefinitionUsedInDropdown.type === 'NUMBER' && ( {['NUMBER', 'CURRENCY'].includes(
<ObjectFilterDropdownNumberSearchInput /> filterDefinitionUsedInDropdown.type,
)} ) && <ObjectFilterDropdownNumberSearchInput />}
{filterDefinitionUsedInDropdown.type === 'DATE_TIME' && ( {filterDefinitionUsedInDropdown.type === 'DATE_TIME' && (
<ObjectFilterDropdownDateSearchInput /> <ObjectFilterDropdownDateSearchInput />
)} )}

View File

@ -1 +1,6 @@
export type FilterType = 'TEXT' | 'DATE_TIME' | 'ENTITY' | 'NUMBER'; export type FilterType =
| 'TEXT'
| 'DATE_TIME'
| 'ENTITY'
| 'NUMBER'
| 'CURRENCY';

View File

@ -8,6 +8,7 @@ export const getOperandsForFilterType = (
switch (filterType) { switch (filterType) {
case 'TEXT': case 'TEXT':
return [ViewFilterOperand.Contains, ViewFilterOperand.DoesNotContain]; return [ViewFilterOperand.Contains, ViewFilterOperand.DoesNotContain];
case 'CURRENCY':
case 'NUMBER': case 'NUMBER':
case 'DATE_TIME': case 'DATE_TIME':
return [ViewFilterOperand.GreaterThan, ViewFilterOperand.LessThan]; return [ViewFilterOperand.GreaterThan, ViewFilterOperand.LessThan];

View File

@ -62,6 +62,23 @@ export const turnFiltersIntoWhereClauseV2 = (
`Unknown operand ${filter.operand} for ${filter.definition.type} filter`, `Unknown operand ${filter.operand} for ${filter.definition.type} filter`,
); );
} }
case 'CURRENCY':
switch (filter.operand) {
case ViewFilterOperand.GreaterThan:
whereClause[correspondingField.name] = {
amountMicros: { gte: parseFloat(filter.value) * 1000000 },
};
return;
case ViewFilterOperand.LessThan:
whereClause[correspondingField.name] = {
amountMicros: { lte: parseFloat(filter.value) * 1000000 },
};
return;
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filter.definition.type} filter`,
);
}
case 'DATE_TIME': case 'DATE_TIME':
switch (filter.operand) { switch (filter.operand) {
case ViewFilterOperand.GreaterThan: case ViewFilterOperand.GreaterThan:

View File

@ -19,6 +19,7 @@ import { opportunitiesBoardOptions } from '~/pages/opportunities/opportunitiesBo
const StyledBoardContainer = styled.div` const StyledBoardContainer = styled.div`
display: flex; display: flex;
height: 100%;
width: 100%; width: 100%;
`; `;

View File

@ -33,6 +33,39 @@ export const seedOpportunity = async (
personId: '86083141-1c0e-494c-a1b6-85b1c6fefaa5', personId: '86083141-1c0e-494c-a1b6-85b1c6fefaa5',
companyId: 'fe256b39-3ec3-4fe3-8997-b76aa0bfa408', companyId: 'fe256b39-3ec3-4fe3-8997-b76aa0bfa408',
}, },
{
id: '53f66647-0543-4cc2-9f96-95cc699960f2',
amountAmountMicros: 2000000,
amountCurrencyCode: 'USD',
closeDate: new Date(),
probability: 0.5,
pipelineStepId: 'd8361722-03fb-4e65-bd4f-ec9e52e5ec0a',
pointOfContactId: '93c72d2e-f517-42fd-80ae-14173b3b70ae',
personId: '93c72d2e-f517-42fd-80ae-14173b3b70ae',
companyId: '118995f3-5d81-46d6-bf83-f7fd33ea6102',
},
{
id: '81ab695d-2f89-406f-90ea-180f433b2445',
amountAmountMicros: 300000,
amountCurrencyCode: 'USD',
closeDate: new Date(),
probability: 0.5,
pipelineStepId: '30b14887-d592-427d-bd97-6e670158db02',
pointOfContactId: '9b324a88-6784-4449-afdf-dc62cb8702f2',
personId: '9b324a88-6784-4449-afdf-dc62cb8702f2',
companyId: '460b6fb1-ed89-413a-b31a-962986e67bb4',
},
{
id: '9b059852-35b1-4045-9cde-42f715148954',
amountAmountMicros: 4000000,
amountCurrencyCode: 'USD',
closeDate: new Date(),
probability: 0.5,
pipelineStepId: '30b14887-d592-427d-bd97-6e670158db02',
pointOfContactId: '98406e26-80f1-4dff-b570-a74942528de3',
personId: '98406e26-80f1-4dff-b570-a74942528de3',
companyId: '460b6fb1-ed89-413a-b31a-962986e67bb4',
},
]) ])
.execute(); .execute();
}; };