Feat/generic editable board card (#1089)

* Fixed BoardColumnMenu

* Fixed naming

* Optimized board loading

* Added GenericEditableField

* Introduce GenericEditableField for BoardCards

* remove logs

* delete unused files

* fix stories

---------

Co-authored-by: corentin <corentin@twenty.com>
This commit is contained in:
Lucas Bordeau
2023-08-09 05:08:37 +02:00
committed by GitHub
parent 77d356f78a
commit 3666980ccc
103 changed files with 1551 additions and 922 deletions

View File

@ -0,0 +1,49 @@
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { BoardCardIdContext } from '@/ui/board/states/BoardCardIdContext';
import {
ViewFieldDateMetadata,
ViewFieldDefinition,
} from '@/ui/editable-field/types/ViewField';
import { DateInputDisplay } from '@/ui/input/date/components/DateInputDisplay';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { parseDate } from '~/utils/date-utils';
import { FieldContext } from '../states/FieldContext';
import { genericEntityFieldFamilySelector } from '../states/genericEntityFieldFamilySelector';
import { EditableField } from './EditableField';
import { GenericEditableDateFieldEditMode } from './GenericEditableDateFieldEditMode';
type OwnProps = {
viewField: ViewFieldDefinition<ViewFieldDateMetadata>;
};
export function GenericEditableDateField({ viewField }: OwnProps) {
const currentEntityId = useContext(BoardCardIdContext);
const fieldValue = useRecoilValue<string>(
genericEntityFieldFamilySelector({
entityId: currentEntityId ?? '',
fieldName: viewField.metadata.fieldName,
}),
);
const internalDateValue = fieldValue
? parseDate(fieldValue).toJSDate()
: null;
return (
<RecoilScope SpecificContext={FieldContext}>
<EditableField
iconLabel={viewField.columnIcon}
editModeContent={
<GenericEditableDateFieldEditMode viewField={viewField} />
}
displayModeContent={<DateInputDisplay value={internalDateValue} />}
isDisplayModeContentEmpty={!fieldValue}
/>
</RecoilScope>
);
}

View File

@ -0,0 +1,44 @@
import { useContext } from 'react';
import { useRecoilState } from 'recoil';
import { BoardCardIdContext } from '@/ui/board/states/BoardCardIdContext';
import {
ViewFieldDateMetadata,
ViewFieldDefinition,
} from '@/ui/editable-field/types/ViewField';
import { useUpdateGenericEntityField } from '../hooks/useUpdateGenericEntityField';
import { genericEntityFieldFamilySelector } from '../states/genericEntityFieldFamilySelector';
import { EditableFieldEditModeDate } from '../variants/components/EditableFieldEditModeDate';
type OwnProps = {
viewField: ViewFieldDefinition<ViewFieldDateMetadata>;
};
export function GenericEditableDateFieldEditMode({ viewField }: OwnProps) {
const currentEntityId = useContext(BoardCardIdContext);
// TODO: we could use a hook that would return the field value with the right type
const [fieldValue, setFieldValue] = useRecoilState<string>(
genericEntityFieldFamilySelector({
entityId: currentEntityId ?? '',
fieldName: viewField.metadata.fieldName,
}),
);
const updateField = useUpdateGenericEntityField();
function handleSubmit(newDateISO: string) {
if (newDateISO === fieldValue || !newDateISO) return;
setFieldValue(newDateISO);
if (currentEntityId && updateField && newDateISO) {
updateField(currentEntityId, viewField, newDateISO);
}
}
return (
<EditableFieldEditModeDate value={fieldValue} onChange={handleSubmit} />
);
}

View File

@ -0,0 +1,35 @@
import {
ViewFieldDefinition,
ViewFieldMetadata,
} from '@/ui/editable-field/types/ViewField';
import { isViewFieldDate } from '../types/guards/isViewFieldDate';
import { isViewFieldNumber } from '../types/guards/isViewFieldNumber';
import { isViewFieldProbability } from '../types/guards/isViewFieldProbability';
import { isViewFieldRelation } from '../types/guards/isViewFieldRelation';
import { GenericEditableDateField } from './GenericEditableDateField';
import { GenericEditableNumberField } from './GenericEditableNumberField';
import { GenericEditableRelationField } from './GenericEditableRelationField';
import { ProbabilityEditableField } from './ProbabilityEditableField';
type OwnProps = {
viewField: ViewFieldDefinition<ViewFieldMetadata>;
};
export function GenericEditableField({ viewField: fieldDefinition }: OwnProps) {
if (isViewFieldDate(fieldDefinition)) {
return <GenericEditableDateField viewField={fieldDefinition} />;
} else if (isViewFieldNumber(fieldDefinition)) {
return <GenericEditableNumberField viewField={fieldDefinition} />;
} else if (isViewFieldRelation(fieldDefinition)) {
return <GenericEditableRelationField viewField={fieldDefinition} />;
} else if (isViewFieldProbability(fieldDefinition)) {
return <ProbabilityEditableField viewField={fieldDefinition} />;
} else {
console.warn(
`Unknown field metadata type: ${fieldDefinition.metadata.type} in GenericEditableField`,
);
return <></>;
}
}

View File

@ -0,0 +1,43 @@
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { BoardCardIdContext } from '@/ui/board/states/BoardCardIdContext';
import {
ViewFieldDefinition,
ViewFieldNumberMetadata,
} from '@/ui/editable-field/types/ViewField';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { FieldContext } from '../states/FieldContext';
import { genericEntityFieldFamilySelector } from '../states/genericEntityFieldFamilySelector';
import { EditableField } from './EditableField';
import { GenericEditableNumberFieldEditMode } from './GenericEditableNumberFieldEditMode';
type OwnProps = {
viewField: ViewFieldDefinition<ViewFieldNumberMetadata>;
};
export function GenericEditableNumberField({ viewField }: OwnProps) {
const currentEntityId = useContext(BoardCardIdContext);
const fieldValue = useRecoilValue<string>(
genericEntityFieldFamilySelector({
entityId: currentEntityId ?? '',
fieldName: viewField.metadata.fieldName,
}),
);
return (
<RecoilScope SpecificContext={FieldContext}>
<EditableField
iconLabel={viewField.columnIcon}
editModeContent={
<GenericEditableNumberFieldEditMode viewField={viewField} />
}
displayModeContent={fieldValue}
isDisplayModeContentEmpty={!fieldValue}
/>
</RecoilScope>
);
}

View File

@ -0,0 +1,78 @@
import { useContext, useRef, useState } from 'react';
import { useRecoilState } from 'recoil';
import { BoardCardIdContext } from '@/ui/board/states/BoardCardIdContext';
import {
ViewFieldDefinition,
ViewFieldNumberMetadata,
} from '@/ui/editable-field/types/ViewField';
import { TextInputEdit } from '@/ui/input/text/components/TextInputEdit';
import {
canBeCastAsIntegerOrNull,
castAsIntegerOrNull,
} from '~/utils/cast-as-integer-or-null';
import { useRegisterCloseFieldHandlers } from '../hooks/useRegisterCloseFieldHandlers';
import { useUpdateGenericEntityField } from '../hooks/useUpdateGenericEntityField';
import { genericEntityFieldFamilySelector } from '../states/genericEntityFieldFamilySelector';
type OwnProps = {
viewField: ViewFieldDefinition<ViewFieldNumberMetadata>;
};
export function GenericEditableNumberFieldEditMode({ viewField }: OwnProps) {
const currentEntityId = useContext(BoardCardIdContext);
// TODO: we could use a hook that would return the field value with the right type
const [fieldValue, setFieldValue] = useRecoilState<number | null>(
genericEntityFieldFamilySelector({
entityId: currentEntityId ?? '',
fieldName: viewField.metadata.fieldName,
}),
);
const [internalValue, setInternalValue] = useState(
fieldValue ? fieldValue.toString() : '',
);
const updateField = useUpdateGenericEntityField();
function handleSubmit() {
if (!canBeCastAsIntegerOrNull(internalValue)) {
return;
}
if (internalValue === fieldValue) return;
setFieldValue(castAsIntegerOrNull(internalValue));
if (currentEntityId && updateField) {
updateField(
currentEntityId,
viewField,
castAsIntegerOrNull(internalValue),
);
}
}
function onCancel() {
setFieldValue(fieldValue);
}
function handleChange(newValue: string) {
setInternalValue(newValue);
}
const wrapperRef = useRef(null);
useRegisterCloseFieldHandlers(wrapperRef, handleSubmit, onCancel);
return (
<div ref={wrapperRef}>
<TextInputEdit
autoFocus
value={internalValue ? internalValue.toString() : ''}
onChange={(newValue: string) => {
handleChange(newValue);
}}
/>
</div>
);
}

View File

@ -0,0 +1,80 @@
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { PersonChip } from '@/people/components/PersonChip';
import { BoardCardIdContext } from '@/ui/board/states/BoardCardIdContext';
import {
ViewFieldDefinition,
ViewFieldRelationMetadata,
} from '@/ui/editable-field/types/ViewField';
import { Entity } from '@/ui/input/relation-picker/types/EntityTypeForSelect';
import { RelationPickerHotkeyScope } from '@/ui/input/relation-picker/types/RelationPickerHotkeyScope';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { FieldContext } from '../states/FieldContext';
import { genericEntityFieldFamilySelector } from '../states/genericEntityFieldFamilySelector';
import { EditableField } from './EditableField';
import { GenericEditableRelationFieldEditMode } from './GenericEditableRelationFieldEditMode';
type OwnProps = {
viewField: ViewFieldDefinition<ViewFieldRelationMetadata>;
};
function RelationChip({
fieldDefinition,
fieldValue,
}: {
fieldDefinition: ViewFieldDefinition<ViewFieldRelationMetadata>;
fieldValue: any | null;
}) {
switch (fieldDefinition.metadata.relationType) {
case Entity.Person: {
return (
<PersonChip
id={fieldValue?.id ?? ''}
name={fieldValue?.displayName ?? ''}
pictureUrl={fieldValue?.avatarUrl ?? ''}
/>
);
}
default:
console.warn(
`Unknown relation type: "${fieldDefinition.metadata.relationType}" in GenericEditableRelationField`,
);
return <> </>;
}
}
export function GenericEditableRelationField({ viewField }: OwnProps) {
const currentEntityId = useContext(BoardCardIdContext);
const fieldValue = useRecoilValue<any | null>(
genericEntityFieldFamilySelector({
entityId: currentEntityId ?? '',
fieldName: viewField.metadata.fieldName,
}),
);
return (
<RecoilScope SpecificContext={FieldContext}>
<RecoilScope>
<EditableField
useEditButton
customEditHotkeyScope={{
scope: RelationPickerHotkeyScope.RelationPicker,
}}
iconLabel={viewField.columnIcon}
editModeContent={
<GenericEditableRelationFieldEditMode viewField={viewField} />
}
displayModeContent={
<RelationChip fieldDefinition={viewField} fieldValue={fieldValue} />
}
isDisplayModeContentEmpty={!fieldValue}
isDisplayModeFixHeight
/>
</RecoilScope>
</RecoilScope>
);
}

View File

@ -0,0 +1,102 @@
import { useContext } from 'react';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { PeoplePicker } from '@/people/components/PeoplePicker';
import { BoardCardIdContext } from '@/ui/board/states/BoardCardIdContext';
import {
ViewFieldDefinition,
ViewFieldRelationMetadata,
ViewFieldRelationValue,
} from '@/ui/editable-field/types/ViewField';
import { EntityForSelect } from '@/ui/input/relation-picker/types/EntityForSelect';
import { Entity } from '@/ui/input/relation-picker/types/EntityTypeForSelect';
import { useEditableField } from '../hooks/useEditableField';
import { useUpdateGenericEntityField } from '../hooks/useUpdateGenericEntityField';
import { genericEntityFieldFamilySelector } from '../states/genericEntityFieldFamilySelector';
const RelationPickerContainer = styled.div`
left: 0px;
position: absolute;
top: -8px;
`;
type OwnProps = {
viewField: ViewFieldDefinition<ViewFieldRelationMetadata>;
};
function RelationPicker({
fieldDefinition,
fieldValue,
handleEntitySubmit,
handleCancel,
}: {
fieldDefinition: ViewFieldDefinition<ViewFieldRelationMetadata>;
fieldValue: ViewFieldRelationValue;
handleEntitySubmit: (newRelationId: EntityForSelect | null) => void;
handleCancel: () => void;
}) {
switch (fieldDefinition.metadata.relationType) {
case Entity.Person: {
return (
<PeoplePicker
personId={fieldValue?.id ?? null}
onSubmit={handleEntitySubmit}
onCancel={handleCancel}
/>
);
}
default:
console.warn(
`Unknown relation type: "${fieldDefinition.metadata.relationType}" in GenericEditableRelationField`,
);
return <> </>;
}
}
export function GenericEditableRelationFieldEditMode({ viewField }: OwnProps) {
const currentEntityId = useContext(BoardCardIdContext);
// TODO: we could use a hook that would return the field value with the right type
const [fieldValue, setFieldValue] = useRecoilState<any | null>(
genericEntityFieldFamilySelector({
entityId: currentEntityId ?? '',
fieldName: viewField.metadata.fieldName,
}),
);
const updateField = useUpdateGenericEntityField();
const { closeEditableField } = useEditableField();
function handleSubmit(newRelation: EntityForSelect | null) {
if (newRelation?.id === fieldValue?.id) return;
setFieldValue({
id: newRelation?.id ?? null,
displayName: newRelation?.name ?? null,
avatarUrl: newRelation?.avatarUrl ?? null,
});
if (currentEntityId && updateField) {
updateField(currentEntityId, viewField, newRelation);
}
closeEditableField();
}
function handleCancel() {
closeEditableField();
}
return (
<RelationPickerContainer>
<RelationPicker
fieldDefinition={viewField}
fieldValue={fieldValue}
handleEntitySubmit={handleSubmit}
handleCancel={handleCancel}
/>
</RelationPickerContainer>
);
}

View File

@ -0,0 +1,28 @@
import { EditableField } from '@/ui/editable-field/components/EditableField';
import { FieldContext } from '@/ui/editable-field/states/FieldContext';
import {
ViewFieldDefinition,
ViewFieldProbabilityMetadata,
} from '@/ui/editable-field/types/ViewField';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { ProbabilityEditableFieldEditMode } from './ProbabilityEditableFieldEditMode';
type OwnProps = {
viewField: ViewFieldDefinition<ViewFieldProbabilityMetadata>;
};
export function ProbabilityEditableField({ viewField }: OwnProps) {
return (
<RecoilScope SpecificContext={FieldContext}>
<EditableField
iconLabel={viewField.columnIcon}
displayModeContent={
<ProbabilityEditableFieldEditMode viewField={viewField} />
}
displayModeContentOnly
disableHoverEffect
/>
</RecoilScope>
);
}

View File

@ -0,0 +1,136 @@
import { useContext, useState } from 'react';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { BoardCardIdContext } from '@/ui/board/states/BoardCardIdContext';
import { useEditableField } from '@/ui/editable-field/hooks/useEditableField';
import { useUpdateGenericEntityField } from '../hooks/useUpdateGenericEntityField';
import { genericEntityFieldFamilySelector } from '../states/genericEntityFieldFamilySelector';
import {
ViewFieldDefinition,
ViewFieldProbabilityMetadata,
} from '../types/ViewField';
const StyledContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
justify-content: flex-start;
width: 100%;
`;
const StyledProgressBarItemContainer = styled.div`
align-items: center;
display: flex;
height: ${({ theme }) => theme.spacing(4)};
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 = {
viewField: ViewFieldDefinition<ViewFieldProbabilityMetadata>;
};
const PROBABILITY_VALUES = [
{ label: '0%', value: 0 },
{ label: '25%', value: 25 },
{ label: '50%', value: 50 },
{ label: '75%', value: 75 },
{ label: '100%', value: 100 },
];
export function ProbabilityEditableFieldEditMode({ viewField }: OwnProps) {
const [nextProbabilityIndex, setNextProbabilityIndex] = useState<
number | null
>(null);
const currentEntityId = useContext(BoardCardIdContext);
const [fieldValue, setFieldValue] = useRecoilState<number>(
genericEntityFieldFamilySelector({
entityId: currentEntityId ?? '',
fieldName: viewField.metadata.fieldName,
}),
);
const probabilityIndex = Math.ceil(fieldValue / 25);
const { closeEditableField } = useEditableField();
const updateField = useUpdateGenericEntityField();
function handleChange(newValue: number) {
setFieldValue(newValue);
if (currentEntityId && updateField && newValue) {
updateField(currentEntityId, viewField, newValue);
}
closeEditableField();
}
console.log(probabilityIndex);
return (
<StyledContainer>
<StyledLabel>
{
PROBABILITY_VALUES[
nextProbabilityIndex || nextProbabilityIndex === 0
? nextProbabilityIndex
: probabilityIndex
].label
}
</StyledLabel>
<StyledProgressBarContainer>
{PROBABILITY_VALUES.map((probability, i) => (
<StyledProgressBarItemContainer
key={i}
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>
);
}