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 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 { RecordTableBody } from '@/object-record/record-table/record-table-body/components/RecordTableBody';
import { RecordTableBodyEffect } from '@/object-record/record-table/record-table-body/components/RecordTableBodyEffect'; 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 { 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 { RecordTableScope } from '@/object-record/record-table/scopes/RecordTableScope';
import { useRecoilValue } from 'recoil';
const StyledTable = styled.table` const StyledTable = styled.table`
border-radius: ${({ theme }) => theme.border.radius.sm}; border-radius: ${({ theme }) => theme.border.radius.sm};
@ -32,6 +35,27 @@ export const RecordTable = ({
}: RecordTableProps) => { }: RecordTableProps) => {
const { scopeId } = useRecordTableStates(recordTableId); 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)) { if (!isNonEmptyString(objectNameSingular)) {
return <></>; return <></>;
} }
@ -45,11 +69,22 @@ export const RecordTable = ({
objectNameSingular={objectNameSingular} objectNameSingular={objectNameSingular}
recordTableId={recordTableId} recordTableId={recordTableId}
> >
<StyledTable className="entity-table-cell"> <RecordTableBodyEffect />
<RecordTableHeader createRecord={createRecord} /> {!isRecordTableInitialLoading &&
<RecordTableBodyEffect /> tableRowIds.length === 0 &&
<RecordTableBody /> isNull(pendingRecordId) ? (
</StyledTable> <RecordTableEmptyState
objectNameSingular={objectNameSingular}
objectLabel={objectLabel}
createRecord={createRecord}
isRemote={isRemote}
/>
) : (
<StyledTable className="entity-table-cell">
<RecordTableHeader createRecord={createRecord} />
<RecordTableBody />
</StyledTable>
)}
</RecordTableContextProvider> </RecordTableContextProvider>
</RecordTableScope> </RecordTableScope>
); );

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { ReactNode } from 'react';
import { act, renderHook } from '@testing-library/react'; import { act, renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { RecoilRoot } from 'recoil'; import { RecoilRoot } from 'recoil';
import { createState } from 'twenty-ui'; 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 { useUpsertRecord } from '@/object-record/record-table/record-table-cell/hooks/useUpsertRecord';
import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope'; import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope';
const pendingRecordId = 'a7286b9a-c039-4a89-9567-2dfa7953cda9';
const draftValue = 'updated Name'; 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', () => ({ jest.mock('@/object-record/hooks/useCreateOneRecord', () => ({
__esModule: true, __esModule: true,
useCreateOneRecord: jest.fn(), useCreateOneRecord: jest.fn(),
@ -93,17 +94,25 @@ describe('useUpsertRecord', () => {
}); });
it('calls update record if there is no pending record', async () => { it('calls update record if there is no pending record', async () => {
const { result } = renderHook(() => useUpsertRecord(), { const { result } = renderHook(
wrapper: ({ children }) => () => useUpsertRecord({ objectNameSingular: 'person' }),
Wrapper({ {
pendingRecordIdMockedValue: null, wrapper: ({ children }) =>
draftValueMockedValue: null, Wrapper({
children, pendingRecordIdMockedValue: null,
}), draftValueMockedValue: null,
}); children,
}),
},
);
await act(async () => { await act(async () => {
await result.current.upsertRecord(updateOneRecordMock); await result.current.upsertRecord(
updateOneRecordMock,
'entityId',
'name',
'recordTableId',
);
}); });
expect(createOneRecordMock).not.toHaveBeenCalled(); expect(createOneRecordMock).not.toHaveBeenCalled();
@ -111,42 +120,28 @@ describe('useUpsertRecord', () => {
}); });
it('calls update record if pending record is empty', async () => { it('calls update record if pending record is empty', async () => {
const { result } = renderHook(() => useUpsertRecord(), { const { result } = renderHook(
wrapper: ({ children }) => () => useUpsertRecord({ objectNameSingular: 'person' }),
Wrapper({ {
pendingRecordIdMockedValue: null, wrapper: ({ children }) =>
draftValueMockedValue: draftValue, Wrapper({
children, pendingRecordIdMockedValue: null,
}), draftValueMockedValue: draftValue,
}); children,
}),
},
);
await act(async () => { await act(async () => {
await result.current.upsertRecord(updateOneRecordMock); await result.current.upsertRecord(
updateOneRecordMock,
'entityId',
'name',
'recordTableId',
);
}); });
expect(createOneRecordMock).not.toHaveBeenCalled(); expect(createOneRecordMock).not.toHaveBeenCalled();
expect(updateOneRecordMock).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 { useRecoilCallback } from 'recoil';
import { useRecoilValue } from 'recoil';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; import { recordFieldInputDraftValueComponentSelector } from '@/object-record/record-field/states/selectors/recordFieldInputDraftValueComponentSelector';
import { useRecordFieldInputStates } from '@/object-record/record-field/hooks/internal/useRecordFieldInputStates'; import { recordTablePendingRecordIdComponentState } from '@/object-record/record-table/states/recordTablePendingRecordIdComponentState';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; 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'; import { isDefined } from '~/utils/isDefined';
export const useUpsertRecord = () => { export const useUpsertRecord = ({
const { entityId, fieldDefinition } = useContext(FieldContext); objectNameSingular,
}: {
const { pendingRecordIdState } = useRecordTableStates(); objectNameSingular: string;
}) => {
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({ const { createOneRecord } = useCreateOneRecord({
objectNameSingular, objectNameSingular,
}); });
const upsertRecord = (persistField: () => void) => { const upsertRecord = useRecoilCallback(
if (isDefined(pendingRecordId) && isDefined(draftValue)) { ({ snapshot }) =>
createOneRecord({ (
id: pendingRecordId, persistField: () => void,
name: draftValue, entityId: string,
position: 'first', fieldName: string,
}); recordTableId: string,
} else if (!pendingRecordId) { ) => {
persistField(); 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 }; 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 styled from '@emotion/styled';
import { useParams } from 'react-router-dom';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import { RecordIndexContainer } from '@/object-record/record-index/components/RecordIndexContainer'; import { RecordIndexContainer } from '@/object-record/record-index/components/RecordIndexContainer';