Uniformize folder structure (#693)

* Uniformize folder structure

* Fix icons

* Fix icons

* Fix tests

* Fix tests
This commit is contained in:
Charles Bochet
2023-07-16 14:29:28 -07:00
committed by GitHub
parent 900ec5572f
commit 6ced8434bd
462 changed files with 931 additions and 960 deletions

View File

@ -0,0 +1,16 @@
import React from 'react';
import { useRecoilValue } from 'recoil';
import { ActionBar } from '@/ui/action-bar/components/ActionBar';
import { selectedRowIdsSelector } from '../../states/selectedRowIdsSelector';
type OwnProps = {
children: React.ReactNode | React.ReactNode[];
};
export function EntityTableActionBar({ children }: OwnProps) {
const selectedRowIds = useRecoilValue(selectedRowIdsSelector);
return <ActionBar selectedIds={selectedRowIds}>{children}</ActionBar>;
}

View File

@ -0,0 +1,51 @@
import { ReactNode } from 'react';
import styled from '@emotion/styled';
type OwnProps = {
icon: ReactNode;
label: string;
type?: 'standard' | 'warning';
onClick: () => void;
};
type StyledButtonProps = {
type: 'standard' | 'warning';
};
const StyledButton = styled.div<StyledButtonProps>`
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${(props) =>
props.type === 'warning'
? props.theme.color.red
: props.theme.font.color.secondary};
cursor: pointer;
display: flex;
justify-content: center;
padding: ${({ theme }) => theme.spacing(2)};
transition: background 0.1s ease;
user-select: none;
&:hover {
background: ${({ theme }) => theme.background.tertiary};
}
`;
const StyledButtonLabel = styled.div`
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-left: ${({ theme }) => theme.spacing(2)};
`;
export function EntityTableActionBarButton({
label,
icon,
type = 'standard',
onClick,
}: OwnProps) {
return (
<StyledButton type={type} onClick={onClick}>
{icon}
<StyledButtonLabel>{label}</StyledButtonLabel>
</StyledButton>
);
}

View File

@ -0,0 +1,17 @@
import { IconNotes } from '@/ui/icon/index';
import { EntityTableActionBarButton } from './EntityTableActionBarButton';
type OwnProps = {
onClick: () => void;
};
export function TableActionBarButtonToggleComments({ onClick }: OwnProps) {
return (
<EntityTableActionBarButton
label="Note"
icon={<IconNotes size={16} />}
onClick={onClick}
/>
);
}

View File

@ -0,0 +1,39 @@
import * as React from 'react';
import styled from '@emotion/styled';
import { useSetRecoilState } from 'recoil';
import { Checkbox } from '@/ui/input/components/Checkbox';
import { useCurrentRowSelected } from '../hooks/useCurrentRowSelected';
import { contextMenuPositionState } from '../states/contextMenuPositionState';
const StyledContainer = styled.div`
align-items: center;
display: flex;
height: 32px;
justify-content: center;
`;
export function CheckboxCell() {
const setContextMenuPosition = useSetRecoilState(contextMenuPositionState);
const { currentRowSelected, setCurrentRowSelected } = useCurrentRowSelected();
function onChange(checked: boolean) {
handleCheckboxChange(checked);
}
function handleCheckboxChange(newCheckedValue: boolean) {
setCurrentRowSelected(newCheckedValue);
setContextMenuPosition({ x: null, y: null });
}
return (
<StyledContainer>
<Checkbox checked={currentRowSelected} onChange={onChange} />
</StyledContainer>
);
}

View File

@ -0,0 +1,30 @@
import { ReactNode } from 'react';
import styled from '@emotion/styled';
type OwnProps = {
viewName: string;
viewIcon?: ReactNode;
};
const StyledTitle = styled.div`
align-items: center;
display: flex;
flex-direction: row;
font-weight: ${({ theme }) => theme.font.weight.medium};
height: ${({ theme }) => theme.spacing(8)};
padding-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledIcon = styled.div`
display: flex;
margin-right: ${({ theme }) => theme.spacing(1)};
`;
export function ColumnHead({ viewName, viewIcon }: OwnProps) {
return (
<StyledTitle>
<StyledIcon>{viewIcon}</StyledIcon>
{viewName}
</StyledTitle>
);
}

View File

@ -0,0 +1,113 @@
import * as React from 'react';
import styled from '@emotion/styled';
import { TableColumn } from '@/people/table/components/peopleColumns';
import { SelectedSortType, SortType } from '@/ui/filter-n-sort/types/interface';
import { useListenClickOutsideArrayOfRef } from '@/ui/hooks/useListenClickOutsideArrayOfRef';
import { useLeaveTableFocus } from '../hooks/useLeaveTableFocus';
import { TableHeader } from '../table-header/components/TableHeader';
import { EntityTableBody } from './EntityTableBody';
import { EntityTableHeader } from './EntityTableHeader';
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.background.tertiary};
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;
}
:last-of-type {
min-width: 0;
width: 100%;
}
}
td {
border: 1px solid ${({ theme }) => theme.background.tertiary};
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;
}
:last-of-type {
min-width: 0;
width: 100%;
}
}
`;
const StyledTableWithHeader = styled.div`
display: flex;
flex: 1;
flex-direction: column;
width: 100%;
`;
type OwnProps<SortField> = {
columns: Array<TableColumn>;
viewName: string;
viewIcon?: React.ReactNode;
availableSorts?: Array<SortType<SortField>>;
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
onRowSelectionChange?: (rowSelection: string[]) => void;
};
export function EntityTable<SortField>({
columns,
viewName,
viewIcon,
availableSorts,
onSortsUpdate,
}: OwnProps<SortField>) {
const tableBodyRef = React.useRef<HTMLDivElement>(null);
const leaveTableFocus = useLeaveTableFocus();
useListenClickOutsideArrayOfRef([tableBodyRef], () => {
leaveTableFocus();
});
return (
<StyledTableWithHeader>
<TableHeader
viewName={viewName}
viewIcon={viewIcon}
availableSorts={availableSorts}
onSortsUpdate={onSortsUpdate}
/>
<div ref={tableBodyRef}>
<StyledTable>
<EntityTableHeader columns={columns} />
<EntityTableBody columns={columns} />
</StyledTable>
</div>
</StyledTableWithHeader>
);
}

View File

@ -0,0 +1,33 @@
import { useRecoilValue } from 'recoil';
import { TableColumn } from '@/people/table/components/peopleColumns';
import { isNavbarSwitchingSizeState } from '@/ui/layout/states/isNavbarSwitchingSizeState';
import { RecoilScope } from '@/ui/recoil-scope/components/RecoilScope';
import { isFetchingEntityTableDataState } from '../states/isFetchingEntityTableDataState';
import { RowContext } from '../states/RowContext';
import { tableRowIdsState } from '../states/tableRowIdsState';
import { EntityTableRow } from './EntityTableRow';
export function EntityTableBody({ columns }: { columns: Array<TableColumn> }) {
const rowIds = useRecoilValue(tableRowIdsState);
const isNavbarSwitchingSize = useRecoilValue(isNavbarSwitchingSizeState);
const isFetchingEntityTableData = useRecoilValue(
isFetchingEntityTableDataState,
);
return (
<tbody>
{!isFetchingEntityTableData && !isNavbarSwitchingSize
? rowIds.map((rowId, index) => (
<RecoilScope SpecificContext={RowContext} key={rowId}>
<EntityTableRow columns={columns} rowId={rowId} index={index} />
</RecoilScope>
))
: null}
</tbody>
);
}

View File

@ -0,0 +1,58 @@
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
import { useRecoilScopedState } from '@/ui/recoil-scope/hooks/useRecoilScopedState';
import { useCurrentRowSelected } from '../hooks/useCurrentRowSelected';
import { CellContext } from '../states/CellContext';
import { contextMenuPositionState } from '../states/contextMenuPositionState';
import { currentColumnNumberScopedState } from '../states/currentColumnNumberScopedState';
export function EntityTableCell({
rowId,
cellIndex,
children,
size,
}: {
size: number;
rowId: string;
cellIndex: number;
children: React.ReactNode;
}) {
const [, setCurrentColumnNumber] = useRecoilScopedState(
currentColumnNumberScopedState,
CellContext,
);
useEffect(() => {
setCurrentColumnNumber(cellIndex);
}, [cellIndex, setCurrentColumnNumber]);
const setContextMenuPosition = useSetRecoilState(contextMenuPositionState);
const { setCurrentRowSelected } = useCurrentRowSelected();
function handleContextMenu(event: React.MouseEvent) {
event.preventDefault();
setCurrentRowSelected(true);
setContextMenuPosition({
x: event.clientX,
y: event.clientY,
});
}
return (
<td
onContextMenu={(event) => handleContextMenu(event)}
style={{
width: size,
minWidth: size,
maxWidth: size,
}}
>
{children}
</td>
);
}

View File

@ -0,0 +1,39 @@
import { TableColumn } from '@/people/table/components/peopleColumns';
import { ColumnHead } from './ColumnHead';
import { SelectAllCheckbox } from './SelectAllCheckbox';
export function EntityTableHeader({
columns,
}: {
columns: Array<TableColumn>;
}) {
return (
<thead>
<tr>
<th
style={{
width: 30,
minWidth: 30,
maxWidth: 30,
}}
>
<SelectAllCheckbox />
</th>
{columns.map((column) => (
<th
key={column.id.toString()}
style={{
width: column.size,
minWidth: column.size,
maxWidth: column.size,
}}
>
<ColumnHead viewName={column.title} viewIcon={column.icon} />
</th>
))}
<th></th>
</tr>
</thead>
);
}

View File

@ -0,0 +1,81 @@
import { useEffect } from 'react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { TableColumn } from '@/people/table/components/peopleColumns';
import { RecoilScope } from '@/ui/recoil-scope/components/RecoilScope';
import { useRecoilScopedState } from '@/ui/recoil-scope/hooks/useRecoilScopedState';
import { CellContext } from '../states/CellContext';
import { currentRowEntityIdScopedState } from '../states/currentRowEntityIdScopedState';
import { currentRowNumberScopedState } from '../states/currentRowNumberScopedState';
import { isRowSelectedFamilyState } from '../states/isRowSelectedFamilyState';
import { RowContext } from '../states/RowContext';
import { CheckboxCell } from './CheckboxCell';
import { EntityTableCell } from './EntityTableCell';
const StyledRow = styled.tr<{ selected: boolean }>`
background: ${(props) =>
props.selected ? props.theme.background.secondary : 'none'};
`;
export function EntityTableRow({
columns,
rowId,
index,
}: {
columns: TableColumn[];
rowId: string;
index: number;
}) {
const [currentRowEntityId, setCurrentRowEntityId] = useRecoilScopedState(
currentRowEntityIdScopedState,
RowContext,
);
const isCurrentRowSelected = useRecoilValue(isRowSelectedFamilyState(rowId));
const [, setCurrentRowNumber] = useRecoilScopedState(
currentRowNumberScopedState,
RowContext,
);
useEffect(() => {
if (currentRowEntityId !== rowId) {
setCurrentRowEntityId(rowId);
}
}, [rowId, setCurrentRowEntityId, currentRowEntityId]);
useEffect(() => {
setCurrentRowNumber(index);
}, [index, setCurrentRowNumber]);
return (
<StyledRow
key={rowId}
data-testid={`row-id-${rowId}`}
selected={isCurrentRowSelected}
>
<td>
<CheckboxCell />
</td>
{columns.map((column, columnIndex) => {
return (
<RecoilScope SpecificContext={CellContext} key={column.id.toString()}>
<RecoilScope>
<EntityTableCell
rowId={rowId}
size={column.size}
cellIndex={columnIndex}
>
{column.cellComponent}
</EntityTableCell>
</RecoilScope>
</RecoilScope>
);
})}
<td></td>
</StyledRow>
);
}

View File

@ -0,0 +1,25 @@
import { FilterDefinition } from '@/ui/filter-n-sort/types/FilterDefinition';
import { useInitializeEntityTable } from '../hooks/useInitializeEntityTable';
import { useInitializeEntityTableFilters } from '../hooks/useInitializeEntityTableFilters';
import { useMapKeyboardToSoftFocus } from '../hooks/useMapKeyboardToSoftFocus';
export function HooksEntityTable({
numberOfColumns,
availableFilters,
}: {
numberOfColumns: number;
availableFilters: FilterDefinition[];
}) {
useMapKeyboardToSoftFocus();
useInitializeEntityTable({
numberOfColumns,
});
useInitializeEntityTableFilters({
availableFilters,
});
return <></>;
}

View File

@ -0,0 +1,35 @@
import React from 'react';
import styled from '@emotion/styled';
import { Checkbox } from '@/ui/input/components/Checkbox';
import { useSelectAllRows } from '../hooks/useSelectAllRows';
const StyledContainer = styled.div`
align-items: center;
display: flex;
height: 32px;
justify-content: center;
`;
export const SelectAllCheckbox = () => {
const { selectAllRows, allRowsSelectedStatus } = useSelectAllRows();
const checked = allRowsSelectedStatus === 'all';
const indeterminate = allRowsSelectedStatus === 'some';
function onChange(value: boolean) {
selectAllRows();
}
return (
<StyledContainer>
<Checkbox
checked={checked}
onChange={onChange}
indeterminate={indeterminate}
/>
</StyledContainer>
);
};

View File

@ -0,0 +1,9 @@
import Skeleton from 'react-loading-skeleton';
export function CellSkeleton() {
return (
<div style={{ width: '100%', alignItems: 'center' }}>
<Skeleton />
</div>
);
}

View File

@ -0,0 +1,67 @@
import { ReactElement } from 'react';
import styled from '@emotion/styled';
import { HotkeyScope } from '@/ui/hotkey/types/HotkeyScope';
import { useCurrentCellEditMode } from '../hooks/useCurrentCellEditMode';
import { useIsSoftFocusOnCurrentCell } from '../hooks/useIsSoftFocusOnCurrentCell';
import { EditableCellDisplayMode } from './EditableCellDisplayMode';
import { EditableCellEditMode } from './EditableCellEditMode';
import { EditableCellSoftFocusMode } from './EditableCellSoftFocusMode';
export const CellBaseContainer = styled.div`
align-items: center;
box-sizing: border-box;
cursor: pointer;
display: flex;
height: 32px;
position: relative;
user-select: none;
width: 100%;
`;
type OwnProps = {
editModeContent: ReactElement;
nonEditModeContent: ReactElement;
editModeHorizontalAlign?: 'left' | 'right';
editModeVerticalPosition?: 'over' | 'below';
editHotkeyScope?: HotkeyScope;
onSubmit?: () => void;
onCancel?: () => void;
};
export function EditableCell({
editModeHorizontalAlign = 'left',
editModeVerticalPosition = 'over',
editModeContent,
nonEditModeContent,
editHotkeyScope,
onSubmit,
onCancel,
}: OwnProps) {
const { isCurrentCellInEditMode } = useCurrentCellEditMode();
const hasSoftFocus = useIsSoftFocusOnCurrentCell();
return (
<CellBaseContainer>
{isCurrentCellInEditMode ? (
<EditableCellEditMode
editModeHorizontalAlign={editModeHorizontalAlign}
editModeVerticalPosition={editModeVerticalPosition}
onSubmit={onSubmit}
onCancel={onCancel}
>
{editModeContent}
</EditableCellEditMode>
) : hasSoftFocus ? (
<EditableCellSoftFocusMode editHotkeyScope={editHotkeyScope}>
{nonEditModeContent}
</EditableCellSoftFocusMode>
) : (
<EditableCellDisplayMode>{nonEditModeContent}</EditableCellDisplayMode>
)}
</CellBaseContainer>
);
}

View File

@ -0,0 +1,51 @@
import styled from '@emotion/styled';
import { useSetSoftFocusOnCurrentCell } from '../hooks/useSetSoftFocusOnCurrentCell';
type Props = {
softFocus?: boolean;
};
export const EditableCellNormalModeOuterContainer = styled.div<Props>`
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.md};
box-shadow: inset 0 0 0 1px ${props.theme.font.color.extraLight};`
: ''}
`;
export const EditableCellNormalModeInnerContainer = styled.div`
align-items: center;
display: flex;
height: 100%;
overflow: hidden;
width: 100%;
`;
export function EditableCellDisplayMode({
children,
}: React.PropsWithChildren<unknown>) {
const setSoftFocusOnCurrentCell = useSetSoftFocusOnCurrentCell();
function handleOnClick() {
setSoftFocusOnCurrentCell();
}
return (
<EditableCellNormalModeOuterContainer onClick={handleOnClick}>
<EditableCellNormalModeInnerContainer>
{children}
</EditableCellNormalModeInnerContainer>
</EditableCellNormalModeOuterContainer>
);
}

View File

@ -0,0 +1,59 @@
import { ReactElement, useRef } from 'react';
import styled from '@emotion/styled';
import { overlayBackground } from '@/ui/themes/effects';
import { useRegisterCloseCellHandlers } from '../hooks/useRegisterCloseCellHandlers';
export const EditableCellEditModeContainer = styled.div<OwnProps>`
align-items: center;
border: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
left: ${(props) =>
props.editModeHorizontalAlign === 'right' ? 'auto' : '0'};
margin-left: -1px;
margin-top: -1px;
min-height: 100%;
position: absolute;
right: ${(props) =>
props.editModeHorizontalAlign === 'right' ? '0' : 'auto'};
top: ${(props) => (props.editModeVerticalPosition === 'over' ? '0' : '100%')};
width: 100%;
z-index: 1;
${overlayBackground}
`;
type OwnProps = {
children: ReactElement;
editModeHorizontalAlign?: 'left' | 'right';
editModeVerticalPosition?: 'over' | 'below';
onOutsideClick?: () => void;
onCancel?: () => void;
onSubmit?: () => void;
};
export function EditableCellEditMode({
editModeHorizontalAlign,
editModeVerticalPosition,
children,
onCancel,
onSubmit,
}: OwnProps) {
const wrapperRef = useRef(null);
useRegisterCloseCellHandlers(wrapperRef, onSubmit, onCancel);
return (
<EditableCellEditModeContainer
data-testid="editable-cell-edit-mode-container"
ref={wrapperRef}
editModeHorizontalAlign={editModeHorizontalAlign}
editModeVerticalPosition={editModeVerticalPosition}
>
{children}
</EditableCellEditModeContainer>
);
}

View File

@ -0,0 +1,73 @@
import React from 'react';
import { useScopedHotkeys } from '@/ui/hotkey/hooks/useScopedHotkeys';
import { HotkeyScope } from '@/ui/hotkey/types/HotkeyScope';
import { isNonTextWritingKey } from '@/ui/hotkey/utils/isNonTextWritingKey';
import { TableHotkeyScope } from '../../types/TableHotkeyScope';
import { useEditableCell } from '../hooks/useEditableCell';
import {
EditableCellNormalModeInnerContainer,
EditableCellNormalModeOuterContainer,
} from './EditableCellDisplayMode';
export function EditableCellSoftFocusMode({
children,
editHotkeyScope,
}: React.PropsWithChildren<{ editHotkeyScope?: HotkeyScope }>) {
const { openEditableCell } = useEditableCell();
function openEditMode() {
openEditableCell(
editHotkeyScope ?? {
scope: TableHotkeyScope.CellEditMode,
},
);
}
useScopedHotkeys(
'enter',
() => {
openEditMode();
},
TableHotkeyScope.TableSoftFocus,
[openEditMode],
);
useScopedHotkeys(
'*',
(keyboardEvent) => {
const isWritingText =
!isNonTextWritingKey(keyboardEvent.key) &&
!keyboardEvent.ctrlKey &&
!keyboardEvent.metaKey;
if (!isWritingText) {
return;
}
openEditMode();
},
TableHotkeyScope.TableSoftFocus,
[openEditMode],
{
preventDefault: false,
},
);
function handleClick() {
openEditMode();
}
return (
<EditableCellNormalModeOuterContainer
onClick={handleClick}
softFocus={true}
>
<EditableCellNormalModeInnerContainer>
{children}
</EditableCellNormalModeInnerContainer>
</EditableCellNormalModeOuterContainer>
);
}

View File

@ -0,0 +1,19 @@
import styled from '@emotion/styled';
export const HoverableMenuItem = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.primary};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-sizing: border-box;
cursor: pointer;
display: flex;
height: 100%;
position: relative;
transition: background 0.1s ease;
user-select: none;
width: 100%;
&:hover {
background: ${({ theme }) => theme.background.transparent.light};
}
`;

