Fix/opportunities board (#2610)

* WIP

* wip

* update pipelineStepId

* rename pipeline stage to pipeline step

* rename pipelineProgress to Opportunity

* fix UUID type not queried

* fix boardColumnTotal

* fix micros

* fixing filters, sorts and fields

* wip

* wip

* Fix opportunity board re-render

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
Co-authored-by: bosiraphael <raphael.bosi@gmail.com>
This commit is contained in:
Charles Bochet
2023-11-21 01:24:25 +01:00
committed by GitHub
parent a33d4c8b8d
commit 09533e286b
36 changed files with 364 additions and 277 deletions

View File

@ -143,7 +143,7 @@ export const CompanyBoardCard = () => {
const showCompactView = isCompactViewEnabled && isCardInCompactView;
const { pipelineProgress, company } = companyProgress ?? {};
const { opportunity, company } = companyProgress ?? {};
const visibleBoardCardFields = useRecoilScopedValue(
visibleBoardCardFieldsScopedSelector,
@ -175,7 +175,7 @@ export const CompanyBoardCard = () => {
};
// boardCardId check can be moved to a wrapper to avoid unnecessary logic above
if (!company || !pipelineProgress || !boardCardId) {
if (!company || !opportunity || !boardCardId) {
return null;
}

View File

@ -17,7 +17,7 @@ export type CompanyProgressPickerProps = {
companyId: string | null;
onSubmit: (
newCompanyId: EntityForSelect | null,
newPipelineStageId: string | null,
newPipelineStepId: string | null,
) => void;
onCancel?: () => void;
};
@ -39,40 +39,40 @@ export const CompanyProgressPicker = ({
const [isProgressSelectionUnfolded, setIsProgressSelectionUnfolded] =
useState(false);
const [selectedPipelineStageId, setSelectedPipelineStageId] = useState<
const [selectedPipelineStepId, setSelectedPipelineStepId] = useState<
string | null
>(null);
const [currentPipeline] = useRecoilState(currentPipelineState);
const currentPipelineStages = useMemo(
() => currentPipeline?.pipelineStages ?? [],
const currentPipelineSteps = useMemo(
() => currentPipeline?.pipelineSteps ?? [],
[currentPipeline],
);
const handlePipelineStageChange = (newPipelineStageId: string) => {
setSelectedPipelineStageId(newPipelineStageId);
const handlePipelineStepChange = (newPipelineStepId: string) => {
setSelectedPipelineStepId(newPipelineStepId);
setIsProgressSelectionUnfolded(false);
};
const handleEntitySelected = async (
selectedCompany: EntityForSelect | null | undefined,
) => {
onSubmit(selectedCompany ?? null, selectedPipelineStageId);
onSubmit(selectedCompany ?? null, selectedPipelineStepId);
};
useEffect(() => {
if (currentPipelineStages?.[0]?.id) {
setSelectedPipelineStageId(currentPipelineStages?.[0]?.id);
if (currentPipelineSteps?.[0]?.id) {
setSelectedPipelineStepId(currentPipelineSteps?.[0]?.id);
}
}, [currentPipelineStages]);
}, [currentPipelineSteps]);
const selectedPipelineStage = useMemo(
const selectedPipelineStep = useMemo(
() =>
currentPipelineStages.find(
(pipelineStage: any) => pipelineStage.id === selectedPipelineStageId,
currentPipelineSteps.find(
(pipelineStep: any) => pipelineStep.id === selectedPipelineStepId,
),
[currentPipelineStages, selectedPipelineStageId],
[currentPipelineSteps, selectedPipelineStepId],
);
return (
@ -82,14 +82,14 @@ export const CompanyProgressPicker = ({
>
{isProgressSelectionUnfolded ? (
<DropdownMenuItemsContainer>
{currentPipelineStages.map((pipelineStage: any, index: number) => (
{currentPipelineSteps.map((pipelineStep: any, index: number) => (
<MenuItem
key={pipelineStage.id}
key={pipelineStep.id}
testId={`select-pipeline-stage-${index}`}
onClick={() => {
handlePipelineStageChange(pipelineStage.id);
handlePipelineStepChange(pipelineStep.id);
}}
text={pipelineStage.name}
text={pipelineStep.name}
/>
))}
</DropdownMenuItemsContainer>
@ -100,7 +100,7 @@ export const CompanyProgressPicker = ({
EndIcon={IconChevronDown}
onClick={() => setIsProgressSelectionUnfolded(true)}
>
{selectedPipelineStage?.name}
{selectedPipelineStep?.name}
</DropdownMenuHeader>
<DropdownMenuSeparator />
<DropdownMenuSearchInput

View File

@ -1,10 +1,13 @@
import { useCallback, useEffect, useMemo, 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 { useComputeDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useComputeDefinitionsFromFieldMetadata';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useFindManyObjectRecords } from '@/object-record/hooks/useFindManyObjectRecords';
import { PaginatedObjectTypeResults } from '@/object-record/types/PaginatedObjectTypeResults';
import { filterAvailableTableColumns } from '@/object-record/utils/filterAvailableTableColumns';
import { Opportunity } from '@/pipeline/types/Opportunity';
import { PipelineStep } from '@/pipeline/types/PipelineStep';
import { useBoardActionBarEntries } from '@/ui/layout/board/hooks/useBoardActionBarEntries';
@ -14,11 +17,12 @@ import { availableBoardCardFieldsScopedState } from '@/ui/layout/board/states/av
import { boardCardFieldsScopedState } from '@/ui/layout/board/states/boardCardFieldsScopedState';
import { isBoardLoadedState } from '@/ui/layout/board/states/isBoardLoadedState';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useRecoilScopedStateV2 } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedStateV2';
import { useViewScopedStates } from '@/views/hooks/internal/useViewScopedStates';
import { useView } from '@/views/hooks/useView';
import { ViewType } from '@/views/types/ViewType';
import { mapViewFieldsToBoardFieldDefinitions } from '@/views/utils/mapViewFieldsToBoardFieldDefinitions';
import { opportunitiesBoardOptions } from '~/pages/opportunities/opportunitiesBoardOptions';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { useUpdateCompanyBoardCardIds } from '../hooks/useUpdateBoardCardIds';
import { useUpdateCompanyBoard } from '../hooks/useUpdateCompanyBoardColumns';
@ -41,6 +45,13 @@ export const HooksCompanyBoardEffect = () => {
const currentViewFields = useRecoilValue(currentViewFieldsState);
const { objectMetadataItem } = useObjectMetadataItem({
objectNamePlural: 'opportunities',
});
const { columnDefinitions, filterDefinitions, sortDefinitions } =
useComputeDefinitionsFromFieldMetadata(objectMetadataItem);
const [, setIsBoardLoaded] = useRecoilState(isBoardLoadedState);
const { BoardRecoilScopeContext } = useBoardContext();
@ -50,10 +61,6 @@ export const HooksCompanyBoardEffect = () => {
BoardRecoilScopeContext,
);
const [, setAvailableBoardCardFields] = useRecoilScopedState(
availableBoardCardFieldsScopedState,
BoardRecoilScopeContext,
);
const updateCompanyBoardCardIds = useUpdateCompanyBoardCardIds();
const updateCompanyBoard = useUpdateCompanyBoard();
@ -70,9 +77,9 @@ export const HooksCompanyBoardEffect = () => {
const whereFilters = useMemo(() => {
return {
AND: [
and: [
{
pipelineStageId: {
pipelineStepId: {
in: pipelineSteps.map((pipelineStep) => pipelineStep.id),
},
},
@ -86,8 +93,10 @@ export const HooksCompanyBoardEffect = () => {
objectNamePlural: 'opportunities',
filter: whereFilters,
onCompleted: useCallback(
(_data: PaginatedObjectTypeResults<Opportunity>) => {
const pipelineProgresses: Array<Opportunity> = [];
(data: PaginatedObjectTypeResults<Opportunity>) => {
const pipelineProgresses: Array<Opportunity> = data.edges.map(
(edge) => edge.node,
);
updateCompanyBoardCardIds(pipelineProgresses);
@ -112,15 +121,63 @@ export const HooksCompanyBoardEffect = () => {
});
useEffect(() => {
setAvailableFilterDefinitions(opportunitiesBoardOptions.filterDefinitions);
setAvailableSortDefinitions?.(opportunitiesBoardOptions.sortDefinitions);
setAvailableFieldDefinitions?.([]);
if (!objectMetadataItem) {
return;
}
setAvailableFilterDefinitions?.(filterDefinitions);
setAvailableSortDefinitions?.(sortDefinitions);
setAvailableFieldDefinitions?.(columnDefinitions);
}, [
columnDefinitions,
filterDefinitions,
objectMetadataItem,
setAvailableFieldDefinitions,
setAvailableFilterDefinitions,
setAvailableSortDefinitions,
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(() => {
const availableTableColumns = columnDefinitions.filter(
filterAvailableTableColumns,
);
setAvailableBoardCardFields(availableTableColumns);
}, [columnDefinitions, setAvailableBoardCardFields]);
useEffect(() => {
setViewObjectMetadataId?.('company');
setViewType?.(ViewType.Kanban);
@ -137,21 +194,19 @@ export const HooksCompanyBoardEffect = () => {
if (!loading && opportunities && companies) {
setActionBarEntries();
setContextMenuEntries();
setAvailableBoardCardFields([]);
updateCompanyBoard(pipelineSteps, opportunities, companies);
setEntityCountInCurrentView(companies.length);
}
}, [
companies,
loading,
updateCompanyBoard,
opportunities,
pipelineSteps,
setActionBarEntries,
setContextMenuEntries,
searchParams,
setEntityCountInCurrentView,
setAvailableBoardCardFields,
opportunities,
companies,
pipelineSteps,
updateCompanyBoard,
]);
useEffect(() => {

View File

@ -12,7 +12,7 @@ export const NewCompanyProgressButton = () => {
const [isCreatingCard, setIsCreatingCard] = useState(false);
const column = useContext(BoardColumnContext);
const pipelineStageId = column?.columnDefinition.id || '';
const pipelineStepId = column?.columnDefinition.id || '';
const { enqueueSnackBar } = useSnackBar();
@ -25,7 +25,7 @@ export const NewCompanyProgressButton = () => {
setIsCreatingCard(false);
goBackToPreviousHotkeyScope();
if (!pipelineStageId) {
if (!pipelineStepId) {
enqueueSnackBar('Pipeline stage id is not defined', {
variant: 'error',
});
@ -33,7 +33,7 @@ export const NewCompanyProgressButton = () => {
throw new Error('Pipeline stage id is not defined');
}
//createCompanyProgress(company.id, pipelineStageId);
//createCompanyProgress(company.id, pipelineStepId);
};
const handleNewClick = useCallback(() => {

View File

@ -7,7 +7,7 @@ import { boardColumnsState } from '@/ui/layout/board/states/boardColumnsState';
export const useUpdateCompanyBoardCardIds = () =>
useRecoilCallback(
({ snapshot, set }) =>
(pipelineProgresses: Opportunity[]) => {
(pipelineProgresses: Pick<Opportunity, 'pipelineStepId' | 'id'>[]) => {
const boardColumns = snapshot
.getLoadable(boardColumnsState)
.valueOrThrow();
@ -16,7 +16,7 @@ export const useUpdateCompanyBoardCardIds = () =>
const boardCardIds = pipelineProgresses
.filter(
(pipelineProgressToFilter) =>
pipelineProgressToFilter.pipelineStageId === boardColumn.id,
pipelineProgressToFilter.pipelineStepId === boardColumn.id,
)
.map((pipelineProgress) => pipelineProgress.id);

View File

@ -20,7 +20,7 @@ export const useUpdateCompanyBoard = () =>
({ set, snapshot }) =>
(
pipelineSteps: PipelineStep[],
pipelineProgresses: Opportunity[],
opportunities: Opportunity[],
companies: CompanyForBoard[],
) => {
const indexCompanyByIdReducer = (
@ -37,27 +37,26 @@ export const useUpdateCompanyBoard = () =>
{} as { [key: string]: CompanyForBoard },
) ?? {};
const indexPipelineProgressByIdReducer = (
const indexOpportunityByIdReducer = (
acc: CompanyProgressDict,
pipelineProgress: Opportunity,
opportunity: Opportunity,
) => {
const company =
pipelineProgress.companyId &&
companiesDict[pipelineProgress.companyId];
opportunity.companyId && companiesDict[opportunity.companyId];
if (!company) return acc;
return {
...acc,
[pipelineProgress.id]: {
pipelineProgress,
[opportunity.id]: {
opportunity,
company,
},
};
};
const companyBoardIndex = pipelineProgresses.reduce(
indexPipelineProgressByIdReducer,
const companyBoardIndex = opportunities.reduce(
indexOpportunityByIdReducer,
{} as CompanyProgressDict,
);
@ -68,7 +67,7 @@ export const useUpdateCompanyBoard = () =>
if (!isDeeplyEqual(currentCompanyProgress, companyProgress)) {
set(companyProgressesFamilyState(id), companyProgress);
set(entityFieldsFamilyState(id), companyProgress.pipelineProgress);
set(entityFieldsFamilyState(id), companyProgress.opportunity);
}
}
@ -81,29 +80,29 @@ export const useUpdateCompanyBoard = () =>
.valueOrThrow();
if (!isDeeplyEqual(pipelineSteps, currentPipelineSteps)) {
set(currentPipelineStepsState, currentPipelineSteps);
set(currentPipelineStepsState, pipelineSteps);
}
const orderedPipelineStages = [...pipelineSteps].sort((a, b) => {
const orderedPipelineSteps = [...pipelineSteps].sort((a, b) => {
if (!a.position || !b.position) return 0;
return a.position - b.position;
});
const newBoardColumns: BoardColumnDefinition[] =
orderedPipelineStages?.map((pipelineStage) => {
if (!isThemeColor(pipelineStage.color)) {
orderedPipelineSteps?.map((pipelineStep) => {
if (!isThemeColor(pipelineStep.color)) {
logError(
`Color ${pipelineStage.color} is not recognized in useUpdateCompanyBoard.`,
`Color ${pipelineStep.color} is not recognized in useUpdateCompanyBoard.`,
);
}
return {
id: pipelineStage.id,
title: pipelineStage.name,
colorCode: isThemeColor(pipelineStage.color)
? pipelineStage.color
id: pipelineStep.id,
title: pipelineStep.name,
colorCode: isThemeColor(pipelineStep.color)
? pipelineStep.color
: undefined,
position: pipelineStage.position ?? 0,
position: pipelineStep.position ?? 0,
};
});
if (currentBoardColumns.length === 0) {
@ -111,12 +110,12 @@ export const useUpdateCompanyBoard = () =>
set(savedBoardColumnsState, newBoardColumns);
}
for (const boardColumn of newBoardColumns) {
const boardCardIds = pipelineProgresses
const boardCardIds = opportunities
.filter(
(pipelineProgressToFilter) =>
pipelineProgressToFilter.pipelineStageId === boardColumn.id,
(opportunityToFilter) =>
opportunityToFilter.pipelineStepId === boardColumn.id,
)
.map((pipelineProgress) => pipelineProgress.id);
.map((opportunity) => opportunity.id);
const currentBoardCardIds = snapshot
.getLoadable(boardCardIdsByColumnIdFamilyState(boardColumn.id))

View File

@ -2,11 +2,10 @@ import { Company } from '@/companies/types/Company';
import { Opportunity } from '@/pipeline/types/Opportunity';
export type CompanyForBoard = Pick<Company, 'id' | 'name' | 'domainName'>;
export type PipelineProgressForBoard = Opportunity;
export type CompanyProgress = {
company: CompanyForBoard;
pipelineProgress: PipelineProgressForBoard;
opportunity: Opportunity;
};
export type CompanyProgressDict = {

View File

@ -1,47 +1,34 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { MainIdentifierMapper } from '@/ui/object/field/types/MainIdentifierMapper';
import { AvatarType } from '@/users/components/Avatar';
import { Nullable } from '~/types/Nullable';
export const useObjectMainIdentifier = (
objectMetadataItem?: Nullable<ObjectMetadataItem>,
) => {
if (!objectMetadataItem) {
return {
mainIdentifierMapper: undefined,
mainIdentifierFieldMetadataId: undefined,
basePathToShowPage: undefined,
};
return {};
}
const mainIdentifierMapper: MainIdentifierMapper = (record: any) => {
if (objectMetadataItem.nameSingular === 'company') {
return {
id: record.id,
name: record.name,
avatarUrl: record.avatarUrl,
avatarType: 'squared',
record: record,
};
}
if (objectMetadataItem.nameSingular === 'workspaceMember') {
return {
id: record.id,
name: record.name.firstName + ' ' + record.name.lastName,
avatarUrl: record.avatarUrl,
avatarType: 'rounded',
record: record,
};
}
return {
id: record.id,
name: record.name,
avatarUrl: record.avatarUrl,
avatarType: 'rounded',
record: record,
};
};
const labelIdentifierFieldPaths = ['person', 'workspaceMember'].includes(
objectMetadataItem.nameSingular,
)
? ['name.firstName', 'name.lastName']
: ['name'];
const imageIdentifierFormat: AvatarType = ['company'].includes(
objectMetadataItem.nameSingular,
)
? 'squared'
: 'rounded';
const imageIdentifierUrlPrefix = ['company'].includes(
objectMetadataItem.nameSingular,
)
? 'https://favicon.twenty.com/'
: '';
const imageIdentifierUrlField = ['company'].includes(
objectMetadataItem.nameSingular,
)
? 'domainName'
: 'avatarUrl';
const mainIdentifierFieldMetadataId = objectMetadataItem.fields.find(
({ name }) => name === 'name',
@ -50,7 +37,10 @@ export const useObjectMainIdentifier = (
const basePathToShowPage = `/object/${objectMetadataItem.nameSingular}/`;
return {
mainIdentifierMapper,
labelIdentifierFieldPaths,
imageIdentifierUrlField,
imageIdentifierUrlPrefix,
imageIdentifierFormat,
mainIdentifierFieldMetadataId,
basePathToShowPage,
};

View File

@ -1,8 +1,7 @@
import { parseFieldRelationType } from '@/object-metadata/utils/parseFieldRelationType';
import { FieldMetadata } from '@/ui/object/field/types/FieldMetadata';
import { MainIdentifierMapper } from '@/ui/object/field/types/MainIdentifierMapper';
import { ColumnDefinition } from '@/ui/object/record-table/types/ColumnDefinition';
import { getLogoUrlFromDomainName } from '~/utils';
import { AvatarType } from '@/users/components/Avatar';
import { FieldMetadataItem } from '../types/FieldMetadataItem';
@ -18,34 +17,26 @@ export const formatFieldMetadataItemAsColumnDefinition = ({
const relationObjectMetadataItem =
field.toRelationMetadata?.fromObjectMetadata;
const mainIdentifierMapper: MainIdentifierMapper = (record: any) => {
if (relationObjectMetadataItem?.nameSingular === 'company') {
return {
id: record.id,
name: record.name,
avatarUrl: getLogoUrlFromDomainName(record.domainName),
avatarType: 'squared',
record: record,
};
}
if (relationObjectMetadataItem?.nameSingular === 'workspaceMember') {
return {
id: record.id,
name: record.name.firstName + ' ' + record.name.lastName,
avatarUrl: record.avatarUrl,
avatarType: 'rounded',
record: record,
};
}
return {
id: record.id,
name: record.name,
avatarUrl: record.avatarUrl,
avatarType: 'rounded',
record: record,
};
};
const labelIdentifierFieldPaths = ['person', 'workspaceMember'].includes(
relationObjectMetadataItem?.nameSingular ?? '',
)
? ['name.firstName', 'name.lastName']
: ['name'];
const imageIdentifierFormat: AvatarType = ['company'].includes(
relationObjectMetadataItem?.nameSingular ?? '',
)
? 'squared'
: 'rounded';
const imageIdentifierUrlPrefix = ['company'].includes(
relationObjectMetadataItem?.nameSingular ?? '',
)
? 'https://favicon.twenty.com/'
: '';
const imageIdentifierUrlField = ['company'].includes(
relationObjectMetadataItem?.nameSingular ?? '',
)
? 'domainName'
: 'avatarUrl';
return {
position,
@ -56,11 +47,15 @@ export const formatFieldMetadataItemAsColumnDefinition = ({
metadata: {
fieldName: field.name,
placeHolder: field.label,
mainIdentifierMapper: mainIdentifierMapper,
labelIdentifierFieldPaths,
imageIdentifierUrlField,
imageIdentifierUrlPrefix,
imageIdentifierFormat,
relationType: parseFieldRelationType(field),
searchFields: ['name'],
objectMetadataNamePlural: relationObjectMetadataItem?.namePlural,
objectMetadataNameSingular: relationObjectMetadataItem?.nameSingular,
objectMetadataNamePlural: relationObjectMetadataItem?.namePlural ?? '',
objectMetadataNameSingular:
relationObjectMetadataItem?.nameSingular ?? '',
},
iconName: field.icon ?? 'Icon123',
isVisible: true,

View File

@ -22,9 +22,12 @@ export const RecordTableEffect = () => {
});
const {
mainIdentifierMapper,
basePathToShowPage,
mainIdentifierFieldMetadataId,
labelIdentifierFieldPaths,
imageIdentifierUrlField,
imageIdentifierUrlPrefix,
imageIdentifierFormat,
} = useObjectMainIdentifier(objectMetadataItem);
const { columnDefinitions, filterDefinitions, sortDefinitions } =
@ -40,13 +43,12 @@ export const RecordTableEffect = () => {
} = useView();
useEffect(() => {
if (
mainIdentifierMapper &&
basePathToShowPage &&
mainIdentifierFieldMetadataId
) {
if (basePathToShowPage && mainIdentifierFieldMetadataId) {
setObjectMetadataConfig?.({
mainIdentifierMapper,
labelIdentifierFieldPaths,
imageIdentifierUrlField,
imageIdentifierUrlPrefix,
imageIdentifierFormat,
basePathToShowPage,
mainIdentifierFieldMetadataId,
});
@ -55,8 +57,11 @@ export const RecordTableEffect = () => {
basePathToShowPage,
objectMetadataItem,
mainIdentifierFieldMetadataId,
mainIdentifierMapper,
setObjectMetadataConfig,
labelIdentifierFieldPaths,
imageIdentifierUrlField,
imageIdentifierUrlPrefix,
imageIdentifierFormat,
]);
useEffect(() => {

View File

@ -1,3 +1,4 @@
import { useCallback } from 'react';
import { useMutation } from '@apollo/client';
import { getOperationName } from '@apollo/client/utilities';
@ -20,20 +21,25 @@ export const useDeleteOneObjectRecord = <T>({
// TODO: type this with a minimal type at least with Record<string, any>
const [mutate] = useMutation(deleteOneMutation);
const deleteOneObject =
objectNameSingular && foundObjectMetadataItem
? async (idToDelete: string) => {
const deletedObject = await mutate({
variables: {
idToDelete,
},
refetchQueries: [getOperationName(findManyQuery) ?? ''],
});
return deletedObject.data[
`create${capitalize(objectNameSingular)}`
] as T;
}
: undefined;
const deleteOneObject = useCallback(
async (idToDelete: string) => {
if (objectNameSingular && foundObjectMetadataItem) {
const deletedObject = await mutate({
variables: {
idToDelete,
},
refetchQueries: [getOperationName(findManyQuery) ?? ''],
});
return deletedObject.data[
`create${capitalize(objectNameSingular)}`
] as T;
}
return null;
},
[foundObjectMetadataItem, mutate, objectNameSingular, findManyQuery],
);
return {
deleteOneObject,

View File

@ -16,5 +16,12 @@ export const filterAvailableTableColumns = (
return false;
}
if (
isFieldRelation(columnDefinition) &&
columnDefinition.metadata?.fieldName === 'pipelineStep'
) {
return false;
}
return true;
};

View File

@ -19,7 +19,7 @@ export const PipelineAddButton = () => {
const handleCompanySelected = (
selectedCompany: EntityForSelect | null,
selectedPipelineStageId: string | null,
selectedPipelineStepId: string | null,
) => {
if (!selectedCompany?.id) {
enqueueSnackBar(
@ -33,7 +33,7 @@ export const PipelineAddButton = () => {
return;
}
if (!selectedPipelineStageId) {
if (!selectedPipelineStepId) {
enqueueSnackBar(
'There was a problem with the pipeline stage selection, please retry.',
{
@ -45,7 +45,7 @@ export const PipelineAddButton = () => {
return;
}
closeDropdown();
//createCompanyProgress(selectedCompany.id, selectedPipelineStageId);
//createCompanyProgress(selectedCompany.id, selectedPipelineStepId);
};
return (

View File

@ -7,7 +7,7 @@ import { BoardColumnDefinition } from '@/ui/layout/board/types/BoardColumnDefini
import { currentPipelineState } from '../states/currentPipelineState';
export const usePipelineStages = () => {
export const usePipelineSteps = () => {
const currentPipeline = useRecoilValue(currentPipelineState);
const { createOneObject: createOnePipelineStep } =
@ -20,7 +20,7 @@ export const usePipelineStages = () => {
objectNameSingular: 'pipelineStep',
});
const handlePipelineStageAdd = async (boardColumn: BoardColumnDefinition) => {
const handlePipelineStepAdd = async (boardColumn: BoardColumnDefinition) => {
if (!currentPipeline?.id) return;
return createOnePipelineStep?.({
@ -37,11 +37,11 @@ export const usePipelineStages = () => {
});
};
const handlePipelineStageDelete = async (boardColumnId: string) => {
const handlePipelineStepDelete = async (boardColumnId: string) => {
if (!currentPipeline?.id) return;
return deleteOnePipelineStep?.(boardColumnId);
};
return { handlePipelineStageAdd, handlePipelineStageDelete };
return { handlePipelineStepAdd, handlePipelineStepDelete };
};

View File

@ -2,6 +2,7 @@ import { Person } from '@/people/types/Person';
import { PipelineStep } from '@/pipeline/types/PipelineStep';
export type Opportunity = {
[key: string]: any;
id: string;
amount: {
amountMicros: number;
@ -13,5 +14,4 @@ export type Opportunity = {
pipelineStep: PipelineStep;
pointOfContactId: string;
pointOfContact: Pick<Person, 'id' | 'name' | 'avatarUrl'>;
[key: string]: any;
};

View File

@ -92,7 +92,10 @@ export const SettingsObjectFieldPreview = ({
const {
defaultValue: relationDefaultValue,
recordMapper: mainIdentifierMapper,
labelIdentifierFieldPaths,
imageIdentifierUrlField,
imageIdentifierUrlPrefix,
imageIdentifierFormat,
} = useRelationFieldPreview({
relationObjectMetadataId,
skipDefaultValue:
@ -104,6 +107,15 @@ export const SettingsObjectFieldPreview = ({
? relationDefaultValue
: dataTypes[fieldMetadata.type].defaultValue;
if (
!labelIdentifierFieldPaths ||
!imageIdentifierUrlField ||
!imageIdentifierUrlPrefix ||
!imageIdentifierFormat
) {
return <></>;
}
return (
<StyledContainer className={className}>
<StyledObjectSummary>
@ -146,7 +158,13 @@ export const SettingsObjectFieldPreview = ({
iconName: 'FieldIcon',
fieldMetadataId: fieldMetadata.id || '',
label: fieldMetadata.label,
metadata: { fieldName, mainIdentifierMapper },
metadata: {
fieldName,
labelIdentifierFieldPaths,
imageIdentifierUrlField,
imageIdentifierUrlPrefix,
imageIdentifierFormat,
},
},
hotkeyScope: 'field-preview',
}}

View File

@ -1,7 +1,6 @@
import { useObjectMainIdentifier } from '@/object-metadata/hooks/useObjectMainIdentifier';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { useFindManyObjectRecords } from '@/object-record/hooks/useFindManyObjectRecords';
import { MainIdentifierMapper } from '@/ui/object/field/types/MainIdentifierMapper';
export const useRelationFieldPreview = ({
relationObjectMetadataId,
@ -21,24 +20,18 @@ export const useRelationFieldPreview = ({
skip: skipDefaultValue || !relationObjectMetadataItem,
});
const { mainIdentifierMapper } = useObjectMainIdentifier(
relationObjectMetadataItem,
);
const recordMapper: MainIdentifierMapper | undefined =
relationObjectMetadataItem && mainIdentifierMapper
? (record: { id: string }) => {
const mappedRecord = mainIdentifierMapper(record);
return {
...mappedRecord,
name: mappedRecord.name || relationObjectMetadataItem.labelSingular,
};
}
: undefined;
const {
labelIdentifierFieldPaths,
imageIdentifierUrlField,
imageIdentifierUrlPrefix,
imageIdentifierFormat,
} = useObjectMainIdentifier(relationObjectMetadataItem);
return {
defaultValue: relationObjects?.[0],
recordMapper,
labelIdentifierFieldPaths,
imageIdentifierUrlField,
imageIdentifierUrlPrefix,
imageIdentifierFormat,
};
};

View File

@ -74,6 +74,7 @@ export const BoardOptionsDropdownContent = ({
hiddenBoardCardFieldsScopedSelector,
BoardRecoilScopeContext,
);
const hasHiddenFields = hiddenBoardCardFields.length > 0;
const visibleBoardCardFields = useRecoilScopedValue(
visibleBoardCardFieldsScopedSelector,

View File

@ -65,11 +65,11 @@ export const EntityBoard = ({
const { unselectAllActiveCards } = useCurrentCardSelected();
const updatePipelineProgressStageInDB = useCallback(
async (pipelineProgressId: string, pipelineStageId: string) => {
async (pipelineProgressId: string, pipelineStepId: string) => {
await updateOneOpportunity?.({
idToUpdate: pipelineProgressId,
input: {
pipelineStepId: pipelineStageId,
pipelineStepId: pipelineStepId,
},
});
@ -80,7 +80,7 @@ export const EntityBoard = ({
__typename: 'PipelineProgress',
}),
fields: {
pipelineStageId: () => pipelineStageId,
pipelineStepId: () => pipelineStepId,
},
});
},

View File

@ -1,3 +1,4 @@
import { useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
import { IconTrash } from '@/ui/display/icon';
@ -5,19 +6,22 @@ import { useDeleteSelectedBoardCards } from '@/ui/layout/board/hooks/useDeleteSe
import { actionBarEntriesState } from '@/ui/navigation/action-bar/states/actionBarEntriesState';
export const useBoardActionBarEntries = () => {
const setActionBarEntries = useSetRecoilState(actionBarEntriesState);
const setActionBarEntriesRecoil = useSetRecoilState(actionBarEntriesState);
const deleteSelectedBoardCards = useDeleteSelectedBoardCards();
const setActionBarEntries = useCallback(() => {
setActionBarEntriesRecoil([
{
label: 'Delete',
Icon: IconTrash,
accent: 'danger',
onClick: deleteSelectedBoardCards,
},
]);
}, [deleteSelectedBoardCards, setActionBarEntriesRecoil]);
return {
setActionBarEntries: () =>
setActionBarEntries([
{
label: 'Delete',
Icon: IconTrash,
accent: 'danger',
onClick: deleteSelectedBoardCards,
},
]),
setActionBarEntries,
};
};

View File

@ -17,7 +17,7 @@ export const useBoardColumns = () => {
objectNameSingular: 'pipelineStep',
});
const updatedPipelineStages = (stages: BoardColumnDefinition[]) => {
const updatedPipelineSteps = (stages: BoardColumnDefinition[]) => {
if (!stages.length) return;
return Promise.all(
@ -33,7 +33,7 @@ export const useBoardColumns = () => {
};
const persistBoardColumns = async () => {
await updatedPipelineStages(boardColumns);
await updatedPipelineSteps(boardColumns);
};
const handleMoveBoardColumn = (

View File

@ -1,3 +1,4 @@
import { useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
import { IconTrash } from '@/ui/display/icon';
@ -5,19 +6,24 @@ import { useDeleteSelectedBoardCards } from '@/ui/layout/board/hooks/useDeleteSe
import { contextMenuEntriesState } from '@/ui/navigation/context-menu/states/contextMenuEntriesState';
export const useBoardContextMenuEntries = () => {
const setContextMenuEntries = useSetRecoilState(contextMenuEntriesState);
const setContextMenuEntriesRecoil = useSetRecoilState(
contextMenuEntriesState,
);
const deleteSelectedBoardCards = useDeleteSelectedBoardCards();
const setContextMenuEntries = useCallback(() => {
setContextMenuEntriesRecoil([
{
label: 'Delete',
Icon: IconTrash,
accent: 'danger',
onClick: deleteSelectedBoardCards,
},
]);
}, [deleteSelectedBoardCards, setContextMenuEntriesRecoil]);
return {
setContextMenuEntries: () =>
setContextMenuEntries([
{
label: 'Delete',
Icon: IconTrash,
accent: 'danger',
onClick: () => deleteSelectedBoardCards(),
},
]),
setContextMenuEntries,
};
};

View File

@ -34,7 +34,7 @@ export const useDeleteSelectedBoardCards = () => {
apolloClient.cache.evict({ id: `Opportunity:${id}` });
});
},
[apolloClient.cache, deleteOneOpportunity, removeCardIds],
[apolloClient.cache, removeCardIds, deleteOneOpportunity],
);
return deleteSelectedBoardCards;

View File

@ -1,13 +1,11 @@
import { atomFamily } from 'recoil';
import { FieldMetadata } from '@/ui/object/field/types/FieldMetadata';
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
import { BoardFieldDefinition } from '../types/BoardFieldDefinition';
export const availableBoardCardFieldsScopedState = atomFamily<
BoardFieldDefinition<FieldMetadata>[],
string
export const availableBoardCardFieldsScopedState = createScopedState<
BoardFieldDefinition<FieldMetadata>[]
>({
key: 'availableBoardCardFieldsScopedState',
default: [],
defaultValue: [],
});

View File

@ -9,20 +9,21 @@ import { boardCardIdsByColumnIdFamilyState } from '../boardCardIdsByColumnIdFami
export const boardColumnTotalsFamilySelector = selectorFamily({
key: 'boardColumnTotalsFamilySelector',
get:
(pipelineStageId: string) =>
(pipelineStepId: string) =>
({ get }) => {
const cardIds = get(boardCardIdsByColumnIdFamilyState(pipelineStageId));
const cardIds = get(boardCardIdsByColumnIdFamilyState(pipelineStepId));
const pipelineProgresses = cardIds.map((pipelineProgressId: string) =>
get(companyProgressesFamilyState(pipelineProgressId)),
const opportunities = cardIds.map((opportunityId: string) =>
get(companyProgressesFamilyState(opportunityId)),
);
const pipelineStageTotal: number =
pipelineProgresses?.reduce(
(acc: number, curr: any) => acc + curr?.pipelineProgress.amount,
const pipelineStepTotal: number =
opportunities?.reduce(
(acc: number, curr: any) =>
acc + curr?.opportunity.amount.amountMicros / 1000000,
0,
) || 0;
return pipelineStageTotal;
return pipelineStepTotal;
},
});

View File

@ -11,7 +11,7 @@ export const hiddenBoardCardFieldsScopedSelector = selectorFamily({
const fields = get(boardCardFieldsScopedState(scopeId));
const fieldKeys = fields.map(({ fieldMetadataId }) => fieldMetadataId);
const otherAvailableKeys = get(
availableBoardCardFieldsScopedState(scopeId),
availableBoardCardFieldsScopedState({ scopeId }),
).filter(({ fieldMetadataId }) => !fieldKeys.includes(fieldMetadataId));
return [

View File

@ -5,19 +5,20 @@ import { useRelationField } from '../../hooks/useRelationField';
export const RelationFieldDisplay = () => {
const { fieldValue, fieldDefinition } = useRelationField();
const { mapToObjectIdentifiers } = useRelationField();
if (!fieldValue || !fieldDefinition) {
return <></>;
}
const mainIdentifierMapped =
fieldDefinition.metadata.mainIdentifierMapper(fieldValue);
const objectIdentifiers = mapToObjectIdentifiers(fieldValue);
return (
<EntityChip
entityId={fieldValue.id}
name={mainIdentifierMapped.name}
avatarUrl={mainIdentifierMapped.avatarUrl}
avatarType={mainIdentifierMapped.avatarType}
name={objectIdentifiers.name}
avatarUrl={objectIdentifiers.avatarUrl}
avatarType={objectIdentifiers.avatarType}
/>
);
};

View File

@ -30,11 +30,40 @@ export const useRelationField = () => {
const initialValue = fieldInitialValue?.isEmpty ? null : fieldValue;
const mapToObjectIdentifiers = (record: any) => {
let name = '';
for (const fieldPath of fieldDefinition.metadata
.labelIdentifierFieldPaths) {
const fieldPathParts = fieldPath.split('.');
if (fieldPathParts.length === 1) {
name += record[fieldPathParts[0]];
} else if (fieldPathParts.length === 2) {
name += record[fieldPathParts[0]][fieldPathParts[1]];
} else {
throw new Error(
`Invalid field path ${fieldPath}. Relation picker only supports field paths with 1 or 2 parts.`,
);
}
}
return {
id: record.id,
name: record[name],
avatarUrl:
fieldDefinition.metadata.imageIdentifierUrlPrefix +
record[fieldDefinition.metadata.imageIdentifierUrlField],
avatarType: fieldDefinition.metadata.imageIdentifierFormat,
record: record,
};
};
return {
fieldDefinition,
fieldValue,
initialValue,
initialSearchValue,
setFieldValue,
mapToObjectIdentifiers,
};
};

View File

@ -7,6 +7,7 @@ import { IconUserCircle } from '@/ui/display/icon';
import { SingleEntitySelect } from '@/ui/input/relation-picker/components/SingleEntitySelect';
import { relationPickerSearchFilterScopedState } from '@/ui/input/relation-picker/states/relationPickerSearchFilterScopedState';
import { EntityForSelect } from '@/ui/input/relation-picker/types/EntityForSelect';
import { useRelationField } from '@/ui/object/field/meta-types/hooks/useRelationField';
import { FieldDefinition } from '@/ui/object/field/types/FieldDefinition';
import { FieldRelationMetadata } from '@/ui/object/field/types/FieldMetadata';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
@ -41,6 +42,8 @@ export const RelationPicker = ({
const useFindManyQuery = (options: any) => useQuery(findManyQuery, options);
const { mapToObjectIdentifiers } = useRelationField();
const workspaceMembers = useFilteredSearchEntityQuery({
queryHook: useFindManyQuery,
filters: [
@ -50,7 +53,7 @@ export const RelationPicker = ({
},
],
orderByField: 'createdAt',
mappingFunction: fieldDefinition.metadata.mainIdentifierMapper,
mappingFunction: mapToObjectIdentifiers,
selectedIds: recordId ? [recordId] : [],
objectNamePlural: fieldDefinition.metadata.objectMetadataNamePlural,
});

View File

@ -1,5 +1,4 @@
import { EntityForSelect } from '@/ui/input/relation-picker/types/EntityForSelect';
import { MainIdentifierMapper } from '@/ui/object/field/types/MainIdentifierMapper';
export type FieldUuidMetadata = {
fieldName: string;
@ -65,7 +64,10 @@ export type FieldRelationMetadata = {
fieldName: string;
useEditButton?: boolean;
relationType?: FieldDefinitionRelationType;
mainIdentifierMapper: MainIdentifierMapper;
labelIdentifierFieldPaths: string[];
imageIdentifierUrlField: string;
imageIdentifierUrlPrefix: string;
imageIdentifierFormat: 'squared' | 'rounded';
searchFields: string[];
objectMetadataNameSingular: string;
objectMetadataNamePlural: string;

View File

@ -1,9 +0,0 @@
import { AvatarType } from '@/users/components/Avatar';
export type MainIdentifierMapper = (record: any) => {
id: string;
name: string;
avatarUrl?: string;
avatarType: AvatarType;
record: any;
};

View File

@ -1,14 +0,0 @@
import { MainIdentifierMapper } from '@/ui/object/field/types/MainIdentifierMapper';
export type FieldDefinitionRelationType =
| 'FROM_MANY_OBJECTS'
| 'FROM_ONE_OBJECT'
| 'TO_MANY_OBJECTS'
| 'TO_ONE_OBJECT';
export type RelationFieldConfig = {
relationType?: FieldDefinitionRelationType;
mainIdentifierMapper: MainIdentifierMapper;
searchFields: string[];
objectMetadataNameSingular: string;
};

View File

@ -63,7 +63,6 @@ export const RecordTableCell = ({ cellIndex }: { cellIndex: number }) => {
isMainIdentifier:
columnDefinition.fieldMetadataId ===
objectMetadataConfig?.mainIdentifierFieldMetadataId,
mainIdentifierMapper: objectMetadataConfig?.mainIdentifierMapper,
}}
>
<TableCell customHotkeyScope={{ scope: customHotkeyScope }} />

View File

@ -2,10 +2,9 @@ import { AvatarType } from '@/users/components/Avatar';
export type ObjectMetadataConfig = {
mainIdentifierFieldMetadataId: string;
mainIdentifierMapper: (record: any) => {
name: string;
avatarUrl?: string;
avatarType: AvatarType;
};
labelIdentifierFieldPaths: string[];
imageIdentifierUrlField: string;
imageIdentifierUrlPrefix: string;
imageIdentifierFormat: AvatarType;
basePathToShowPage: string;
};

View File

@ -6,7 +6,7 @@ import { CompanyBoardRecoilScopeContext } from '@/companies/states/recoil-scope-
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useUpdateOneObjectRecord } from '@/object-record/hooks/useUpdateOneObjectRecord';
import { PipelineAddButton } from '@/pipeline/components/PipelineAddButton';
import { usePipelineStages } from '@/pipeline/hooks/usePipelineStages';
import { usePipelineSteps } from '@/pipeline/hooks/usePipelineSteps';
import { PipelineStep } from '@/pipeline/types/PipelineStep';
import { IconTargetArrow } from '@/ui/display/icon';
import { BoardOptionsContext } from '@/ui/layout/board/contexts/BoardOptionsContext';
@ -23,8 +23,8 @@ const StyledBoardContainer = styled.div`
`;
export const Opportunities = () => {
const { handlePipelineStageAdd, handlePipelineStageDelete } =
usePipelineStages();
const { handlePipelineStepAdd, handlePipelineStepDelete } =
usePipelineSteps();
const { updateOneObject: updateOnePipelineStep } =
useUpdateOneObjectRecord<PipelineStep>({
@ -68,8 +68,8 @@ export const Opportunities = () => {
<CompanyBoardRecoilScopeContext.Provider value="opportunities">
<StyledBoardContainer>
<CompanyBoard
onColumnAdd={handlePipelineStageAdd}
onColumnDelete={handlePipelineStageDelete}
onColumnAdd={handlePipelineStepAdd}
onColumnDelete={handlePipelineStepDelete}
onEditColumnTitle={handleEditColumnTitle}
/>
</StyledBoardContainer>

View File

@ -43,17 +43,17 @@ export const AddCompanyFromHeader: Story = {
});
await step('Change pipeline stage', async () => {
const pipelineStageDropdownHeader = await canvas.findByRole(
const pipelineStepDropdownHeader = await canvas.findByRole(
'listitem',
{ name: (_, element) => !!element?.textContent?.includes('New') },
{ timeout: 1000 },
);
const pipelineStageDropdownUnfoldButton = within(
pipelineStageDropdownHeader,
const pipelineStepDropdownUnfoldButton = within(
pipelineStepDropdownHeader,
).getByRole('button');
await userEvent.click(pipelineStageDropdownUnfoldButton);
await userEvent.click(pipelineStepDropdownUnfoldButton);
const menuItem1 = await canvas.findByRole(
'listitem',