Skip to content

Commit 8000da0

Browse files
committed
fix: improve record table widget UX — grid NaN freeze, drag-select scoping, widget title, footer aggregate, column width
1 parent cc3480f commit 8000da0

File tree

35 files changed

+2689
-10
lines changed

35 files changed

+2689
-10
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { hasRecordGroupsComponentSelector } from '@/object-record/record-group/states/selectors/hasRecordGroupsComponentSelector';
2+
import { RECORD_TABLE_COLUMN_ADD_COLUMN_BUTTON_WIDTH } from '@/object-record/record-table/constants/RecordTableColumnAddColumnButtonWidth';
3+
import { RECORD_TABLE_COLUMN_ADD_COLUMN_BUTTON_WIDTH_CLASS_NAME } from '@/object-record/record-table/constants/RecordTableColumnAddColumnButtonWidthClassName';
4+
import { RECORD_TABLE_ROW_HEIGHT } from '@/object-record/record-table/constants/RecordTableRowHeight';
5+
import { isRecordTableRowActiveComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowActiveComponentFamilyState';
6+
import { isRecordTableRowFocusActiveComponentState } from '@/object-record/record-table/states/isRecordTableRowFocusActiveComponentState';
7+
import { isRecordTableRowFocusedComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowFocusedComponentFamilyState';
8+
import { isRecordTableScrolledVerticallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledVerticallyComponentState';
9+
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
10+
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
11+
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
12+
import { cx } from '@linaria/core';
13+
import { styled } from '@linaria/react';
14+
import { themeCssVariables } from 'twenty-ui/theme-constants';
15+
16+
const StyledEmptyHeaderCell = styled.div<{
17+
shouldDisplayBorderBottom: boolean;
18+
}>`
19+
background-color: ${themeCssVariables.background.primary};
20+
border-bottom: ${({ shouldDisplayBorderBottom }) =>
21+
shouldDisplayBorderBottom
22+
? `1px solid ${themeCssVariables.border.color.light}`
23+
: 'none'};
24+
border-right: ${themeCssVariables.border.color.light} !important;
25+
height: ${RECORD_TABLE_ROW_HEIGHT}px;
26+
max-height: ${RECORD_TABLE_ROW_HEIGHT}px;
27+
width: ${RECORD_TABLE_COLUMN_ADD_COLUMN_BUTTON_WIDTH}px;
28+
z-index: 1;
29+
`;
30+
31+
export const RecordTableHeaderEmptyLastColumn = () => {
32+
const isRecordTableRowActive = useAtomComponentFamilyStateValue(
33+
isRecordTableRowActiveComponentFamilyState,
34+
0,
35+
);
36+
37+
const isRecordTableRowFocused = useAtomComponentFamilyStateValue(
38+
isRecordTableRowFocusedComponentFamilyState,
39+
0,
40+
);
41+
42+
const isRecordTableRowFocusActive = useAtomComponentStateValue(
43+
isRecordTableRowFocusActiveComponentState,
44+
);
45+
46+
const isFirstRowActiveOrFocused =
47+
isRecordTableRowActive ||
48+
(isRecordTableRowFocused && isRecordTableRowFocusActive);
49+
50+
const isRecordTableScrolledVertically = useAtomComponentStateValue(
51+
isRecordTableScrolledVerticallyComponentState,
52+
);
53+
54+
const hasRecordGroups = useAtomComponentSelectorValue(
55+
hasRecordGroupsComponentSelector,
56+
);
57+
58+
const shouldDisplayBorderBottom =
59+
hasRecordGroups ||
60+
!isFirstRowActiveOrFocused ||
61+
isRecordTableScrolledVertically;
62+
63+
return (
64+
<StyledEmptyHeaderCell
65+
shouldDisplayBorderBottom={shouldDisplayBorderBottom}
66+
className={cx(
67+
'header-cell',
68+
RECORD_TABLE_COLUMN_ADD_COLUMN_BUTTON_WIDTH_CLASS_NAME,
69+
)}
70+
/>
71+
);
72+
};
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext';
2+
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
3+
4+
export const isRecordTableCellsNonEditableComponentState =
5+
createAtomComponentState<boolean>({
6+
key: 'isRecordTableCellsNonEditableComponentState',
7+
defaultValue: false,
8+
componentInstanceContext: RecordTableComponentInstanceContext,
9+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext';
2+
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
3+
4+
export const isRecordTableColumnHeadersReadOnlyComponentState =
5+
createAtomComponentState<boolean>({
6+
key: 'isRecordTableColumnHeadersReadOnlyComponentState',
7+
defaultValue: false,
8+
componentInstanceContext: RecordTableComponentInstanceContext,
9+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext';
2+
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
3+
4+
export const isRecordTableColumnResizableComponentState =
5+
createAtomComponentState<boolean>({
6+
key: 'isRecordTableColumnResizableComponentState',
7+
defaultValue: true,
8+
componentInstanceContext: RecordTableComponentInstanceContext,
9+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export const getRecordTableCellId = (
2+
recordTableId: string,
3+
column: number,
4+
row: number,
5+
): string => {
6+
return `record-table-cell-${recordTableId}-${column}-${row}`;
7+
};
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const getRecordTableHtmlId = (recordTableId: string): string => {
2+
return `record-table-${recordTableId}`;
3+
};

packages/twenty-front/src/modules/page-layout/components/PageLayoutGridLayout.tsx

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -127,16 +127,6 @@ export const PageLayoutGridLayout = ({ tabId }: PageLayoutGridLayoutProps) => {
127127
tabListInstanceId,
128128
});
129129

130-
const handleLayoutChangeWithoutPendingPlaceholder = (
131-
currentLayout: Layout[],
132-
allLayouts: Layouts,
133-
) => {
134-
handleLayoutChange(
135-
currentLayout,
136-
filterPendingPlaceholderFromLayouts(allLayouts),
137-
);
138-
};
139-
140130
const gridContainerRef = useRef<HTMLDivElement>(null);
141131

142132
const isPageLayoutInEditMode = useIsPageLayoutInEditMode();
@@ -161,6 +151,16 @@ export const PageLayoutGridLayout = ({ tabId }: PageLayoutGridLayoutProps) => {
161151

162152
const hasPendingPlaceholder = isDefined(pageLayoutDraggedArea);
163153

154+
const handleLayoutChangeWithoutPendingPlaceholder = (
155+
currentLayout: Layout[],
156+
allLayouts: Layouts,
157+
) => {
158+
handleLayoutChange(
159+
currentLayout,
160+
filterPendingPlaceholderFromLayouts(allLayouts),
161+
);
162+
};
163+
164164
const baseLayouts = isLayoutEmpty
165165
? EMPTY_LAYOUT
166166
: (pageLayoutCurrentLayouts[tabId] ?? EMPTY_LAYOUT);

packages/twenty-front/src/modules/page-layout/hooks/useChangePageLayoutDragSelection.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ export const useChangePageLayoutDragSelection = (
2222

2323
const changePageLayoutDragSelection = useCallback(
2424
(cellId: string, selected: boolean) => {
25+
if (!cellId.startsWith('cell-')) {
26+
return;
27+
}
28+
2529
store.set(pageLayoutSelectedCellsState, (prev) => {
2630
const newSet = new Set(prev);
2731
if (selected) {
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { WIDGET_SIZES } from '@/page-layout/constants/WidgetSizes';
2+
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
3+
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
4+
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
5+
import { pageLayoutDraggedAreaComponentState } from '@/page-layout/states/pageLayoutDraggedAreaComponentState';
6+
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
7+
import { addWidgetToTab } from '@/page-layout/utils/addWidgetToTab';
8+
import { createDefaultRecordTableWidget } from '@/page-layout/utils/createDefaultRecordTableWidget';
9+
import { getDefaultWidgetPosition } from '@/page-layout/utils/getDefaultWidgetPosition';
10+
import { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId';
11+
import { getUpdatedTabLayouts } from '@/page-layout/utils/getUpdatedTabLayouts';
12+
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
13+
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
14+
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
15+
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
16+
import { useStore } from 'jotai';
17+
import { useCallback } from 'react';
18+
import { isDefined } from 'twenty-shared/utils';
19+
import { v4 as uuidv4 } from 'uuid';
20+
import { WidgetType } from '~/generated-metadata/graphql';
21+
22+
export const useCreatePageLayoutRecordTableWidget = (
23+
pageLayoutIdFromProps?: string,
24+
) => {
25+
const pageLayoutId = useAvailableComponentInstanceIdOrThrow(
26+
PageLayoutComponentInstanceContext,
27+
pageLayoutIdFromProps,
28+
);
29+
30+
const activeTabId = useAtomComponentStateValue(
31+
activeTabIdComponentState,
32+
getTabListInstanceIdFromPageLayoutId(pageLayoutId),
33+
);
34+
35+
const pageLayoutCurrentLayoutsState = useAtomComponentStateCallbackState(
36+
pageLayoutCurrentLayoutsComponentState,
37+
pageLayoutId,
38+
);
39+
40+
const pageLayoutDraggedAreaState = useAtomComponentStateCallbackState(
41+
pageLayoutDraggedAreaComponentState,
42+
pageLayoutId,
43+
);
44+
45+
const pageLayoutDraftState = useAtomComponentStateCallbackState(
46+
pageLayoutDraftComponentState,
47+
pageLayoutId,
48+
);
49+
50+
const store = useStore();
51+
52+
const createPageLayoutRecordTableWidget =
53+
useCallback((): PageLayoutWidget => {
54+
const allTabLayouts = store.get(pageLayoutCurrentLayoutsState);
55+
const pageLayoutDraggedArea = store.get(pageLayoutDraggedAreaState);
56+
57+
if (!isDefined(activeTabId)) {
58+
throw new Error(
59+
'A tab must be selected to create a new record table widget',
60+
);
61+
}
62+
63+
const widgetId = uuidv4();
64+
const recordTableSize = WIDGET_SIZES[WidgetType.RECORD_TABLE]!;
65+
const defaultSize = recordTableSize.default;
66+
const minimumSize = recordTableSize.minimum;
67+
const position = getDefaultWidgetPosition(
68+
pageLayoutDraggedArea,
69+
defaultSize,
70+
minimumSize,
71+
);
72+
73+
const newWidget = createDefaultRecordTableWidget(
74+
widgetId,
75+
activeTabId,
76+
'Record Table',
77+
{
78+
row: position.y,
79+
column: position.x,
80+
rowSpan: position.h,
81+
columnSpan: position.w,
82+
},
83+
);
84+
85+
const newLayout = {
86+
i: widgetId,
87+
x: position.x,
88+
y: position.y,
89+
w: position.w,
90+
h: position.h,
91+
minW: minimumSize.w,
92+
minH: minimumSize.h,
93+
};
94+
95+
const updatedLayouts = getUpdatedTabLayouts(
96+
allTabLayouts,
97+
activeTabId,
98+
newLayout,
99+
);
100+
101+
store.set(pageLayoutCurrentLayoutsState, updatedLayouts);
102+
103+
store.set(pageLayoutDraftState, (prev) => ({
104+
...prev,
105+
tabs: addWidgetToTab(prev.tabs, activeTabId, newWidget),
106+
}));
107+
108+
store.set(pageLayoutDraggedAreaState, null);
109+
110+
return newWidget;
111+
}, [
112+
activeTabId,
113+
pageLayoutCurrentLayoutsState,
114+
pageLayoutDraftState,
115+
pageLayoutDraggedAreaState,
116+
store,
117+
]);
118+
119+
return { createPageLayoutRecordTableWidget };
120+
};
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import {
2+
type GridPosition,
3+
PageLayoutTabLayoutMode,
4+
type PageLayoutWidget,
5+
WidgetConfigurationType,
6+
WidgetType,
7+
} from '~/generated-metadata/graphql';
8+
9+
export const createDefaultRecordTableWidget = (
10+
id: string,
11+
pageLayoutTabId: string,
12+
title: string,
13+
gridPosition: GridPosition,
14+
): PageLayoutWidget => {
15+
return {
16+
__typename: 'PageLayoutWidget',
17+
id,
18+
pageLayoutTabId,
19+
title,
20+
type: WidgetType.RECORD_TABLE,
21+
configuration: {
22+
configurationType: WidgetConfigurationType.RECORD_TABLE,
23+
},
24+
gridPosition,
25+
position: {
26+
__typename: 'PageLayoutWidgetGridPosition',
27+
layoutMode: PageLayoutTabLayoutMode.GRID,
28+
row: gridPosition.row,
29+
column: gridPosition.column,
30+
rowSpan: gridPosition.rowSpan,
31+
columnSpan: gridPosition.columnSpan,
32+
},
33+
objectMetadataId: null,
34+
isOverridden: false,
35+
createdAt: new Date().toISOString(),
36+
updatedAt: new Date().toISOString(),
37+
deletedAt: null,
38+
};
39+
};

0 commit comments

Comments
 (0)