View File

@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';
import {
CellPositionDecorator,
ComponentDecorator,
} from '~/testing/decorators';
import { EditableCellText } from '../../types/EditableCellText';
const meta: Meta<typeof EditableCellText> = {
title: 'UI/EditableCell/EditableCellText',
component: EditableCellText,
decorators: [ComponentDecorator, CellPositionDecorator],
args: {
value: 'Content',
},
};
export default meta;
type Story = StoryObj<typeof EditableCellText>;
export const DisplayMode: Story = {
render: EditableCellText,
};
export const SoftFocusMode: Story = {
...DisplayMode,
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
await step('Click once', () =>
userEvent.click(canvas.getByText('Content')),
);
},
};
export const EditMode: Story = {
...DisplayMode,
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
const click = async () => userEvent.click(canvas.getByText('Content'));
await step('Click once', click);
await step('Click twice', click);
},
};

View File

@ -0,0 +1,23 @@
import { useCallback } from 'react';
import { useRecoilState } from 'recoil';
import { useMoveEditModeToCellPosition } from '../../hooks/useMoveEditModeToCellPosition';
import { isCellInEditModeFamilyState } from '../../states/isCellInEditModeFamilyState';
import { useCurrentCellPosition } from './useCurrentCellPosition';
export function useCurrentCellEditMode() {
const moveEditModeToCellPosition = useMoveEditModeToCellPosition();
const currentCellPosition = useCurrentCellPosition();
const [isCurrentCellInEditMode] = useRecoilState(
isCellInEditModeFamilyState(currentCellPosition),
);
const setCurrentCellInEditMode = useCallback(() => {
moveEditModeToCellPosition(currentCellPosition);
}, [currentCellPosition, moveEditModeToCellPosition]);
return { isCurrentCellInEditMode, setCurrentCellInEditMode };
}

