Add probability picker on Opportunity card (#747)

* Fix padding

* Update date input component

* Add Probability picker component on opportunity card

* lint
This commit is contained in:
Emilien Chauvet
2023-07-18 23:54:34 -07:00
committed by GitHub
parent 8a23a65c17
commit c2fb8fd040
6 changed files with 214 additions and 22 deletions

View File

@ -1,16 +1,16 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { getOperationName } from '@apollo/client/utilities'; import { getOperationName } from '@apollo/client/utilities';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import { companyProgressesFamilyState } from '@/companies/states/companyProgressesFamilyState'; import { companyProgressesFamilyState } from '@/companies/states/companyProgressesFamilyState';
import { ProbabilityEditableField } from '@/pipeline/editable-field/components/ProbabilityEditableField';
import { GET_PIPELINE_PROGRESS, GET_PIPELINES } from '@/pipeline/queries'; import { GET_PIPELINE_PROGRESS, GET_PIPELINES } from '@/pipeline/queries';
import { BoardCardContext } from '@/pipeline/states/BoardCardContext'; import { BoardCardContext } from '@/pipeline/states/BoardCardContext';
import { pipelineProgressIdScopedState } from '@/pipeline/states/pipelineProgressIdScopedState'; import { pipelineProgressIdScopedState } from '@/pipeline/states/pipelineProgressIdScopedState';
import { selectedBoardCardsState } from '@/pipeline/states/selectedBoardCardsState'; import { selectedBoardCardsState } from '@/pipeline/states/selectedBoardCardsState';
import { BoardCardEditableFieldDate } from '@/ui/board/card-field/components/BoardCardEditableFieldDate';
import { ChipVariant } from '@/ui/chip/components/EntityChip'; import { ChipVariant } from '@/ui/chip/components/EntityChip';
import { DateEditableField } from '@/ui/editable-field/variants/components/DateEditableField';
import { NumberEditableField } from '@/ui/editable-field/variants/components/NumberEditableField'; import { NumberEditableField } from '@/ui/editable-field/variants/components/NumberEditableField';
import { IconCheck, IconCurrencyDollar } from '@/ui/icon'; import { IconCheck, IconCurrencyDollar } from '@/ui/icon';
import { IconCalendarEvent } from '@/ui/icon'; import { IconCalendarEvent } from '@/ui/icon';
@ -74,8 +74,6 @@ const StyledBoardCardBody = styled.div`
`; `;
export function CompanyBoardCard() { export function CompanyBoardCard() {
const theme = useTheme();
const [updatePipelineProgress] = useUpdateOnePipelineProgressMutation(); const [updatePipelineProgress] = useUpdateOnePipelineProgressMutation();
const [pipelineProgressId] = useRecoilScopedState( const [pipelineProgressId] = useRecoilScopedState(
@ -155,21 +153,19 @@ export function CompanyBoardCard() {
} }
/> />
<CompanyAccountOwnerEditableField company={company} /> <CompanyAccountOwnerEditableField company={company} />
<span> <DateEditableField
<IconCalendarEvent size={theme.icon.size.md} /> icon={<IconCalendarEvent />}
<BoardCardEditableFieldDate value={pipelineProgress.closeDate || new Date().toISOString()}
value={new Date(pipelineProgress.closeDate || Date.now())} onSubmit={(value) =>
onChange={(value) => { handleCardUpdate({
handleCardUpdate({ ...pipelineProgress,
...pipelineProgress, closeDate: value,
closeDate: value.toISOString(), })
}); }
}} />
/>
</span> <ProbabilityEditableField
<NumberEditableField
icon={<IconCheck />} icon={<IconCheck />}
placeholder="Opportunity probability for closing"
value={pipelineProgress.probability} value={pipelineProgress.probability}
onSubmit={(value) => onSubmit={(value) =>
handleCardUpdate({ handleCardUpdate({

View File

@ -0,0 +1,71 @@
import { useEffect, useState } from 'react';
import { EditableField } from '@/ui/editable-field/components/EditableField';
import { FieldContext } from '@/ui/editable-field/states/FieldContext';
import { RecoilScope } from '@/ui/recoil-scope/components/RecoilScope';
import { ProbabilityFieldEditMode } from './ProbabilityFieldEditMode';
type OwnProps = {
icon?: React.ReactNode;
value: number | null | undefined;
onSubmit?: (newValue: number) => void;
};
export function ProbabilityEditableField({ icon, value, onSubmit }: OwnProps) {
const [internalValue, setInternalValue] = useState(value);
useEffect(() => {
setInternalValue(value);
}, [value]);
async function handleChange(newValue: number) {
setInternalValue(newValue);
}
async function handleSubmit() {
if (!internalValue) return;
try {
const numberValue = internalValue;
if (isNaN(numberValue)) {
throw new Error('Not a number');
}
if (numberValue < 0 || numberValue > 100) {
throw new Error('Not a probability');
}
onSubmit?.(numberValue);
setInternalValue(numberValue);
} catch {
handleCancel();
}
}
async function handleCancel() {
setInternalValue(value);
}
return (
<RecoilScope SpecificContext={FieldContext}>
<EditableField
onSubmit={handleSubmit}
onCancel={handleCancel}
iconLabel={icon}
displayModeContentOnly
disableHoverEffect
displayModeContent={
<ProbabilityFieldEditMode
value={internalValue ?? 0}
onChange={(newValue: number) => {
handleChange(newValue);
}}
/>
}
/>
</RecoilScope>
);
}

View File

@ -0,0 +1,112 @@
import { useState } from 'react';
import styled from '@emotion/styled';
import { useEditableField } from '@/ui/editable-field/hooks/useEditableField';
import { HotkeyScope } from '@/ui/hotkey/types/HotkeyScope';
const StyledContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
justify-content: flex-start;
width: 100%;
`;
const StyledProgressBarItemContainer = styled.div`
padding-right: ${({ theme }) => theme.spacing(1)};
`;
const StyledProgressBarItem = styled.div<{
isFirst: boolean;
isLast: boolean;
isActive: boolean;
}>`
background-color: ${({ theme, isActive }) =>
isActive
? theme.font.color.secondary
: theme.background.transparent.medium};
border-bottom-left-radius: ${({ theme, isFirst }) =>
isFirst ? theme.border.radius.sm : theme.border.radius.xs};
border-bottom-right-radius: ${({ theme, isLast }) =>
isLast ? theme.border.radius.sm : theme.border.radius.xs};
border-top-left-radius: ${({ theme, isFirst }) =>
isFirst ? theme.border.radius.sm : theme.border.radius.xs};
border-top-right-radius: ${({ theme, isLast }) =>
isLast ? theme.border.radius.sm : theme.border.radius.xs};
height: ${({ theme }) => theme.spacing(2)};
width: ${({ theme }) => theme.spacing(3)};
`;
const StyledProgressBarContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
justify-content: flex-start;
width: 100%;
`;
const StyledLabel = styled.div`
width: ${({ theme }) => theme.spacing(12)};
`;
type OwnProps = {
value: number;
onChange?: (newValue: number) => void;
parentHotkeyScope?: HotkeyScope;
};
const PROBABILITY_VALUES = [
{ label: 'Lost', value: 0 },
{ label: '25%', value: 25 },
{ label: '50%', value: 50 },
{ label: '75%', value: 75 },
{ label: '100%', value: 100 },
];
export function ProbabilityFieldEditMode({ value, onChange }: OwnProps) {
const [nextProbabilityIndex, setNextProbabilityIndex] = useState<
number | null
>(null);
const probabilityIndex = Math.ceil(value / 25);
const { closeEditableField } = useEditableField();
function handleChange(newValue: number) {
onChange?.(newValue);
closeEditableField();
}
return (
<StyledContainer>
<StyledLabel>
{
PROBABILITY_VALUES[
nextProbabilityIndex || nextProbabilityIndex === 0
? nextProbabilityIndex
: probabilityIndex
].label
}
</StyledLabel>
<StyledProgressBarContainer>
{PROBABILITY_VALUES.map((probability, i) => (
<StyledProgressBarItemContainer
onClick={() => handleChange(probability.value)}
onMouseEnter={() => setNextProbabilityIndex(i)}
onMouseLeave={() => setNextProbabilityIndex(null)}
>
<StyledProgressBarItem
isActive={
nextProbabilityIndex || nextProbabilityIndex === 0
? i <= nextProbabilityIndex
: i <= probabilityIndex
}
key={probability.label}
isFirst={i === 0}
isLast={i === PROBABILITY_VALUES.length - 1}
/>
</StyledProgressBarItemContainer>
))}
</StyledProgressBarContainer>
</StyledContainer>
);
}

View File

@ -6,6 +6,7 @@ export const StyledBoard = styled.div`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
overflow-x: auto; overflow-x: auto;
padding-left: ${({ theme }) => theme.spacing(2)};
width: 100%; width: 100%;
`; `;

View File

@ -59,7 +59,9 @@ type OwnProps = {
label?: string; label?: string;
labelFixedWidth?: number; labelFixedWidth?: number;
useEditButton?: boolean; useEditButton?: boolean;
editModeContent: React.ReactNode; editModeContent?: React.ReactNode;
displayModeContentOnly?: boolean;
disableHoverEffect?: boolean;
displayModeContent: React.ReactNode; displayModeContent: React.ReactNode;
parentHotkeyScope?: HotkeyScope; parentHotkeyScope?: HotkeyScope;
customEditHotkeyScope?: HotkeyScope; customEditHotkeyScope?: HotkeyScope;
@ -77,7 +79,9 @@ export function EditableField({
displayModeContent, displayModeContent,
parentHotkeyScope, parentHotkeyScope,
customEditHotkeyScope, customEditHotkeyScope,
disableHoverEffect,
isDisplayModeContentEmpty, isDisplayModeContentEmpty,
displayModeContentOnly,
onSubmit, onSubmit,
onCancel, onCancel,
}: OwnProps) { }: OwnProps) {
@ -115,12 +119,13 @@ export function EditableField({
<StyledLabel labelFixedWidth={labelFixedWidth}>{label}</StyledLabel> <StyledLabel labelFixedWidth={labelFixedWidth}>{label}</StyledLabel>
)} )}
</StyledLabelAndIconContainer> </StyledLabelAndIconContainer>
{isFieldInEditMode ? ( {isFieldInEditMode && !displayModeContentOnly ? (
<EditableFieldEditMode onSubmit={onSubmit} onCancel={onCancel}> <EditableFieldEditMode onSubmit={onSubmit} onCancel={onCancel}>
{editModeContent} {editModeContent}
</EditableFieldEditMode> </EditableFieldEditMode>
) : ( ) : (
<EditableFieldDisplayMode <EditableFieldDisplayMode
disableHoverEffect={disableHoverEffect}
disableClick={useEditButton} disableClick={useEditButton}
onClick={handleDisplayModeClick} onClick={handleDisplayModeClick}
isDisplayModeContentEmpty={isDisplayModeContentEmpty} isDisplayModeContentEmpty={isDisplayModeContentEmpty}

View File

@ -2,7 +2,10 @@ import { css } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
export const EditableFieldNormalModeOuterContainer = styled.div< export const EditableFieldNormalModeOuterContainer = styled.div<
Pick<OwnProps, 'disableClick' | 'isDisplayModeContentEmpty'> Pick<
OwnProps,
'disableClick' | 'isDisplayModeContentEmpty' | 'disableHoverEffect'
>
>` >`
align-items: center; align-items: center;
border-radius: ${({ theme }) => theme.border.radius.sm}; border-radius: ${({ theme }) => theme.border.radius.sm};
@ -37,7 +40,8 @@ export const EditableFieldNormalModeOuterContainer = styled.div<
cursor: pointer; cursor: pointer;
&:hover { &:hover {
background-color: ${props.theme.background.transparent.light}; background-color: ${!props.disableHoverEffect &&
props.theme.background.transparent.light};
} }
`; `;
} }
@ -62,6 +66,7 @@ type OwnProps = {
disableClick?: boolean; disableClick?: boolean;
onClick?: () => void; onClick?: () => void;
isDisplayModeContentEmpty?: boolean; isDisplayModeContentEmpty?: boolean;
disableHoverEffect?: boolean;
}; };
export function EditableFieldDisplayMode({ export function EditableFieldDisplayMode({
@ -69,12 +74,14 @@ export function EditableFieldDisplayMode({
disableClick, disableClick,
onClick, onClick,
isDisplayModeContentEmpty, isDisplayModeContentEmpty,
disableHoverEffect,
}: React.PropsWithChildren<OwnProps>) { }: React.PropsWithChildren<OwnProps>) {
return ( return (
<EditableFieldNormalModeOuterContainer <EditableFieldNormalModeOuterContainer
onClick={disableClick ? undefined : onClick} onClick={disableClick ? undefined : onClick}
disableClick={disableClick} disableClick={disableClick}
isDisplayModeContentEmpty={isDisplayModeContentEmpty} isDisplayModeContentEmpty={isDisplayModeContentEmpty}
disableHoverEffect={disableHoverEffect}
> >
<EditableFieldNormalModeInnerContainer> <EditableFieldNormalModeInnerContainer>
{children} {children}