Merge commit 'cd3a32e55503dc1e6b9873d812dd401bf7d51045' into context-menu-vertical

This commit is contained in:
brendanlaschke
2023-08-14 22:00:49 +02:00
179 changed files with 1971 additions and 3230 deletions

View File

@ -1,16 +1,19 @@
import { useRef } from 'react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import type {
ViewFieldDefinition,
ViewFieldMetadata,
} from '@/ui/editable-field/types/ViewField';
import { SelectedSortType, SortType } from '@/ui/filter-n-sort/types/interface';
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useLeaveTableFocus } from '../hooks/useLeaveTableFocus';
import { useMapKeyboardToSoftFocus } from '../hooks/useMapKeyboardToSoftFocus';
import { useResetTableRowSelection } from '../hooks/useResetTableRowSelection';
import { useSetRowSelectedState } from '../hooks/useSetRowSelectedState';
import { EntityUpdateMutationHookContext } from '../states/EntityUpdateMutationHookContext';
import { tableRowIdsState } from '../states/tableRowIdsState';
import { EntityUpdateMutationContext } from '../states/EntityUpdateMutationHookContext';
import { TableHeader } from '../table-header/components/TableHeader';
import { EntityTableBody } from './EntityTableBody';
@ -25,6 +28,8 @@ const StyledTable = styled.table`
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;
@ -90,28 +95,24 @@ type OwnProps<SortField> = {
viewName: string;
viewIcon?: React.ReactNode;
availableSorts?: Array<SortType<SortField>>;
onColumnsChange?: (columns: ViewFieldDefinition<ViewFieldMetadata>[]) => void;
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
onRowSelectionChange?: (rowSelection: string[]) => void;
useUpdateEntityMutation: any;
updateEntityMutation: any;
};
export function EntityTable<SortField>({
viewName,
viewIcon,
availableSorts,
onColumnsChange,
onSortsUpdate,
useUpdateEntityMutation,
updateEntityMutation,
}: OwnProps<SortField>) {
const tableBodyRef = useRef<HTMLDivElement>(null);
const rowIds = useRecoilValue(tableRowIdsState);
const setRowSelectedState = useSetRowSelectedState();
function resetSelections() {
for (const rowId of rowIds) {
setRowSelectedState(rowId, false);
}
}
const resetTableRowSelection = useResetTableRowSelection();
useMapKeyboardToSoftFocus();
@ -125,28 +126,29 @@ export function EntityTable<SortField>({
});
return (
<EntityUpdateMutationHookContext.Provider value={useUpdateEntityMutation}>
<EntityUpdateMutationContext.Provider value={updateEntityMutation}>
<StyledTableWithHeader>
<StyledTableContainer ref={tableBodyRef}>
<TableHeader
viewName={viewName}
viewIcon={viewIcon}
availableSorts={availableSorts}
onColumnsChange={onColumnsChange}
onSortsUpdate={onSortsUpdate}
/>
<StyledTableWrapper>
<StyledTable>
<EntityTableHeader />
<EntityTableHeader onColumnsChange={onColumnsChange} />
<EntityTableBody />
</StyledTable>
</StyledTableWrapper>
<DragSelect
dragSelectable={tableBodyRef}
onDragSelectionStart={resetSelections}
onDragSelectionStart={resetTableRowSelection}
onDragSelectionChange={setRowSelectedState}
/>
</StyledTableContainer>
</StyledTableWithHeader>
</EntityUpdateMutationHookContext.Provider>
</EntityUpdateMutationContext.Provider>
);
}

View File

@ -1,6 +1,7 @@
import { cloneElement, ComponentProps, useRef } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { IconButton } from '@/ui/button/components/IconButton';
import { DropdownMenu } from '@/ui/dropdown/components/DropdownMenu';
@ -9,32 +10,27 @@ import { DropdownMenuItemsContainer } from '@/ui/dropdown/components/DropdownMen
import { IconPlus } from '@/ui/icon';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import type {
ViewFieldDefinition,
ViewFieldMetadata,
} from '../../editable-field/types/ViewField';
import { hiddenTableColumnsState } from '../states/tableColumnsState';
const StyledColumnMenu = styled(DropdownMenu)`
font-weight: ${({ theme }) => theme.font.weight.regular};
`;
type EntityTableColumnMenuProps = {
onAddViewField: (
viewFieldDefinition: ViewFieldDefinition<ViewFieldMetadata>,
) => void;
onAddColumn: (columnId: string) => void;
onClickOutside?: () => void;
viewFieldDefinitions: ViewFieldDefinition<ViewFieldMetadata>[];
} & ComponentProps<'div'>;
export const EntityTableColumnMenu = ({
onAddViewField,
onAddColumn,
onClickOutside = () => undefined,
viewFieldDefinitions,
...props
}: EntityTableColumnMenuProps) => {
const ref = useRef<HTMLDivElement>(null);
const theme = useTheme();
const hiddenColumns = useRecoilValue(hiddenTableColumnsState);
useListenClickOutside({
refs: [ref],
callback: onClickOutside,
@ -43,21 +39,21 @@ export const EntityTableColumnMenu = ({
return (
<StyledColumnMenu {...props} ref={ref}>
<DropdownMenuItemsContainer>
{viewFieldDefinitions.map((viewFieldDefinition) => (
{hiddenColumns.map((column) => (
<DropdownMenuItem
key={viewFieldDefinition.id}
key={column.id}
actions={
<IconButton
icon={<IconPlus size={theme.icon.size.sm} />}
onClick={() => onAddViewField(viewFieldDefinition)}
onClick={() => onAddColumn(column.id)}
/>
}
>
{viewFieldDefinition.columnIcon &&
cloneElement(viewFieldDefinition.columnIcon, {
{column.columnIcon &&
cloneElement(column.columnIcon, {
size: theme.icon.size.md,
})}
{viewFieldDefinition.columnLabel}
{column.columnLabel}
</DropdownMenuItem>
))}
</DropdownMenuItemsContainer>

View File

@ -1,5 +1,4 @@
import { useCallback, useState } from 'react';
import { getOperationName } from '@apollo/client/utilities';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilCallback, useRecoilState, useRecoilValue } from 'recoil';
@ -11,21 +10,14 @@ import type {
} from '@/ui/editable-field/types/ViewField';
import { IconPlus } from '@/ui/icon';
import { useTrackPointer } from '@/ui/utilities/pointer-event/hooks/useTrackPointer';
import { GET_VIEW_FIELDS } from '@/views/queries/select';
import { currentViewIdState } from '@/views/states/currentViewIdState';
import {
useCreateViewFieldMutation,
useUpdateViewFieldMutation,
} from '~/generated/graphql';
import { toViewFieldInput } from '../hooks/useLoadViewFields';
import { resizeFieldOffsetState } from '../states/resizeFieldOffsetState';
import {
addableViewFieldDefinitionsState,
columnWidthByViewFieldIdState,
viewFieldsState,
visibleViewFieldsState,
} from '../states/viewFieldsState';
hiddenTableColumnsState,
tableColumnsByIdState,
tableColumnsState,
visibleTableColumnsState,
} from '../states/tableColumnsState';
import { ColumnHead } from './ColumnHead';
import { EntityTableColumnMenu } from './EntityTableColumnMenu';
@ -86,17 +78,18 @@ const StyledEntityTableColumnMenu = styled(EntityTableColumnMenu)`
z-index: ${({ theme }) => theme.lastLayerZIndex};
`;
export function EntityTableHeader() {
export type EntityTableHeaderProps = {
onColumnsChange?: (columns: ViewFieldDefinition<ViewFieldMetadata>[]) => void;
};
export function EntityTableHeader({ onColumnsChange }: EntityTableHeaderProps) {
const theme = useTheme();
const [{ objectName }, setViewFieldsState] = useRecoilState(viewFieldsState);
const currentViewId = useRecoilValue(currentViewIdState);
const viewFields = useRecoilValue(visibleViewFieldsState);
const columnWidths = useRecoilValue(columnWidthByViewFieldIdState);
const addableViewFieldDefinitions = useRecoilValue(
addableViewFieldDefinitionsState,
);
const [columns, setColumns] = useRecoilState(tableColumnsState);
const [offset, setOffset] = useRecoilState(resizeFieldOffsetState);
const columnsById = useRecoilValue(tableColumnsByIdState);
const hiddenColumns = useRecoilValue(hiddenTableColumnsState);
const visibleColumns = useRecoilValue(visibleTableColumnsState);
const [initialPointerPositionX, setInitialPointerPositionX] = useState<
number | null
@ -104,9 +97,6 @@ export function EntityTableHeader() {
const [resizedFieldId, setResizedFieldId] = useState<string | null>(null);
const [isColumnMenuOpen, setIsColumnMenuOpen] = useState(false);
const [createViewFieldMutation] = useCreateViewFieldMutation();
const [updateViewFieldMutation] = useUpdateViewFieldMutation();
const handleResizeHandlerStart = useCallback((positionX: number) => {
setInitialPointerPositionX(positionX);
}, []);
@ -126,37 +116,28 @@ export function EntityTableHeader() {
const nextWidth = Math.round(
Math.max(
columnWidths[resizedFieldId] +
columnsById[resizedFieldId].columnSize +
snapshot.getLoadable(resizeFieldOffsetState).valueOrThrow(),
COLUMN_MIN_WIDTH,
),
);
if (nextWidth !== columnWidths[resizedFieldId]) {
// Optimistic update to avoid "bouncing width" visual effect on resize.
setViewFieldsState((previousState) => ({
...previousState,
viewFields: previousState.viewFields.map((viewField) =>
viewField.id === resizedFieldId
? { ...viewField, columnSize: nextWidth }
: viewField,
),
}));
if (nextWidth !== columnsById[resizedFieldId].columnSize) {
const nextColumns = columns.map((column) =>
column.id === resizedFieldId
? { ...column, columnSize: nextWidth }
: column,
);
updateViewFieldMutation({
variables: {
data: { sizeInPx: nextWidth },
where: { id: resizedFieldId },
},
refetchQueries: [getOperationName(GET_VIEW_FIELDS) ?? ''],
});
setColumns(nextColumns);
onColumnsChange?.(nextColumns);
}
set(resizeFieldOffsetState, 0);
setInitialPointerPositionX(null);
setResizedFieldId(null);
},
[resizedFieldId, columnWidths, setResizedFieldId],
[resizedFieldId, columnsById, setResizedFieldId],
);
useTrackPointer({
@ -170,26 +151,18 @@ export function EntityTableHeader() {
setIsColumnMenuOpen((previousValue) => !previousValue);
}, []);
const handleAddViewField = useCallback(
(viewFieldDefinition: ViewFieldDefinition<ViewFieldMetadata>) => {
const handleAddColumn = useCallback(
(columnId: string) => {
setIsColumnMenuOpen(false);
if (!objectName) return;
const nextColumns = columns.map((column) =>
column.id === columnId ? { ...column, isVisible: true } : column,
);
createViewFieldMutation({
variables: {
data: {
...toViewFieldInput(objectName, {
...viewFieldDefinition,
columnOrder: viewFields.length + 1,
}),
view: { connect: { id: currentViewId } },
},
},
refetchQueries: [getOperationName(GET_VIEW_FIELDS) ?? ''],
});
setColumns(nextColumns);
onColumnsChange?.(nextColumns);
},
[createViewFieldMutation, currentViewId, objectName, viewFields.length],
[columns, onColumnsChange, setColumns],
);
return (
@ -205,31 +178,31 @@ export function EntityTableHeader() {
<SelectAllCheckbox />
</th>
{viewFields.map((viewField) => (
{visibleColumns.map((column) => (
<StyledColumnHeaderCell
key={viewField.id}
isResizing={resizedFieldId === viewField.id}
key={column.id}
isResizing={resizedFieldId === column.id}
columnWidth={Math.max(
columnWidths[viewField.id] +
(resizedFieldId === viewField.id ? offset : 0),
columnsById[column.id].columnSize +
(resizedFieldId === column.id ? offset : 0),
COLUMN_MIN_WIDTH,
)}
>
<ColumnHead
viewName={viewField.columnLabel}
viewIcon={viewField.columnIcon}
viewName={column.columnLabel}
viewIcon={column.columnIcon}
/>
<StyledResizeHandler
className="cursor-col-resize"
role="separator"
onPointerDown={() => {
setResizedFieldId(viewField.id);
setResizedFieldId(column.id);
}}
/>
</StyledColumnHeaderCell>
))}
<th>
{addableViewFieldDefinitions.length > 0 && (
{hiddenColumns.length > 0 && (
<StyledAddIconButtonWrapper>
<StyledAddIconButton
size="large"
@ -238,9 +211,8 @@ export function EntityTableHeader() {
/>
{isColumnMenuOpen && (
<StyledEntityTableColumnMenu
onAddViewField={handleAddViewField}
onAddColumn={handleAddColumn}
onClickOutside={toggleColumnMenu}
viewFieldDefinitions={addableViewFieldDefinitions}
/>
)}
</StyledAddIconButtonWrapper>

View File

@ -1,8 +1,8 @@
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { visibleTableColumnsState } from '../states/tableColumnsState';
import { ViewFieldContext } from '../states/ViewFieldContext';
import { visibleViewFieldsState } from '../states/viewFieldsState';
import { CheckboxCell } from './CheckboxCell';
import { EntityTableCell } from './EntityTableCell';
@ -13,7 +13,7 @@ const StyledRow = styled.tr<{ selected: boolean }>`
`;
export function EntityTableRow({ rowId }: { rowId: string }) {
const viewFields = useRecoilValue(visibleViewFieldsState);
const columns = useRecoilValue(visibleTableColumnsState);
return (
<StyledRow
@ -24,9 +24,9 @@ export function EntityTableRow({ rowId }: { rowId: string }) {
<td>
<CheckboxCell />
</td>
{viewFields.map((viewField, columnIndex) => {
{columns.map((column, columnIndex) => {
return (
<ViewFieldContext.Provider value={viewField} key={viewField.id}>
<ViewFieldContext.Provider value={column} key={column.id}>
<EntityTableCell cellIndex={columnIndex} />
</ViewFieldContext.Provider>
);

View File

@ -1,34 +1,21 @@
import { defaultOrderBy } from '@/people/queries';
import {
ViewFieldDefinition,
ViewFieldMetadata,
} from '@/ui/editable-field/types/ViewField';
import { FilterDefinition } from '@/ui/filter-n-sort/types/FilterDefinition';
import { useSetEntityTableData } from '@/ui/table/hooks/useSetEntityTableData';
import { useLoadViewFields } from '../hooks/useLoadViewFields';
export function GenericEntityTableData({
objectName,
useGetRequest,
getRequestResultKey,
orderBy = defaultOrderBy,
whereFilters,
viewFieldDefinitions,
filterDefinitionArray,
}: {
objectName: 'company' | 'person';
useGetRequest: any;
getRequestResultKey: string;
orderBy?: any;
whereFilters?: any;
viewFieldDefinitions: ViewFieldDefinition<ViewFieldMetadata>[];
filterDefinitionArray: FilterDefinition[];
}) {
const setEntityTableData = useSetEntityTableData();
useLoadViewFields({ objectName, viewFieldDefinitions });
useGetRequest({
variables: { orderBy, where: whereFilters },
onCompleted: (data: any) => {

View File

@ -1,19 +0,0 @@
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

@ -1,5 +1,6 @@
import { useContext } from 'react';
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';
@ -17,17 +18,20 @@ export function useEditableCell() {
const { setCurrentCellInEditMode } = useCurrentCellEditMode();
const setHotkeyScope = useSetHotkeyScope();
const { setDragSelectionStartEnabled } = useDragSelect();
const closeCurrentCellInEditMode = useCloseCurrentCellInEditMode();
const customCellHotkeyScope = useContext(CellHotkeyScopeContext);
function closeEditableCell() {
setDragSelectionStartEnabled(true);
closeCurrentCellInEditMode();
setHotkeyScope(TableHotkeyScope.TableSoftFocus);
}
function openEditableCell() {
setDragSelectionStartEnabled(false);
setCurrentCellInEditMode();
if (customCellHotkeyScope) {

View File

@ -2,11 +2,6 @@ import { useContext } from 'react';
import { RowIdContext } from '../states/RowIdContext';
export type TableDimensions = {
numberOfColumns: number;
numberOfRows: number;
};
export function useCurrentRowEntityId() {
const currentEntityId = useContext(RowIdContext);

View File

@ -1,35 +0,0 @@
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

@ -1,22 +0,0 @@
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/utilities/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

@ -1,96 +0,0 @@
import { getOperationName } from '@apollo/client/utilities';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import type {
ViewFieldDefinition,
ViewFieldMetadata,
ViewFieldTextMetadata,
} from '@/ui/editable-field/types/ViewField';
import { GET_VIEW_FIELDS } from '@/views/queries/select';
import { currentViewIdState } from '@/views/states/currentViewIdState';
import {
SortOrder,
useCreateViewFieldsMutation,
useGetViewFieldsQuery,
} from '~/generated/graphql';
import { entityTableDimensionsState } from '../states/entityTableDimensionsState';
import { viewFieldsState } from '../states/viewFieldsState';
const DEFAULT_VIEW_FIELD_METADATA: ViewFieldTextMetadata = {
type: 'text',
placeHolder: '',
fieldName: '',
};
export const toViewFieldInput = (
objectName: 'company' | 'person',
viewFieldDefinition: ViewFieldDefinition<ViewFieldMetadata>,
) => ({
fieldName: viewFieldDefinition.columnLabel,
index: viewFieldDefinition.columnOrder,
isVisible: viewFieldDefinition.isVisible ?? true,
objectName,
sizeInPx: viewFieldDefinition.columnSize,
});
export const useLoadViewFields = ({
objectName,
viewFieldDefinitions,
}: {
objectName: 'company' | 'person';
viewFieldDefinitions: ViewFieldDefinition<ViewFieldMetadata>[];
}) => {
const currentViewId = useRecoilValue(currentViewIdState);
const setEntityTableDimensions = useSetRecoilState(
entityTableDimensionsState,
);
const setViewFieldsState = useSetRecoilState(viewFieldsState);
const [createViewFieldsMutation] = useCreateViewFieldsMutation();
useGetViewFieldsQuery({
variables: {
orderBy: { index: SortOrder.Asc },
where: {
objectName: { equals: objectName },
viewId: { equals: currentViewId ?? null },
},
},
onCompleted: (data) => {
if (data.viewFields.length) {
const viewFields = data.viewFields.map<
ViewFieldDefinition<ViewFieldMetadata>
>((viewField) => ({
...(viewFieldDefinitions.find(
({ columnLabel }) => viewField.fieldName === columnLabel,
) || { metadata: DEFAULT_VIEW_FIELD_METADATA }),
id: viewField.id,
columnLabel: viewField.fieldName,
columnOrder: viewField.index,
columnSize: viewField.sizeInPx,
isVisible: viewField.isVisible,
}));
setViewFieldsState({ objectName, viewFields });
setEntityTableDimensions((prevState) => ({
...prevState,
numberOfColumns: data.viewFields.length,
}));
return;
}
// Populate if empty
createViewFieldsMutation({
variables: {
data: viewFieldDefinitions.map((viewFieldDefinition) => ({
...toViewFieldInput(objectName, viewFieldDefinition),
viewId: currentViewId,
})),
},
refetchQueries: [getOperationName(GET_VIEW_FIELDS) ?? ''],
});
},
});
};

View File

@ -1,8 +1,8 @@
import { useRecoilCallback } from 'recoil';
import { numberOfTableColumnsSelectorState } from '../states/numberOfTableColumnsSelectorState';
import { numberOfTableRowsSelectorState } from '../states/numberOfTableRowsSelectorState';
import { numberOfTableRowsState } from '../states/numberOfTableRowsState';
import { softFocusPositionState } from '../states/softFocusPositionState';
import { numberOfTableColumnsState } from '../states/tableColumnsState';
import { useSetSoftFocusPosition } from './useSetSoftFocusPosition';
@ -39,7 +39,7 @@ export function useMoveSoftFocus() {
.valueOrThrow();
const numberOfTableRows = snapshot
.getLoadable(numberOfTableRowsSelectorState)
.getLoadable(numberOfTableRowsState)
.valueOrThrow();
let newRowNumber = softFocusPosition.row + 1;
@ -64,11 +64,11 @@ export function useMoveSoftFocus() {
.valueOrThrow();
const numberOfTableColumns = snapshot
.getLoadable(numberOfTableColumnsSelectorState)
.getLoadable(numberOfTableColumnsState)
.valueOrThrow();
const numberOfTableRows = snapshot
.getLoadable(numberOfTableRowsSelectorState)
.getLoadable(numberOfTableRowsState)
.valueOrThrow();
const currentColumnNumber = softFocusPosition.column;
@ -112,7 +112,7 @@ export function useMoveSoftFocus() {
.valueOrThrow();
const numberOfTableColumns = snapshot
.getLoadable(numberOfTableColumnsSelectorState)
.getLoadable(numberOfTableColumnsState)
.valueOrThrow();
const currentColumnNumber = softFocusPosition.column;

View File

@ -3,13 +3,14 @@ import { useRecoilCallback } from 'recoil';
import { availableFiltersScopedState } from '@/ui/filter-n-sort/states/availableFiltersScopedState';
import { FilterDefinition } from '@/ui/filter-n-sort/types/FilterDefinition';
import { useResetTableRowSelection } from '@/ui/table/hooks/useResetTableRowSelection';
import { entityTableDimensionsState } from '@/ui/table/states/entityTableDimensionsState';
import { isFetchingEntityTableDataState } from '@/ui/table/states/isFetchingEntityTableDataState';
import { TableContext } from '@/ui/table/states/TableContext';
import { tableEntitiesFamilyState } from '@/ui/table/states/tableEntitiesFamilyState';
import { tableRowIdsState } from '@/ui/table/states/tableRowIdsState';
import { useContextScopeId } from '@/ui/utilities/recoil-scope/hooks/useContextScopeId';
import { isFetchingEntityTableDataState } from '../states/isFetchingEntityTableDataState';
import { numberOfTableRowsState } from '../states/numberOfTableRowsState';
export function useSetEntityTableData() {
const resetTableRowSelection = useResetTableRowSelection();
@ -43,10 +44,7 @@ export function useSetEntityTableData() {
resetTableRowSelection();
set(entityTableDimensionsState, (prevState) => ({
...prevState,
numberOfRows: entityIds.length,
}));
set(numberOfTableRowsState, entityIds.length);
set(availableFiltersScopedState(tableContextScopeId), filters);

View File

@ -0,0 +1,19 @@
import { useRecoilCallback } from 'recoil';
import { tableRowIdsState } from '../states/tableRowIdsState';
export function useSetTableRowIds() {
return useRecoilCallback(
({ set, snapshot }) =>
(rowIds: string[]) => {
const currentRowIds = snapshot
.getLoadable(tableRowIdsState)
.valueOrThrow();
if (JSON.stringify(rowIds) !== JSON.stringify(currentRowIds)) {
set(tableRowIdsState, rowIds);
}
},
[],
);
}

View File

@ -17,7 +17,6 @@ import { isViewFieldText } from '@/ui/editable-field/types/guards/isViewFieldTex
import { isViewFieldTextValue } from '@/ui/editable-field/types/guards/isViewFieldTextValue';
import { isViewFieldURL } from '@/ui/editable-field/types/guards/isViewFieldURL';
import { isViewFieldURLValue } from '@/ui/editable-field/types/guards/isViewFieldURLValue';
import { EntityUpdateMutationHookContext } from '@/ui/table/states/EntityUpdateMutationHookContext';
import { isViewFieldChipValue } from '../../editable-field/types/guards/isViewFieldChipValue';
import {
@ -42,11 +41,10 @@ import {
ViewFieldURLMetadata,
ViewFieldURLValue,
} from '../../editable-field/types/ViewField';
import { EntityUpdateMutationContext } from '../states/EntityUpdateMutationHookContext';
export function useUpdateEntityField() {
const useUpdateEntityMutation = useContext(EntityUpdateMutationHookContext);
const [updateEntity] = useUpdateEntityMutation();
const updateEntity = useContext(EntityUpdateMutationContext);
return function updatePeopleField<
MetadataType extends ViewFieldMetadata,

View File

@ -0,0 +1,19 @@
import { useRecoilCallback } from 'recoil';
import { tableEntitiesFamilyState } from '@/ui/table/states/tableEntitiesFamilyState';
export function useUpsertEntityTableItem() {
return useRecoilCallback(
({ set, snapshot }) =>
<T extends { id: string }>(entity: T) => {
const currentEntity = snapshot
.getLoadable(tableEntitiesFamilyState(entity.id))
.valueOrThrow();
if (JSON.stringify(currentEntity) !== JSON.stringify(entity)) {
set(tableEntitiesFamilyState(entity.id), entity);
}
},
[],
);
}

View File

@ -0,0 +1,17 @@
import { useRecoilCallback } from 'recoil';
import { tableRowIdsState } from '../states/tableRowIdsState';
export function useUpsertTableRowId() {
return useRecoilCallback(
({ set, snapshot }) =>
(rowId: string) => {
const currentRowIds = snapshot
.getLoadable(tableRowIdsState)
.valueOrThrow();
set(tableRowIdsState, Array.from(new Set([rowId, ...currentRowIds])));
},
[],
);
}

View File

@ -0,0 +1,136 @@
import { useCallback, useState } from 'react';
import { useTheme } from '@emotion/react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { IconButton } from '@/ui/button/components/IconButton';
import { DropdownMenuHeader } from '@/ui/dropdown/components/DropdownMenuHeader';
import { DropdownMenuItem } from '@/ui/dropdown/components/DropdownMenuItem';
import { DropdownMenuItemsContainer } from '@/ui/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/dropdown/components/DropdownMenuSeparator';
import {
ViewFieldDefinition,
ViewFieldMetadata,
} from '@/ui/editable-field/types/ViewField';
import DropdownButton from '@/ui/filter-n-sort/components/DropdownButton';
import { FiltersHotkeyScope } from '@/ui/filter-n-sort/types/FiltersHotkeyScope';
import { IconChevronLeft, IconMinus, IconPlus, IconTag } from '@/ui/icon';
import {
hiddenTableColumnsState,
tableColumnsState,
visibleTableColumnsState,
} from '@/ui/table/states/tableColumnsState';
import { TableOptionsDropdownSection } from './TableOptionsDropdownSection';
type TableOptionsDropdownButtonProps = {
onColumnsChange?: (columns: ViewFieldDefinition<ViewFieldMetadata>[]) => void;
HotkeyScope: FiltersHotkeyScope;
};
enum Option {
Properties = 'Properties',
}
export const TableOptionsDropdownButton = ({
onColumnsChange,
HotkeyScope,
}: TableOptionsDropdownButtonProps) => {
const theme = useTheme();
const [isUnfolded, setIsUnfolded] = useState(false);
const [selectedOption, setSelectedOption] = useState<Option | undefined>(
undefined,
);
const [columns, setColumns] = useRecoilState(tableColumnsState);
const visibleColumns = useRecoilValue(visibleTableColumnsState);
const hiddenColumns = useRecoilValue(hiddenTableColumnsState);
const handleColumnVisibilityChange = useCallback(
(columnId: string, nextIsVisible: boolean) => {
const nextColumns = columns.map((column) =>
column.id === columnId
? { ...column, isVisible: nextIsVisible }
: column,
);
setColumns(nextColumns);
onColumnsChange?.(nextColumns);
},
[columns, onColumnsChange, setColumns],
);
const renderFieldActions = useCallback(
(column: ViewFieldDefinition<ViewFieldMetadata>) =>
// Do not allow hiding last visible column
!column.isVisible || visibleColumns.length > 1 ? (
<IconButton
icon={
column.isVisible ? (
<IconMinus size={theme.icon.size.sm} />
) : (
<IconPlus size={theme.icon.size.sm} />
)
}
onClick={() =>
handleColumnVisibilityChange(column.id, !column.isVisible)
}
/>
) : undefined,
[handleColumnVisibilityChange, theme.icon.size.sm, visibleColumns.length],
);
const resetSelectedOption = useCallback(() => {
setSelectedOption(undefined);
}, []);
return (
<DropdownButton
label="Options"
isActive={false}
isUnfolded={isUnfolded}
onIsUnfoldedChange={setIsUnfolded}
HotkeyScope={HotkeyScope}
>
{!selectedOption && (
<>
<DropdownMenuHeader>View settings</DropdownMenuHeader>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<DropdownMenuItem
onClick={() => setSelectedOption(Option.Properties)}
>
<IconTag size={theme.icon.size.md} />
Properties
</DropdownMenuItem>
</DropdownMenuItemsContainer>
</>
)}
{selectedOption === Option.Properties && (
<>
<DropdownMenuHeader
startIcon={<IconChevronLeft size={theme.icon.size.md} />}
onClick={resetSelectedOption}
>
Properties
</DropdownMenuHeader>
<DropdownMenuSeparator />
<TableOptionsDropdownSection
renderActions={renderFieldActions}
title="Visible"
columns={visibleColumns}
/>
{hiddenColumns.length > 0 && (
<>
<DropdownMenuSeparator />
<TableOptionsDropdownSection
renderActions={renderFieldActions}
title="Hidden"
columns={hiddenColumns}
/>
</>
)}
</>
)}
</DropdownButton>
);
};

View File

@ -0,0 +1,46 @@
import { cloneElement } from 'react';
import { useTheme } from '@emotion/react';
import {
DropdownMenuItem,
DropdownMenuItemProps,
} from '@/ui/dropdown/components/DropdownMenuItem';
import { DropdownMenuItemsContainer } from '@/ui/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSubheader } from '@/ui/dropdown/components/DropdownMenuSubheader';
import {
ViewFieldDefinition,
ViewFieldMetadata,
} from '@/ui/editable-field/types/ViewField';
type TableOptionsDropdownSectionProps = {
renderActions: (
column: ViewFieldDefinition<ViewFieldMetadata>,
) => DropdownMenuItemProps['actions'];
title: string;
columns: ViewFieldDefinition<ViewFieldMetadata>[];
};
export const TableOptionsDropdownSection = ({
renderActions,
title,
columns,
}: TableOptionsDropdownSectionProps) => {
const theme = useTheme();
return (
<>
<DropdownMenuSubheader>{title}</DropdownMenuSubheader>
<DropdownMenuItemsContainer>
{columns.map((column) => (
<DropdownMenuItem key={column.id} actions={renderActions(column)}>
{column.columnIcon &&
cloneElement(column.columnIcon, {
size: theme.icon.size.md,
})}
{column.columnLabel}
</DropdownMenuItem>
))}
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -1,3 +1,3 @@
import { createContext } from 'react';
export const EntityUpdateMutationHookContext = createContext<any | null>(null);
export const EntityUpdateMutationContext = createContext<any | null>(null);

View File

@ -2,13 +2,13 @@ import { selector } from 'recoil';
import { AllRowsSelectedStatus } from '../types/AllRowSelectedStatus';
import { numberOfTableRowsSelectorState } from './numberOfTableRowsSelectorState';
import { numberOfTableRowsState } from './numberOfTableRowsState';
import { selectedRowIdsSelector } from './selectedRowIdsSelector';
export const allRowsSelectedStatusSelector = selector<AllRowsSelectedStatus>({
key: 'allRowsSelectedStatusSelector',
get: ({ get }) => {
const numberOfRows = get(numberOfTableRowsSelectorState);
const numberOfRows = get(numberOfTableRowsState);
const selectedRowIds = get(selectedRowIdsSelector);

View File

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

View File

@ -1,12 +0,0 @@
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

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

View File

@ -0,0 +1,37 @@
import { atom, selector } from 'recoil';
import type {
ViewFieldDefinition,
ViewFieldMetadata,
} from '@/ui/editable-field/types/ViewField';
export const tableColumnsState = atom<ViewFieldDefinition<ViewFieldMetadata>[]>(
{
key: 'tableColumnsState',
default: [],
},
);
export const tableColumnsByIdState = selector({
key: 'tableColumnsByIdState',
get: ({ get }) =>
get(tableColumnsState).reduce<
Record<string, ViewFieldDefinition<ViewFieldMetadata>>
>((result, column) => ({ ...result, [column.id]: column }), {}),
});
export const numberOfTableColumnsState = selector<number>({
key: 'numberOfTableColumnsState',
get: ({ get }) => get(tableColumnsState).length,
});
export const visibleTableColumnsState = selector({
key: 'visibleTableColumnsState',
get: ({ get }) => get(tableColumnsState).filter((column) => column.isVisible),
});
export const hiddenTableColumnsState = selector({
key: 'hiddenTableColumnsState',
get: ({ get }) =>
get(tableColumnsState).filter((column) => !column.isVisible),
});

View File

@ -1,61 +0,0 @@
import { atom, selector } from 'recoil';
import { companyViewFields } from '@/companies/constants/companyViewFields';
import { peopleViewFields } from '@/people/constants/peopleViewFields';
import type {
ViewFieldDefinition,
ViewFieldMetadata,
} from '../../editable-field/types/ViewField';
export const viewFieldsState = atom<{
objectName: 'company' | 'person' | '';
viewFields: ViewFieldDefinition<ViewFieldMetadata>[];
}>({
key: 'viewFieldsState',
default: { objectName: '', viewFields: [] },
});
export const columnWidthByViewFieldIdState = selector({
key: 'columnWidthByViewFieldIdState',
get: ({ get }) =>
get(viewFieldsState).viewFields.reduce<Record<string, number>>(
(result, viewField) => ({
...result,
[viewField.id]: viewField.columnSize,
}),
{},
),
});
export const addableViewFieldDefinitionsState = selector({
key: 'addableViewFieldDefinitionsState',
get: ({ get }) => {
const { objectName, viewFields } = get(viewFieldsState);
if (!objectName) return [];
const existingColumnLabels = viewFields.map(
(viewField) => viewField.columnLabel,
);
const viewFieldDefinitions =
objectName === 'company' ? companyViewFields : peopleViewFields;
return viewFieldDefinitions.filter(
(viewFieldDefinition) =>
!existingColumnLabels.includes(viewFieldDefinition.columnLabel),
);
},
});
export const visibleViewFieldsState = selector({
key: 'visibleViewFieldsState',
get: ({ get }) =>
get(viewFieldsState).viewFields.filter((viewField) => viewField.isVisible),
});
export const hiddenViewFieldsState = selector({
key: 'hiddenViewFieldsState',
get: ({ get }) =>
get(viewFieldsState).viewFields.filter((viewField) => !viewField.isVisible),
});

View File

@ -1,15 +1,19 @@
import { ReactNode, useCallback } from 'react';
import styled from '@emotion/styled';
import type {
ViewFieldDefinition,
ViewFieldMetadata,
} from '@/ui/editable-field/types/ViewField';
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 { sortScopedState } from '@/ui/filter-n-sort/states/sortScopedState';
import { FiltersHotkeyScope } from '@/ui/filter-n-sort/types/FiltersHotkeyScope';
import { SelectedSortType, SortType } from '@/ui/filter-n-sort/types/interface';
import { TableOptionsDropdownButton } from '@/ui/table/options/components/TableOptionsDropdownButton';
import { TopBar } from '@/ui/top-bar/TopBar';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { OptionsDropdownButton } from '@/views/components/OptionsDropdownButton';
import { TableContext } from '../../states/TableContext';
@ -17,6 +21,7 @@ type OwnProps<SortField> = {
viewName: string;
viewIcon?: ReactNode;
availableSorts?: Array<SortType<SortField>>;
onColumnsChange?: (columns: ViewFieldDefinition<ViewFieldMetadata>[]) => void;
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
};
@ -34,6 +39,7 @@ export function TableHeader<SortField>({
viewName,
viewIcon,
availableSorts,
onColumnsChange,
onSortsUpdate,
}: OwnProps<SortField>) {
const [sorts, setSorts] = useRecoilScopedState<SelectedSortType<SortField>[]>(
@ -79,7 +85,8 @@ export function TableHeader<SortField>({
onSortSelect={sortSelect}
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
/>
<OptionsDropdownButton
<TableOptionsDropdownButton
onColumnsChange={onColumnsChange}
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
/>
</>