View File

@ -0,0 +1,31 @@
import { useMemo } from 'react';
import { useRecoilScopedState } from '@/ui/recoil-scope/hooks/useRecoilScopedState';
import { CellContext } from '../../states/CellContext';
import { currentColumnNumberScopedState } from '../../states/currentColumnNumberScopedState';
import { currentRowNumberScopedState } from '../../states/currentRowNumberScopedState';
import { RowContext } from '../../states/RowContext';
import { CellPosition } from '../../types/CellPosition';
export function useCurrentCellPosition() {
const [currentRowNumber] = useRecoilScopedState(
currentRowNumberScopedState,
RowContext,
);
const [currentColumnNumber] = useRecoilScopedState(
currentColumnNumberScopedState,
CellContext,
);
const currentCellPosition: CellPosition = useMemo(
() => ({
column: currentColumnNumber,
row: currentRowNumber,
}),
[currentColumnNumber, currentRowNumber],
);
return currentCellPosition;
}

View File

@ -0,0 +1,46 @@
import { useRecoilCallback } from 'recoil';
import { useSetHotkeyScope } from '@/ui/hotkey/hooks/useSetHotkeyScope';
import { HotkeyScope } from '@/ui/hotkey/types/HotkeyScope';
import { useCloseCurrentCellInEditMode } from '../../hooks/useClearCellInEditMode';
import { isSomeInputInEditModeState } from '../../states/isSomeInputInEditModeState';
import { TableHotkeyScope } from '../../types/TableHotkeyScope';
import { useCurrentCellEditMode } from './useCurrentCellEditMode';
export function useEditableCell() {
const { setCurrentCellInEditMode } = useCurrentCellEditMode();
const setHotkeyScope = useSetHotkeyScope();
const closeCurrentCellInEditMode = useCloseCurrentCellInEditMode();
function closeEditableCell() {
closeCurrentCellInEditMode();
setHotkeyScope(TableHotkeyScope.TableSoftFocus);
}
const openEditableCell = useRecoilCallback(
({ snapshot, set }) =>
(HotkeyScope: HotkeyScope) => {
const isSomeInputInEditMode = snapshot
.getLoadable(isSomeInputInEditModeState)
.valueOrThrow();
if (!isSomeInputInEditMode) {
set(isSomeInputInEditModeState, true);
setCurrentCellInEditMode();
setHotkeyScope(HotkeyScope.scope);
}
},
[setCurrentCellInEditMode, setHotkeyScope],
);
return {
closeEditableCell,
openEditableCell,
};
}

