Chore/move records related to record folder (#2859)

* WIP

* Finished multi select filter

* Cleaned console log

* Fix naming

* Fixed naming

* Moved RelationPicker folder

* Moved EntitySelect components

* Moved story

* Moved RelationPicker non component folders

* Moved everything else
This commit is contained in:
Lucas Bordeau
2023-12-07 12:43:10 +01:00
committed by GitHub
parent ef536ebb06
commit a8ecc23cbe
445 changed files with 407 additions and 412 deletions

View File

@ -0,0 +1,12 @@
import React from 'react';
import { useRecoilValue } from 'recoil';
import { ActionBar } from '@/ui/navigation/action-bar/components/ActionBar';
import { selectedRowIdsSelector } from '../../states/selectors/selectedRowIdsSelector';
export const RecordTableActionBar = () => {
const selectedRowIds = useRecoilValue(selectedRowIdsSelector);
return <ActionBar selectedIds={selectedRowIds} />;
};

View File

@ -0,0 +1,34 @@
import { useCallback } from 'react';
import styled from '@emotion/styled';
import { useSetRecoilState } from 'recoil';
import { Checkbox } from '@/ui/input/components/Checkbox';
import { actionBarOpenState } from '@/ui/navigation/action-bar/states/actionBarIsOpenState';
import { useCurrentRowSelected } from '../record-table-row/hooks/useCurrentRowSelected';
const StyledContainer = styled.div`
align-items: center;
cursor: pointer;
display: flex;
height: 32px;
justify-content: center;
`;
export const CheckboxCell = () => {
const setActionBarOpenState = useSetRecoilState(actionBarOpenState);
const { currentRowSelected, setCurrentRowSelected } = useCurrentRowSelected();
const handleClick = useCallback(() => {
setCurrentRowSelected(!currentRowSelected);
setActionBarOpenState(true);
}, [currentRowSelected, setActionBarOpenState, setCurrentRowSelected]);
return (
<StyledContainer onClick={handleClick}>
<Checkbox checked={currentRowSelected} />
</StyledContainer>
);
};

View File

@ -0,0 +1,55 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { FieldMetadata } from '@/object-record/field/types/FieldMetadata';
import { useLazyLoadIcons } from '@/ui/input/hooks/useLazyLoadIcons';
import { ColumnDefinition } from '../types/ColumnDefinition';
type ColumnHeadProps = {
column: ColumnDefinition<FieldMetadata>;
};
const StyledTitle = styled.div`
align-items: center;
display: flex;
flex-direction: row;
font-weight: ${({ theme }) => theme.font.weight.medium};
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ theme }) => theme.spacing(8)};
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
`;
const StyledIcon = styled.div`
display: flex;
& > svg {
height: ${({ theme }) => theme.icon.size.md}px;
width: ${({ theme }) => theme.icon.size.md}px;
}
`;
const StyledText = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
export const ColumnHead = ({ column }: ColumnHeadProps) => {
const theme = useTheme();
const { icons, isLoadingIcons } = useLazyLoadIcons();
const Icon = icons[column.iconName];
return (
<>
<StyledTitle>
<StyledIcon>
{!isLoadingIcons && <Icon size={theme.icon.size.md} />}
</StyledIcon>
<StyledText>{column.label}</StyledText>
</StyledTitle>
</>
);
};

View File

@ -0,0 +1,41 @@
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { FieldMetadata } from '../../field/types/FieldMetadata';
import { ColumnDefinition } from '../types/ColumnDefinition';
import { ColumnHead } from './ColumnHead';
import { RecordTableColumnDropdownMenu } from './RecordTableColumnDropdownMenu';
type ColumnHeadWithDropdownProps = {
column: ColumnDefinition<FieldMetadata>;
isFirstColumn: boolean;
isLastColumn: boolean;
primaryColumnKey: string;
};
export const ColumnHeadWithDropdown = ({
column,
isFirstColumn,
isLastColumn,
primaryColumnKey,
}: ColumnHeadWithDropdownProps) => {
return (
<DropdownScope dropdownScopeId={column.fieldMetadataId + '-header'}>
<Dropdown
clickableComponent={<ColumnHead column={column} />}
dropdownComponents={
<RecordTableColumnDropdownMenu
column={column}
isFirstColumn={isFirstColumn}
isLastColumn={isLastColumn}
primaryColumnKey={primaryColumnKey}
/>
}
dropdownOffset={{ x: -1 }}
dropdownPlacement="bottom-start"
dropdownHotkeyScope={{ scope: column.fieldMetadataId + '-header' }}
/>
</DropdownScope>
);
};

View File

@ -0,0 +1,128 @@
import { useRef } from 'react';
import styled from '@emotion/styled';
import { useRecoilCallback } from 'recoil';
import { RecordTableBodyEffect } from '@/object-record/record-table/components/RecordTableBodyEffect';
import { RecordTableHeader } from '@/object-record/record-table/components/RecordTableHeader';
import { RecordTableInternalEffect } from '@/object-record/record-table/components/RecordTableInternalEffect';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { RecordTableScope } from '@/object-record/record-table/scopes/RecordTableScope';
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useViewFields } from '@/views/hooks/internal/useViewFields';
import { mapColumnDefinitionsToViewFields } from '@/views/utils/mapColumnDefinitionToViewField';
import { EntityUpdateMutationContext } from '../contexts/EntityUpdateMutationHookContext';
import { RecordTableBody } from './RecordTableBody';
const StyledTable = styled.table`
border-collapse: collapse;
border-radius: ${({ theme }) => theme.border.radius.sm};
border-spacing: 0;
margin-left: ${({ theme }) => theme.table.horizontalCellMargin};
margin-right: ${({ theme }) => theme.table.horizontalCellMargin};
table-layout: fixed;
width: calc(100% - ${({ theme }) => theme.table.horizontalCellMargin} * 2);
th {
border: 1px solid ${({ theme }) => theme.border.color.light};
border-collapse: collapse;
color: ${({ theme }) => theme.font.color.tertiary};
padding: 0;
text-align: left;
:last-child {
border-right-color: transparent;
}
:first-of-type {
border-left-color: transparent;
border-right-color: transparent;
}
}
td {
border: 1px solid ${({ theme }) => theme.border.color.light};
border-collapse: collapse;
color: ${({ theme }) => theme.font.color.primary};
padding: 0;
text-align: left;
:last-child {
border-right-color: transparent;
}
:first-of-type {
border-left-color: transparent;
border-right-color: transparent;
}
}
`;
const StyledTableWithHeader = styled.div`
display: flex;
flex: 1;
flex-direction: column;
width: 100%;
`;
const StyledTableContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
position: relative;
`;
type RecordTableProps = {
recordTableId: string;
viewBarId: string;
updateRecordMutation: (params: any) => void;
createRecord: () => void;
};
export const RecordTable = ({
updateRecordMutation,
createRecord,
recordTableId,
viewBarId,
}: RecordTableProps) => {
const tableBodyRef = useRef<HTMLDivElement>(null);
const { resetTableRowSelection, setRowSelectedState } = useRecordTable({
recordTableScopeId: recordTableId,
});
const { persistViewFields } = useViewFields(viewBarId);
return (
<RecordTableScope
recordTableScopeId={recordTableId}
onColumnsChange={useRecoilCallback(() => (columns) => {
persistViewFields(mapColumnDefinitionsToViewFields(columns));
})}
>
<ScrollWrapper>
<EntityUpdateMutationContext.Provider value={updateRecordMutation}>
<StyledTableWithHeader>
<StyledTableContainer>
<div ref={tableBodyRef}>
<StyledTable className="entity-table-cell">
<RecordTableHeader createRecord={createRecord} />
<RecordTableBodyEffect />
<RecordTableBody />
</StyledTable>
<DragSelect
dragSelectable={tableBodyRef}
onDragSelectionStart={resetTableRowSelection}
onDragSelectionChange={setRowSelectedState}
/>
</div>
<RecordTableInternalEffect tableBodyRef={tableBodyRef} />
</StyledTableContainer>
</StyledTableWithHeader>
</EntityUpdateMutationContext.Provider>
</ScrollWrapper>
</RecordTableScope>
);
};

View File

@ -0,0 +1,33 @@
import { useRecoilValue } from 'recoil';
import { RecordTableBodyFetchMoreLoader } from '@/object-record/record-table/components/RecordTableBodyFetchMoreLoader';
import { RecordTableRow } from '@/object-record/record-table/components/RecordTableRow';
import { RowIdContext } from '@/object-record/record-table/contexts/RowIdContext';
import { RowIndexContext } from '@/object-record/record-table/contexts/RowIndexContext';
import { isFetchingRecordTableDataState } from '@/object-record/record-table/states/isFetchingRecordTableDataState';
import { tableRowIdsState } from '@/object-record/record-table/states/tableRowIdsState';
export const RecordTableBody = () => {
const tableRowIds = useRecoilValue(tableRowIdsState);
const isFetchingRecordTableData = useRecoilValue(
isFetchingRecordTableDataState,
);
if (isFetchingRecordTableData) {
return <></>;
}
return (
<>
{tableRowIds.slice().map((rowId, rowIndex) => (
<RowIdContext.Provider value={rowId} key={rowId}>
<RowIndexContext.Provider value={rowIndex}>
<RecordTableRow key={rowId} rowId={rowId} />
</RowIndexContext.Provider>
</RowIdContext.Provider>
))}
<RecordTableBodyFetchMoreLoader />
</>
);
};

View File

@ -0,0 +1,43 @@
import { useEffect } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useObjectRecordTable } from '@/object-record/hooks/useObjectRecordTable';
import { useRecordTableScopedStates } from '@/object-record/record-table/hooks/internal/useRecordTableScopedStates';
import { isFetchingMoreRecordsFamilyState } from '@/object-record/states/isFetchingMoreRecordsFamilyState';
export const RecordTableBodyEffect = () => {
const {
fetchMoreRecords: fetchMoreObjects,
records,
setRecordTableData,
queryStateIdentifier,
loading,
} = useObjectRecordTable();
const { tableLastRowVisibleState } = useRecordTableScopedStates();
const [tableLastRowVisible, setTableLastRowVisible] = useRecoilState(
tableLastRowVisibleState,
);
const isFetchingMoreObjects = useRecoilValue(
isFetchingMoreRecordsFamilyState(queryStateIdentifier),
);
useEffect(() => {
if (!loading) {
setRecordTableData(records);
}
}, [records, setRecordTableData, loading]);
useEffect(() => {
if (tableLastRowVisible && !isFetchingMoreObjects) {
fetchMoreObjects();
}
}, [
fetchMoreObjects,
isFetchingMoreObjects,
setTableLastRowVisible,
tableLastRowVisible,
]);
return <></>;
};

View File

@ -0,0 +1,53 @@
import { useInView } from 'react-intersection-observer';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { useObjectRecordTable } from '@/object-record/hooks/useObjectRecordTable';
import { StyledRow } from '@/object-record/record-table/components/RecordTableRow';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { isFetchingRecordTableDataState } from '@/object-record/record-table/states/isFetchingRecordTableDataState';
import { getRecordTableScopedStates } from '@/object-record/record-table/utils/getRecordTableScopedStates';
import { isFetchingMoreRecordsFamilyState } from '@/object-record/states/isFetchingMoreRecordsFamilyState';
export const RecordTableBodyFetchMoreLoader = () => {
const { queryStateIdentifier } = useObjectRecordTable();
const { scopeId } = useRecordTable();
const isFetchingMoreObjects = useRecoilValue(
isFetchingMoreRecordsFamilyState(queryStateIdentifier),
);
const isFetchingRecordTableData = useRecoilValue(
isFetchingRecordTableDataState,
);
const onLastRowVisible = useRecoilCallback(
({ set }) =>
async (inView: boolean) => {
const { tableLastRowVisibleState } = getRecordTableScopedStates({
recordTableScopeId: scopeId,
});
set(tableLastRowVisibleState, inView);
},
[scopeId],
);
const { ref: tbodyRef } = useInView({
onChange: onLastRowVisible,
});
if (isFetchingRecordTableData) {
return <></>;
}
return (
<tbody ref={tbodyRef}>
{isFetchingMoreObjects && (
<StyledRow selected={false}>
<td style={{ height: 50 }} colSpan={1000}>
Loading more...
</td>
</StyledRow>
)}
</tbody>
);
};

View File

@ -0,0 +1,75 @@
import { useContext } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useRecordTableScopedStates } from '@/object-record/record-table/hooks/internal/useRecordTableScopedStates';
import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope';
import { contextMenuIsOpenState } from '@/ui/navigation/context-menu/states/contextMenuIsOpenState';
import { contextMenuPositionState } from '@/ui/navigation/context-menu/states/contextMenuPositionState';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { FieldContext } from '../../field/contexts/FieldContext';
import { isFieldRelation } from '../../field/types/guards/isFieldRelation';
import { ColumnContext } from '../contexts/ColumnContext';
import { ColumnIndexContext } from '../contexts/ColumnIndexContext';
import { EntityUpdateMutationContext } from '../contexts/EntityUpdateMutationHookContext';
import { RowIdContext } from '../contexts/RowIdContext';
import { TableCell } from '../record-table-cell/components/RecordTableCell';
import { useCurrentRowSelected } from '../record-table-row/hooks/useCurrentRowSelected';
import { TableHotkeyScope } from '../types/TableHotkeyScope';
export const RecordTableCell = ({ cellIndex }: { cellIndex: number }) => {
const setContextMenuPosition = useSetRecoilState(contextMenuPositionState);
const setContextMenuOpenState = useSetRecoilState(contextMenuIsOpenState);
const currentRowId = useContext(RowIdContext);
const { objectMetadataConfigState } = useRecordTableScopedStates();
const objectMetadataConfig = useRecoilValue(objectMetadataConfigState);
const { setCurrentRowSelected } = useCurrentRowSelected();
const handleContextMenu = (event: React.MouseEvent) => {
event.preventDefault();
setCurrentRowSelected(true);
setContextMenuPosition({
x: event.clientX,
y: event.clientY,
});
setContextMenuOpenState(true);
};
const columnDefinition = useContext(ColumnContext);
const updateEntityMutation = useContext(EntityUpdateMutationContext);
if (!columnDefinition || !currentRowId) {
return null;
}
const customHotkeyScope = isFieldRelation(columnDefinition)
? RelationPickerHotkeyScope.RelationPicker
: TableHotkeyScope.CellEditMode;
return (
<RecoilScope>
<ColumnIndexContext.Provider value={cellIndex}>
<td onContextMenu={(event) => handleContextMenu(event)}>
<FieldContext.Provider
value={{
recoilScopeId: currentRowId + columnDefinition.label,
entityId: currentRowId,
fieldDefinition: columnDefinition,
useUpdateEntityMutation: () => [updateEntityMutation, {}],
hotkeyScope: customHotkeyScope,
basePathToShowPage: objectMetadataConfig?.basePathToShowPage,
isLabelIdentifier:
columnDefinition.fieldMetadataId ===
objectMetadataConfig?.labelIdentifierFieldMetadataId,
}}
>
<TableCell customHotkeyScope={{ scope: customHotkeyScope }} />
</FieldContext.Provider>
</td>
</ColumnIndexContext.Provider>
</RecoilScope>
);
};

View File

@ -0,0 +1,74 @@
import { FieldMetadata } from '@/object-record/field/types/FieldMetadata';
import { IconArrowLeft, IconArrowRight, IconEyeOff } from '@/ui/display/icon';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { useTableColumns } from '../hooks/useTableColumns';
import { ColumnDefinition } from '../types/ColumnDefinition';
export type RecordTableColumnDropdownMenuProps = {
column: ColumnDefinition<FieldMetadata>;
isFirstColumn: boolean;
isLastColumn: boolean;
primaryColumnKey: string;
};
export const RecordTableColumnDropdownMenu = ({
column,
isFirstColumn,
isLastColumn,
primaryColumnKey,
}: RecordTableColumnDropdownMenuProps) => {
const { handleColumnVisibilityChange, handleMoveTableColumn } =
useTableColumns();
const { closeDropdown } = useDropdown();
const handleColumnMoveLeft = () => {
closeDropdown();
if (isFirstColumn) {
return;
}
handleMoveTableColumn('left', column);
};
const handleColumnMoveRight = () => {
closeDropdown();
if (isLastColumn) {
return;
}
handleMoveTableColumn('right', column);
};
const handleColumnVisibility = () => {
closeDropdown();
handleColumnVisibilityChange(column);
};
return column.fieldMetadataId === primaryColumnKey ? (
<></>
) : (
<DropdownMenuItemsContainer>
{!isFirstColumn && (
<MenuItem
LeftIcon={IconArrowLeft}
onClick={handleColumnMoveLeft}
text="Move left"
/>
)}
{!isLastColumn && (
<MenuItem
LeftIcon={IconArrowRight}
onClick={handleColumnMoveRight}
text="Move right"
/>
)}
<MenuItem
LeftIcon={IconEyeOff}
onClick={handleColumnVisibility}
text="Hide"
/>
</DropdownMenuItemsContainer>
);
};

View File

@ -0,0 +1,104 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { RecordTableHeaderCell } from '@/object-record/record-table/components/RecordTableHeaderCell';
import { IconPlus } from '@/ui/display/icon';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { useRecordTableScopedStates } from '../hooks/internal/useRecordTableScopedStates';
import { RecordTableHeaderPlusButtonContent } from './RecordTableHeaderPlusButtonContent';
import { SelectAllCheckbox } from './SelectAllCheckbox';
const StyledTableHead = styled.thead`
cursor: pointer;
`;
const StyledPlusIconHeaderCell = styled.th`
${({ theme }) => {
return `
&:hover {
background: ${theme.background.transparent.light};
};
padding-left: ${theme.spacing(3)};
`;
}};
border-bottom: none !important;
border-left: none !important;
min-width: 32px;
position: relative;
z-index: 1;
`;
const StyledPlusIconContainer = styled.div`
align-items: center;
display: flex;
height: 32px;
justify-content: center;
width: 32px;
`;
const HIDDEN_TABLE_COLUMN_DROPDOWN_SCOPE_ID =
'hidden-table-columns-dropdown-scope-id';
const HIDDEN_TABLE_COLUMN_DROPDOWN_HOTKEY_SCOPE_ID =
'hidden-table-columns-dropdown-hotkey-scope-id';
export const RecordTableHeader = ({
createRecord,
}: {
createRecord: () => void;
}) => {
const { hiddenTableColumnsSelector, visibleTableColumnsSelector } =
useRecordTableScopedStates();
const hiddenTableColumns = useRecoilValue(hiddenTableColumnsSelector);
const visibleTableColumns = useRecoilValue(visibleTableColumnsSelector);
const theme = useTheme();
return (
<StyledTableHead data-select-disable>
<tr>
<th
style={{
width: 30,
minWidth: 30,
maxWidth: 30,
}}
>
<SelectAllCheckbox />
</th>
{visibleTableColumns.map((column) => (
<RecordTableHeaderCell
key={column.fieldMetadataId}
column={column}
createRecord={createRecord}
/>
))}
{hiddenTableColumns.length > 0 && (
<StyledPlusIconHeaderCell>
<DropdownScope
dropdownScopeId={HIDDEN_TABLE_COLUMN_DROPDOWN_SCOPE_ID}
>
<Dropdown
clickableComponent={
<StyledPlusIconContainer>
<IconPlus size={theme.icon.size.md} />
</StyledPlusIconContainer>
}
dropdownComponents={<RecordTableHeaderPlusButtonContent />}
dropdownPlacement="bottom-start"
dropdownHotkeyScope={{
scope: HIDDEN_TABLE_COLUMN_DROPDOWN_HOTKEY_SCOPE_ID,
}}
/>
</DropdownScope>
</StyledPlusIconHeaderCell>
)}
</tr>
</StyledTableHead>
);
};

View File

@ -0,0 +1,200 @@
import { useCallback, useState } from 'react';
import styled from '@emotion/styled';
import { useRecoilCallback, useRecoilState, useRecoilValue } from 'recoil';
import { FieldMetadata } from '@/object-record/field/types/FieldMetadata';
import { useRecordTableScopedStates } from '@/object-record/record-table/hooks/internal/useRecordTableScopedStates';
import { useTableColumns } from '@/object-record/record-table/hooks/useTableColumns';
import { resizeFieldOffsetState } from '@/object-record/record-table/states/resizeFieldOffsetState';
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { IconPlus } from '@/ui/display/icon';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { useTrackPointer } from '@/ui/utilities/pointer-event/hooks/useTrackPointer';
import { ColumnHeadWithDropdown } from './ColumnHeadWithDropdown';
const COLUMN_MIN_WIDTH = 104;
const StyledColumnHeaderCell = styled.th<{
columnWidth: number;
isResizing?: boolean;
}>`
${({ columnWidth }) => `
min-width: ${columnWidth}px;
width: ${columnWidth}px;
`}
position: relative;
user-select: none;
${({ theme }) => {
return `
&:hover {
background: ${theme.background.transparent.light};
};
`;
}};
${({ isResizing, theme }) => {
if (isResizing) {
return `&:after {
background-color: ${theme.color.blue};
bottom: 0;
content: '';
display: block;
position: absolute;
right: -1px;
top: 0;
width: 2px;
}`;
}
}};
`;
const StyledResizeHandler = styled.div`
bottom: 0;
cursor: col-resize;
padding: 0 ${({ theme }) => theme.spacing(2)};
position: absolute;
right: -9px;
top: 0;
width: 3px;
z-index: 1;
`;
const StyledColumnHeadContainer = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
position: relative;
z-index: 1;
`;
const StyledHeaderIcon = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(1)};
margin-right: ${({ theme }) => theme.spacing(1)};
margin-top: ${({ theme }) => theme.spacing(1)};
`;
export const RecordTableHeaderCell = ({
column,
createRecord,
}: {
column: ColumnDefinition<FieldMetadata>;
createRecord: () => void;
}) => {
const [resizeFieldOffset, setResizeFieldOffset] = useRecoilState(
resizeFieldOffsetState,
);
const {
tableColumnsState,
tableColumnsByKeySelector,
visibleTableColumnsSelector,
} = useRecordTableScopedStates();
const tableColumns = useRecoilValue(tableColumnsState);
const tableColumnsByKey = useRecoilValue(tableColumnsByKeySelector);
const visibleTableColumns = useRecoilValue(visibleTableColumnsSelector);
const [initialPointerPositionX, setInitialPointerPositionX] = useState<
number | null
>(null);
const [resizedFieldKey, setResizedFieldKey] = useState<string | null>(null);
const { handleColumnsChange } = useTableColumns();
const handleResizeHandlerStart = useCallback((positionX: number) => {
setInitialPointerPositionX(positionX);
}, []);
const [iconVisibility, setIconVisibility] = useState(false);
const primaryColumn = visibleTableColumns.find(
(column) => column.position === 0,
);
const handleResizeHandlerMove = useCallback(
(positionX: number) => {
if (!initialPointerPositionX) return;
setResizeFieldOffset(positionX - initialPointerPositionX);
},
[setResizeFieldOffset, initialPointerPositionX],
);
const handleResizeHandlerEnd = useRecoilCallback(
({ snapshot, set }) =>
async () => {
if (!resizedFieldKey) return;
const nextWidth = Math.round(
Math.max(
tableColumnsByKey[resizedFieldKey].size +
snapshot.getLoadable(resizeFieldOffsetState).valueOrThrow(),
COLUMN_MIN_WIDTH,
),
);
set(resizeFieldOffsetState, 0);
setInitialPointerPositionX(null);
setResizedFieldKey(null);
if (nextWidth !== tableColumnsByKey[resizedFieldKey].size) {
const nextColumns = tableColumns.map((column) =>
column.fieldMetadataId === resizedFieldKey
? { ...column, size: nextWidth }
: column,
);
await handleColumnsChange(nextColumns);
}
},
[resizedFieldKey, tableColumnsByKey, tableColumns, handleColumnsChange],
);
useTrackPointer({
shouldTrackPointer: resizedFieldKey !== null,
onMouseDown: handleResizeHandlerStart,
onMouseMove: handleResizeHandlerMove,
onMouseUp: handleResizeHandlerEnd,
});
return (
<StyledColumnHeaderCell
key={column.fieldMetadataId}
isResizing={resizedFieldKey === column.fieldMetadataId}
columnWidth={Math.max(
tableColumnsByKey[column.fieldMetadataId].size +
(resizedFieldKey === column.fieldMetadataId ? resizeFieldOffset : 0) +
24,
COLUMN_MIN_WIDTH,
)}
>
<StyledColumnHeadContainer
onMouseEnter={() => setIconVisibility(true)}
onMouseLeave={() => setIconVisibility(false)}
>
<ColumnHeadWithDropdown
column={column}
isFirstColumn={column.position === 1}
isLastColumn={column.position === visibleTableColumns.length - 1}
primaryColumnKey={primaryColumn?.fieldMetadataId || ''}
/>
{iconVisibility && column.position === 0 && (
<StyledHeaderIcon>
<LightIconButton
Icon={IconPlus}
size="small"
accent="tertiary"
onClick={createRecord}
/>
</StyledHeaderIcon>
)}
</StyledColumnHeadContainer>
<StyledResizeHandler
className="cursor-col-resize"
role="separator"
onPointerDown={() => {
setResizedFieldKey(column.fieldMetadataId);
}}
/>
</StyledColumnHeaderCell>
);
};

View File

@ -0,0 +1,67 @@
import { useCallback } from 'react';
import { Link } from 'react-router-dom';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { IconPlus, IconSettings } from '@/ui/display/icon';
import { useLazyLoadIcons } from '@/ui/input/hooks/useLazyLoadIcons';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { FieldMetadata } from '../../field/types/FieldMetadata';
import { useRecordTableScopedStates } from '../hooks/internal/useRecordTableScopedStates';
import { useTableColumns } from '../hooks/useTableColumns';
import { ColumnDefinition } from '../types/ColumnDefinition';
export const RecordTableHeaderPlusButtonContent = () => {
const { closeDropdown } = useDropdown();
const { hiddenTableColumnsSelector } = useRecordTableScopedStates();
const hiddenTableColumns = useRecoilValue(hiddenTableColumnsSelector);
const { icons } = useLazyLoadIcons();
const { handleColumnVisibilityChange } = useTableColumns();
const handleAddColumn = useCallback(
(column: ColumnDefinition<FieldMetadata>) => {
closeDropdown();
handleColumnVisibilityChange(column);
},
[handleColumnVisibilityChange, closeDropdown],
);
const StyledMenuItemLink = styled(Link)`
text-decoration: none;
width: 100%;
`;
return (
<>
<DropdownMenuItemsContainer>
{hiddenTableColumns.map((column) => (
<MenuItem
key={column.fieldMetadataId}
iconButtons={[
{
Icon: IconPlus,
onClick: () => handleAddColumn(column),
},
]}
LeftIcon={icons[column.iconName]}
text={column.label}
/>
))}
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<StyledMenuItemLink to="/settings/objects">
<MenuItem LeftIcon={IconSettings} text="Customize fields" />
</StyledMenuItemLink>
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -0,0 +1,45 @@
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import {
useListenClickOutside,
useListenClickOutsideByClassName,
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
type RecordTableInternalEffectProps = {
tableBodyRef: React.RefObject<HTMLDivElement>;
};
export const RecordTableInternalEffect = ({
tableBodyRef,
}: RecordTableInternalEffectProps) => {
const { leaveTableFocus, resetTableRowSelection, useMapKeyboardToSoftFocus } =
useRecordTable();
useMapKeyboardToSoftFocus();
useListenClickOutside({
refs: [tableBodyRef],
callback: () => {
leaveTableFocus();
},
});
useScopedHotkeys(
'escape',
() => {
resetTableRowSelection();
},
TableHotkeyScope.Table,
);
useListenClickOutsideByClassName({
classNames: ['entity-table-cell'],
excludeClassNames: ['action-bar', 'context-menu'],
callback: () => {
resetTableRowSelection();
},
});
return <></>;
};

View File

@ -0,0 +1,73 @@
import { useContext } from 'react';
import { useInView } from 'react-intersection-observer';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { ScrollWrapperContext } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { ColumnContext } from '../contexts/ColumnContext';
import { useRecordTableScopedStates } from '../hooks/internal/useRecordTableScopedStates';
import { useCurrentRowSelected } from '../record-table-row/hooks/useCurrentRowSelected';
import { CheckboxCell } from './CheckboxCell';
import { RecordTableCell } from './RecordTableCell';
export const StyledRow = styled.tr<{ selected: boolean }>`
background: ${(props) =>
props.selected ? props.theme.accent.quaternary : 'none'};
`;
type RecordTableRowProps = {
rowId: string;
};
const StyledPlaceholder = styled.td`
height: 30px;
`;
export const RecordTableRow = ({ rowId }: RecordTableRowProps) => {
const { visibleTableColumnsSelector } = useRecordTableScopedStates();
const visibleTableColumns = useRecoilValue(visibleTableColumnsSelector);
const { currentRowSelected } = useCurrentRowSelected();
const scrollWrapperRef = useContext(ScrollWrapperContext);
const { ref: elementRef, inView } = useInView({
root: scrollWrapperRef.current,
rootMargin: '1000px',
});
return (
<StyledRow
ref={elementRef}
data-testid={`row-id-${rowId}`}
selected={currentRowSelected}
data-selectable-id={rowId}
>
{inView ? (
<>
<td>
<CheckboxCell />
</td>
{[...visibleTableColumns]
.sort((columnA, columnB) => columnA.position - columnB.position)
.map((column, columnIndex) => {
return (
<ColumnContext.Provider
value={column}
key={column.fieldMetadataId}
>
<RecordTableCell cellIndex={columnIndex} />
</ColumnContext.Provider>
);
})}
<td></td>
</>
) : (
<StyledPlaceholder />
)}
</StyledRow>
);
};

View File

@ -0,0 +1,38 @@
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { Checkbox } from '@/ui/input/components/Checkbox';
import { useRecordTable } from '../hooks/useRecordTable';
import { allRowsSelectedStatusSelector } from '../states/selectors/allRowsSelectedStatusSelector';
const StyledContainer = styled.div`
align-items: center;
display: flex;
height: 32px;
justify-content: center;
`;
export const SelectAllCheckbox = () => {
const allRowsSelectedStatus = useRecoilValue(allRowsSelectedStatusSelector);
const { selectAllRows } = useRecordTable();
const checked = allRowsSelectedStatus === 'all';
const indeterminate = allRowsSelectedStatus === 'some';
const onChange = () => {
selectAllRows();
};
return (
<StyledContainer>
<Checkbox
checked={checked}
onChange={onChange}
indeterminate={indeterminate}
/>
</StyledContainer>
);
};

View File

@ -0,0 +1 @@
export const ColumnHeadDropdownId = 'table-head-options';

View File

@ -0,0 +1,2 @@
// We should either apply the constant all caps case or maybe define a more general enum to store those ids ?
export const TableOptionsDropdownId = 'table-options';

View File

@ -0,0 +1,11 @@
import React from 'react';
import { useRecoilValue } from 'recoil';
import { ContextMenu } from '@/ui/navigation/context-menu/components/ContextMenu';
import { selectedRowIdsSelector } from '../../states/selectors/selectedRowIdsSelector';
export const RecordTableContextMenu = () => {
const selectedRowIds = useRecoilValue(selectedRowIdsSelector);
return <ContextMenu selectedIds={selectedRowIds} />;
};

View File

@ -0,0 +1,5 @@
import { createContext } from 'react';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
export const CellHotkeyScopeContext = createContext<HotkeyScope | null>(null);

View File

@ -0,0 +1,8 @@
import { createContext } from 'react';
import { FieldMetadata } from '@/object-record/field/types/FieldMetadata';
import { ColumnDefinition } from '../types/ColumnDefinition';
export const ColumnContext =
createContext<ColumnDefinition<FieldMetadata> | null>(null);

View File

@ -0,0 +1,3 @@
import { createContext } from 'react';
export const ColumnIndexContext = createContext<number>(0);

View File

@ -0,0 +1,5 @@
import { createContext } from 'react';
export const EntityUpdateMutationContext = createContext<(params: any) => void>(
{} as any,
);

View File

@ -0,0 +1,3 @@
import { createContext } from 'react';
export const RowIdContext = createContext<string | null>(null);

View File

@ -0,0 +1,3 @@
import { createContext } from 'react';
export const RowIndexContext = createContext<number>(0);

View File

@ -0,0 +1,18 @@
import { useRecoilCallback } from 'recoil';
import { currentTableCellInEditModePositionState } from '../../states/currentTableCellInEditModePositionState';
import { isTableCellInEditModeFamilyState } from '../../states/isTableCellInEditModeFamilyState';
export const useCloseCurrentTableCellInEditMode = () =>
useRecoilCallback(({ set, snapshot }) => {
return async () => {
const currentTableCellInEditModePosition = snapshot
.getLoadable(currentTableCellInEditModePositionState)
.valueOrThrow();
set(
isTableCellInEditModeFamilyState(currentTableCellInEditModePosition),
false,
);
};
}, []);

View File

@ -0,0 +1,18 @@
import { useRecoilCallback } from 'recoil';
import { isSoftFocusActiveState } from '../../states/isSoftFocusActiveState';
import { isSoftFocusOnTableCellFamilyState } from '../../states/isSoftFocusOnTableCellFamilyState';
import { softFocusPositionState } from '../../states/softFocusPositionState';
export const useDisableSoftFocus = () =>
useRecoilCallback(({ set, snapshot }) => {
return () => {
const currentPosition = snapshot
.getLoadable(softFocusPositionState)
.valueOrThrow();
set(isSoftFocusActiveState, false);
set(isSoftFocusOnTableCellFamilyState(currentPosition), false);
};
}, []);

View File

@ -0,0 +1,26 @@
import { useRecoilCallback } from 'recoil';
import { currentTableCellInEditModePositionState } from '../../states/currentTableCellInEditModePositionState';
import { isTableCellInEditModeFamilyState } from '../../states/isTableCellInEditModeFamilyState';
export const useGetIsSomeCellInEditMode = () => {
return useRecoilCallback(
({ snapshot }) =>
() => {
const currentTableCellInEditModePosition = snapshot
.getLoadable(currentTableCellInEditModePositionState)
.valueOrThrow();
const isSomeCellInEditMode = snapshot
.getLoadable(
isTableCellInEditModeFamilyState(
currentTableCellInEditModePosition,
),
)
.valueOrThrow();
return isSomeCellInEditMode;
},
[],
);
};

View File

@ -0,0 +1,39 @@
import { useRecoilCallback } from 'recoil';
import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState';
import { isSoftFocusActiveState } from '../../states/isSoftFocusActiveState';
import { TableHotkeyScope } from '../../types/TableHotkeyScope';
import { useCloseCurrentTableCellInEditMode } from './useCloseCurrentTableCellInEditMode';
import { useDisableSoftFocus } from './useDisableSoftFocus';
export const useLeaveTableFocus = () => {
const disableSoftFocus = useDisableSoftFocus();
const closeCurrentCellInEditMode = useCloseCurrentTableCellInEditMode();
return useRecoilCallback(
({ snapshot }) =>
() => {
const isSoftFocusActive = snapshot
.getLoadable(isSoftFocusActiveState)
.valueOrThrow();
const currentHotkeyScope = snapshot
.getLoadable(currentHotkeyScopeState)
.valueOrThrow();
if (!isSoftFocusActive) {
return;
}
if (currentHotkeyScope?.scope === TableHotkeyScope.Table) {
return;
}
closeCurrentCellInEditMode();
disableSoftFocus();
},
[closeCurrentCellInEditMode, disableSoftFocus],
);
};

View File

@ -0,0 +1,23 @@
import { useRecoilCallback } from 'recoil';
import { currentTableCellInEditModePositionState } from '../../states/currentTableCellInEditModePositionState';
import { isTableCellInEditModeFamilyState } from '../../states/isTableCellInEditModeFamilyState';
import { TableCellPosition } from '../../types/TableCellPosition';
export const useMoveEditModeToTableCellPosition = () =>
useRecoilCallback(({ set, snapshot }) => {
return (newPosition: TableCellPosition) => {
const currentTableCellInEditModePosition = snapshot
.getLoadable(currentTableCellInEditModePositionState)
.valueOrThrow();
set(
isTableCellInEditModeFamilyState(currentTableCellInEditModePosition),
false,
);
set(currentTableCellInEditModePositionState, newPosition);
set(isTableCellInEditModeFamilyState(newPosition), true);
};
}, []);

View File

@ -0,0 +1,46 @@
import { RecordTableScopeInternalContext } from '@/object-record/record-table/scopes/scope-internal-context/RecordTableScopeInternalContext';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { getRecordTableScopedStates } from '../../utils/getRecordTableScopedStates';
export const useRecordTableScopedStates = (args?: {
customRecordTableScopeId?: string;
}) => {
const { customRecordTableScopeId } = args ?? {};
const scopeId = useAvailableScopeIdOrThrow(
RecordTableScopeInternalContext,
customRecordTableScopeId,
);
const {
availableTableColumnsState,
tableFiltersState,
tableSortsState,
tableColumnsState,
objectMetadataConfigState,
tableColumnsByKeySelector,
hiddenTableColumnsSelector,
visibleTableColumnsSelector,
onEntityCountChangeState,
onColumnsChangeState,
tableLastRowVisibleState,
} = getRecordTableScopedStates({
recordTableScopeId: scopeId,
});
return {
scopeId,
availableTableColumnsState,
tableFiltersState,
tableSortsState,
tableColumnsState,
objectMetadataConfigState,
tableColumnsByKeySelector,
hiddenTableColumnsSelector,
visibleTableColumnsSelector,
onEntityCountChangeState,
onColumnsChangeState,
tableLastRowVisibleState,
};
};

View File

@ -0,0 +1,19 @@
import { useRecoilCallback } from 'recoil';
import { isRowSelectedFamilyState } from '../../record-table-row/states/isRowSelectedFamilyState';
import { tableRowIdsState } from '../../states/tableRowIdsState';
export const useResetTableRowSelection = () =>
useRecoilCallback(
({ snapshot, set }) =>
() => {
const tableRowIds = snapshot
.getLoadable(tableRowIdsState)
.valueOrThrow();
for (const rowId of tableRowIds) {
set(isRowSelectedFamilyState(rowId), false);
}
},
[],
);

View File

@ -0,0 +1,38 @@
import { useRecoilCallback } from 'recoil';
import { isRowSelectedFamilyState } from '../../record-table-row/states/isRowSelectedFamilyState';
import { allRowsSelectedStatusSelector } from '../../states/selectors/allRowsSelectedStatusSelector';
import { tableRowIdsState } from '../../states/tableRowIdsState';
export const useSelectAllRows = () => {
const selectAllRows = useRecoilCallback(
({ set, snapshot }) =>
() => {
const allRowsSelectedStatus = snapshot
.getLoadable(allRowsSelectedStatusSelector)
.valueOrThrow();
const tableRowIds = snapshot
.getLoadable(tableRowIdsState)
.valueOrThrow();
if (
allRowsSelectedStatus === 'none' ||
allRowsSelectedStatus === 'some'
) {
for (const rowId of tableRowIds) {
set(isRowSelectedFamilyState(rowId), true);
}
} else {
for (const rowId of tableRowIds) {
set(isRowSelectedFamilyState(rowId), false);
}
}
},
[],
);
return {
selectAllRows,
};
};

View File

@ -0,0 +1,49 @@
import { useRecoilCallback } from 'recoil';
import { entityFieldsFamilyState } from '@/object-record/field/states/entityFieldsFamilyState';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { isFetchingRecordTableDataState } from '../../states/isFetchingRecordTableDataState';
import { numberOfTableRowsState } from '../../states/numberOfTableRowsState';
import { tableRowIdsState } from '../../states/tableRowIdsState';
import { useResetTableRowSelection } from './useResetTableRowSelection';
type useSetRecordTableDataProps = {
onEntityCountChange: (entityCount: number) => void;
};
export const useSetRecordTableData = ({
onEntityCountChange,
}: useSetRecordTableDataProps) => {
const resetTableRowSelection = useResetTableRowSelection();
return useRecoilCallback(
({ set, snapshot }) =>
<T extends { id: string }>(newEntityArray: T[]) => {
for (const entity of newEntityArray) {
const currentEntity = snapshot
.getLoadable(entityFieldsFamilyState(entity.id))
.valueOrThrow();
if (JSON.stringify(currentEntity) !== JSON.stringify(entity)) {
set(entityFieldsFamilyState(entity.id), entity);
}
}
const currentRowIds = snapshot.getLoadable(tableRowIdsState).getValue();
const entityIds = newEntityArray.map((entity) => entity.id);
if (!isDeeplyEqual(currentRowIds, entityIds)) {
set(tableRowIdsState, entityIds);
}
resetTableRowSelection();
set(numberOfTableRowsState, entityIds.length);
onEntityCountChange(entityIds.length);
set(isFetchingRecordTableDataState, false);
},
[onEntityCountChange, resetTableRowSelection],
);
};

View File

@ -0,0 +1,8 @@
import { useRecoilCallback } from 'recoil';
import { isRowSelectedFamilyState } from '../../record-table-row/states/isRowSelectedFamilyState';
export const useSetRowSelectedState = () =>
useRecoilCallback(({ set }) => (rowId: string, selected: boolean) => {
set(isRowSelectedFamilyState(rowId), selected);
});

View File

@ -0,0 +1,23 @@
import { useRecoilCallback } from 'recoil';
import { isSoftFocusActiveState } from '../../states/isSoftFocusActiveState';
import { isSoftFocusOnTableCellFamilyState } from '../../states/isSoftFocusOnTableCellFamilyState';
import { softFocusPositionState } from '../../states/softFocusPositionState';
import { TableCellPosition } from '../../types/TableCellPosition';
export const useSetSoftFocusPosition = () =>
useRecoilCallback(({ set, snapshot }) => {
return (newPosition: TableCellPosition) => {
const currentPosition = snapshot
.getLoadable(softFocusPositionState)
.valueOrThrow();
set(isSoftFocusActiveState, true);
set(isSoftFocusOnTableCellFamilyState(currentPosition), false);
set(softFocusPositionState, newPosition);
set(isSoftFocusOnTableCellFamilyState(newPosition), true);
};
}, []);

View File

@ -0,0 +1,19 @@
import { useRecoilCallback } from 'recoil';
import { entityFieldsFamilyState } from '@/object-record/field/states/entityFieldsFamilyState';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const useUpsertRecordTableItem = () =>
useRecoilCallback(
({ set, snapshot }) =>
<T extends { id: string }>(entity: T) => {
const currentEntity = snapshot
.getLoadable(entityFieldsFamilyState(entity.id))
.valueOrThrow();
if (!isDeeplyEqual(currentEntity, entity)) {
set(entityFieldsFamilyState(entity.id), entity);
}
},
[],
);

View File

@ -0,0 +1,321 @@
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { RecordTableScopeInternalContext } from '@/object-record/record-table/scopes/scope-internal-context/RecordTableScopeInternalContext';
import { onColumnsChangeScopedState } from '@/object-record/record-table/states/onColumnsChangeScopedState';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { getScopedState } from '@/ui/utilities/recoil-scope/utils/getScopedState';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { FieldMetadata } from '../../field/types/FieldMetadata';
import { numberOfTableRowsState } from '../states/numberOfTableRowsState';
import { onEntityCountChangeScopedState } from '../states/onEntityCountChange';
import { numberOfTableColumnsScopedSelector } from '../states/selectors/numberOfTableColumnsScopedSelector';
import { softFocusPositionState } from '../states/softFocusPositionState';
import { ColumnDefinition } from '../types/ColumnDefinition';
import { TableHotkeyScope } from '../types/TableHotkeyScope';
import { useDisableSoftFocus } from './internal/useDisableSoftFocus';
import { useLeaveTableFocus } from './internal/useLeaveTableFocus';
import { useRecordTableScopedStates } from './internal/useRecordTableScopedStates';
import { useResetTableRowSelection } from './internal/useResetTableRowSelection';
import { useSelectAllRows } from './internal/useSelectAllRows';
import { useSetRecordTableData } from './internal/useSetRecordTableData';
import { useSetRowSelectedState } from './internal/useSetRowSelectedState';
import { useSetSoftFocusPosition } from './internal/useSetSoftFocusPosition';
import { useUpsertRecordTableItem } from './internal/useUpsertRecordTableItem';
type useRecordTableProps = {
recordTableScopeId?: string;
};
export const useRecordTable = (props?: useRecordTableProps) => {
const scopeId = useAvailableScopeIdOrThrow(
RecordTableScopeInternalContext,
props?.recordTableScopeId,
);
const {
availableTableColumnsState,
tableFiltersState,
tableSortsState,
tableColumnsState,
objectMetadataConfigState,
onEntityCountChangeState,
} = useRecordTableScopedStates({
customRecordTableScopeId: scopeId,
});
const setAvailableTableColumns = useSetRecoilState(
availableTableColumnsState,
);
const setOnEntityCountChange = useSetRecoilState(onEntityCountChangeState);
const setTableFilters = useSetRecoilState(tableFiltersState);
const setObjectMetadataConfig = useSetRecoilState(objectMetadataConfigState);
const setTableSorts = useSetRecoilState(tableSortsState);
const setTableColumns = useSetRecoilState(tableColumnsState);
const onColumnsChange = useRecoilCallback(
({ snapshot }) =>
(columns: ColumnDefinition<FieldMetadata>[]) => {
const onColumnsChangeState = getScopedState(
onColumnsChangeScopedState,
scopeId,
);
const onColumnsChange = getSnapshotValue(
snapshot,
onColumnsChangeState,
);
onColumnsChange?.(columns);
},
[scopeId],
);
const onEntityCountChange = useRecoilCallback(
({ snapshot }) =>
(count: number) => {
const onEntityCountChangeState = getScopedState(
onEntityCountChangeScopedState,
scopeId,
);
const onEntityCountChange = getSnapshotValue(
snapshot,
onEntityCountChangeState,
);
onEntityCountChange?.(count);
},
[scopeId],
);
const setRecordTableData = useSetRecordTableData({ onEntityCountChange });
const leaveTableFocus = useLeaveTableFocus();
const setRowSelectedState = useSetRowSelectedState();
const resetTableRowSelection = useResetTableRowSelection();
const upsertRecordTableItem = useUpsertRecordTableItem();
const setSoftFocusPosition = useSetSoftFocusPosition();
const moveUp = useRecoilCallback(
({ snapshot }) =>
() => {
const softFocusPosition = snapshot
.getLoadable(softFocusPositionState)
.valueOrThrow();
let newRowNumber = softFocusPosition.row - 1;
if (newRowNumber < 0) {
newRowNumber = 0;
}
setSoftFocusPosition({
...softFocusPosition,
row: newRowNumber,
});
},
[setSoftFocusPosition],
);
const moveDown = useRecoilCallback(
({ snapshot }) =>
() => {
const softFocusPosition = snapshot
.getLoadable(softFocusPositionState)
.valueOrThrow();
const numberOfTableRows = snapshot
.getLoadable(numberOfTableRowsState)
.valueOrThrow();
let newRowNumber = softFocusPosition.row + 1;
if (newRowNumber >= numberOfTableRows) {
newRowNumber = numberOfTableRows - 1;
}
setSoftFocusPosition({
...softFocusPosition,
row: newRowNumber,
});
},
[setSoftFocusPosition],
);
const moveRight = useRecoilCallback(
({ snapshot }) =>
() => {
const softFocusPosition = snapshot
.getLoadable(softFocusPositionState)
.valueOrThrow();
const numberOfTableColumns = snapshot
.getLoadable(numberOfTableColumnsScopedSelector(scopeId))
.valueOrThrow();
const numberOfTableRows = snapshot
.getLoadable(numberOfTableRowsState)
.valueOrThrow();
const currentColumnNumber = softFocusPosition.column;
const currentRowNumber = softFocusPosition.row;
const isLastRowAndLastColumn =
currentColumnNumber === numberOfTableColumns - 1 &&
currentRowNumber === numberOfTableRows - 1;
const isLastColumnButNotLastRow =
currentColumnNumber === numberOfTableColumns - 1 &&
currentRowNumber !== numberOfTableRows - 1;
const isNotLastColumn =
currentColumnNumber !== numberOfTableColumns - 1;
if (isLastRowAndLastColumn) {
return;
}
if (isNotLastColumn) {
setSoftFocusPosition({
row: currentRowNumber,
column: currentColumnNumber + 1,
});
} else if (isLastColumnButNotLastRow) {
setSoftFocusPosition({
row: currentRowNumber + 1,
column: 0,
});
}
},
[scopeId, setSoftFocusPosition],
);
const moveLeft = useRecoilCallback(
({ snapshot }) =>
() => {
const softFocusPosition = snapshot
.getLoadable(softFocusPositionState)
.valueOrThrow();
const numberOfTableColumns = snapshot
.getLoadable(numberOfTableColumnsScopedSelector(scopeId))
.valueOrThrow();
const currentColumnNumber = softFocusPosition.column;
const currentRowNumber = softFocusPosition.row;
const isFirstRowAndFirstColumn =
currentColumnNumber === 0 && currentRowNumber === 0;
const isFirstColumnButNotFirstRow =
currentColumnNumber === 0 && currentRowNumber > 0;
const isNotFirstColumn = currentColumnNumber > 0;
if (isFirstRowAndFirstColumn) {
return;
}
if (isNotFirstColumn) {
setSoftFocusPosition({
row: currentRowNumber,
column: currentColumnNumber - 1,
});
} else if (isFirstColumnButNotFirstRow) {
setSoftFocusPosition({
row: currentRowNumber - 1,
column: numberOfTableColumns - 1,
});
}
},
[scopeId, setSoftFocusPosition],
);
const useMapKeyboardToSoftFocus = () => {
const disableSoftFocus = useDisableSoftFocus();
const setHotkeyScope = useSetHotkeyScope();
useScopedHotkeys(
[Key.ArrowUp, `${Key.Shift}+${Key.Enter}`],
() => {
moveUp();
},
TableHotkeyScope.TableSoftFocus,
[moveUp],
);
useScopedHotkeys(
Key.ArrowDown,
() => {
moveDown();
},
TableHotkeyScope.TableSoftFocus,
[moveDown],
);
useScopedHotkeys(
[Key.ArrowLeft, `${Key.Shift}+${Key.Tab}`],
() => {
moveLeft();
},
TableHotkeyScope.TableSoftFocus,
[moveLeft],
);
useScopedHotkeys(
[Key.ArrowRight, Key.Tab],
() => {
moveRight();
},
TableHotkeyScope.TableSoftFocus,
[moveRight],
);
useScopedHotkeys(
[Key.Escape],
() => {
setHotkeyScope(TableHotkeyScope.Table, {
goto: true,
keyboardShortcutMenu: true,
});
disableSoftFocus();
},
TableHotkeyScope.TableSoftFocus,
[disableSoftFocus],
);
};
const { selectAllRows } = useSelectAllRows();
return {
scopeId,
onColumnsChange,
setAvailableTableColumns,
setTableFilters,
setTableSorts,
setObjectMetadataConfig,
setOnEntityCountChange,
setRecordTableData,
setTableColumns,
leaveTableFocus,
setRowSelectedState,
resetTableRowSelection,
upsertRecordTableItem,
moveDown,
moveLeft,
moveRight,
moveUp,
useMapKeyboardToSoftFocus,
selectAllRows,
};
};

View File

@ -0,0 +1,125 @@
import { useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { FieldMetadata } from '@/object-record/field/types/FieldMetadata';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { RecordTableScopeInternalContext } from '@/object-record/record-table/scopes/scope-internal-context/RecordTableScopeInternalContext';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { useMoveViewColumns } from '@/views/hooks/useMoveViewColumns';
import { ColumnDefinition } from '../types/ColumnDefinition';
import { useRecordTableScopedStates } from './internal/useRecordTableScopedStates';
type useRecordTableProps = {
recordTableScopeId?: string;
};
export const useTableColumns = (props?: useRecordTableProps) => {
const scopeId = useAvailableScopeIdOrThrow(
RecordTableScopeInternalContext,
props?.recordTableScopeId,
);
const { onColumnsChange, setTableColumns } = useRecordTable({
recordTableScopeId: scopeId,
});
const {
availableTableColumnsState,
tableColumnsState,
visibleTableColumnsSelector,
} = useRecordTableScopedStates({
customRecordTableScopeId: scopeId,
});
const availableTableColumns = useRecoilValue(availableTableColumnsState);
const tableColumns = useRecoilValue(tableColumnsState);
const visibleTableColumns = useRecoilValue(visibleTableColumnsSelector);
const { handleColumnMove } = useMoveViewColumns();
const handleColumnsChange = useCallback(
async (columns: ColumnDefinition<FieldMetadata>[]) => {
setTableColumns(columns);
await onColumnsChange?.(columns);
},
[onColumnsChange, setTableColumns],
);
const handleColumnVisibilityChange = useCallback(
async (
viewField: Omit<ColumnDefinition<FieldMetadata>, 'size' | 'position'>,
) => {
const isNewColumn = !tableColumns.some(
(tableColumns) =>
tableColumns.fieldMetadataId === viewField.fieldMetadataId,
);
if (isNewColumn) {
const newColumn = availableTableColumns.find(
(availableTableColumn) =>
availableTableColumn.fieldMetadataId === viewField.fieldMetadataId,
);
if (!newColumn) return;
const nextColumns = [
...tableColumns,
{ ...newColumn, isVisible: true },
];
await handleColumnsChange(nextColumns);
} else {
const nextColumns = tableColumns.map((previousColumn) =>
previousColumn.fieldMetadataId === viewField.fieldMetadataId
? { ...previousColumn, isVisible: !viewField.isVisible }
: previousColumn,
);
await handleColumnsChange(nextColumns);
}
},
[tableColumns, availableTableColumns, handleColumnsChange],
);
const handleMoveTableColumn = useCallback(
async (
direction: 'left' | 'right',
column: ColumnDefinition<FieldMetadata>,
) => {
const currentColumnArrayIndex = visibleTableColumns.findIndex(
(visibleColumn) =>
visibleColumn.fieldMetadataId === column.fieldMetadataId,
);
const columns = handleColumnMove(
direction,
currentColumnArrayIndex,
visibleTableColumns,
);
await handleColumnsChange(columns);
},
[visibleTableColumns, handleColumnMove, handleColumnsChange],
);
const handleColumnReorder = useCallback(
async (columns: ColumnDefinition<FieldMetadata>[]) => {
const updatedColumns = columns.map((column, index) => ({
...column,
position: index,
}));
await handleColumnsChange(updatedColumns);
},
[handleColumnsChange],
);
return {
handleColumnVisibilityChange,
handleMoveTableColumn,
handleColumnReorder,
handleColumnsChange,
};
};

View File

@ -0,0 +1,36 @@
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { useViewBar } from '@/views/hooks/useViewBar';
import { TableOptionsDropdownId } from '../../constants/TableOptionsDropdownId';
import { TableOptionsHotkeyScope } from '../../types/TableOptionsHotkeyScope';
import { TableOptionsDropdownButton } from './TableOptionsDropdownButton';
import { TableOptionsDropdownContent } from './TableOptionsDropdownContent';
export const TableOptionsDropdown = ({
onImport,
recordTableId,
}: {
onImport?: () => void;
recordTableId: string;
}) => {
const { setViewEditMode } = useViewBar();
return (
<DropdownScope dropdownScopeId={TableOptionsDropdownId}>
<Dropdown
clickableComponent={<TableOptionsDropdownButton />}
dropdownHotkeyScope={{ scope: TableOptionsHotkeyScope.Dropdown }}
dropdownOffset={{ y: 8 }}
dropdownComponents={
<TableOptionsDropdownContent
onImport={onImport}
recordTableId={recordTableId}
/>
}
onClickOutside={() => setViewEditMode('none')}
/>
</DropdownScope>
);
};

View File

@ -0,0 +1,18 @@
import { TableOptionsDropdownId } from '@/object-record/record-table/constants/TableOptionsDropdownId';
import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/StyledHeaderDropdownButton';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
export const TableOptionsDropdownButton = () => {
const { isDropdownOpen, toggleDropdown } = useDropdown({
dropdownScopeId: TableOptionsDropdownId,
});
return (
<StyledHeaderDropdownButton
isUnfolded={isDropdownOpen}
onClick={toggleDropdown}
>
Options
</StyledHeaderDropdownButton>
);
};

View File

@ -0,0 +1,164 @@
import { useCallback, useRef, useState } from 'react';
import { OnDragEndResponder } from '@hello-pangea/dnd';
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { IconChevronLeft, IconFileImport, IconTag } from '@/ui/display/icon';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
import { DropdownMenuInput } from '@/ui/layout/dropdown/components/DropdownMenuInput';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { ViewFieldsVisibilityDropdownSection } from '@/views/components/ViewFieldsVisibilityDropdownSection';
import { useViewScopedStates } from '@/views/hooks/internal/useViewScopedStates';
import { useViewBar } from '@/views/hooks/useViewBar';
import { useRecordTableScopedStates } from '../../hooks/internal/useRecordTableScopedStates';
import { useTableColumns } from '../../hooks/useTableColumns';
import { TableOptionsHotkeyScope } from '../../types/TableOptionsHotkeyScope';
type TableOptionsMenu = 'fields';
export const TableOptionsDropdownContent = ({
onImport,
recordTableId,
}: {
onImport?: () => void;
recordTableId: string;
}) => {
const { setViewEditMode, handleViewNameSubmit } = useViewBar();
const { viewEditModeState, currentViewSelector } = useViewScopedStates();
const viewEditMode = useRecoilValue(viewEditModeState);
const currentView = useRecoilValue(currentViewSelector);
const { closeDropdown } = useDropdown();
const [currentMenu, setCurrentMenu] = useState<TableOptionsMenu | undefined>(
undefined,
);
const viewEditInputRef = useRef<HTMLInputElement>(null);
const { hiddenTableColumnsSelector, visibleTableColumnsSelector } =
useRecordTableScopedStates({ customRecordTableScopeId: recordTableId });
const hiddenTableColumns = useRecoilValue(hiddenTableColumnsSelector);
const visibleTableColumns = useRecoilValue(visibleTableColumnsSelector);
const { handleColumnVisibilityChange, handleColumnReorder } = useTableColumns(
{ recordTableScopeId: recordTableId },
);
const handleSelectMenu = (option: TableOptionsMenu) => {
const name = viewEditInputRef.current?.value;
handleViewNameSubmit(name);
setCurrentMenu(option);
};
const handleReorderField: OnDragEndResponder = useCallback(
(result) => {
if (
!result.destination ||
result.destination.index === 1 ||
result.source.index === 1
) {
return;
}
const reorderFields = [...visibleTableColumns];
const [removed] = reorderFields.splice(result.source.index - 1, 1);
reorderFields.splice(result.destination.index - 1, 0, removed);
handleColumnReorder(reorderFields);
},
[visibleTableColumns, handleColumnReorder],
);
const resetMenu = () => setCurrentMenu(undefined);
useScopedHotkeys(
Key.Escape,
() => {
closeDropdown();
},
TableOptionsHotkeyScope.Dropdown,
);
useScopedHotkeys(
Key.Enter,
() => {
const name = viewEditInputRef.current?.value;
handleViewNameSubmit(name);
resetMenu();
setViewEditMode('none');
closeDropdown();
},
TableOptionsHotkeyScope.Dropdown,
);
return (
<>
{!currentMenu && (
<>
<DropdownMenuInput
ref={viewEditInputRef}
autoFocus={viewEditMode !== 'none'}
placeholder={
viewEditMode === 'create'
? 'New view'
: viewEditMode === 'edit'
? 'View name'
: ''
}
defaultValue={viewEditMode === 'create' ? '' : currentView?.name}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<MenuItem
onClick={() => handleSelectMenu('fields')}
LeftIcon={IconTag}
text="Fields"
/>
{onImport && (
<MenuItem
onClick={onImport}
LeftIcon={IconFileImport}
text="Import"
/>
)}
</DropdownMenuItemsContainer>
</>
)}
{currentMenu === 'fields' && (
<>
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={resetMenu}>
Fields
</DropdownMenuHeader>
<DropdownMenuSeparator />
<ViewFieldsVisibilityDropdownSection
title="Visible"
fields={visibleTableColumns}
isVisible={true}
onVisibilityChange={handleColumnVisibilityChange}
isDraggable={true}
onDragEnd={handleReorderField}
/>
{hiddenTableColumns.length > 0 && (
<>
<DropdownMenuSeparator />
<ViewFieldsVisibilityDropdownSection
title="Hidden"
fields={hiddenTableColumns}
isVisible={false}
onVisibilityChange={handleColumnVisibilityChange}
isDraggable={false}
/>
</>
)}
</>
)}
</>
);
};

View File

@ -0,0 +1,75 @@
import { FieldDisplay } from '@/object-record/field/components/FieldDisplay';
import { FieldInput } from '@/object-record/field/components/FieldInput';
import { FieldInputEvent } from '@/object-record/field/types/FieldInputEvent';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useRecordTable } from '../../hooks/useRecordTable';
import { useTableCell } from '../hooks/useTableCell';
import { TableCellContainer } from './RecordTableCellContainer';
export const TableCell = ({
customHotkeyScope,
}: {
customHotkeyScope: HotkeyScope;
}) => {
const { closeTableCell } = useTableCell();
const { moveLeft, moveRight, moveDown } = useRecordTable();
const handleEnter: FieldInputEvent = (persistField) => {
persistField();
closeTableCell();
moveDown();
};
const handleSubmit: FieldInputEvent = (persistField) => {
persistField();
closeTableCell();
};
const handleCancel = () => {
closeTableCell();
};
const handleClickOutside: FieldInputEvent = (persistField) => {
persistField();
closeTableCell();
};
const handleEscape: FieldInputEvent = (persistField) => {
persistField();
closeTableCell();
};
const handleTab: FieldInputEvent = (persistField) => {
persistField();
closeTableCell();
moveRight();
};
const handleShiftTab: FieldInputEvent = (persistField) => {
persistField();
closeTableCell();
moveLeft();
};
return (
<TableCellContainer
editHotkeyScope={customHotkeyScope}
editModeContent={
<FieldInput
onCancel={handleCancel}
onClickOutside={handleClickOutside}
onEnter={handleEnter}
onEscape={handleEscape}
onShiftTab={handleShiftTab}
onSubmit={handleSubmit}
onTab={handleTab}
/>
}
nonEditModeContent={<FieldDisplay />}
></TableCellContainer>
);
};

View File

@ -0,0 +1,26 @@
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { FloatingIconButton } from '@/ui/input/button/components/FloatingIconButton';
const StyledEditButtonContainer = styled(motion.div)`
position: absolute;
right: 5px;
`;
type TableCellButtonProps = {
onClick?: () => void;
Icon: IconComponent;
};
export const TableCellButton = ({ onClick, Icon }: TableCellButtonProps) => (
<StyledEditButtonContainer
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.1 }}
whileHover={{ scale: 1.04 }}
>
<FloatingIconButton size="small" onClick={onClick} Icon={Icon} />
</StyledEditButtonContainer>
);

View File

@ -0,0 +1,147 @@
import { ReactElement, useContext, useState } from 'react';
import styled from '@emotion/styled';
import { useGetButtonIcon } from '@/object-record/field/hooks/useGetButtonIcon';
import { useIsFieldEmpty } from '@/object-record/field/hooks/useIsFieldEmpty';
import { useIsFieldInputOnly } from '@/object-record/field/hooks/useIsFieldInputOnly';
import { useGetIsSomeCellInEditMode } from '@/object-record/record-table/hooks/internal/useGetIsSomeCellInEditMode';
import { IconArrowUpRight } from '@/ui/display/icon';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { CellHotkeyScopeContext } from '../../contexts/CellHotkeyScopeContext';
import { ColumnIndexContext } from '../../contexts/ColumnIndexContext';
import { TableHotkeyScope } from '../../types/TableHotkeyScope';
import { useCurrentTableCellEditMode } from '../hooks/useCurrentTableCellEditMode';
import { useIsSoftFocusOnCurrentTableCell } from '../hooks/useIsSoftFocusOnCurrentTableCell';
import { useMoveSoftFocusToCurrentCellOnHover } from '../hooks/useMoveSoftFocusToCurrentCellOnHover';
import { useSetSoftFocusOnCurrentTableCell } from '../hooks/useSetSoftFocusOnCurrentTableCell';
import { useTableCell } from '../hooks/useTableCell';
import { TableCellButton } from './RecordTableCellButton';
import { TableCellDisplayMode } from './RecordTableCellDisplayMode';
import { TableCellEditMode } from './RecordTableCellEditMode';
import { TableCellSoftFocusMode } from './RecordTableCellSoftFocusMode';
const StyledCellBaseContainer = styled.div`
align-items: center;
box-sizing: border-box;
cursor: pointer;
display: flex;
height: 32px;
position: relative;
user-select: none;
`;
export type TableCellContainerProps = {
editModeContent: ReactElement;
nonEditModeContent: ReactElement;
editModeHorizontalAlign?: 'left' | 'right';
editModeVerticalPosition?: 'over' | 'below';
editHotkeyScope?: HotkeyScope;
transparent?: boolean;
maxContentWidth?: number;
onSubmit?: () => void;
onCancel?: () => void;
};
const DEFAULT_CELL_SCOPE: HotkeyScope = {
scope: TableHotkeyScope.CellEditMode,
};
export const TableCellContainer = ({
editModeHorizontalAlign = 'left',
editModeVerticalPosition = 'over',
editModeContent,
nonEditModeContent,
editHotkeyScope,
}: TableCellContainerProps) => {
const { isCurrentTableCellInEditMode } = useCurrentTableCellEditMode();
const getIsSomeCellInEditMode = useGetIsSomeCellInEditMode();
const [isHovered, setIsHovered] = useState(false);
const moveSoftFocusToCurrentCellOnHover =
useMoveSoftFocusToCurrentCellOnHover();
const hasSoftFocus = useIsSoftFocusOnCurrentTableCell();
const setSoftFocusOnCurrentTableCell = useSetSoftFocusOnCurrentTableCell();
const { openTableCell } = useTableCell();
const handleButtonClick = () => {
setSoftFocusOnCurrentTableCell();
openTableCell();
};
const handleContainerMouseEnter = () => {
const isSomeCellInEditMode = getIsSomeCellInEditMode();
if (!isHovered && !isSomeCellInEditMode) {
setIsHovered(true);
moveSoftFocusToCurrentCellOnHover();
}
};
const handleContainerMouseLeave = () => {
setIsHovered(false);
};
const editModeContentOnly = useIsFieldInputOnly();
const isFirstColumnCell = useContext(ColumnIndexContext) === 0;
const isEmpty = useIsFieldEmpty();
const isFirstColumn = useContext(ColumnIndexContext) === 0;
const customButtonIcon = useGetButtonIcon();
const buttonIcon = isFirstColumn ? IconArrowUpRight : customButtonIcon;
const showButton =
!!buttonIcon &&
hasSoftFocus &&
!isCurrentTableCellInEditMode &&
!editModeContentOnly &&
(!isFirstColumnCell || !isEmpty);
return (
<CellHotkeyScopeContext.Provider
value={editHotkeyScope ?? DEFAULT_CELL_SCOPE}
>
<StyledCellBaseContainer
onMouseEnter={handleContainerMouseEnter}
onMouseLeave={handleContainerMouseLeave}
>
{isCurrentTableCellInEditMode ? (
<TableCellEditMode
editModeHorizontalAlign={editModeHorizontalAlign}
editModeVerticalPosition={editModeVerticalPosition}
>
{editModeContent}
</TableCellEditMode>
) : hasSoftFocus ? (
<>
{showButton && (
<TableCellButton onClick={handleButtonClick} Icon={buttonIcon} />
)}
<TableCellSoftFocusMode>
{editModeContentOnly ? editModeContent : nonEditModeContent}
</TableCellSoftFocusMode>
</>
) : (
<>
{showButton && (
<TableCellButton onClick={handleButtonClick} Icon={buttonIcon} />
)}
<TableCellDisplayMode>
{editModeContentOnly ? editModeContent : nonEditModeContent}
</TableCellDisplayMode>
</>
)}
</StyledCellBaseContainer>
</CellHotkeyScopeContext.Provider>
);
};

View File

@ -0,0 +1,55 @@
import { Ref } from 'react';
import styled from '@emotion/styled';
export type EditableCellDisplayContainerProps = {
softFocus?: boolean;
onClick?: () => void;
scrollRef?: Ref<HTMLDivElement>;
isHovered?: boolean;
};
const StyledEditableCellDisplayModeOuterContainer = styled.div<
Pick<EditableCellDisplayContainerProps, 'softFocus' | 'isHovered'>
>`
align-items: center;
display: flex;
height: 100%;
overflow: hidden;
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(1)};
width: 100%;
${(props) =>
props.softFocus
? `background: ${props.theme.background.transparent.secondary};
border-radius: ${props.theme.border.radius.sm};
outline: 1px solid ${props.theme.font.color.extraLight};`
: ''}
`;
const StyledEditableCellDisplayModeInnerContainer = styled.div`
align-items: center;
display: flex;
height: 100%;
overflow: hidden;
width: 100%;
`;
export const TableCellDisplayContainer = ({
children,
softFocus,
onClick,
scrollRef,
}: React.PropsWithChildren<EditableCellDisplayContainerProps>) => (
<StyledEditableCellDisplayModeOuterContainer
data-testid={
softFocus ? 'editable-cell-soft-focus-mode' : 'editable-cell-display-mode'
}
onClick={onClick}
softFocus={softFocus}
ref={scrollRef}
>
<StyledEditableCellDisplayModeInnerContainer>
{children}
</StyledEditableCellDisplayModeInnerContainer>
</StyledEditableCellDisplayModeOuterContainer>
);

View File

@ -0,0 +1,30 @@
import { useIsFieldInputOnly } from '@/object-record/field/hooks/useIsFieldInputOnly';
import { useSetSoftFocusOnCurrentTableCell } from '../hooks/useSetSoftFocusOnCurrentTableCell';
import { useTableCell } from '../hooks/useTableCell';
import { TableCellDisplayContainer } from './RecordTableCellDisplayContainer';
export const TableCellDisplayMode = ({
children,
}: React.PropsWithChildren<unknown>) => {
const setSoftFocusOnCurrentCell = useSetSoftFocusOnCurrentTableCell();
const isFieldInputOnly = useIsFieldInputOnly();
const { openTableCell } = useTableCell();
const handleClick = () => {
setSoftFocusOnCurrentCell();
if (!isFieldInputOnly) {
openTableCell();
}
};
return (
<TableCellDisplayContainer onClick={handleClick}>
{children}
</TableCellDisplayContainer>
);
};

View File

@ -0,0 +1,26 @@
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { FloatingIconButton } from '@/ui/input/button/components/FloatingIconButton';
const StyledEditButtonContainer = styled(motion.div)`
position: absolute;
right: 5px;
`;
type TableCellButtonProps = {
onClick?: () => void;
Icon: IconComponent;
};
export const TableCellButton = ({ onClick, Icon }: TableCellButtonProps) => (
<StyledEditButtonContainer
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.1 }}
whileHover={{ scale: 1.04 }}
>
<FloatingIconButton size="small" onClick={onClick} Icon={Icon} />
</StyledEditButtonContainer>
);

View File

@ -0,0 +1,33 @@
import { ReactElement } from 'react';
import styled from '@emotion/styled';
const StyledEditableCellEditModeContainer = styled.div<TableCellEditModeProps>`
align-items: center;
display: flex;
min-width: 200px;
width: calc(100% + 2px);
z-index: 1;
`;
export type TableCellEditModeProps = {
children: ReactElement;
transparent?: boolean;
maxContentWidth?: number;
editModeHorizontalAlign?: 'left' | 'right';
editModeVerticalPosition?: 'over' | 'below';
initialValue?: string;
};
export const TableCellEditMode = ({
editModeHorizontalAlign,
editModeVerticalPosition,
children,
}: TableCellEditModeProps) => (
<StyledEditableCellEditModeContainer
data-testid="editable-cell-edit-mode-container"
editModeHorizontalAlign={editModeHorizontalAlign}
editModeVerticalPosition={editModeVerticalPosition}
>
{children}
</StyledEditableCellEditModeContainer>
);

View File

@ -0,0 +1,106 @@
import { PropsWithChildren, useEffect, useRef } from 'react';
import { Key } from 'ts-key-enum';
import { useIsFieldInputOnly } from '@/object-record/field/hooks/useIsFieldInputOnly';
import { useToggleEditOnlyInput } from '@/object-record/field/hooks/useToggleEditOnlyInput';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { isNonTextWritingKey } from '@/ui/utilities/hotkey/utils/isNonTextWritingKey';
import { TableHotkeyScope } from '../../types/TableHotkeyScope';
import { useTableCell } from '../hooks/useTableCell';
import { TableCellDisplayContainer } from './RecordTableCellDisplayContainer';
type TableCellSoftFocusModeProps = PropsWithChildren<unknown>;
export const TableCellSoftFocusMode = ({
children,
}: TableCellSoftFocusModeProps) => {
const { openTableCell } = useTableCell();
const isFieldInputOnly = useIsFieldInputOnly();
const toggleEditOnlyInput = useToggleEditOnlyInput();
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
scrollRef.current?.scrollIntoView({ block: 'nearest' });
}, []);
useScopedHotkeys(
[Key.Backspace, Key.Delete],
() => {
if (!isFieldInputOnly) {
openTableCell({
initialValue: {
isEmpty: true,
},
});
}
},
TableHotkeyScope.TableSoftFocus,
[openTableCell],
{
enabled: !isFieldInputOnly,
},
);
useScopedHotkeys(
Key.Enter,
() => {
if (!isFieldInputOnly) {
openTableCell();
} else {
toggleEditOnlyInput();
}
},
TableHotkeyScope.TableSoftFocus,
[openTableCell],
);
useScopedHotkeys(
'*',
(keyboardEvent) => {
if (!isFieldInputOnly) {
const isWritingText =
!isNonTextWritingKey(keyboardEvent.key) &&
!keyboardEvent.ctrlKey &&
!keyboardEvent.metaKey;
if (!isWritingText) {
return;
}
keyboardEvent.preventDefault();
keyboardEvent.stopPropagation();
keyboardEvent.stopImmediatePropagation();
openTableCell({
initialValue: {
value: keyboardEvent.key,
},
});
}
},
TableHotkeyScope.TableSoftFocus,
[openTableCell],
{
preventDefault: false,
},
);
const handleClick = () => {
if (!isFieldInputOnly) {
openTableCell();
}
};
return (
<TableCellDisplayContainer
onClick={handleClick}
softFocus
scrollRef={scrollRef}
>
{children}
</TableCellDisplayContainer>
);
};

View File

@ -0,0 +1,20 @@
import { useContext, useMemo } from 'react';
import { ColumnIndexContext } from '../../contexts/ColumnIndexContext';
import { RowIndexContext } from '../../contexts/RowIndexContext';
import { TableCellPosition } from '../../types/TableCellPosition';
export const useCurrentTableCellPosition = () => {
const currentRowNumber = useContext(RowIndexContext);
const currentColumnNumber = useContext(ColumnIndexContext);
const currentTableCellPosition: TableCellPosition = useMemo(
() => ({
column: currentColumnNumber,
row: currentRowNumber,
}),
[currentColumnNumber, currentRowNumber],
);
return currentTableCellPosition;
};

View File

@ -0,0 +1,26 @@
import { useCallback } from 'react';
import { useRecoilState } from 'recoil';
import { useMoveEditModeToTableCellPosition } from '../../hooks/internal/useMoveEditModeToCellPosition';
import { isTableCellInEditModeFamilyState } from '../../states/isTableCellInEditModeFamilyState';
import { useCurrentTableCellPosition } from './useCurrentCellPosition';
export const useCurrentTableCellEditMode = () => {
const moveEditModeToTableCellPosition = useMoveEditModeToTableCellPosition();
const currentTableCellPosition = useCurrentTableCellPosition();
const [isCurrentTableCellInEditMode] = useRecoilState(
isTableCellInEditModeFamilyState(currentTableCellPosition),
);
const setCurrentTableCellInEditMode = useCallback(() => {
moveEditModeToTableCellPosition(currentTableCellPosition);
}, [currentTableCellPosition, moveEditModeToTableCellPosition]);
return {
isCurrentTableCellInEditMode,
setCurrentTableCellInEditMode,
};
};

View File

@ -0,0 +1,15 @@
import { useRecoilValue } from 'recoil';
import { isSoftFocusOnTableCellFamilyState } from '../../states/isSoftFocusOnTableCellFamilyState';
import { useCurrentTableCellPosition } from './useCurrentCellPosition';
export const useIsSoftFocusOnCurrentTableCell = () => {
const currentTableCellPosition = useCurrentTableCellPosition();
const isSoftFocusOnTableCell = useRecoilValue(
isSoftFocusOnTableCellFamilyState(currentTableCellPosition),
);
return isSoftFocusOnTableCell;
};

View File

@ -0,0 +1,43 @@
import { useRecoilCallback } from 'recoil';
import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState';
import { currentTableCellInEditModePositionState } from '../../states/currentTableCellInEditModePositionState';
import { isTableCellInEditModeFamilyState } from '../../states/isTableCellInEditModeFamilyState';
import { TableHotkeyScope } from '../../types/TableHotkeyScope';
import { useSetSoftFocusOnCurrentTableCell } from './useSetSoftFocusOnCurrentTableCell';
export const useMoveSoftFocusToCurrentCellOnHover = () => {
const setSoftFocusOnCurrentTableCell = useSetSoftFocusOnCurrentTableCell();
return useRecoilCallback(
({ snapshot }) =>
() => {
const currentTableCellInEditModePosition = snapshot
.getLoadable(currentTableCellInEditModePositionState)
.valueOrThrow();
const isSomeCellInEditMode = snapshot.getLoadable(
isTableCellInEditModeFamilyState(currentTableCellInEditModePosition),
);
const currentHotkeyScope = snapshot
.getLoadable(currentHotkeyScopeState)
.valueOrThrow();
if (
currentHotkeyScope.scope !== TableHotkeyScope.TableSoftFocus &&
currentHotkeyScope.scope !== TableHotkeyScope.CellEditMode &&
currentHotkeyScope.scope !== TableHotkeyScope.Table
) {
return;
}
if (!isSomeCellInEditMode.contents) {
setSoftFocusOnCurrentTableCell();
}
},
[setSoftFocusOnCurrentTableCell],
);
};

View File

@ -0,0 +1,29 @@
import { useRecoilCallback } from 'recoil';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { useSetSoftFocusPosition } from '../../hooks/internal/useSetSoftFocusPosition';
import { isSoftFocusActiveState } from '../../states/isSoftFocusActiveState';
import { TableHotkeyScope } from '../../types/TableHotkeyScope';
import { useCurrentTableCellPosition } from './useCurrentCellPosition';
export const useSetSoftFocusOnCurrentTableCell = () => {
const setSoftFocusPosition = useSetSoftFocusPosition();
const currentTableCellPosition = useCurrentTableCellPosition();
const setHotkeyScope = useSetHotkeyScope();
return useRecoilCallback(
({ set }) =>
() => {
setSoftFocusPosition(currentTableCellPosition);
set(isSoftFocusActiveState, true);
setHotkeyScope(TableHotkeyScope.TableSoftFocus);
},
[setHotkeyScope, currentTableCellPosition, setSoftFocusPosition],
);
};

View File

@ -0,0 +1,89 @@
import { useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import { useRecoilState, useRecoilValue } from 'recoil';
import { FieldContext } from '@/object-record/field/contexts/FieldContext';
import { useIsFieldEmpty } from '@/object-record/field/hooks/useIsFieldEmpty';
import { entityFieldInitialValueFamilyState } from '@/object-record/field/states/entityFieldInitialValueFamilyState';
import { FieldInitialValue } from '@/object-record/field/types/FieldInitialValue';
import { useRecordTableScopedStates } from '@/object-record/record-table/hooks/internal/useRecordTableScopedStates';
import { useDragSelect } from '@/ui/utilities/drag-select/hooks/useDragSelect';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { CellHotkeyScopeContext } from '../../contexts/CellHotkeyScopeContext';
import { ColumnIndexContext } from '../../contexts/ColumnIndexContext';
import { useCloseCurrentTableCellInEditMode } from '../../hooks/internal/useCloseCurrentTableCellInEditMode';
import { TableHotkeyScope } from '../../types/TableHotkeyScope';
import { useCurrentTableCellEditMode } from './useCurrentTableCellEditMode';
const DEFAULT_CELL_SCOPE: HotkeyScope = {
scope: TableHotkeyScope.CellEditMode,
};
export const useTableCell = () => {
const { objectMetadataConfigState } = useRecordTableScopedStates();
const objectMetadataConfig = useRecoilValue(objectMetadataConfigState);
const basePathToShowPage = objectMetadataConfig?.basePathToShowPage;
const { setCurrentTableCellInEditMode } = useCurrentTableCellEditMode();
const setHotkeyScope = useSetHotkeyScope();
const { setDragSelectionStartEnabled } = useDragSelect();
const closeCurrentTableCellInEditMode = useCloseCurrentTableCellInEditMode();
const customCellHotkeyScope = useContext(CellHotkeyScopeContext);
const navigate = useNavigate();
const isFirstColumnCell = useContext(ColumnIndexContext) === 0;
const isEmpty = useIsFieldEmpty();
const { entityId, fieldDefinition } = useContext(FieldContext);
const [, setFieldInitialValue] = useRecoilState(
entityFieldInitialValueFamilyState({
entityId,
fieldMetadataId: fieldDefinition.fieldMetadataId,
}),
);
const openTableCell = (options?: { initialValue?: FieldInitialValue }) => {
if (isFirstColumnCell && !isEmpty && basePathToShowPage) {
navigate(`${basePathToShowPage}${entityId}`);
return;
}
setDragSelectionStartEnabled(false);
setCurrentTableCellInEditMode();
if (options?.initialValue) {
setFieldInitialValue(options.initialValue);
}
if (customCellHotkeyScope) {
setHotkeyScope(
customCellHotkeyScope.scope,
customCellHotkeyScope.customScopes,
);
} else {
setHotkeyScope(DEFAULT_CELL_SCOPE.scope, DEFAULT_CELL_SCOPE.customScopes);
}
};
const closeTableCell = () => {
setDragSelectionStartEnabled(true);
closeCurrentTableCellInEditMode();
setFieldInitialValue(undefined);
setHotkeyScope(TableHotkeyScope.TableSoftFocus);
};
return {
closeTableCell,
openTableCell,
};
};

View File

@ -0,0 +1,6 @@
import { atomFamily } from 'recoil';
export const isCreateModeScopedState = atomFamily<boolean, string>({
key: 'isCreateModeScopedState',
default: false,
});

View File

@ -0,0 +1,36 @@
import { useContext } from 'react';
import { useRecoilCallback, useRecoilState } from 'recoil';
import { RowIdContext } from '../../contexts/RowIdContext';
import { isRowSelectedFamilyState } from '../states/isRowSelectedFamilyState';
export const useCurrentRowSelected = () => {
const currentRowId = useContext(RowIdContext);
const [isRowSelected] = useRecoilState(
isRowSelectedFamilyState(currentRowId ?? ''),
);
const setCurrentRowSelected = useRecoilCallback(
({ set, snapshot }) =>
(newSelectedState: boolean) => {
if (!currentRowId) return;
const isRowSelected = snapshot
.getLoadable(isRowSelectedFamilyState(currentRowId))
.valueOrThrow();
if (newSelectedState && !isRowSelected) {
set(isRowSelectedFamilyState(currentRowId), true);
} else if (!newSelectedState && isRowSelected) {
set(isRowSelectedFamilyState(currentRowId), false);
}
},
[currentRowId],
);
return {
currentRowSelected: isRowSelected,
setCurrentRowSelected,
};
};

View File

@ -0,0 +1,6 @@
import { atomFamily } from 'recoil';
export const isRowSelectedFamilyState = atomFamily<boolean, string>({
key: 'isRowSelectedFamilyState',
default: false,
});

View File

@ -0,0 +1,31 @@
import { ReactNode } from 'react';
import { FieldMetadata } from '../../field/types/FieldMetadata';
import { ColumnDefinition } from '../types/ColumnDefinition';
import { RecordTableScopeInternalContext } from './scope-internal-context/RecordTableScopeInternalContext';
import { RecordTableScopeInitEffect } from './RecordTableScopeInitEffect';
type RecordTableScopeProps = {
children: ReactNode;
recordTableScopeId: string;
onColumnsChange: (columns: ColumnDefinition<FieldMetadata>[]) => void;
};
export const RecordTableScope = ({
children,
recordTableScopeId,
onColumnsChange,
}: RecordTableScopeProps) => {
return (
<RecordTableScopeInternalContext.Provider
value={{
scopeId: recordTableScopeId,
onColumnsChange,
}}
>
<RecordTableScopeInitEffect onColumnsChange={onColumnsChange} />
{children}
</RecordTableScopeInternalContext.Provider>
);
};

View File

@ -0,0 +1,25 @@
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
import { FieldMetadata } from '../../field/types/FieldMetadata';
import { useRecordTableScopedStates } from '../hooks/internal/useRecordTableScopedStates';
import { ColumnDefinition } from '../types/ColumnDefinition';
type RecordTableScopeInitEffectProps = {
onColumnsChange: (columns: ColumnDefinition<FieldMetadata>[]) => void;
onEntityCountChange?: (count: number) => void | Promise<void>;
};
export const RecordTableScopeInitEffect = ({
onColumnsChange,
}: RecordTableScopeInitEffectProps) => {
const { onColumnsChangeState } = useRecordTableScopedStates();
const setOnColumnsChange = useSetRecoilState(onColumnsChangeState);
useEffect(() => {
setOnColumnsChange(() => onColumnsChange);
}, [onColumnsChange, setOnColumnsChange]);
return <></>;
};

View File

@ -0,0 +1,12 @@
import { FieldMetadata } from '@/object-record/field/types/FieldMetadata';
import { ScopedStateKey } from '@/ui/utilities/recoil-scope/scopes-internal/types/ScopedStateKey';
import { createScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext';
import { ColumnDefinition } from '../../types/ColumnDefinition';
type RecordTableScopeInternalContextProps = ScopedStateKey & {
onColumnsChange: (columns: ColumnDefinition<FieldMetadata>[]) => void;
};
export const RecordTableScopeInternalContext =
createScopeInternalContext<RecordTableScopeInternalContextProps>();

View File

@ -0,0 +1,11 @@
import { FieldMetadata } from '@/object-record/field/types/FieldMetadata';
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
import { ColumnDefinition } from '../types/ColumnDefinition';
export const availableTableColumnsScopedState = createScopedState<
ColumnDefinition<FieldMetadata>[]
>({
key: 'availableTableColumnsScopedState',
defaultValue: [],
});

View File

@ -0,0 +1,11 @@
import { atom } from 'recoil';
import { TableCellPosition } from '../types/TableCellPosition';
export const currentTableCellInEditModePositionState = atom<TableCellPosition>({
key: 'currentTableCellInEditModePositionState',
default: {
row: 0,
column: 1,
},
});

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const isFetchingRecordTableDataState = atom<boolean>({
key: 'isFetchingRecordTableDataState',
default: true,
});

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const isSoftFocusActiveState = atom<boolean>({
key: 'isSoftFocusActiveState',
default: false,
});

View File

@ -0,0 +1,11 @@
import { atomFamily } from 'recoil';
import { TableCellPosition } from '../types/TableCellPosition';
export const isSoftFocusOnTableCellFamilyState = atomFamily<
boolean,
TableCellPosition
>({
key: 'isSoftFocusOnTableCellFamilyState',
default: false,
});

View File

@ -0,0 +1,11 @@
import { atomFamily } from 'recoil';
import { TableCellPosition } from '../types/TableCellPosition';
export const isTableCellInEditModeFamilyState = atomFamily<
boolean,
TableCellPosition
>({
key: 'isTableCellInEditModeFamilyState',
default: false,
});

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const numberOfTableRowsState = atom<number>({
key: 'numberOfTableRowsState',
default: 0,
});

View File

@ -0,0 +1,8 @@
import { ObjectMetadataConfig } from '@/object-record/record-table/types/ObjectMetadataConfig';
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
export const objectMetadataConfigScopedState =
createScopedState<ObjectMetadataConfig | null>({
key: 'objectMetadataConfigScopedState',
defaultValue: null,
});

View File

@ -0,0 +1,11 @@
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
import { FieldMetadata } from '../../field/types/FieldMetadata';
import { ColumnDefinition } from '../types/ColumnDefinition';
export const onColumnsChangeScopedState = createScopedState<
((columns: ColumnDefinition<FieldMetadata>[]) => void) | undefined
>({
key: 'onColumnsChangeScopedState',
defaultValue: undefined,
});

View File

@ -0,0 +1,8 @@
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
export const onEntityCountChangeScopedState = createScopedState<
((entityCount: number) => void) | undefined
>({
key: 'onEntityCountChangeScopedState',
defaultValue: undefined,
});

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const resizeFieldOffsetState = atom<number>({
key: 'resizeFieldOffsetState',
default: 0,
});

View File

@ -0,0 +1,26 @@
import { selector } from 'recoil';
import { AllRowsSelectedStatus } from '../../types/AllRowSelectedStatus';
import { numberOfTableRowsState } from '../numberOfTableRowsState';
import { selectedRowIdsSelector } from './selectedRowIdsSelector';
export const allRowsSelectedStatusSelector = selector<AllRowsSelectedStatus>({
key: 'allRowsSelectedStatusSelector',
get: ({ get }) => {
const numberOfRows = get(numberOfTableRowsState);
const selectedRowIds = get(selectedRowIdsSelector);
const numberOfSelectedRows = selectedRowIds.length;
const allRowsSelectedStatus =
numberOfSelectedRows === 0
? 'none'
: numberOfRows === numberOfSelectedRows
? 'all'
: 'some';
return allRowsSelectedStatus;
},
});

View File

@ -0,0 +1,22 @@
import { selectorFamily } from 'recoil';
import { availableTableColumnsScopedState } from '../availableTableColumnsScopedState';
import { tableColumnsScopedState } from '../tableColumnsScopedState';
export const hiddenTableColumnsScopedSelector = selectorFamily({
key: 'hiddenTableColumnsScopedSelector',
get:
(scopeId: string) =>
({ get }) => {
const columns = get(tableColumnsScopedState({ scopeId }));
const columnKeys = columns.map(({ fieldMetadataId }) => fieldMetadataId);
const otherAvailableColumns = get(
availableTableColumnsScopedState({ scopeId }),
).filter(({ fieldMetadataId }) => !columnKeys.includes(fieldMetadataId));
return [
...columns.filter((column) => !column.isVisible),
...otherAvailableColumns,
];
},
});

View File

@ -0,0 +1,11 @@
import { selectorFamily } from 'recoil';
import { tableColumnsScopedState } from '../tableColumnsScopedState';
export const numberOfTableColumnsScopedSelector = selectorFamily({
key: 'numberOfTableColumnsScopedSelector',
get:
(scopeId: string) =>
({ get }) =>
get(tableColumnsScopedState({ scopeId })).length,
});

View File

@ -0,0 +1,15 @@
import { selector } from 'recoil';
import { isRowSelectedFamilyState } from '../../record-table-row/states/isRowSelectedFamilyState';
import { tableRowIdsState } from '../tableRowIdsState';
export const selectedRowIdsSelector = selector<string[]>({
key: 'selectedRowIdsSelector',
get: ({ get }) => {
const rowIds = get(tableRowIdsState);
return rowIds.filter(
(rowId) => get(isRowSelectedFamilyState(rowId)) === true,
);
},
});

View File

@ -0,0 +1,19 @@
import { selectorFamily } from 'recoil';
import { FieldMetadata } from '@/object-record/field/types/FieldMetadata';
import { ColumnDefinition } from '../../types/ColumnDefinition';
import { tableColumnsScopedState } from '../tableColumnsScopedState';
export const tableColumnsByKeyScopedSelector = selectorFamily({
key: 'tableColumnsByKeyScopedSelector',
get:
(scopeId: string) =>
({ get }) =>
get(tableColumnsScopedState({ scopeId })).reduce<
Record<string, ColumnDefinition<FieldMetadata>>
>(
(result, column) => ({ ...result, [column.fieldMetadataId]: column }),
{},
),
});

View File

@ -0,0 +1,24 @@
import { selectorFamily } from 'recoil';
import { availableTableColumnsScopedState } from '../availableTableColumnsScopedState';
import { tableColumnsScopedState } from '../tableColumnsScopedState';
export const visibleTableColumnsScopedSelector = selectorFamily({
key: 'visibleTableColumnsScopedSelector',
get:
(scopeId: string) =>
({ get }) => {
const columns = get(tableColumnsScopedState({ scopeId }));
const availableColumnKeys = get(
availableTableColumnsScopedState({ scopeId }),
).map(({ fieldMetadataId }) => fieldMetadataId);
return [...columns]
.filter(
(column) =>
column.isVisible &&
availableColumnKeys.includes(column.fieldMetadataId),
)
.sort((a, b) => a.position - b.position);
},
});

View File

@ -0,0 +1,11 @@
import { atom } from 'recoil';
import { TableCellPosition } from '../types/TableCellPosition';
export const softFocusPositionState = atom<TableCellPosition>({
key: 'softFocusPositionState',
default: {
row: 0,
column: 1,
},
});

View File

@ -0,0 +1,11 @@
import { FieldMetadata } from '@/object-record/field/types/FieldMetadata';
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
import { ColumnDefinition } from '../types/ColumnDefinition';
export const tableColumnsScopedState = createScopedState<
ColumnDefinition<FieldMetadata>[]
>({
key: 'tableColumnsScopedState',
defaultValue: [],
});

View File

@ -0,0 +1,8 @@
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
import { Filter } from '../../object-filter-dropdown/types/Filter';
export const tableFiltersScopedState = createScopedState<Filter[]>({
key: 'tableFiltersScopedState',
defaultValue: [],
});

View File

@ -0,0 +1,6 @@
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
export const tableLastRowVisibleScopedState = createScopedState<boolean>({
key: 'tableLastRowVisibleScopedState',
defaultValue: false,
});

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const tableRowIdsState = atom<string[]>({
key: 'tableRowIdsState',
default: [],
});

View File

@ -0,0 +1,8 @@
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
import { Sort } from '../../object-sort-dropdown/types/Sort';
export const tableSortsScopedState = createScopedState<Sort[]>({
key: 'tableSortsScopedState',
defaultValue: [],
});

View File

@ -0,0 +1 @@
export type AllRowsSelectedStatus = 'none' | 'some' | 'all';

View File

@ -0,0 +1,9 @@
import { FieldDefinition } from '@/object-record/field/types/FieldDefinition';
import { FieldMetadata } from '@/object-record/field/types/FieldMetadata';
export type ColumnDefinition<T extends FieldMetadata> = FieldDefinition<T> & {
size: number;
position: number;
isVisible?: boolean;
viewFieldId?: string;
};

View File

@ -0,0 +1,4 @@
export type ObjectMetadataConfig = {
labelIdentifierFieldMetadataId: string;
basePathToShowPage: string;
};

View File

@ -0,0 +1,4 @@
export type TableCellPosition = {
row: number;
column: number;
};

View File

@ -0,0 +1,4 @@
export type TablePosition = {
numberOfRows: number;
numberOfColumns: number;
};

View File

@ -0,0 +1,7 @@
export enum TableHotkeyScope {
CellDoubleTextInput = 'cell-double-text-input',
CellEditMode = 'cell-edit-mode',
CellDateEditMode = 'cell-date-edit-mode',
TableSoftFocus = 'table-soft-focus',
Table = 'table',
}

View File

@ -0,0 +1,3 @@
export enum TableOptionsHotkeyScope {
Dropdown = 'table-options-dropdown',
}

View File

@ -0,0 +1,82 @@
import { objectMetadataConfigScopedState } from '@/object-record/record-table/states/objectMetadataConfigScopedState';
import { tableLastRowVisibleScopedState } from '@/object-record/record-table/states/tableLastRowVisibleScopedState';
import { getScopedState } from '@/ui/utilities/recoil-scope/utils/getScopedState';
import { availableTableColumnsScopedState } from '../states/availableTableColumnsScopedState';
import { onColumnsChangeScopedState } from '../states/onColumnsChangeScopedState';
import { onEntityCountChangeScopedState } from '../states/onEntityCountChange';
import { hiddenTableColumnsScopedSelector } from '../states/selectors/hiddenTableColumnsScopedSelector';
import { tableColumnsByKeyScopedSelector } from '../states/selectors/tableColumnsByKeyScopedSelector';
import { visibleTableColumnsScopedSelector } from '../states/selectors/visibleTableColumnsScopedSelector';
import { tableColumnsScopedState } from '../states/tableColumnsScopedState';
import { tableFiltersScopedState } from '../states/tableFiltersScopedState';
import { tableSortsScopedState } from '../states/tableSortsScopedState';
export const getRecordTableScopedStates = ({
recordTableScopeId,
}: {
recordTableScopeId: string;
}) => {
const availableTableColumnsState = getScopedState(
availableTableColumnsScopedState,
recordTableScopeId,
);
const tableFiltersState = getScopedState(
tableFiltersScopedState,
recordTableScopeId,
);
const tableSortsState = getScopedState(
tableSortsScopedState,
recordTableScopeId,
);
const tableColumnsState = getScopedState(
tableColumnsScopedState,
recordTableScopeId,
);
const objectMetadataConfigState = getScopedState(
objectMetadataConfigScopedState,
recordTableScopeId,
);
const tableColumnsByKeySelector =
tableColumnsByKeyScopedSelector(recordTableScopeId);
const hiddenTableColumnsSelector =
hiddenTableColumnsScopedSelector(recordTableScopeId);
const visibleTableColumnsSelector =
visibleTableColumnsScopedSelector(recordTableScopeId);
const onColumnsChangeState = getScopedState(
onColumnsChangeScopedState,
recordTableScopeId,
);
const onEntityCountChangeState = getScopedState(
onEntityCountChangeScopedState,
recordTableScopeId,
);
const tableLastRowVisibleState = getScopedState(
tableLastRowVisibleScopedState,
recordTableScopeId,
);
return {
availableTableColumnsState,
tableFiltersState,
tableSortsState,
tableColumnsState,
objectMetadataConfigState,
tableColumnsByKeySelector,
hiddenTableColumnsSelector,
visibleTableColumnsSelector,
onColumnsChangeState,
onEntityCountChangeState,
tableLastRowVisibleState,
};
};