[feat][FE] Stop persisting new empty records (#4853)

## Context
Closes [#4773](https://github.com/twentyhq/twenty/issues/4773)
Persisting of new records is delayed to cell escape and not performed
for empty records.

## How was it tested?
Locally tested + jest

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Marie 2024-04-10 19:16:34 +02:00 committed by GitHub
parent 01991fe717
commit f25d58b0d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 270 additions and 22 deletions

View File

@ -35,6 +35,7 @@ const meta: Meta = {
iconName: 'IconCalendarEvent',
metadata: {
fieldName: 'Date',
objectMetadataNameSingular: 'person',
},
},
hotkeyScope: 'hotkey-scope',

View File

@ -36,6 +36,7 @@ const meta: Meta = {
metadata: {
fieldName: 'Email',
placeHolder: 'Email',
objectMetadataNameSingular: 'person',
},
},
hotkeyScope: 'hotkey-scope',

View File

@ -35,6 +35,7 @@ const meta: Meta = {
fieldName: 'Number',
placeHolder: 'Number',
isPositive: true,
objectMetadataNameSingular: 'person',
},
},
hotkeyScope: 'hotkey-scope',

View File

@ -34,6 +34,7 @@ const meta: Meta = {
metadata: {
fieldName: 'Text',
placeHolder: 'Text',
objectMetadataNameSingular: 'person',
},
},
hotkeyScope: 'hotkey-scope',

View File

@ -58,6 +58,7 @@ const AddressInputWithContext = ({
metadata: {
fieldName: 'Address',
placeHolder: 'Enter text',
objectMetadataNameSingular: 'person',
},
}}
entityId={entityId}

View File

@ -47,6 +47,7 @@ const BooleanFieldInputWithContext = ({
type: FieldMetadataType.Boolean,
metadata: {
fieldName: 'Boolean',
objectMetadataNameSingular: 'person',
},
}}
entityId={entityId}

View File

@ -49,6 +49,7 @@ const DateFieldInputWithContext = ({
iconName: 'IconCalendarEvent',
metadata: {
fieldName: 'Date',
objectMetadataNameSingular: 'person',
},
}}
entityId={entityId}

View File

@ -50,6 +50,7 @@ const EmailFieldInputWithContext = ({
metadata: {
fieldName: 'email',
placeHolder: 'username@email.com',
objectMetadataNameSingular: 'person',
},
}}
entityId={entityId}

View File

@ -50,6 +50,7 @@ const NumberFieldInputWithContext = ({
metadata: {
fieldName: 'number',
placeHolder: 'Enter number',
objectMetadataNameSingular: 'person',
},
}}
entityId={entityId}

View File

@ -50,6 +50,7 @@ const RatingFieldInputWithContext = ({
iconName: 'Icon123',
metadata: {
fieldName: 'Rating',
objectMetadataNameSingular: 'person',
},
}}
entityId={entityId}

View File

@ -73,6 +73,7 @@ const RelationFieldInputWithContext = ({
relationObjectMetadataNamePlural: 'workspaceMembers',
relationObjectMetadataNameSingular:
CoreObjectNameSingular.WorkspaceMember,
objectMetadataNameSingular: 'person',
},
}}
entityId={entityId}

View File

@ -50,6 +50,7 @@ const TextFieldInputWithContext = ({
metadata: {
fieldName: 'Text',
placeHolder: 'Enter text',
objectMetadataNameSingular: 'person',
},
}}
entityId={entityId}

View File