View File

@ -0,0 +1,15 @@
import { useRecoilValue } from 'recoil';
import { isSoftFocusOnCellFamilyState } from '../../states/isSoftFocusOnCellFamilyState';
import { useCurrentCellPosition } from './useCurrentCellPosition';
export function useIsSoftFocusOnCurrentCell() {
const currentCellPosition = useCurrentCellPosition();
const isSoftFocusOnCell = useRecoilValue(
isSoftFocusOnCellFamilyState(currentCellPosition),
);
return isSoftFocusOnCell;
}

View File

@ -0,0 +1,67 @@
import { useListenClickOutsideArrayOfRef } from '@/ui/hooks/useListenClickOutsideArrayOfRef';
import { useScopedHotkeys } from '@/ui/hotkey/hooks/useScopedHotkeys';
import { useMoveSoftFocus } from '../../hooks/useMoveSoftFocus';
import { TableHotkeyScope } from '../../types/TableHotkeyScope';
import { useCurrentCellEditMode } from './useCurrentCellEditMode';
import { useEditableCell } from './useEditableCell';
export function useRegisterCloseCellHandlers(
wrapperRef: React.RefObject<HTMLDivElement>,
onSubmit?: () => void,
onCancel?: () => void,
) {
const { closeEditableCell } = useEditableCell();
const { isCurrentCellInEditMode } = useCurrentCellEditMode();
useListenClickOutsideArrayOfRef([wrapperRef], () => {
if (isCurrentCellInEditMode) {
onSubmit?.();
closeEditableCell();
}
});
const { moveRight, moveLeft, moveDown } = useMoveSoftFocus();
useScopedHotkeys(
'enter',
() => {
onSubmit?.();
closeEditableCell();
moveDown();
},
TableHotkeyScope.CellEditMode,
[closeEditableCell, onSubmit, moveDown],
);
useScopedHotkeys(
'esc',
() => {
closeEditableCell();
onCancel?.();
},
TableHotkeyScope.CellEditMode,
[closeEditableCell, onCancel],
);
useScopedHotkeys(
'tab',
() => {
onSubmit?.();
closeEditableCell();
moveRight();
},
TableHotkeyScope.CellEditMode,
[closeEditableCell, onSubmit, moveRight],
);
useScopedHotkeys(
'shift+tab',
() => {
onSubmit?.();
closeEditableCell();
moveLeft();
},
TableHotkeyScope.CellEditMode,
[closeEditableCell, onSubmit, moveRight],
);
}

View File

@ -0,0 +1,50 @@
import { useMemo } from 'react';
import { useRecoilCallback } from 'recoil';
import { useSetHotkeyScope } from '@/ui/hotkey/hooks/useSetHotkeyScope';
import { useRecoilScopedState } from '@/ui/recoil-scope/hooks/useRecoilScopedState';
import { useSetSoftFocusPosition } from '../../hooks/useSetSoftFocusPosition';
import { CellContext } from '../../states/CellContext';
import { currentColumnNumberScopedState } from '../../states/currentColumnNumberScopedState';
import { currentRowNumberScopedState } from '../../states/currentRowNumberScopedState';
import { isSoftFocusActiveState } from '../../states/isSoftFocusActiveState';
import { RowContext } from '../../states/RowContext';
import { CellPosition } from '../../types/CellPosition';
import { TableHotkeyScope } from '../../types/TableHotkeyScope';
export function useSetSoftFocusOnCurrentCell() {
const setSoftFocusPosition = useSetSoftFocusPosition();
const [currentRowNumber] = useRecoilScopedState(
currentRowNumberScopedState,
RowContext,
);
const [currentColumnNumber] = useRecoilScopedState(
currentColumnNumberScopedState,
CellContext,
);
const currentTablePosition: CellPosition = useMemo(
() => ({
column: currentColumnNumber,
row: currentRowNumber,
}),
[currentColumnNumber, currentRowNumber],
);
const setHotkeyScope = useSetHotkeyScope();
return useRecoilCallback(
({ set }) =>
() => {
setSoftFocusPosition(currentTablePosition);
set(isSoftFocusActiveState, true);
setHotkeyScope(TableHotkeyScope.TableSoftFocus);
},
[setHotkeyScope, currentTablePosition, setSoftFocusPosition],
);
}

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,29 @@
import { InplaceInputDateDisplayMode } from '@/ui/display/component/InplaceInputDateDisplayMode';
import { TableHotkeyScope } from '../../types/TableHotkeyScope';
import { EditableCell } from '../components/EditableCell';
import { EditableCellDateEditMode } from './EditableCellDateEditMode';
export type EditableDateProps = {
value: Date;
onChange: (date: Date) => void;
editModeHorizontalAlign?: 'left' | 'right';
};
export function EditableCellDate({
value,
onChange,
editModeHorizontalAlign,
}: EditableDateProps) {
return (
<EditableCell
editModeHorizontalAlign={editModeHorizontalAlign}
editModeContent={
<EditableCellDateEditMode onChange={onChange} value={value} />
}
nonEditModeContent={<InplaceInputDateDisplayMode value={value} />}
editHotkeyScope={{ scope: TableHotkeyScope.CellDateEditMode }}
></EditableCell>
);
}

