mirror of
https://github.com/twentyhq/twenty.git
synced 2025-01-06 03:45:15 +03:00
[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:
parent
01991fe717
commit
f25d58b0d9
@ -35,6 +35,7 @@ const meta: Meta = {
|
||||
iconName: 'IconCalendarEvent',
|
||||
metadata: {
|
||||
fieldName: 'Date',
|
||||
objectMetadataNameSingular: 'person',
|
||||
},
|
||||
},
|
||||
hotkeyScope: 'hotkey-scope',
|
||||
|
@ -36,6 +36,7 @@ const meta: Meta = {
|
||||
metadata: {
|
||||
fieldName: 'Email',
|
||||
placeHolder: 'Email',
|
||||
objectMetadataNameSingular: 'person',
|
||||
},
|
||||
},
|
||||
hotkeyScope: 'hotkey-scope',
|
||||
|
@ -35,6 +35,7 @@ const meta: Meta = {
|
||||
fieldName: 'Number',
|
||||
placeHolder: 'Number',
|
||||
isPositive: true,
|
||||
objectMetadataNameSingular: 'person',
|
||||
},
|
||||
},
|
||||
hotkeyScope: 'hotkey-scope',
|
||||
|
@ -34,6 +34,7 @@ const meta: Meta = {
|
||||
metadata: {
|
||||
fieldName: 'Text',
|
||||
placeHolder: 'Text',
|
||||
objectMetadataNameSingular: 'person',
|
||||
},
|
||||
},
|
||||
hotkeyScope: 'hotkey-scope',
|
||||
|
@ -58,6 +58,7 @@ const AddressInputWithContext = ({
|
||||
metadata: {
|
||||
fieldName: 'Address',
|
||||
placeHolder: 'Enter text',
|
||||
objectMetadataNameSingular: 'person',
|
||||
},
|
||||
}}
|
||||
entityId={entityId}
|
||||
|
@ -47,6 +47,7 @@ const BooleanFieldInputWithContext = ({
|
||||
type: FieldMetadataType.Boolean,
|
||||
metadata: {
|
||||
fieldName: 'Boolean',
|
||||
objectMetadataNameSingular: 'person',
|
||||
},
|
||||
}}
|
||||
entityId={entityId}
|
||||
|
@ -49,6 +49,7 @@ const DateFieldInputWithContext = ({
|
||||
iconName: 'IconCalendarEvent',
|
||||
metadata: {
|
||||
fieldName: 'Date',
|
||||
objectMetadataNameSingular: 'person',
|
||||
},
|
||||
}}
|
||||
entityId={entityId}
|
||||
|
@ -50,6 +50,7 @@ const EmailFieldInputWithContext = ({
|
||||
metadata: {
|
||||
fieldName: 'email',
|
||||
placeHolder: 'username@email.com',
|
||||
objectMetadataNameSingular: 'person',
|
||||
},
|
||||
}}
|
||||
entityId={entityId}
|
||||
|
@ -50,6 +50,7 @@ const NumberFieldInputWithContext = ({
|
||||
metadata: {
|
||||
fieldName: 'number',
|
||||
placeHolder: 'Enter number',
|
||||
objectMetadataNameSingular: 'person',
|
||||
},
|
||||
}}
|
||||
entityId={entityId}
|
||||
|
@ -50,6 +50,7 @@ const RatingFieldInputWithContext = ({
|
||||
iconName: 'Icon123',
|
||||
metadata: {
|
||||
fieldName: 'Rating',
|
||||
objectMetadataNameSingular: 'person',
|
||||
},
|
||||
}}
|
||||
entityId={entityId}
|
||||
|
@ -73,6 +73,7 @@ const RelationFieldInputWithContext = ({
|
||||
relationObjectMetadataNamePlural: 'workspaceMembers',
|
||||
relationObjectMetadataNameSingular:
|
||||
CoreObjectNameSingular.WorkspaceMember,
|
||||
objectMetadataNameSingular: 'person',
|
||||
},
|
||||
}}
|
||||
entityId={entityId}
|
||||
|
@ -50,6 +50,7 @@ const TextFieldInputWithContext = ({
|
||||
metadata: {
|
||||
fieldName: 'Text',
|
||||
placeHolder: 'Enter text',
|
||||
objectMetadataNameSingular: 'person',
|
||||
},
|
||||
}}
|
||||
entityId={entityId}
|
||||
|
@ -153,6 +153,7 @@ export const RecordShowContainer = ({
|
||||
metadata: {
|
||||
fieldName:
|
||||
labelIdentifierFieldMetadataItem?.name || '',
|
||||
objectMetadataNameSingular: objectNameSingular,
|
||||
},
|
||||
},
|
||||
useUpdateRecord: useUpdateOneObjectRecordMutation,
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -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();
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
@ -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 {
|
||||
|
@ -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 };
|
||||
};
|
@ -0,0 +1,8 @@
|
||||
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
|
||||
|
||||
export const recordTablePendingRecordIdComponentState = createComponentState<
|
||||
string | null
|
||||
>({
|
||||
key: 'recordTablePendingRecordIdState',
|
||||
defaultValue: null,
|
||||
});
|
@ -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);
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user