Kanban card creation revamp (#7169)

fixes #6957
This commit is contained in:
nitin
2024-09-25 22:00:39 +05:30
committed by GitHub
parent 89b50c020f
commit 7752510316
11 changed files with 384 additions and 95 deletions

View File

@@ -1,9 +1,3 @@
import styled from '@emotion/styled';
import { ReactNode, useContext, useState } from 'react';
import { useInView } from 'react-intersection-observer';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { AvatarChipVariant, IconEye } from 'twenty-ui';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates';
import { RecordBoardCardContext } from '@/object-record/record-board/record-board-card/contexts/RecordBoardCardContext';
@@ -15,15 +9,24 @@ import {
import { getFieldButtonIcon } from '@/object-record/record-field/utils/getFieldButtonIcon';
import { RecordIdentifierChip } from '@/object-record/record-index/components/RecordIndexRecordChip';
import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell';
import { RecordInlineCellEditMode } from '@/object-record/record-inline-cell/components/RecordInlineCellEditMode';
import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope';
import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { Checkbox, CheckboxVariant } from '@/ui/input/components/Checkbox';
import { TextInput } from '@/ui/input/components/TextInput';
import { contextMenuIsOpenState } from '@/ui/navigation/context-menu/states/contextMenuIsOpenState';
import { contextMenuPositionState } from '@/ui/navigation/context-menu/states/contextMenuPositionState';
import { AnimatedEaseInOut } from '@/ui/utilities/animation/components/AnimatedEaseInOut';
import { RecordBoardScrollWrapperContext } from '@/ui/utilities/scroll/contexts/ScrollWrapperContexts';
import styled from '@emotion/styled';
import { ReactNode, useContext, useState } from 'react';
import { useInView } from 'react-intersection-observer';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { AvatarChipVariant, IconEye } from 'twenty-ui';
import { useAddNewCard } from '../../record-board-column/hooks/useAddNewCard';
const StyledBoardCard = styled.div<{ selected: boolean }>`
background-color: ${({ theme, selected }) =>
@@ -61,6 +64,14 @@ const StyledBoardCard = styled.div<{ selected: boolean }>`
}
`;
const StyledTextInput = styled(TextInput)`
backdrop-filter: blur(12px) saturate(200%) contrast(50%) brightness(130%);
background: ${({ theme }) => theme.background.primary};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
width: ${({ theme }) => theme.spacing(53)};
border-radius: ${({ theme }) => theme.border.radius.sm};
`;
const StyledBoardCardWrapper = styled.div`
padding-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
@@ -130,7 +141,21 @@ const StyledRecordInlineCellPlaceholder = styled.div`
height: 24px;
`;
export const RecordBoardCard = () => {
const StyledRecordInlineCell = styled(RecordInlineCell)`
height: 24px;
`;
export const RecordBoardCard = ({
isCreating = false,
onCreateSuccess,
position,
}: {
isCreating?: boolean;
onCreateSuccess?: () => void;
position?: 'first' | 'last';
}) => {
const [newLabelValue, setNewLabelValue] = useState('');
const { handleBlur, handleInputEnter } = useAddNewCard();
const { recordId } = useContext(RecordBoardCardContext);
const { updateOneRecord, objectMetadataItem } =
useContext(RecordBoardContext);
@@ -139,7 +164,6 @@ export const RecordBoardCard = () => {
isRecordBoardCardSelectedFamilyState,
visibleFieldDefinitionsState,
} = useRecordBoardStates();
const isCompactModeActive = useRecoilValue(isCompactModeActiveState);
const [isCardInCompactMode, setIsCardInCompactMode] = useState(true);
@@ -205,66 +229,106 @@ export const RecordBoardCard = () => {
rootMargin: '1000px',
});
if (!record) {
return null;
}
const visibleFieldDefinitionsFiltered = visibleFieldDefinitions.filter(
(boardField) => !boardField.isLabelIdentifier,
);
const labelIdentifierField = visibleFieldDefinitions.find(
(field) => field.isLabelIdentifier,
);
return (
<StyledBoardCardWrapper onContextMenu={handleContextMenu}>
<RecordValueSetterEffect recordId={recordId} />
{!isCreating && <RecordValueSetterEffect recordId={recordId} />}
<StyledBoardCard
ref={cardRef}
selected={isCurrentCardSelected}
onMouseLeave={onMouseLeaveBoard}
onClick={() => {
setIsCurrentCardSelected(!isCurrentCardSelected);
if (!isCreating) {
setIsCurrentCardSelected(!isCurrentCardSelected);
}
}}
>
<StyledBoardCardHeader showCompactView={isCompactModeActive}>
<RecordIdentifierChip
objectNameSingular={objectMetadataItem.nameSingular}
record={record}
variant={AvatarChipVariant.Transparent}
/>
{isCompactModeActive && (
<StyledCompactIconContainer className="compact-icon-container">
<LightIconButton
Icon={IconEye}
accent="tertiary"
onClick={(e) => {
e.stopPropagation();
setIsCardInCompactMode(false);
}}
{isCreating && position !== undefined ? (
<RecordInlineCellEditMode>
<StyledTextInput
autoFocus
value={newLabelValue}
onInputEnter={() =>
handleInputEnter(
labelIdentifierField?.label ?? '',
newLabelValue,
position,
onCreateSuccess,
)
}
onBlur={() =>
handleBlur(
labelIdentifierField?.label ?? '',
newLabelValue,
position,
onCreateSuccess,
)
}
onChange={(text: string) => setNewLabelValue(text)}
placeholder={labelIdentifierField?.label}
/>
</StyledCompactIconContainer>
)}
<StyledCheckboxContainer className="checkbox-container">
<Checkbox
hoverable
checked={isCurrentCardSelected}
onChange={() => setIsCurrentCardSelected(!isCurrentCardSelected)}
variant={CheckboxVariant.Secondary}
</RecordInlineCellEditMode>
) : (
<RecordIdentifierChip
objectNameSingular={objectMetadataItem.nameSingular}
record={record as ObjectRecord}
variant={AvatarChipVariant.Transparent}
/>
</StyledCheckboxContainer>
)}
{!isCreating && (
<>
{isCompactModeActive && (
<StyledCompactIconContainer className="compact-icon-container">
<LightIconButton
Icon={IconEye}
accent="tertiary"
onClick={(e) => {
e.stopPropagation();
setIsCardInCompactMode(false);
}}
/>
</StyledCompactIconContainer>
)}
<StyledCheckboxContainer className="checkbox-container">
<Checkbox
hoverable
checked={isCurrentCardSelected}
onChange={() =>
setIsCurrentCardSelected(!isCurrentCardSelected)
}
variant={CheckboxVariant.Secondary}
/>
</StyledCheckboxContainer>
</>
)}
</StyledBoardCardHeader>
<StyledBoardCardBody>
<AnimatedEaseInOut
isOpen={!isCardInCompactMode || !isCompactModeActive}
initial={false}
>
<AnimatedEaseInOut
isOpen={!isCardInCompactMode || !isCompactModeActive}
initial={false}
>
<StyledBoardCardBody>
{visibleFieldDefinitionsFiltered.map((fieldDefinition) => (
<PreventSelectOnClickContainer
key={fieldDefinition.fieldMetadataId}
>
<FieldContext.Provider
value={{
recordId,
recordId: isCreating ? '' : recordId,
maxWidth: 156,
recoilScopeId: recordId + fieldDefinition.fieldMetadataId,
recoilScopeId:
(isCreating ? 'new' : recordId) +
fieldDefinition.fieldMetadataId,
isLabelIdentifier: false,
fieldDefinition: {
disableTooltip: false,
@@ -284,15 +348,15 @@ export const RecordBoardCard = () => {
}}
>
{inView ? (
<RecordInlineCell />
<StyledRecordInlineCell />
) : (
<StyledRecordInlineCellPlaceholder />
)}
</FieldContext.Provider>
</PreventSelectOnClickContainer>
))}
</AnimatedEaseInOut>
</StyledBoardCardBody>
</StyledBoardCardBody>
</AnimatedEaseInOut>
</StyledBoardCard>
</StyledBoardCardWrapper>
);

View File

@@ -1,6 +1,6 @@
import React, { useContext } from 'react';
import styled from '@emotion/styled';
import { Draggable, DroppableProvided } from '@hello-pangea/dnd';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
@@ -110,7 +110,7 @@ export const RecordBoardColumnCardsContainer = ({
CoreObjectNameSingular.Opportunity ? (
<RecordBoardColumnNewOpportunityButton />
) : (
<RecordBoardColumnNewButton />
<RecordBoardColumnNewButton columnId={columnDefinition.id} />
)}
</StyledNewButtonContainer>
</div>

View File

@@ -4,10 +4,11 @@ import { IconDotsVertical, IconPlus, Tag } from 'twenty-ui';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { RecordBoardCard } from '@/object-record/record-board/record-board-card/components/RecordBoardCard';
import { RecordBoardColumnDropdownMenu } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnDropdownMenu';
import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext';
import { useAddNewCard } from '@/object-record/record-board/record-board-column/hooks/useAddNewCard';
import { useAddNewOpportunity } from '@/object-record/record-board/record-board-column/hooks/useAddNewOpportunity';
import { useColumnNewCardActions } from '@/object-record/record-board/record-board-column/hooks/useColumnNewCardActions';
import { RecordBoardColumnHotkeyScope } from '@/object-record/record-board/types/BoardColumnHotkeyScope';
import { RecordBoardColumnDefinitionType } from '@/object-record/record-board/types/RecordBoardColumnDefinition';
import { SingleEntitySelect } from '@/object-record/relation-picker/components/SingleEntitySelect';
@@ -94,16 +95,16 @@ export const RecordBoardColumnHeader = () => {
handleCancel,
handleEntitySelect,
} = useAddNewOpportunity('first');
const { handleAddNewCardClick } = useAddNewCard('first');
const { newRecord, handleNewButtonClick, handleCreateSuccess } =
useColumnNewCardActions(columnDefinition.id);
const isOpportunity =
objectMetadataItem.nameSingular === CoreObjectNameSingular.Opportunity;
const handleClick = isOpportunity
? handleAddNewOpportunityClick
: () => {
handleAddNewCardClick();
};
: () => handleNewButtonClick('first');
return (
<>
@@ -164,6 +165,13 @@ export const RecordBoardColumnHeader = () => {
stageId={columnDefinition.id}
/>
)}
{newRecord?.isCreating && newRecord.position === 'first' && (
<RecordBoardCard
isCreating={true}
onCreateSuccess={() => handleCreateSuccess('first')}
position="first"
/>
)}
{isCreatingCard && (
<SingleEntitySelect
disableBackgroundBlur

View File

@@ -1,10 +1,10 @@
import { RecordBoardCard } from '@/object-record/record-board/record-board-card/components/RecordBoardCard';
import { useColumnNewCardActions } from '@/object-record/record-board/record-board-column/hooks/useColumnNewCardActions';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconPlus } from 'twenty-ui';
import { useAddNewCard } from '@/object-record/record-board/record-board-column/hooks/useAddNewCard';
const StyledButton = styled.button`
const StyledNewButton = styled.button`
align-items: center;
align-self: baseline;
background-color: ${({ theme }) => theme.background.primary};
@@ -15,19 +15,35 @@ const StyledButton = styled.button`
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(1)};
&:hover {
background-color: ${({ theme }) => theme.background.tertiary};
}
`;
export const RecordBoardColumnNewButton = () => {
export const RecordBoardColumnNewButton = ({
columnId,
}: {
columnId: string;
}) => {
const theme = useTheme();
const { handleAddNewCardClick } = useAddNewCard('last');
const { newRecord, handleNewButtonClick, handleCreateSuccess } =
useColumnNewCardActions(columnId);
if (newRecord.isCreating && newRecord.position === 'last') {
return (
<RecordBoardCard
isCreating={true}
onCreateSuccess={() => handleCreateSuccess('last')}
position="last"
/>
);
}
return (
<StyledButton onClick={handleAddNewCardClick}>
<StyledNewButton onClick={() => handleNewButtonClick('last')}>
<IconPlus size={theme.icon.size.md} />
New
</StyledButton>
</StyledNewButton>
);
};

View File

@@ -1,20 +1,135 @@
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext';
import { useContext } from 'react';
import { recordBoardNewRecordByColumnIdSelector } from '@/object-record/record-board/states/selectors/recordBoardNewRecordByColumnIdSelector';
import { useCallback, useContext } from 'react';
import { useRecoilCallback } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
export const useAddNewCard = (position: string) => {
const { columnDefinition } = useContext(RecordBoardColumnContext);
export const useAddNewCard = () => {
const columnContext = useContext(RecordBoardColumnContext);
const { createOneRecord, selectFieldMetadataItem } =
useContext(RecordBoardContext);
const handleAddNewCardClick = () => {
createOneRecord({
[selectFieldMetadataItem.name]: columnDefinition.value,
position: position,
});
const getColumnDefinitionId = useCallback(
(columnId?: string) => {
const columnDefinitionId = columnId || columnContext?.columnDefinition.id;
if (!columnDefinitionId) {
throw new Error('Column ID is required');
}
return columnDefinitionId;
},
[columnContext],
);
const addNewCard = useCallback(
(set: any, columnDefinitionId: string, position: 'first' | 'last') => {
set(
recordBoardNewRecordByColumnIdSelector({
familyKey: columnDefinitionId,
scopeId: columnDefinitionId,
}),
{
id: uuidv4(),
columnId: columnDefinitionId,
isCreating: true,
position,
},
);
},
[],
);
const createRecord = useCallback(
(
labelIdentifier: string,
labelValue: string,
position: 'first' | 'last',
) => {
if (labelValue !== '') {
createOneRecord({
[selectFieldMetadataItem.name]: columnContext?.columnDefinition.value,
position,
[labelIdentifier.toLowerCase()]: labelValue,
});
}
},
[createOneRecord, columnContext, selectFieldMetadataItem],
);
const handleAddNewCardClick = useRecoilCallback(
({ set }) =>
(
labelIdentifier: string,
labelValue: string,
position: 'first' | 'last',
columnId?: string,
): void => {
const columnDefinitionId = getColumnDefinitionId(columnId);
addNewCard(set, columnDefinitionId, position);
createRecord(labelIdentifier, labelValue, position);
},
[addNewCard, createRecord, getColumnDefinitionId],
);
const handleCreateSuccess = useRecoilCallback(
({ set }) =>
(position: 'first' | 'last', columnId?: string): void => {
const columnDefinitionId = getColumnDefinitionId(columnId);
set(
recordBoardNewRecordByColumnIdSelector({
familyKey: columnDefinitionId,
scopeId: columnDefinitionId,
}),
{
id: '',
columnId: columnDefinitionId,
isCreating: false,
position,
},
);
},
[getColumnDefinitionId],
);
const handleCreate = (
labelIdentifier: string,
labelValue: string,
position: 'first' | 'last',
onCreateSuccess?: () => void,
) => {
if (labelValue.trim() !== '' && position !== undefined) {
handleAddNewCardClick(labelIdentifier, labelValue.trim(), position);
onCreateSuccess?.();
}
};
const handleBlur = (
labelIdentifier: string,
labelValue: string,
position: 'first' | 'last',
onCreateSuccess?: () => void,
) => {
if (labelValue.trim() === '') {
onCreateSuccess?.();
} else {
handleCreate(labelIdentifier, labelValue, position, onCreateSuccess);
}
};
const handleInputEnter = (
labelIdentifier: string,
labelValue: string,
position: 'first' | 'last',
onCreateSuccess?: () => void,
) => {
handleCreate(labelIdentifier, labelValue, position, onCreateSuccess);
};
return {
handleAddNewCardClick,
handleCreateSuccess,
handleCreate,
handleBlur,
handleInputEnter,
};
};

View File

@@ -0,0 +1,38 @@
import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates';
import { useAddNewCard } from '@/object-record/record-board/record-board-column/hooks/useAddNewCard';
import { recordBoardNewRecordByColumnIdSelector } from '@/object-record/record-board/states/selectors/recordBoardNewRecordByColumnIdSelector';
import { useRecoilValue } from 'recoil';
export const useColumnNewCardActions = (columnId: string) => {
const { visibleFieldDefinitionsState } = useRecordBoardStates();
const visibleFieldDefinitions = useRecoilValue(
visibleFieldDefinitionsState(),
);
const labelIdentifierField = visibleFieldDefinitions.find(
(field) => field.isLabelIdentifier,
);
const { handleAddNewCardClick, handleCreateSuccess } = useAddNewCard();
const newRecord = useRecoilValue(
recordBoardNewRecordByColumnIdSelector({
familyKey: columnId,
scopeId: columnId,
}),
);
const handleNewButtonClick = (position: 'first' | 'last') => {
handleAddNewCardClick(
labelIdentifierField?.label ?? '',
'',
position,
columnId,
);
};
return {
newRecord,
handleNewButtonClick,
handleCreateSuccess,
};
};

View File

@@ -0,0 +1,19 @@
import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState';
export type NewCard = {
id: string;
columnId: string;
isCreating: boolean;
position: 'first' | 'last';
};
export const recordBoardNewRecordByColumnIdComponentFamilyState =
createComponentFamilyState<NewCard, string>({
key: 'recordBoardNewRecordByColumnIdComponentFamilyState',
defaultValue: {
id: '',
columnId: '',
isCreating: false,
position: 'last',
},
});

View File

@@ -0,0 +1,31 @@
import { createComponentFamilySelector } from '@/ui/utilities/state/component-state/utils/createComponentFamilySelector';
import {
NewCard,
recordBoardNewRecordByColumnIdComponentFamilyState,
} from '../recordBoardNewRecordByColumnIdComponentFamilyState';
export const recordBoardNewRecordByColumnIdSelector =
createComponentFamilySelector<NewCard, string>({
key: 'recordBoardNewRecordByColumnIdSelector',
get:
({ familyKey, scopeId }: { familyKey: string; scopeId: string }) =>
({ get }) => {
return get(
recordBoardNewRecordByColumnIdComponentFamilyState({
familyKey,
scopeId,
}),
) as NewCard;
},
set:
({ familyKey, scopeId }: { familyKey: string; scopeId: string }) =>
({ set }, newValue) => {
set(
recordBoardNewRecordByColumnIdComponentFamilyState({
familyKey,
scopeId,
}),
newValue as NewCard,
);
},
});

View File

@@ -1,5 +1,6 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates';
import { useAddNewCard } from '@/object-record/record-board/record-board-column/hooks/useAddNewCard';
import { RecordBoardColumnDefinition } from '@/object-record/record-board/types/RecordBoardColumnDefinition';
import { RecordIndexPageKanbanAddMenuItem } from '@/object-record/record-index/components/RecordIndexPageKanbanAddMenuItem';
import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext';
@@ -37,8 +38,15 @@ export const RecordIndexPageKanbanAddButton = () => {
RecordIndexRootPropsContext,
);
const { columnIdsState } = useRecordBoardStates(recordIndexId);
const { columnIdsState, visibleFieldDefinitionsState } =
useRecordBoardStates(recordIndexId);
const columnIds = useRecoilValue(columnIdsState);
const visibleFieldDefinitions = useRecoilValue(
visibleFieldDefinitionsState(),
);
const labelIdentifierField = visibleFieldDefinitions.find(
(field) => field.isLabelIdentifier,
);
const {
setHotkeyScopeAndMemorizePreviousScope,
@@ -50,14 +58,12 @@ export const RecordIndexPageKanbanAddButton = () => {
const { closeDropdown } = useDropdown(dropdownId);
const {
selectFieldMetadataItem,
isOpportunity,
createOpportunity,
createRecordWithoutCompany,
} = useRecordIndexPageKanbanAddButton({
objectNamePlural,
});
const { selectFieldMetadataItem, isOpportunity, createOpportunity } =
useRecordIndexPageKanbanAddButton({
objectNamePlural,
});
const { handleAddNewCardClick } = useAddNewCard();
const handleItemClick = useCallback(
(columnDefinition: RecordBoardColumnDefinition) => {
@@ -68,18 +74,23 @@ export const RecordIndexPageKanbanAddButton = () => {
RelationPickerHotkeyScope.RelationPicker,
);
} else {
createRecordWithoutCompany(columnDefinition);
handleAddNewCardClick(
labelIdentifierField?.label ?? '',
'',
'first',
columnDefinition.id,
);
closeDropdown();
}
},
[
isOpportunity,
createRecordWithoutCompany,
handleAddNewCardClick,
setHotkeyScopeAndMemorizePreviousScope,
closeDropdown,
labelIdentifierField,
],
);
const handleEntitySelect = useCallback(
(company?: EntityForSelect) => {
setIsSelectingCompany(false);

View File

@@ -45,21 +45,9 @@ export const useRecordIndexPageKanbanAddButton = ({
}
};
const createRecordWithoutCompany = (
columnDefinition: RecordBoardColumnDefinition,
) => {
if (isDefined(selectFieldMetadataItem)) {
createOneRecord({
[selectFieldMetadataItem.name]: columnDefinition?.value,
position: 'first',
});
}
};
return {
selectFieldMetadataItem,
isOpportunity,
createOpportunity,
createRecordWithoutCompany,
};
};

View File

@@ -89,7 +89,6 @@ export const TextInput = ({
onInputEnter?.();
if (isDefined(inputRef) && 'current' in inputRef) {
inputRef.current?.blur();
setIsFocused(false);
}
},