View File

@ -0,0 +1,45 @@
import styled from '@emotion/styled';
import { Key } from 'ts-key-enum';
import { useScopedHotkeys } from '@/ui/hotkey/hooks/useScopedHotkeys';
import { InplaceInputDate } from '@/ui/inplace-input/components/InplaceInputDate';
import { TableHotkeyScope } from '../../types/TableHotkeyScope';
import { useEditableCell } from '../hooks/useEditableCell';
const EditableCellDateEditModeContainer = styled.div`
margin-top: -1px;
width: inherit;
`;
export type EditableDateProps = {
value: Date;
onChange: (date: Date) => void;
};
export function EditableCellDateEditMode({
value,
onChange,
}: EditableDateProps) {
const { closeEditableCell } = useEditableCell();
function handleDateChange(newDate: Date) {
onChange(newDate);
closeEditableCell();
}
useScopedHotkeys(
Key.Escape,
() => {
closeEditableCell();
},
TableHotkeyScope.CellDateEditMode,
[closeEditableCell],
);
return (
<EditableCellDateEditModeContainer>
<InplaceInputDate onChange={handleDateChange} value={value} />
</EditableCellDateEditModeContainer>
);
}

View File

@ -0,0 +1,63 @@
import { ReactElement, useEffect, useState } from 'react';
import { TableHotkeyScope } from '../../types/TableHotkeyScope';
import { CellSkeleton } from '../components/CellSkeleton';
import { EditableCell } from '../components/EditableCell';
import { EditableCellDoubleTextEditMode } from './EditableCellDoubleTextEditMode';
type OwnProps = {
firstValue: string;
secondValue: string;
firstValuePlaceholder: string;
secondValuePlaceholder: string;
nonEditModeContent: ReactElement;
onChange: (firstValue: string, secondValue: string) => void;
onSubmit?: () => void;
onCancel?: () => void;
loading?: boolean;
};
export function EditableCellDoubleText({
firstValue,
secondValue,
firstValuePlaceholder,
secondValuePlaceholder,
onChange,
onSubmit,
onCancel,
nonEditModeContent,
loading,
}: OwnProps) {
const [firstInternalValue, setFirstInternalValue] = useState(firstValue);
const [secondInternalValue, setSecondInternalValue] = useState(secondValue);
useEffect(() => {
setFirstInternalValue(firstValue);
setSecondInternalValue(secondValue);
}, [firstValue, secondValue]);
function handleOnChange(firstValue: string, secondValue: string): void {
setFirstInternalValue(firstValue);
setSecondInternalValue(secondValue);
onChange(firstValue, secondValue);
}
return (
<EditableCell
editHotkeyScope={{ scope: TableHotkeyScope.CellDoubleTextInput }}
editModeContent={
<EditableCellDoubleTextEditMode
firstValue={firstInternalValue}
secondValue={secondInternalValue}
firstValuePlaceholder={firstValuePlaceholder}
secondValuePlaceholder={secondValuePlaceholder}
onChange={handleOnChange}
onSubmit={onSubmit}
onCancel={onCancel}
/>
}
nonEditModeContent={loading ? <CellSkeleton /> : nonEditModeContent}
></EditableCell>
);
}

View File

@ -0,0 +1,129 @@
import { ChangeEvent, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { Key } from 'ts-key-enum';
import { useScopedHotkeys } from '@/ui/hotkey/hooks/useScopedHotkeys';
import { InplaceInputTextEditMode } from '@/ui/inplace-input/components/InplaceInputTextEditMode';
import { useMoveSoftFocus } from '../../hooks/useMoveSoftFocus';
import { TableHotkeyScope } from '../../types/TableHotkeyScope';
import { useEditableCell } from '../hooks/useEditableCell';
type OwnProps = {
firstValue: string;
secondValue: string;
firstValuePlaceholder: string;
secondValuePlaceholder: string;
onChange: (firstValue: string, secondValue: string) => void;
onSubmit?: () => void;
onCancel?: () => void;
};
const StyledContainer = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
& > input:last-child {
border-left: 1px solid ${({ theme }) => theme.border.color.medium};
padding-left: ${({ theme }) => theme.spacing(2)};
}
`;
export function EditableCellDoubleTextEditMode({
firstValue,
secondValue,
firstValuePlaceholder,
secondValuePlaceholder,
onChange,
onSubmit,
onCancel,
}: OwnProps) {
const [focusPosition, setFocusPosition] = useState<'left' | 'right'>('left');
const firstValueInputRef = useRef<HTMLInputElement>(null);
const secondValueInputRef = useRef<HTMLInputElement>(null);
const { closeEditableCell } = useEditableCell();
const { moveRight, moveLeft, moveDown } = useMoveSoftFocus();
function closeCell() {
setFocusPosition('left');
closeEditableCell();
}
useScopedHotkeys(
Key.Enter,
() => {
closeCell();
moveDown();
onSubmit?.();
},
TableHotkeyScope.CellDoubleTextInput,
[closeCell],
);
useScopedHotkeys(
Key.Escape,
() => {
onCancel?.();
closeCell();
},
TableHotkeyScope.CellDoubleTextInput,
[closeCell],
);
useScopedHotkeys(
'tab',
() => {
if (focusPosition === 'left') {
setFocusPosition('right');
secondValueInputRef.current?.focus();
} else {
onSubmit?.();
closeCell();
moveRight();
}
},
TableHotkeyScope.CellDoubleTextInput,
[closeCell, moveRight, focusPosition],
);
useScopedHotkeys(
'shift+tab',
() => {
if (focusPosition === 'right') {
setFocusPosition('left');
firstValueInputRef.current?.focus();
} else {
onSubmit?.();
closeCell();
moveLeft();
}
},
TableHotkeyScope.CellDoubleTextInput,
[closeCell, moveRight, focusPosition],
);
return (
<StyledContainer>
<InplaceInputTextEditMode
autoFocus
placeholder={firstValuePlaceholder}
ref={firstValueInputRef}
value={firstValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value, secondValue);
}}
/>
<InplaceInputTextEditMode
placeholder={secondValuePlaceholder}
ref={secondValueInputRef}
value={secondValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onChange(firstValue, event.target.value);
}}
/>
</StyledContainer>
);
}

View File

@ -0,0 +1,49 @@
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import { InplaceInputPhoneDisplayMode } from '@/ui/display/component/InplaceInputPhoneDisplayMode';
import { InplaceInputTextEditMode } from '@/ui/inplace-input/components/InplaceInputTextEditMode';
import { EditableCell } from '../components/EditableCell';
type OwnProps = {
placeholder?: string;
value: string;
onChange: (updated: string) => void;
onSubmit?: () => void;
onCancel?: () => void;
};
export function EditableCellPhone({
value,
placeholder,
onChange,
onSubmit,
onCancel,
}: OwnProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useState(value);
useEffect(() => {
setInputValue(value);
}, [value]);
return (
<EditableCell
editModeContent={
<InplaceInputTextEditMode
autoFocus
placeholder={placeholder || ''}
ref={inputRef}
value={inputValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
onChange(event.target.value);
}}
/>
}
nonEditModeContent={<InplaceInputPhoneDisplayMode value={inputValue} />}
onSubmit={onSubmit}
onCancel={onCancel}
/>
);
}

View File

@ -0,0 +1,19 @@
import styled from '@emotion/styled';
export const EditableCellRelationCreateButton = styled.button`
align-items: center;
background: none;
border: none;
border-radius: ${({ theme }) => theme.border.radius.sm};
cursor: pointer;
display: flex;
font-family: 'Inter';
font-size: ${({ theme }) => theme.font.size.md};
gap: ${({ theme }) => theme.spacing(2)};
height: 31px;
padding-bottom: ${({ theme }) => theme.spacing(1)};
padding-left: ${({ theme }) => theme.spacing(1)};
padding-top: ${({ theme }) => theme.spacing(1)};
user-select: none;
width: 100%;
`;

View File

@ -0,0 +1,61 @@
import { ChangeEvent, useEffect, useState } from 'react';
import { InplaceInputTextDisplayMode } from '@/ui/display/component/InplaceInputTextDisplayMode';
import { InplaceInputTextEditMode } from '@/ui/inplace-input/components/InplaceInputTextEditMode';
import { CellSkeleton } from '../components/CellSkeleton';
import { EditableCell } from '../components/EditableCell';
type OwnProps = {
placeholder?: string;
value: string;
onChange: (newValue: string) => void;
editModeHorizontalAlign?: 'left' | 'right';
loading?: boolean;
onSubmit?: () => void;
onCancel?: () => void;
};
export function EditableCellText({
value,
placeholder,
onChange,
editModeHorizontalAlign,
loading,
onCancel,
onSubmit,
}: OwnProps) {
const [internalValue, setInternalValue] = useState(value);
useEffect(() => {
setInternalValue(value);
}, [value]);
return (
<EditableCell
editModeHorizontalAlign={editModeHorizontalAlign}
editModeContent={
<InplaceInputTextEditMode
placeholder={placeholder || ''}
autoFocus
value={internalValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setInternalValue(event.target.value);
onChange(event.target.value);
}}
/>
}
onSubmit={onSubmit}
onCancel={onCancel}
nonEditModeContent={
loading ? (
<CellSkeleton />
) : (
<InplaceInputTextDisplayMode>
{internalValue}
</InplaceInputTextDisplayMode>
)
}
></EditableCell>
);
}

View File

@ -0,0 +1,112 @@
import {
ChangeEvent,
ComponentType,
ReactNode,
useEffect,
useRef,
useState,
} from 'react';
import styled from '@emotion/styled';
import { textInputStyle } from '@/ui/themes/effects';
import { EditableCell } from '../components/EditableCell';
export type EditableChipProps = {
id: string;
placeholder?: string;
value: string;
picture: string;
changeHandler: (updated: string) => void;
editModeHorizontalAlign?: 'left' | 'right';
ChipComponent: ComponentType<{
id: string;
name: string;
picture: string;
isOverlapped?: boolean;
}>;
commentThreadCount?: number;
onCommentClick?: (event: React.MouseEvent<HTMLDivElement>) => void;
rightEndContents?: ReactNode[];
onSubmit?: () => void;
onCancel?: () => void;
};
// TODO: refactor
const StyledInplaceInput = styled.input`
width: 100%;
${textInputStyle}
`;
const NoEditModeContainer = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
width: 100%;
`;
const RightContainer = styled.div`
margin-left: ${(props) => props.theme.spacing(1)};
`;
// TODO: move right end content in EditableCell
export function EditableCellChip({
id,
value,
placeholder,
changeHandler,
picture,
editModeHorizontalAlign,
ChipComponent,
rightEndContents,
onSubmit,
onCancel,
}: EditableChipProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useState(value);
useEffect(() => {
setInputValue(value);
}, [value]);
const handleRightEndContentClick = (
event: React.MouseEvent<HTMLDivElement>,
) => {
event.stopPropagation();
};
return (
<EditableCell
editModeHorizontalAlign={editModeHorizontalAlign}
editModeContent={
<StyledInplaceInput
placeholder={placeholder || ''}
autoFocus
ref={inputRef}
value={inputValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
changeHandler(event.target.value);
}}
/>
}
onSubmit={onSubmit}
onCancel={onCancel}
nonEditModeContent={
<NoEditModeContainer>
<ChipComponent id={id} name={inputValue} picture={picture} />
<RightContainer>
{rightEndContents &&
rightEndContents.length > 0 &&
rightEndContents.map((content, index) => (
<div key={index} onClick={handleRightEndContentClick}>
{content}
</div>
))}
</RightContainer>
</NoEditModeContainer>
}
/>
);
}

