Display table record creation row when clicking on Add new from table empty state (#6174)

As per title
This commit is contained in:
Charles Bochet
2024-07-09 14:57:59 +02:00
committed by GitHub
parent de51e653fc
commit ee7b6bf099
7 changed files with 145 additions and 185 deletions

View File

@ -1,12 +1,15 @@
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { isNonEmptyString, isNull } from '@sniptt/guards';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { RecordTableContextProvider } from '@/object-record/record-table/components/RecordTableContextProvider';
import { RecordTableEmptyState } from '@/object-record/record-table/components/RecordTableEmptyState';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { RecordTableBody } from '@/object-record/record-table/record-table-body/components/RecordTableBody';
import { RecordTableBodyEffect } from '@/object-record/record-table/record-table-body/components/RecordTableBodyEffect';
import { RecordTableContextProvider } from '@/object-record/record-table/components/RecordTableContextProvider';
import { RecordTableHeader } from '@/object-record/record-table/record-table-header/components/RecordTableHeader';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { RecordTableScope } from '@/object-record/record-table/scopes/RecordTableScope';
import { useRecoilValue } from 'recoil';
const StyledTable = styled.table`
border-radius: ${({ theme }) => theme.border.radius.sm};
@ -32,6 +35,27 @@ export const RecordTable = ({
}: RecordTableProps) => {
const { scopeId } = useRecordTableStates(recordTableId);
const {
isRecordTableInitialLoadingState,
tableRowIdsState,
pendingRecordIdState,
} = useRecordTableStates(recordTableId);
const isRecordTableInitialLoading = useRecoilValue(
isRecordTableInitialLoadingState,
);
const tableRowIds = useRecoilValue(tableRowIdsState);
const pendingRecordId = useRecoilValue(pendingRecordIdState);
const { objectMetadataItem: foundObjectMetadataItem } = useObjectMetadataItem(
{ objectNameSingular },
);
const objectLabel = foundObjectMetadataItem?.labelSingular;
const isRemote = foundObjectMetadataItem?.isRemote ?? false;
if (!isNonEmptyString(objectNameSingular)) {
return <></>;
}
@ -45,11 +69,22 @@ export const RecordTable = ({
objectNameSingular={objectNameSingular}
recordTableId={recordTableId}
>
<StyledTable className="entity-table-cell">
<RecordTableHeader createRecord={createRecord} />
<RecordTableBodyEffect />
<RecordTableBody />
</StyledTable>
<RecordTableBodyEffect />
{!isRecordTableInitialLoading &&
tableRowIds.length === 0 &&
isNull(pendingRecordId) ? (
<RecordTableEmptyState
objectNameSingular={objectNameSingular}
objectLabel={objectLabel}
createRecord={createRecord}
isRemote={isRemote}
/>
) : (
<StyledTable className="entity-table-cell">
<RecordTableHeader createRecord={createRecord} />
<RecordTableBody />
</StyledTable>
)}
</RecordTableContextProvider>
</RecordTableScope>
);

View File

@ -13,7 +13,7 @@ import {
useOpenRecordTableCellV2,
} from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2';
import { useTriggerContextMenu } from '@/object-record/record-table/record-table-cell/hooks/useTriggerContextMenu';
import { useUpsertRecordV2 } from '@/object-record/record-table/record-table-cell/hooks/useUpsertRecordV2';
import { useUpsertRecord } from '@/object-record/record-table/record-table-cell/hooks/useUpsertRecord';
import { MoveFocusDirection } from '@/object-record/record-table/types/MoveFocusDirection';
import { TableCellPosition } from '@/object-record/record-table/types/TableCellPosition';
@ -32,7 +32,7 @@ export const RecordTableContextProvider = ({
objectNameSingular,
});
const { upsertRecord } = useUpsertRecordV2({
const { upsertRecord } = useUpsertRecord({
objectNameSingular,
});

View File

@ -1,14 +1,11 @@
import { useRef } from 'react';
import styled from '@emotion/styled';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { useRef } from 'react';
import { useRecoilCallback } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { RecordTable } from '@/object-record/record-table/components/RecordTable';
import { RecordTableEmptyState } from '@/object-record/record-table/components/RecordTableEmptyState';
import { EntityDeleteContext } from '@/object-record/record-table/contexts/EntityDeleteHookContext';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
@ -31,6 +28,10 @@ const StyledTableContainer = styled.div`
position: relative;
`;
const StyledTableInternalContainer = styled.div`
height: 100%;
`;
type RecordTableWithWrappersProps = {
objectNameSingular: string;
recordTableId: string;
@ -48,33 +49,14 @@ export const RecordTableWithWrappers = ({
}: RecordTableWithWrappersProps) => {
const tableBodyRef = useRef<HTMLDivElement>(null);
const { isRecordTableInitialLoadingState, tableRowIdsState } =
useRecordTableStates(recordTableId);
const isRecordTableInitialLoading = useRecoilValue(
isRecordTableInitialLoadingState,
);
const tableRowIds = useRecoilValue(tableRowIdsState);
const { resetTableRowSelection, setRowSelected } = useRecordTable({
recordTableId,
});
const { objectMetadataItem: foundObjectMetadataItem } = useObjectMetadataItem(
{
objectNameSingular,
},
);
const { saveViewFields } = useSaveCurrentViewFields(viewBarId);
const { deleteOneRecord } = useDeleteOneRecord({ objectNameSingular });
const objectLabel = foundObjectMetadataItem?.labelSingular;
const isRemote = foundObjectMetadataItem?.isRemote ?? false;
const handleColumnsChange = useRecoilCallback(
() => (columns) => {
saveViewFields(
@ -86,24 +68,13 @@ export const RecordTableWithWrappers = ({
[saveViewFields],
);
if (!isRecordTableInitialLoading && tableRowIds.length === 0) {
return (
<RecordTableEmptyState
objectNameSingular={objectNameSingular}
objectLabel={objectLabel}
createRecord={createRecord}
isRemote={isRemote}
/>
);
}
return (
<EntityDeleteContext.Provider value={deleteOneRecord}>
<ScrollWrapper>
<RecordUpdateContext.Provider value={updateRecordMutation}>
<StyledTableWithHeader>
<StyledTableContainer>
<div ref={tableBodyRef}>
<StyledTableInternalContainer ref={tableBodyRef}>
<RecordTable
recordTableId={recordTableId}
objectNameSingular={objectNameSingular}
@ -115,7 +86,7 @@ export const RecordTableWithWrappers = ({
onDragSelectionStart={resetTableRowSelection}
onDragSelectionChange={setRowSelected}
/>
</div>
</StyledTableInternalContainer>
<RecordTableInternalEffect
recordTableId={recordTableId}
tableBodyRef={tableBodyRef}

View File

@ -1,5 +1,5 @@
import { ReactNode } from 'react';
import { act, renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { RecoilRoot } from 'recoil';
import { createState } from 'twenty-ui';
@ -10,9 +10,10 @@ 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';
// Todo refactor this test to inject the states in a cleaner way instead of mocking hooks
// (this is not easy to maintain while refactoring)
jest.mock('@/object-record/hooks/useCreateOneRecord', () => ({
__esModule: true,
useCreateOneRecord: jest.fn(),
@ -93,17 +94,25 @@ describe('useUpsertRecord', () => {
});
it('calls update record if there is no pending record', async () => {
const { result } = renderHook(() => useUpsertRecord(), {
wrapper: ({ children }) =>
Wrapper({
pendingRecordIdMockedValue: null,
draftValueMockedValue: null,
children,
}),
});
const { result } = renderHook(
() => useUpsertRecord({ objectNameSingular: 'person' }),
{
wrapper: ({ children }) =>
Wrapper({
pendingRecordIdMockedValue: null,
draftValueMockedValue: null,
children,
}),
},
);
await act(async () => {
await result.current.upsertRecord(updateOneRecordMock);
await result.current.upsertRecord(
updateOneRecordMock,
'entityId',
'name',
'recordTableId',
);
});
expect(createOneRecordMock).not.toHaveBeenCalled();
@ -111,42 +120,28 @@ describe('useUpsertRecord', () => {
});
it('calls update record if pending record is empty', async () => {
const { result } = renderHook(() => useUpsertRecord(), {
wrapper: ({ children }) =>
Wrapper({
pendingRecordIdMockedValue: null,
draftValueMockedValue: draftValue,
children,
}),
});
const { result } = renderHook(
() => useUpsertRecord({ objectNameSingular: 'person' }),
{
wrapper: ({ children }) =>
Wrapper({
pendingRecordIdMockedValue: null,
draftValueMockedValue: draftValue,
children,
}),
},
);
await act(async () => {
await result.current.upsertRecord(updateOneRecordMock);
await result.current.upsertRecord(
updateOneRecordMock,
'entityId',
'name',
'recordTableId',
);
});
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,41 +1,65 @@
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { useRecoilCallback } 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 { recordFieldInputDraftValueComponentSelector } from '@/object-record/record-field/states/selectors/recordFieldInputDraftValueComponentSelector';
import { recordTablePendingRecordIdComponentState } from '@/object-record/record-table/states/recordTablePendingRecordIdComponentState';
import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { extractComponentSelector } from '@/ui/utilities/state/component-state/utils/extractComponentSelector';
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
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 ?? '';
export const useUpsertRecord = ({
objectNameSingular,
}: {
objectNameSingular: string;
}) => {
const { createOneRecord } = useCreateOneRecord({
objectNameSingular,
});
const upsertRecord = (persistField: () => void) => {
if (isDefined(pendingRecordId) && isDefined(draftValue)) {
createOneRecord({
id: pendingRecordId,
name: draftValue,
position: 'first',
});
} else if (!pendingRecordId) {
persistField();
}
};
const upsertRecord = useRecoilCallback(
({ snapshot }) =>
(
persistField: () => void,
entityId: string,
fieldName: string,
recordTableId: string,
) => {
const tableScopeId = getScopeIdFromComponentId(recordTableId);
const recordTablePendingRecordIdState = extractComponentState(
recordTablePendingRecordIdComponentState,
tableScopeId,
);
const recordTablePendingRecordId = getSnapshotValue(
snapshot,
recordTablePendingRecordIdState,
);
const fieldScopeId = getScopeIdFromComponentId(
`${entityId}-${fieldName}`,
);
const draftValueSelector = extractComponentSelector(
recordFieldInputDraftValueComponentSelector,
fieldScopeId,
);
const draftValue = getSnapshotValue(snapshot, draftValueSelector());
if (isDefined(recordTablePendingRecordId) && isDefined(draftValue)) {
createOneRecord({
id: recordTablePendingRecordId,
name: draftValue,
position: 'first',
});
} else if (!recordTablePendingRecordId) {
persistField();
}
},
[createOneRecord],
);
return { upsertRecord };
};

View File

@ -1,65 +0,0 @@
import { useRecoilCallback } from 'recoil';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { recordFieldInputDraftValueComponentSelector } from '@/object-record/record-field/states/selectors/recordFieldInputDraftValueComponentSelector';
import { recordTablePendingRecordIdComponentState } from '@/object-record/record-table/states/recordTablePendingRecordIdComponentState';
import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { extractComponentSelector } from '@/ui/utilities/state/component-state/utils/extractComponentSelector';
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
import { isDefined } from '~/utils/isDefined';
export const useUpsertRecordV2 = ({
objectNameSingular,
}: {
objectNameSingular: string;
}) => {
const { createOneRecord } = useCreateOneRecord({
objectNameSingular,
});
const upsertRecord = useRecoilCallback(
({ snapshot }) =>
(
persistField: () => void,
entityId: string,
fieldName: string,
recordTableId: string,
) => {
const tableScopeId = getScopeIdFromComponentId(recordTableId);
const recordTablePendingRecordIdState = extractComponentState(
recordTablePendingRecordIdComponentState,
tableScopeId,
);
const recordTablePendingRecordId = getSnapshotValue(
snapshot,
recordTablePendingRecordIdState,
);
const fieldScopeId = getScopeIdFromComponentId(
`${entityId}-${fieldName}`,
);
const draftValueSelector = extractComponentSelector(
recordFieldInputDraftValueComponentSelector,
fieldScopeId,
);
const draftValue = getSnapshotValue(snapshot, draftValueSelector());
if (isDefined(recordTablePendingRecordId) && isDefined(draftValue)) {
createOneRecord({
id: recordTablePendingRecordId,
name: draftValue,
position: 'first',
});
} else if (!recordTablePendingRecordId) {
persistField();
}
},
[createOneRecord],
);
return { upsertRecord };
};

View File

@ -1,5 +1,5 @@
import { useParams } from 'react-router-dom';
import styled from '@emotion/styled';
import { useParams } from 'react-router-dom';
import { v4 } from 'uuid';
import { RecordIndexContainer } from '@/object-record/record-index/components/RecordIndexContainer';