@ -153,6 +153,7 @@ export const RecordShowContainer = ({
metadata: {
fieldName:
labelIdentifierFieldMetadataItem?.name || '',
objectMetadataNameSingular: objectNameSingular,
},
},
useUpdateRecord: useUpdateOneObjectRecordMutation,

View File

@ -1,6 +1,7 @@
import { useRecoilValue } from 'recoil';
import { RecordTableBodyFetchMoreLoader } from '@/object-record/record-table/components/RecordTableBodyFetchMoreLoader';
import { RecordTablePendingRow } from '@/object-record/record-table/components/RecordTablePendingRow';
import { RecordTableRow } from '@/object-record/record-table/components/RecordTableRow';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
@ -18,6 +19,7 @@ export const RecordTableBody = ({
return (
<>
<tbody>
<RecordTablePendingRow />
{tableRowIds.map((recordId, rowIndex) => (
<RecordTableRow
key={recordId}

View File

@ -0,0 +1,19 @@
import { useRecoilValue } from 'recoil';
import { RecordTableRow } from '@/object-record/record-table/components/RecordTableRow';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
export const RecordTablePendingRow = () => {
const { pendingRecordIdState } = useRecordTableStates();
const pendingRecordId = useRecoilValue(pendingRecordIdState);
if (!pendingRecordId) return;
return (
<RecordTableRow
key={pendingRecordId}
recordId={pendingRecordId}
rowIndex={-1}
/>
);
};

View File

@ -12,6 +12,7 @@ import { onColumnsChangeComponentState } from '@/object-record/record-table/stat
import { onEntityCountChangeComponentState } from '@/object-record/record-table/states/onEntityCountChangeComponentState';
import { onToggleColumnFilterComponentState } from '@/object-record/record-table/states/onToggleColumnFilterComponentState';
import { onToggleColumnSortComponentState } from '@/object-record/record-table/states/onToggleColumnSortComponentState';
import { recordTablePendingRecordIdComponentState } from '@/object-record/record-table/states/recordTablePendingRecordIdComponentState';
import { resizeFieldOffsetComponentState } from '@/object-record/record-table/states/resizeFieldOffsetComponentState';
import { allRowsSelectedStatusComponentSelector } from '@/object-record/record-table/states/selectors/allRowsSelectedStatusComponentSelector';
import { hiddenTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/hiddenTableColumnsComponentSelector';
@ -132,5 +133,9 @@ export const useRecordTableStates = (recordTableId?: string) => {
visibleTableColumnsComponentSelector,
scopeId,
),
pendingRecordIdState: extractComponentState(
recordTablePendingRecordIdComponentState,
scopeId,
),
};
};

View File

@ -44,6 +44,7 @@ export const useRecordTable = (props?: useRecordTableProps) => {
selectedRowIdsSelector,
onToggleColumnFilterState,
onToggleColumnSortState,
pendingRecordIdState,
} = useRecordTableStates(recordTableId);
const setAvailableTableColumns = useRecoilCallback(
@ -194,6 +195,8 @@ export const useRecordTable = (props?: useRecordTableProps) => {
const isSomeCellInEditModeState =
useGetIsSomeCellInEditModeState(recordTableId);
const setPendingRecordId = useSetRecoilState(pendingRecordIdState);
return {
scopeId,
onColumnsChange,
@ -222,5 +225,6 @@ export const useRecordTable = (props?: useRecordTableProps) => {
setHasUserSelectedAllRows,
setOnToggleColumnFilter,
setOnToggleColumnSort,
setPendingRecordId,
};
};

View File

@ -7,6 +7,7 @@ import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEv
import { useRecordTableMoveFocus } from '@/object-record/record-table/hooks/useRecordTableMoveFocus';
import { RecordTableCellContainer } from '@/object-record/record-table/record-table-cell/components/RecordTableCellContainer';
import { useCloseRecordTableCell } from '@/object-record/record-table/record-table-cell/hooks/useCloseRecordTableCell';
import { useUpsertRecord } from '@/object-record/record-table/record-table-cell/hooks/useUpsertRecord';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
export const RecordTableCell = ({
@ -15,19 +16,21 @@ export const RecordTableCell = ({
customHotkeyScope: HotkeyScope;
}) => {
const { closeTableCell } = useCloseRecordTableCell();
const { entityId, fieldDefinition } = useContext(FieldContext);
const { upsertRecord } = useUpsertRecord();
const { moveLeft, moveRight, moveDown } = useRecordTableMoveFocus();
const { entityId, fieldDefinition } = useContext(FieldContext);
const handleEnter: FieldInputEvent = (persistField) => {
persistField();
upsertRecord(persistField);
closeTableCell();
moveDown();
};
const handleSubmit: FieldInputEvent = (persistField) => {
persistField();
upsertRecord(persistField);
closeTableCell();
};
@ -37,26 +40,26 @@ export const RecordTableCell = ({
};
const handleClickOutside: FieldInputEvent = (persistField) => {
persistField();
upsertRecord(persistField);
closeTableCell();
};
const handleEscape: FieldInputEvent = (persistField) => {
persistField();
upsertRecord(persistField);
closeTableCell();
};
const handleTab: FieldInputEvent = (persistField) => {
persistField();
upsertRecord(persistField);
closeTableCell();
moveRight();
};
const handleShiftTab: FieldInputEvent = (persistField) => {
persistField();
upsertRecord(persistField);
closeTableCell();
moveLeft();

View File

@ -0,0 +1,152 @@
import { ReactNode } from 'react';
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { createState } from 'twenty-ui';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { textfieldDefinition } from '@/object-record/record-field/__mocks__/fieldDefinitions';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { useUpsertRecord } from '@/object-record/record-table/record-table-cell/hooks/useUpsertRecord';
import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope';
const pendingRecordId = 'a7286b9a-c039-4a89-9567-2dfa7953cda9';
const draftValue = 'updated Name';
jest.mock('@/object-record/hooks/useCreateOneRecord', () => ({
__esModule: true,
useCreateOneRecord: jest.fn(),
}));
const draftValueState = createState<string | null>({
key: 'draftValueState',
defaultValue: null,
});
jest.mock(
'@/object-record/record-field/hooks/internal/useRecordFieldInputStates',
() => ({
__esModule: true,
useRecordFieldInputStates: jest.fn(() => ({
getDraftValueSelector: () => draftValueState,
})),
}),
);
const pendingRecordIdState = createState<string | null>({
key: 'pendingRecordIdState',
defaultValue: null,
});
jest.mock(
'@/object-record/record-table/hooks/internal/useRecordTableStates',
() => ({
__esModule: true,
useRecordTableStates: jest.fn(() => ({
pendingRecordIdState: pendingRecordIdState,
})),
}),
);
const createOneRecordMock = jest.fn();
const updateOneRecordMock = jest.fn();
(useCreateOneRecord as jest.Mock).mockReturnValue({
createOneRecord: createOneRecordMock,
});
const Wrapper = ({
children,
pendingRecordIdMockedValue,
draftValueMockedValue,
}: {
children: ReactNode;
pendingRecordIdMockedValue: string | null;
draftValueMockedValue: string | null;
}) => (
<RecoilRoot
initializeState={(snapshot) => {
snapshot.set(pendingRecordIdState, pendingRecordIdMockedValue);
snapshot.set(draftValueState, draftValueMockedValue);
}}
>
<FieldContext.Provider
value={{
entityId: 'entityId',
fieldDefinition: {
...textfieldDefinition,
metadata: {
...textfieldDefinition.metadata,
objectMetadataNameSingular: CoreObjectNameSingular.Person,
},
},
hotkeyScope: TableHotkeyScope.Table,
isLabelIdentifier: false,
}}
>
{children}
</FieldContext.Provider>
</RecoilRoot>
);
describe('useUpsertRecord', () => {
beforeEach(async () => {
createOneRecordMock.mockClear();
updateOneRecordMock.mockClear();
});
it('calls update record if there is no pending record', async () => {
const { result } = renderHook(() => useUpsertRecord(), {
wrapper: ({ children }) =>
Wrapper({
pendingRecordIdMockedValue: null,
draftValueMockedValue: null,
children,
}),
});
await act(async () => {
await result.current.upsertRecord(updateOneRecordMock);
});
expect(createOneRecordMock).not.toHaveBeenCalled();
expect(updateOneRecordMock).toHaveBeenCalled();
});
it('calls update record if pending record is empty', async () => {
const { result } = renderHook(() => useUpsertRecord(), {
wrapper: ({ children }) =>
Wrapper({
pendingRecordIdMockedValue: null,
draftValueMockedValue: draftValue,
children,
}),
});
await act(async () => {
await result.current.upsertRecord(updateOneRecordMock);
});
expect(createOneRecordMock).not.toHaveBeenCalled();
expect(updateOneRecordMock).toHaveBeenCalled();
});
it('calls create record if pending record is not empty', async () => {
const { result } = renderHook(() => useUpsertRecord(), {
wrapper: ({ children }) =>
Wrapper({
pendingRecordIdMockedValue: pendingRecordId,
draftValueMockedValue: draftValue,
children,
}),
});
await act(async () => {
await result.current.upsertRecord(updateOneRecordMock);
});
expect(createOneRecordMock).toHaveBeenCalledWith({
id: pendingRecordId,
name: draftValue,
position: 'first',
});
expect(updateOneRecordMock).not.toHaveBeenCalled();
});
});

View File

@ -1,3 +1,6 @@
import { useResetRecoilState } from 'recoil';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { useDragSelect } from '@/ui/utilities/drag-select/hooks/useDragSelect';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
@ -7,13 +10,17 @@ import { TableHotkeyScope } from '../../types/TableHotkeyScope';
export const useCloseRecordTableCell = () => {
const setHotkeyScope = useSetHotkeyScope();
const { setDragSelectionStartEnabled } = useDragSelect();
const { pendingRecordIdState } = useRecordTableStates();
const closeCurrentTableCellInEditMode = useCloseCurrentTableCellInEditMode();
const resetRecordTablePendingRecordId =
useResetRecoilState(pendingRecordIdState);
const closeTableCell = async () => {
setDragSelectionStartEnabled(true);
closeCurrentTableCellInEditMode();
setHotkeyScope(TableHotkeyScope.TableSoftFocus);
resetRecordTablePendingRecordId();
};
return {

View File

@ -0,0 +1,41 @@
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { useRecordFieldInputStates } from '@/object-record/record-field/hooks/internal/useRecordFieldInputStates';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { isDefined } from '~/utils/isDefined';
export const useUpsertRecord = () => {
const { entityId, fieldDefinition } = useContext(FieldContext);
const { pendingRecordIdState } = useRecordTableStates();
const pendingRecordId = useRecoilValue(pendingRecordIdState);
const fieldName = fieldDefinition.metadata.fieldName;
const { getDraftValueSelector } = useRecordFieldInputStates(
`${entityId}-${fieldName}`,
);
const draftValue = useRecoilValue(getDraftValueSelector());
const objectNameSingular =
fieldDefinition.metadata.objectMetadataNameSingular ?? '';
const { createOneRecord } = useCreateOneRecord({
objectNameSingular,
});
const upsertRecord = (persistField: () => void) => {
if (isDefined(pendingRecordId) && isDefined(draftValue)) {
createOneRecord({
id: pendingRecordId,
name: draftValue,
position: 'first',
});
} else if (!pendingRecordId) {
persistField();
}
};
return { upsertRecord };
};

View File

@ -0,0 +1,8 @@
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const recordTablePendingRecordIdComponentState = createComponentState<
string | null
>({
key: 'recordTablePendingRecordIdState',
defaultValue: null,
});

View File

@ -1,9 +1,9 @@
import { useParams } from 'react-router-dom';
import styled from '@emotion/styled';
import { v4 } from 'uuid';
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { RecordIndexContainer } from '@/object-record/record-index/components/RecordIndexContainer';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { DEFAULT_CELL_SCOPE } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCell';
import { useSelectedTableCellEditMode } from '@/object-record/record-table/record-table-cell/hooks/useSelectedTableCellEditMode';
import { PageBody } from '@/ui/layout/page/PageBody';
@ -20,14 +20,6 @@ const StyledIndexContainer = styled.div`
export const RecordIndexPage = () => {
const objectNamePlural = useParams().objectNamePlural ?? '';
const { objectNameSingular } = useObjectNameSingularFromPlural({
objectNamePlural,
});
const { createOneRecord: createOneObject } = useCreateOneRecord({
objectNameSingular,
});
const recordIndexId = objectNamePlural ?? '';
const setHotkeyScope = useSetHotkeyScope();
@ -35,12 +27,14 @@ export const RecordIndexPage = () => {
scopeId: recordIndexId,
});
const handleAddButtonClick = async () => {
await createOneObject?.({
position: 'first',
});
const { setPendingRecordId } = useRecordTable({
recordTableId: recordIndexId,
});
setSelectedTableCellEditMode(0, 0);
const handleAddButtonClick = async () => {
setPendingRecordId(v4());
setSelectedTableCellEditMode(-1, 0);
setHotkeyScope(DEFAULT_CELL_SCOPE.scope, DEFAULT_CELL_SCOPE.customScopes);
};