View File

@ -0,0 +1,22 @@
import { useRecoilCallback } from 'recoil';
import { currentCellInEditModePositionState } from '../states/currentCellInEditModePositionState';
import { isCellInEditModeFamilyState } from '../states/isCellInEditModeFamilyState';
import { isSomeInputInEditModeState } from '../states/isSomeInputInEditModeState';
export function useCloseCurrentCellInEditMode() {
return useRecoilCallback(({ set, snapshot }) => {
return async () => {
const currentCellInEditModePosition = await snapshot.getPromise(
currentCellInEditModePositionState,
);
set(isCellInEditModeFamilyState(currentCellInEditModePosition), false);
// TODO: find a way to remove this
await new Promise((resolve) => setTimeout(resolve, 20));
set(isSomeInputInEditModeState, false);
};
}, []);
}

View File

@ -0,0 +1,18 @@
import { useRecoilScopedValue } from '@/ui/recoil-scope/hooks/useRecoilScopedValue';
import { currentRowEntityIdScopedState } from '../states/currentRowEntityIdScopedState';
import { RowContext } from '../states/RowContext';
export type TableDimensions = {
numberOfColumns: number;
numberOfRows: number;
};
export function useCurrentRowEntityId() {
const currentRowEntityIdScoped = useRecoilScopedValue(
currentRowEntityIdScopedState,
RowContext,
);
return currentRowEntityIdScoped;
}

View File

@ -0,0 +1,36 @@
import { useRecoilCallback, useRecoilState } from 'recoil';
import { isRowSelectedFamilyState } from '../states/isRowSelectedFamilyState';
import { useCurrentRowEntityId } from './useCurrentEntityId';
export function useCurrentRowSelected() {
const currentRowId = useCurrentRowEntityId();
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,19 @@
import { useRecoilCallback } from 'recoil';
import { isSoftFocusActiveState } from '../states/isSoftFocusActiveState';
import { isSoftFocusOnCellFamilyState } from '../states/isSoftFocusOnCellFamilyState';
import { softFocusPositionState } from '../states/softFocusPositionState';
export function useDisableSoftFocus() {
return useRecoilCallback(({ set, snapshot }) => {
return () => {
const currentPosition = snapshot
.getLoadable(softFocusPositionState)
.valueOrThrow();
set(isSoftFocusActiveState, false);
set(isSoftFocusOnCellFamilyState(currentPosition), false);
};
}, []);
}

View File

@ -0,0 +1,35 @@
import { useEffect } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { entityTableDimensionsState } from '../states/entityTableDimensionsState';
import { tableRowIdsState } from '../states/tableRowIdsState';
import { useResetTableRowSelection } from './useResetTableRowSelection';
export type TableDimensions = {
numberOfColumns: number;
numberOfRows: number;
};
export function useInitializeEntityTable({
numberOfColumns,
}: {
numberOfColumns: number;
}) {
const resetTableRowSelection = useResetTableRowSelection();
const tableRowIds = useRecoilValue(tableRowIdsState);
useEffect(() => {
resetTableRowSelection();
}, [resetTableRowSelection]);
const [, setTableDimensions] = useRecoilState(entityTableDimensionsState);
useEffect(() => {
setTableDimensions({
numberOfColumns,
numberOfRows: tableRowIds?.length,
});
}, [tableRowIds, numberOfColumns, setTableDimensions]);
}

View File

@ -0,0 +1,22 @@
import { useEffect } from 'react';
import { availableFiltersScopedState } from '@/ui/filter-n-sort/states/availableFiltersScopedState';
import { FilterDefinition } from '@/ui/filter-n-sort/types/FilterDefinition';
import { useRecoilScopedState } from '@/ui/recoil-scope/hooks/useRecoilScopedState';
import { TableContext } from '../states/TableContext';
export function useInitializeEntityTableFilters({
availableFilters,
}: {
availableFilters: FilterDefinition[];
}) {
const [, setAvailableFilters] = useRecoilScopedState(
availableFiltersScopedState,
TableContext,
);
useEffect(() => {
setAvailableFilters(availableFilters);
}, [setAvailableFilters, availableFilters]);
}

View File

@ -0,0 +1,53 @@
import { useRecoilCallback } from 'recoil';
import { useSetHotkeyScope } from '@/ui/hotkey/hooks/useSetHotkeyScope';
import { currentHotkeyScopeState } from '@/ui/hotkey/states/internal/currentHotkeyScopeState';
import { isSoftFocusActiveState } from '../states/isSoftFocusActiveState';
import { isSomeInputInEditModeState } from '../states/isSomeInputInEditModeState';
import { TableHotkeyScope } from '../types/TableHotkeyScope';
import { useCloseCurrentCellInEditMode } from './useClearCellInEditMode';
import { useDisableSoftFocus } from './useDisableSoftFocus';
export function useLeaveTableFocus() {
const disableSoftFocus = useDisableSoftFocus();
const closeCurrentCellInEditMode = useCloseCurrentCellInEditMode();
const setHotkeyScope = useSetHotkeyScope();
return useRecoilCallback(
({ snapshot }) =>
() => {
const isSoftFocusActive = snapshot
.getLoadable(isSoftFocusActiveState)
.valueOrThrow();
const isSomeInputInEditMode = snapshot
.getLoadable(isSomeInputInEditModeState)
.valueOrThrow();
const currentHotkeyScope = snapshot
.getLoadable(currentHotkeyScopeState)
.valueOrThrow();
if (isSomeInputInEditMode) {
return;
}
if (!isSoftFocusActive && !isSomeInputInEditMode) {
return;
}
if (currentHotkeyScope?.scope === TableHotkeyScope.Table) {
return;
}
closeCurrentCellInEditMode();
disableSoftFocus();
setHotkeyScope(TableHotkeyScope.Table, { goto: true });
},
[setHotkeyScope, closeCurrentCellInEditMode, disableSoftFocus],
);
}

View File

@ -0,0 +1,74 @@
import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { useScopedHotkeys } from '@/ui/hotkey/hooks/useScopedHotkeys';
import { useSetHotkeyScope } from '@/ui/hotkey/hooks/useSetHotkeyScope';
import { isSomeInputInEditModeState } from '../states/isSomeInputInEditModeState';
import { TableHotkeyScope } from '../types/TableHotkeyScope';
import { useDisableSoftFocus } from './useDisableSoftFocus';
import { useMoveSoftFocus } from './useMoveSoftFocus';
export function useMapKeyboardToSoftFocus() {
const { moveDown, moveLeft, moveRight, moveUp } = useMoveSoftFocus();
const disableSoftFocus = useDisableSoftFocus();
const setHotkeyScope = useSetHotkeyScope();
const [isSomeInputInEditMode] = useRecoilState(isSomeInputInEditModeState);
useScopedHotkeys(
[Key.ArrowUp, `${Key.Shift}+${Key.Enter}`],
() => {
if (!isSomeInputInEditMode) {
moveUp();
}
},
TableHotkeyScope.TableSoftFocus,
[moveUp, isSomeInputInEditMode],
);
useScopedHotkeys(
Key.ArrowDown,
() => {
if (!isSomeInputInEditMode) {
moveDown();
}
},
TableHotkeyScope.TableSoftFocus,
[moveDown, isSomeInputInEditMode],
);
useScopedHotkeys(
[Key.ArrowLeft, `${Key.Shift}+${Key.Tab}`],
() => {
if (!isSomeInputInEditMode) {
moveLeft();
}
},
TableHotkeyScope.TableSoftFocus,
[moveLeft, isSomeInputInEditMode],
);
useScopedHotkeys(
[Key.ArrowRight, Key.Tab],
() => {
if (!isSomeInputInEditMode) {
moveRight();
}
},
TableHotkeyScope.TableSoftFocus,
[moveRight, isSomeInputInEditMode],
);
useScopedHotkeys(
[Key.Escape],
() => {
setHotkeyScope(TableHotkeyScope.Table, { goto: true });
disableSoftFocus();
},
TableHotkeyScope.TableSoftFocus,
[disableSoftFocus],
);
}

View File

@ -0,0 +1,21 @@
import { useRecoilCallback } from 'recoil';
import { currentCellInEditModePositionState } from '../states/currentCellInEditModePositionState';
import { isCellInEditModeFamilyState } from '../states/isCellInEditModeFamilyState';
import { CellPosition } from '../types/CellPosition';
export function useMoveEditModeToCellPosition() {
return useRecoilCallback(({ set, snapshot }) => {
return (newPosition: CellPosition) => {
const currentCellInEditModePosition = snapshot
.getLoadable(currentCellInEditModePositionState)
.valueOrThrow();
set(isCellInEditModeFamilyState(currentCellInEditModePosition), false);
set(currentCellInEditModePositionState, newPosition);
set(isCellInEditModeFamilyState(newPosition), true);
};
}, []);
}

View File

@ -0,0 +1,154 @@
import { useRecoilCallback } from 'recoil';
import { numberOfTableColumnsSelectorState } from '../states/numberOfTableColumnsSelectorState';
import { numberOfTableRowsSelectorState } from '../states/numberOfTableRowsSelectorState';
import { softFocusPositionState } from '../states/softFocusPositionState';
import { useSetSoftFocusPosition } from './useSetSoftFocusPosition';
// TODO: stories
export function useMoveSoftFocus() {
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(numberOfTableRowsSelectorState)
.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(numberOfTableColumnsSelectorState)
.valueOrThrow();
const numberOfTableRows = snapshot
.getLoadable(numberOfTableRowsSelectorState)
.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,
});
}
},
[setSoftFocusPosition],
);
const moveLeft = useRecoilCallback(
({ snapshot }) =>
() => {
const softFocusPosition = snapshot
.getLoadable(softFocusPositionState)
.valueOrThrow();
const numberOfTableColumns = snapshot
.getLoadable(numberOfTableColumnsSelectorState)
.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,
});
}
},
[setSoftFocusPosition],
);
return {
moveDown,
moveLeft,
moveRight,
moveUp,
};
}

View File

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

View File

@ -0,0 +1,41 @@
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { allRowsSelectedStatusSelector } from '../states/allRowsSelectedStatusSelector';
import { isRowSelectedFamilyState } from '../states/isRowSelectedFamilyState';
import { tableRowIdsState } from '../states/tableRowIdsState';
export function useSelectAllRows() {
const allRowsSelectedStatus = useRecoilValue(allRowsSelectedStatusSelector);
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 {
allRowsSelectedStatus,
selectAllRows,
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,11 @@
import { atom } from 'recoil';
import { PositionType } from '@/ui/action-bar/types/PositionType';
export const contextMenuPositionState = atom<PositionType>({
key: 'contextMenuPositionState',
default: {
x: null,
y: null,
},
});

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,11 @@
import { atom } from 'recoil';
import { TableDimensions } from '../hooks/useInitializeEntityTable';
export const entityTableDimensionsState = atom<TableDimensions>({
key: 'entityTableDimensionsState',
default: {
numberOfRows: 0,
numberOfColumns: 0,
},
});

View File

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

View File

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

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,6 @@
import { atom } from 'recoil';
export const isSoftFocusActiveState = atom<boolean>({
key: 'isSoftFocusActiveState',
default: false,
});

View File

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

View File

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

View File

@ -0,0 +1,12 @@
import { selector } from 'recoil';
import { entityTableDimensionsState } from './entityTableDimensionsState';
export const numberOfTableColumnsSelectorState = selector<number>({
key: 'numberOfTableColumnsState',
get: ({ get }) => {
const { numberOfColumns } = get(entityTableDimensionsState);
return numberOfColumns;
},
});

View File

@ -0,0 +1,12 @@
import { selector } from 'recoil';
import { entityTableDimensionsState } from './entityTableDimensionsState';
export const numberOfTableRowsSelectorState = selector<number>({
key: 'numberOfTableRowsState',
get: ({ get }) => {
const { numberOfRows } = get(entityTableDimensionsState);
return numberOfRows;
},
});

View File

@ -0,0 +1,15 @@
import { selector } from 'recoil';
import { isRowSelectedFamilyState } from './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,11 @@
import { atom } from 'recoil';
import { CellPosition } from '../types/CellPosition';
export const softFocusPositionState = atom<CellPosition>({
key: 'softFocusPositionState',
default: {
row: 0,
column: 1,
},
});

View File

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

View File

@ -0,0 +1,131 @@
import { ReactNode, useCallback, useState } from 'react';
import styled from '@emotion/styled';
import { FilterDropdownButton } from '@/ui/filter-n-sort/components/FilterDropdownButton';
import SortAndFilterBar from '@/ui/filter-n-sort/components/SortAndFilterBar';
import { SortDropdownButton } from '@/ui/filter-n-sort/components/SortDropdownButton';
import { FiltersHotkeyScope } from '@/ui/filter-n-sort/types/FiltersHotkeyScope';
import { SelectedSortType, SortType } from '@/ui/filter-n-sort/types/interface';
import { TableContext } from '../../states/TableContext';
type OwnProps<SortField> = {
viewName: string;
viewIcon?: ReactNode;
availableSorts?: Array<SortType<SortField>>;
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
};
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
`;
const StyledTableHeader = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.secondary};
display: flex;
flex-direction: row;
font-weight: ${({ theme }) => theme.font.weight.medium};
height: 40px;
justify-content: space-between;
padding-left: ${({ theme }) => theme.spacing(3)};
padding-right: ${({ theme }) => theme.spacing(2)};
`;
const StyledIcon = styled.div`
display: flex;
margin-left: ${({ theme }) => theme.spacing(1)};
margin-right: ${({ theme }) => theme.spacing(2)};
& > svg {
font-size: ${({ theme }) => theme.icon.size.sm};
}
`;
const StyledViewSection = styled.div`
display: flex;
`;
const StyledFilters = styled.div`
display: flex;
font-weight: ${({ theme }) => theme.font.weight.regular};
gap: 2px;
`;
export function TableHeader<SortField>({
viewName,
viewIcon,
availableSorts,
onSortsUpdate,
}: OwnProps<SortField>) {
const [sorts, innerSetSorts] = useState<Array<SelectedSortType<SortField>>>(
[],
);
const sortSelect = useCallback(
(newSort: SelectedSortType<SortField>) => {
const newSorts = updateSortOrFilterByKey(sorts, newSort);
innerSetSorts(newSorts);
onSortsUpdate && onSortsUpdate(newSorts);
},
[onSortsUpdate, sorts],
);
const sortUnselect = useCallback(
(sortKey: string) => {
const newSorts = sorts.filter((sort) => sort.key !== sortKey);
innerSetSorts(newSorts);
onSortsUpdate && onSortsUpdate(newSorts);
},
[onSortsUpdate, sorts],
);
return (
<StyledContainer>
<StyledTableHeader>
<StyledViewSection>
<StyledIcon>{viewIcon}</StyledIcon>
{viewName}
</StyledViewSection>
<StyledFilters>
<FilterDropdownButton
context={TableContext}
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
/>
<SortDropdownButton<SortField>
isSortSelected={sorts.length > 0}
availableSorts={availableSorts || []}
onSortSelect={sortSelect}
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
/>
</StyledFilters>
</StyledTableHeader>
<SortAndFilterBar
context={TableContext}
sorts={sorts}
onRemoveSort={sortUnselect}
onCancelClick={() => {
innerSetSorts([]);
onSortsUpdate && onSortsUpdate([]);
}}
/>
</StyledContainer>
);
}
function updateSortOrFilterByKey<SortOrFilter extends { key: string }>(
sorts: Readonly<SortOrFilter[]>,
newSort: SortOrFilter,
): SortOrFilter[] {
const newSorts = [...sorts];
const existingSortIndex = sorts.findIndex((sort) => sort.key === newSort.key);
if (existingSortIndex !== -1) {
newSorts[existingSortIndex] = newSort;
} else {
newSorts.push(newSort);
}
return newSorts;
}

View File

@ -0,0 +1,68 @@
import type { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';
import { IconList } from '@/ui/icon/index';
import { availableSorts } from '~/pages/companies/companies-sorts';
import { getRenderWrapperForEntityTableComponent } from '~/testing/renderWrappers';
import { TableHeader } from '../TableHeader';
const meta: Meta<typeof TableHeader> = {
title: 'UI/Table/TableHeader',
component: TableHeader,
};
export default meta;
type Story = StoryObj<typeof TableHeader>;
export const Empty: Story = {
render: getRenderWrapperForEntityTableComponent(
<TableHeader
viewName="ViewName"
viewIcon={<IconList />}
availableSorts={availableSorts}
/>,
),
};
export const WithSortsAndFilters: Story = {
render: getRenderWrapperForEntityTableComponent(
<TableHeader
viewName="ViewName"
viewIcon={<IconList />}
availableSorts={availableSorts}
/>,
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const outsideClick = await canvas.findByText('ViewName');
userEvent.click(await canvas.findByText('Filter'));
userEvent.click(await canvas.findByText('Name'));
const nameInput = await canvas.findByPlaceholderText('Name');
userEvent.type(nameInput, 'My name');
userEvent.click(outsideClick);
userEvent.click(await canvas.findByText('Sort'));
userEvent.click(await canvas.findByText('Name'));
userEvent.click(await canvas.findByText('Sort'));
userEvent.click(await canvas.findByText('Creation'));
userEvent.click(await canvas.findByText('Sort'));
userEvent.click(await canvas.findByText('Address'));
userEvent.click(await canvas.findByText('Filter'));
userEvent.click(await canvas.findByText('Employees'));
const employeesInput = await canvas.findByPlaceholderText('Employees');
userEvent.type(employeesInput, '12');
userEvent.click(await canvas.findByText('Sort'));
userEvent.click(await canvas.findByText('Url'));
userEvent.click(await canvas.findByText('Filter'));
userEvent.click(await canvas.findByText('Created at'));
userEvent.click(await canvas.findByText('6'));
userEvent.click(outsideClick);
},
};

View File

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

View File

@ -0,0 +1,4 @@
export type CellPosition = {
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,7 @@
import { CellPosition } from '../CellPosition';
export function isTablePosition(value: any): value is CellPosition {
return (
value && typeof value.row === 'number' && typeof value.column === 'number'
);
}