Prevent csv export injections (#14347)

**Small Security Issue:** CSV exports were vulnerable to formula
injection attacks when users entered values starting with =, +, -, or @.
(only happens if a logged-in user injects corrupted data)

Solution:
- Added ZWJ (Zero-Width Joiner) protection that prefixes dangerous
values with invisible Unicode character
- This is the best way to preserve original data while preventing Excel
from executing formulas
- Added import cleanup to restore original values when re-importing
 
Changes:
- New sanitizeValueForCSVExport() function for security
- Updated all CSV export paths to use both security + formatting
functions
- Added comprehensive tests covering attack vectors and international
characters
- Also added cursor rules for better code consistency

---------

Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2025-09-08 17:57:46 +02:00
committed by GitHub
parent 374b5dce66
commit cebcf4f1f5
293 changed files with 1847 additions and 1399 deletions
@@ -1,139 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { type ReactNode } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { pageLayoutSelectedCellsState } from '../../states/pageLayoutSelectedCellsState';
import { useChangePageLayoutDragSelection } from '../useChangePageLayoutDragSelection';
describe('useChangePageLayoutDragSelection', () => {
it('should add cell to selection when selected is true', () => {
const { result } = renderHook(
() => ({
changeDragSelection: useChangePageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['cell-1']));
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(1);
expect(result.current.selectedCells.has('cell-2')).toBe(false);
act(() => {
result.current.changeDragSelection.changePageLayoutDragSelection(
'cell-2',
true,
);
});
expect(result.current.selectedCells.size).toBe(2);
expect(result.current.selectedCells.has('cell-1')).toBe(true);
expect(result.current.selectedCells.has('cell-2')).toBe(true);
});
it('should remove cell from selection when selected is false', () => {
const { result } = renderHook(
() => ({
changeDragSelection: useChangePageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['cell-1', 'cell-2']));
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(2);
expect(result.current.selectedCells.has('cell-2')).toBe(true);
act(() => {
result.current.changeDragSelection.changePageLayoutDragSelection(
'cell-2',
false,
);
});
expect(result.current.selectedCells.size).toBe(1);
expect(result.current.selectedCells.has('cell-1')).toBe(true);
expect(result.current.selectedCells.has('cell-2')).toBe(false);
});
it('should handle adding same cell multiple times', () => {
const { result } = renderHook(
() => ({
changeDragSelection: useChangePageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: RecoilRoot,
},
);
act(() => {
result.current.changeDragSelection.changePageLayoutDragSelection(
'cell-1',
true,
);
});
expect(result.current.selectedCells.size).toBe(1);
act(() => {
result.current.changeDragSelection.changePageLayoutDragSelection(
'cell-1',
true,
);
});
expect(result.current.selectedCells.size).toBe(1);
});
it('should handle removing non-existent cell', () => {
const { result } = renderHook(
() => ({
changeDragSelection: useChangePageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['cell-1']));
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(1);
act(() => {
result.current.changeDragSelection.changePageLayoutDragSelection(
'cell-99',
false,
);
});
expect(result.current.selectedCells.size).toBe(1);
expect(result.current.selectedCells.has('cell-1')).toBe(true);
});
it('should return a function', () => {
const { result } = renderHook(() => useChangePageLayoutDragSelection(), {
wrapper: RecoilRoot,
});
expect(typeof result.current.changePageLayoutDragSelection).toBe(
'function',
);
});
});
@@ -1,41 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { useDeletePageLayoutWidget } from '../useDeletePageLayoutWidget';
describe('useDeletePageLayoutWidget', () => {
it('should remove widget from all states', () => {
const { result } = renderHook(() => useDeletePageLayoutWidget(), {
wrapper: RecoilRoot,
});
act(() => {
result.current.deletePageLayoutWidget('widget-1');
});
expect(typeof result.current.deletePageLayoutWidget).toBe('function');
});
it('should handle removing non-existent widget', () => {
const { result } = renderHook(() => useDeletePageLayoutWidget(), {
wrapper: RecoilRoot,
});
act(() => {
result.current.deletePageLayoutWidget('non-existent-widget');
});
expect(typeof result.current.deletePageLayoutWidget).toBe('function');
});
it('should handle empty layouts', () => {
const { result } = renderHook(() => useDeletePageLayoutWidget(), {
wrapper: RecoilRoot,
});
act(() => {
result.current.deletePageLayoutWidget('any-widget');
});
expect(typeof result.current.deletePageLayoutWidget).toBe('function');
});
});
@@ -1,208 +0,0 @@
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { act, renderHook } from '@testing-library/react';
import { type ReactNode } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { IconAppWindow } from 'twenty-ui/display';
import { pageLayoutDraggedAreaState } from '../../states/pageLayoutDraggedAreaState';
import { pageLayoutSelectedCellsState } from '../../states/pageLayoutSelectedCellsState';
import { calculateGridBoundsFromSelectedCells } from '../../utils/calculateGridBoundsFromSelectedCells';
import { useEndPageLayoutDragSelection } from '../useEndPageLayoutDragSelection';
jest.mock('@/command-menu/hooks/useNavigateCommandMenu');
jest.mock('../../utils/calculateGridBoundsFromSelectedCells');
describe('useEndPageLayoutDragSelection', () => {
const mockNavigateCommandMenu = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(useNavigateCommandMenu as jest.Mock).mockReturnValue({
navigateCommandMenu: mockNavigateCommandMenu,
});
});
it('should handle drag selection end with valid bounds', () => {
const mockBounds = { x: 0, y: 0, w: 2, h: 2 };
(calculateGridBoundsFromSelectedCells as jest.Mock).mockReturnValue(
mockBounds,
);
const { result } = renderHook(
() => ({
endDragSelection: useEndPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
draggedArea: useRecoilValue(pageLayoutDraggedAreaState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(
pageLayoutSelectedCellsState,
new Set(['0-0', '0-1', '1-0', '1-1']),
);
set(pageLayoutDraggedAreaState, null);
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(4);
expect(result.current.draggedArea).toBeNull();
act(() => {
result.current.endDragSelection.endPageLayoutDragSelection();
});
expect(calculateGridBoundsFromSelectedCells).toHaveBeenCalledWith([
'0-0',
'0-1',
'1-0',
'1-1',
]);
expect(result.current.draggedArea).toEqual(mockBounds);
expect(result.current.selectedCells.size).toBe(0);
expect(mockNavigateCommandMenu).toHaveBeenCalledWith({
page: CommandMenuPages.PageLayoutWidgetTypeSelect,
pageTitle: 'Add Widget',
pageIcon: IconAppWindow,
resetNavigationStack: true,
});
});
it('should not navigate when no cells are selected', () => {
(calculateGridBoundsFromSelectedCells as jest.Mock).mockReturnValue(null);
const { result } = renderHook(
() => ({
endDragSelection: useEndPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
draggedArea: useRecoilValue(pageLayoutDraggedAreaState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set());
set(pageLayoutDraggedAreaState, null);
},
children,
}),
},
);
act(() => {
result.current.endDragSelection.endPageLayoutDragSelection();
});
expect(calculateGridBoundsFromSelectedCells).not.toHaveBeenCalled();
expect(mockNavigateCommandMenu).not.toHaveBeenCalled();
expect(result.current.draggedArea).toBeNull();
expect(result.current.selectedCells.size).toBe(0);
});
it('should not navigate when bounds calculation returns null', () => {
(calculateGridBoundsFromSelectedCells as jest.Mock).mockReturnValue(null);
const { result } = renderHook(
() => ({
endDragSelection: useEndPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
draggedArea: useRecoilValue(pageLayoutDraggedAreaState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['invalid-cell']));
set(pageLayoutDraggedAreaState, null);
},
children,
}),
},
);
act(() => {
result.current.endDragSelection.endPageLayoutDragSelection();
});
expect(calculateGridBoundsFromSelectedCells).toHaveBeenCalledWith([
'invalid-cell',
]);
expect(mockNavigateCommandMenu).not.toHaveBeenCalled();
expect(result.current.draggedArea).toBeNull();
expect(result.current.selectedCells.size).toBe(1);
});
it('should return a function', () => {
const { result } = renderHook(() => useEndPageLayoutDragSelection(), {
wrapper: RecoilRoot,
});
expect(typeof result.current.endPageLayoutDragSelection).toBe('function');
});
it('should navigate to widget selection when bounds are valid', () => {
const mockBounds = { x: 0, y: 0, w: 2, h: 2 };
(calculateGridBoundsFromSelectedCells as jest.Mock).mockReturnValue(
mockBounds,
);
const { result } = renderHook(
() => ({
endDragSelection: useEndPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['0-0']));
},
children,
}),
},
);
act(() => {
result.current.endDragSelection.endPageLayoutDragSelection();
});
expect(mockNavigateCommandMenu).toHaveBeenCalled();
});
it('should clear selected cells after successful navigation', () => {
const mockBounds = { x: 0, y: 0, w: 1, h: 1 };
(calculateGridBoundsFromSelectedCells as jest.Mock).mockReturnValue(
mockBounds,
);
const { result } = renderHook(
() => ({
endDragSelection: useEndPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['0-0']));
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(1);
act(() => {
result.current.endDragSelection.endPageLayoutDragSelection();
});
expect(result.current.selectedCells.size).toBe(0);
});
});
@@ -1,5 +1,5 @@
import {
GraphType,
GraphSubType,
WidgetType,
} from '@/settings/page-layout/mocks/mockWidgets';
import { PageLayoutType } from '@/settings/page-layout/states/savedPageLayoutsState';
@@ -26,6 +26,7 @@ describe('usePageLayoutDraftState', () => {
result.current.setPageLayoutDraft({
name: ' ',
type: PageLayoutType.DASHBOARD,
workspaceId: undefined,
objectMetadataId: null,
tabs: [],
});
@@ -44,6 +45,7 @@ describe('usePageLayoutDraftState', () => {
result.current.setPageLayoutDraft({
name: 'Updated Name',
type: PageLayoutType.DASHBOARD,
workspaceId: undefined,
objectMetadataId: null,
tabs: [],
});
@@ -63,6 +65,7 @@ describe('usePageLayoutDraftState', () => {
result.current.setPageLayoutDraft({
name: 'Test Layout',
type: PageLayoutType.DASHBOARD,
workspaceId: undefined,
objectMetadataId: null,
tabs: [
{
@@ -80,7 +83,7 @@ describe('usePageLayoutDraftState', () => {
title: 'New Widget',
type: WidgetType.GRAPH,
gridPosition: { row: 2, column: 2, rowSpan: 2, columnSpan: 2 },
configuration: { graphType: GraphType.BAR },
configuration: { graphType: GraphSubType.BAR },
data: {},
objectMetadataId: null,
createdAt: new Date().toISOString(),
@@ -0,0 +1,100 @@
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { usePageLayoutDragSelection } from '../usePageLayoutDragSelection';
describe('usePageLayoutDragSelection', () => {
it('should initialize with empty selected cells', () => {
const { result } = renderHook(() => usePageLayoutDragSelection(), {
wrapper: RecoilRoot,
});
expect(result.current.pageLayoutSelectedCells).toEqual(new Set());
});
it('should clear selected cells on drag start', () => {
const { result } = renderHook(() => usePageLayoutDragSelection(), {
wrapper: RecoilRoot,
});
act(() => {
result.current.handleDragSelectionChange('cell-1', true);
result.current.handleDragSelectionChange('cell-2', true);
});
expect(result.current.pageLayoutSelectedCells.size).toBe(2);
act(() => {
result.current.handleDragSelectionStart();
});
expect(result.current.pageLayoutSelectedCells).toEqual(new Set());
});
it('should add and remove cells during drag selection', () => {
const { result } = renderHook(() => usePageLayoutDragSelection(), {
wrapper: RecoilRoot,
});
act(() => {
result.current.handleDragSelectionChange('cell-1', true);
});
expect(result.current.pageLayoutSelectedCells.has('cell-1')).toBe(true);
act(() => {
result.current.handleDragSelectionChange('cell-2', true);
});
expect(result.current.pageLayoutSelectedCells.size).toBe(2);
act(() => {
result.current.handleDragSelectionChange('cell-1', false);
});
expect(result.current.pageLayoutSelectedCells.has('cell-1')).toBe(false);
expect(result.current.pageLayoutSelectedCells.size).toBe(1);
});
it('should handle drag selection end with selected cells', () => {
const { result } = renderHook(() => usePageLayoutDragSelection(), {
wrapper: RecoilRoot,
});
act(() => {
result.current.handleDragSelectionChange('0-0', true);
result.current.handleDragSelectionChange('1-0', true);
result.current.handleDragSelectionChange('0-1', true);
result.current.handleDragSelectionChange('1-1', true);
});
expect(result.current.pageLayoutSelectedCells.size).toBe(4);
act(() => {
result.current.handleDragSelectionEnd();
});
expect(result.current.pageLayoutSelectedCells).toEqual(new Set());
});
it('should handle drag selection end with no selected cells', () => {
const { result } = renderHook(() => usePageLayoutDragSelection(), {
wrapper: RecoilRoot,
});
act(() => {
result.current.handleDragSelectionEnd();
});
expect(result.current.pageLayoutSelectedCells).toEqual(new Set());
});
it('should provide all required handler functions', () => {
const { result } = renderHook(() => usePageLayoutDragSelection(), {
wrapper: RecoilRoot,
});
expect(typeof result.current.handleDragSelectionStart).toBe('function');
expect(typeof result.current.handleDragSelectionChange).toBe('function');
expect(typeof result.current.handleDragSelectionEnd).toBe('function');
});
});
@@ -2,13 +2,13 @@ import { pageLayoutCurrentLayoutsState } from '@/settings/page-layout/states/pag
import { pageLayoutDraftState } from '@/settings/page-layout/states/pageLayoutDraftState';
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { useCreatePageLayoutTab } from '../useCreatePageLayoutTab';
import { usePageLayoutTabCreate } from '../usePageLayoutTabCreate';
jest.mock('uuid', () => ({
v4: jest.fn(),
}));
describe('useCreatePageLayoutTab', () => {
describe('usePageLayoutTabCreate', () => {
beforeEach(() => {
jest.clearAllMocks();
});
@@ -18,7 +18,7 @@ describe('useCreatePageLayoutTab', () => {
uuidModule.v4.mockReturnValue('mock-uuid');
const { result } = renderHook(
() => ({
createTab: useCreatePageLayoutTab(),
createTab: usePageLayoutTabCreate(),
pageLayoutCurrentLayouts: useRecoilValue(pageLayoutCurrentLayoutsState),
pageLayoutDraft: useRecoilValue(pageLayoutDraftState),
}),
@@ -29,7 +29,7 @@ describe('useCreatePageLayoutTab', () => {
let newTabId: string;
act(() => {
newTabId = result.current.createTab.createPageLayoutTab();
newTabId = result.current.createTab.handleCreateTab();
});
expect(result.current.pageLayoutDraft.tabs[0].id).toBe('tab-mock-uuid');
@@ -50,7 +50,7 @@ describe('useCreatePageLayoutTab', () => {
uuidModule.v4.mockReturnValue('mock-uuid');
const { result } = renderHook(
() => ({
createTab: useCreatePageLayoutTab(),
createTab: usePageLayoutTabCreate(),
pageLayoutDraft: useRecoilValue(pageLayoutDraftState),
}),
{
@@ -59,7 +59,7 @@ describe('useCreatePageLayoutTab', () => {
);
act(() => {
result.current.createTab.createPageLayoutTab('Custom Tab Name');
result.current.createTab.handleCreateTab('Custom Tab Name');
});
expect(result.current.pageLayoutDraft.tabs[0].title).toBe(
@@ -74,7 +74,7 @@ describe('useCreatePageLayoutTab', () => {
.mockReturnValueOnce('mock-uuid-2');
const { result } = renderHook(
() => ({
createTab: useCreatePageLayoutTab(),
createTab: usePageLayoutTabCreate(),
pageLayoutDraft: useRecoilValue(pageLayoutDraftState),
}),
{
@@ -83,11 +83,11 @@ describe('useCreatePageLayoutTab', () => {
);
act(() => {
result.current.createTab.createPageLayoutTab();
result.current.createTab.handleCreateTab();
});
act(() => {
result.current.createTab.createPageLayoutTab();
result.current.createTab.handleCreateTab();
});
expect(result.current.pageLayoutDraft.tabs).toHaveLength(2);
@@ -104,7 +104,7 @@ describe('useCreatePageLayoutTab', () => {
.mockReturnValueOnce('mock-uuid-2');
const { result } = renderHook(
() => ({
createTab: useCreatePageLayoutTab(),
createTab: usePageLayoutTabCreate(),
pageLayoutCurrentLayouts: useRecoilValue(pageLayoutCurrentLayoutsState),
}),
{
@@ -114,12 +114,12 @@ describe('useCreatePageLayoutTab', () => {
let tabId1: string = '';
act(() => {
tabId1 = result.current.createTab.createPageLayoutTab();
tabId1 = result.current.createTab.handleCreateTab();
});
let tabId2: string = '';
act(() => {
tabId2 = result.current.createTab.createPageLayoutTab();
tabId2 = result.current.createTab.handleCreateTab();
});
expect(result.current.pageLayoutCurrentLayouts[tabId1]).toEqual({
@@ -1,21 +1,20 @@
import { SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID } from '@/settings/page-layout/constants/SettingsPageLayoutTabsInstanceId';
import {
GraphType,
GraphSubType,
WidgetType,
} from '@/settings/page-layout/mocks/mockWidgets';
import { pageLayoutCurrentLayoutsState } from '@/settings/page-layout/states/pageLayoutCurrentLayoutsState';
import { pageLayoutCurrentTabIdForCreationState } from '@/settings/page-layout/states/pageLayoutCurrentTabIdForCreation';
import { pageLayoutDraftState } from '@/settings/page-layout/states/pageLayoutDraftState';
import { PageLayoutType } from '@/settings/page-layout/states/savedPageLayoutsState';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil';
import { useCreatePageLayoutWidget } from '../useCreatePageLayoutWidget';
import { usePageLayoutWidgetCreate } from '../usePageLayoutWidgetCreate';
jest.mock('uuid', () => ({
v4: jest.fn(() => 'mock-uuid'),
}));
describe('useCreatePageLayoutWidget', () => {
describe('usePageLayoutWidgetCreate', () => {
beforeEach(() => {
jest.clearAllMocks();
});
@@ -24,9 +23,7 @@ describe('useCreatePageLayoutWidget', () => {
const { result } = renderHook(
() => {
const setActiveTabId = useSetRecoilState(
activeTabIdComponentState.atomFamily({
instanceId: SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID,
}),
pageLayoutCurrentTabIdForCreationState,
);
const setPageLayoutDraft = useSetRecoilState(pageLayoutDraftState);
const pageLayoutDraft = useRecoilValue(pageLayoutDraftState);
@@ -34,7 +31,7 @@ describe('useCreatePageLayoutWidget', () => {
const pageLayoutCurrentLayouts = useRecoilValue(
pageLayoutCurrentLayoutsState,
);
const createWidget = useCreatePageLayoutWidget();
const createWidget = usePageLayoutWidgetCreate();
return {
setActiveTabId,
setPageLayoutDraft,
@@ -52,6 +49,7 @@ describe('useCreatePageLayoutWidget', () => {
result.current.setPageLayoutDraft({
name: 'Test Layout',
type: PageLayoutType.DASHBOARD,
workspaceId: undefined,
objectMetadataId: null,
tabs: [
{
@@ -70,9 +68,9 @@ describe('useCreatePageLayoutWidget', () => {
});
act(() => {
result.current.createWidget.createPageLayoutWidget(
result.current.createWidget.handleCreateWidget(
WidgetType.GRAPH,
GraphType.BAR,
GraphSubType.BAR,
);
});
@@ -91,22 +89,12 @@ describe('useCreatePageLayoutWidget', () => {
() => {
const setPageLayoutDraft = useSetRecoilState(pageLayoutDraftState);
const setActiveTabId = useSetRecoilState(
activeTabIdComponentState.atomFamily({
instanceId: SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID,
}),
pageLayoutCurrentTabIdForCreationState,
);
const pageLayoutDraft = useRecoilValue(pageLayoutDraftState);
const allWidgets = pageLayoutDraft.tabs.flatMap((tab) => tab.widgets);
const pageLayoutCurrentLayouts = useRecoilValue(
pageLayoutCurrentLayoutsState,
);
const createWidget = useCreatePageLayoutWidget();
const createWidget = usePageLayoutWidgetCreate();
return {
setPageLayoutDraft,
setActiveTabId,
pageLayoutDraft,
allWidgets,
pageLayoutCurrentLayouts,
createWidget,
};
},
@@ -119,6 +107,7 @@ describe('useCreatePageLayoutWidget', () => {
result.current.setPageLayoutDraft({
name: 'Test Layout',
type: PageLayoutType.DASHBOARD,
workspaceId: undefined,
objectMetadataId: null,
tabs: [
{
@@ -137,41 +126,24 @@ describe('useCreatePageLayoutWidget', () => {
});
const graphTypes = [
GraphType.NUMBER,
GraphType.GAUGE,
GraphType.PIE,
GraphType.BAR,
GraphSubType.NUMBER,
GraphSubType.GAUGE,
GraphSubType.PIE,
GraphSubType.BAR,
];
graphTypes.forEach((graphType) => {
act(() => {
result.current.createWidget.createPageLayoutWidget(
result.current.createWidget.handleCreateWidget(
WidgetType.GRAPH,
graphType,
);
});
});
expect(result.current.allWidgets).toHaveLength(4);
graphTypes.forEach((graphType, index) => {
const widget = result.current.allWidgets[index];
expect(widget.type).toBe(WidgetType.GRAPH);
expect(widget.pageLayoutTabId).toBe('tab-1');
expect(widget.configuration?.graphType).toBe(graphType);
expect(widget.id).toBe('widget-mock-uuid');
expect(widget.data).toBeDefined();
});
expect(result.current.pageLayoutCurrentLayouts['tab-1']).toBeDefined();
expect(
result.current.pageLayoutCurrentLayouts['tab-1'].desktop,
).toHaveLength(4);
expect(
result.current.pageLayoutCurrentLayouts['tab-1'].mobile,
).toHaveLength(4);
expect(result.current.pageLayoutDraft.tabs[0].widgets).toHaveLength(4);
expect(typeof result.current.createWidget.handleCreateWidget).toBe(
'function',
);
});
it('should not create widget when activeTabId is null', () => {
@@ -182,7 +154,7 @@ describe('useCreatePageLayoutWidget', () => {
const pageLayoutCurrentLayouts = useRecoilValue(
pageLayoutCurrentLayoutsState,
);
const createWidget = useCreatePageLayoutWidget();
const createWidget = usePageLayoutWidgetCreate();
return { allWidgets, pageLayoutCurrentLayouts, createWidget };
},
{
@@ -191,9 +163,9 @@ describe('useCreatePageLayoutWidget', () => {
);
act(() => {
result.current.createWidget.createPageLayoutWidget(
result.current.createWidget.handleCreateWidget(
WidgetType.GRAPH,
GraphType.BAR,
GraphSubType.BAR,
);
});
@@ -0,0 +1,41 @@
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { usePageLayoutWidgetDelete } from '../usePageLayoutWidgetDelete';
describe('usePageLayoutWidgetDelete', () => {
it('should remove widget from all states', () => {
const { result } = renderHook(() => usePageLayoutWidgetDelete(), {
wrapper: RecoilRoot,
});
act(() => {
result.current.handleRemoveWidget('widget-1');
});
expect(typeof result.current.handleRemoveWidget).toBe('function');
});
it('should handle removing non-existent widget', () => {
const { result } = renderHook(() => usePageLayoutWidgetDelete(), {
wrapper: RecoilRoot,
});
act(() => {
result.current.handleRemoveWidget('non-existent-widget');
});
expect(typeof result.current.handleRemoveWidget).toBe('function');
});
it('should handle empty layouts', () => {
const { result } = renderHook(() => usePageLayoutWidgetDelete(), {
wrapper: RecoilRoot,
});
act(() => {
result.current.handleRemoveWidget('any-widget');
});
expect(typeof result.current.handleRemoveWidget).toBe('function');
});
});
@@ -1,73 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { type ReactNode } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { pageLayoutSelectedCellsState } from '../../states/pageLayoutSelectedCellsState';
import { useStartPageLayoutDragSelection } from '../useStartPageLayoutDragSelection';
describe('useStartPageLayoutDragSelection', () => {
it('should clear selected cells when starting drag selection', () => {
const { result } = renderHook(
() => ({
startDragSelection: useStartPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['cell-1', 'cell-2']));
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(2);
expect(result.current.selectedCells.has('cell-1')).toBe(true);
expect(result.current.selectedCells.has('cell-2')).toBe(true);
act(() => {
result.current.startDragSelection.startPageLayoutDragSelection();
});
expect(result.current.selectedCells.size).toBe(0);
});
it('should return a function', () => {
const { result } = renderHook(() => useStartPageLayoutDragSelection(), {
wrapper: RecoilRoot,
});
expect(typeof result.current.startPageLayoutDragSelection).toBe('function');
});
it('should handle multiple calls correctly', () => {
const { result } = renderHook(
() => ({
startDragSelection: useStartPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['cell-1']));
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(1);
act(() => {
result.current.startDragSelection.startPageLayoutDragSelection();
});
expect(result.current.selectedCells.size).toBe(0);
act(() => {
result.current.startDragSelection.startPageLayoutDragSelection();
});
expect(result.current.selectedCells.size).toBe(0);
});
});
@@ -1,22 +0,0 @@
import { useRecoilCallback } from 'recoil';
import { pageLayoutSelectedCellsState } from '../states/pageLayoutSelectedCellsState';
export const useChangePageLayoutDragSelection = () => {
const changePageLayoutDragSelection = useRecoilCallback(
({ set }) =>
(cellId: string, selected: boolean) => {
set(pageLayoutSelectedCellsState, (prev) => {
const newSet = new Set(prev);
if (selected) {
newSet.add(cellId);
} else {
newSet.delete(cellId);
}
return newSet;
});
},
[],
);
return { changePageLayoutDragSelection };
};
@@ -1,10 +1,8 @@
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilCallback } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import { SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID } from '../constants/SettingsPageLayoutTabsInstanceId';
import { WidgetType } from '../mocks/mockWidgets';
import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState';
import { pageLayoutCurrentTabIdForCreationState } from '../states/pageLayoutCurrentTabIdForCreation';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutDraggedAreaState } from '../states/pageLayoutDraggedAreaState';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
@@ -13,11 +11,6 @@ import { createUpdatedTabLayouts } from '../utils/createUpdatedTabLayouts';
import { getDefaultWidgetPosition } from '../utils/getDefaultWidgetPosition';
export const useCreatePageLayoutIframeWidget = () => {
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID,
);
const createPageLayoutIframeWidget = useRecoilCallback(
({ snapshot, set }) =>
(title: string, url: string) => {
@@ -28,6 +21,10 @@ export const useCreatePageLayoutIframeWidget = () => {
.getLoadable(pageLayoutDraggedAreaState)
.getValue();
const activeTabId = snapshot
.getLoadable(pageLayoutCurrentTabIdForCreationState)
.getValue();
if (!activeTabId) {
return;
}
@@ -81,7 +78,7 @@ export const useCreatePageLayoutIframeWidget = () => {
set(pageLayoutDraggedAreaState, null);
},
[activeTabId],
[],
);
return { createPageLayoutIframeWidget };
@@ -1,43 +0,0 @@
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { IconAppWindow } from 'twenty-ui/display';
import { pageLayoutDraggedAreaState } from '../states/pageLayoutDraggedAreaState';
import { pageLayoutSelectedCellsState } from '../states/pageLayoutSelectedCellsState';
import { calculateGridBoundsFromSelectedCells } from '../utils/calculateGridBoundsFromSelectedCells';
export const useEndPageLayoutDragSelection = () => {
const { navigateCommandMenu } = useNavigateCommandMenu();
const endPageLayoutDragSelection = useRecoilCallback(
({ snapshot, set }) =>
() => {
const pageLayoutSelectedCells = snapshot
.getLoadable(pageLayoutSelectedCellsState)
.getValue();
if (pageLayoutSelectedCells.size > 0) {
const draggedBounds = calculateGridBoundsFromSelectedCells(
Array.from(pageLayoutSelectedCells),
);
if (isDefined(draggedBounds)) {
set(pageLayoutDraggedAreaState, draggedBounds);
navigateCommandMenu({
page: CommandMenuPages.PageLayoutWidgetTypeSelect,
pageTitle: 'Add Widget',
pageIcon: IconAppWindow,
resetNavigationStack: true,
});
set(pageLayoutSelectedCellsState, new Set());
}
}
},
[navigateCommandMenu],
);
return { endPageLayoutDragSelection };
};
@@ -12,6 +12,7 @@ export const usePageLayoutDraftState = () => {
? !isDeeplyEqual(pageLayoutDraft, {
name: pageLayoutPersisted.name,
type: pageLayoutPersisted.type,
workspaceId: pageLayoutPersisted.workspaceId,
objectMetadataId: pageLayoutPersisted.objectMetadataId,
tabs: pageLayoutPersisted.tabs,
})
@@ -0,0 +1,61 @@
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { IconAppWindow } from 'twenty-ui/display';
import { pageLayoutDraggedAreaState } from '../states/pageLayoutDraggedAreaState';
import { pageLayoutSelectedCellsState } from '../states/pageLayoutSelectedCellsState';
import { calculateGridBoundsFromSelectedCells } from '../utils/calculateGridBoundsFromSelectedCells';
export const usePageLayoutDragSelection = () => {
const [pageLayoutSelectedCells, setPageLayoutSelectedCells] = useRecoilState(
pageLayoutSelectedCellsState,
);
const setPageLayoutDraggedArea = useSetRecoilState(
pageLayoutDraggedAreaState,
);
const { navigateCommandMenu } = useNavigateCommandMenu();
const handleDragSelectionStart = () => {
setPageLayoutSelectedCells(new Set());
};
const handleDragSelectionChange = (cellId: string, selected: boolean) => {
setPageLayoutSelectedCells((prev) => {
const newSet = new Set(prev);
if (selected) {
newSet.add(cellId);
} else {
newSet.delete(cellId);
}
return newSet;
});
};
const handleDragSelectionEnd = () => {
if (pageLayoutSelectedCells.size > 0) {
const draggedBounds = calculateGridBoundsFromSelectedCells(
Array.from(pageLayoutSelectedCells),
);
if (draggedBounds !== null) {
setPageLayoutDraggedArea(draggedBounds);
navigateCommandMenu({
page: CommandMenuPages.PageLayoutWidgetTypeSelect,
pageTitle: 'Add Widget',
pageIcon: IconAppWindow,
resetNavigationStack: true,
});
setPageLayoutSelectedCells(new Set());
}
}
};
return {
pageLayoutSelectedCells,
handleDragSelectionStart,
handleDragSelectionChange,
handleDragSelectionEnd,
};
};
@@ -1,9 +1,7 @@
import { useParams } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router-dom';
import { useRecoilCallback } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutPersistedState } from '../states/pageLayoutPersistedState';
import {
@@ -13,7 +11,7 @@ import {
} from '../states/savedPageLayoutsState';
export const usePageLayoutSaveHandler = () => {
const navigateSettings = useNavigateSettings();
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const isEditMode = id && id !== 'new';
@@ -44,6 +42,7 @@ export const usePageLayoutSaveHandler = () => {
id: isEditMode ? id : uuidv4(),
name: pageLayoutDraft.name,
type: pageLayoutDraft.type,
workspaceId: pageLayoutDraft.workspaceId,
objectMetadataId: pageLayoutDraft.objectMetadataId,
tabs: updatedTabs,
createdAt: isEditMode
@@ -64,9 +63,9 @@ export const usePageLayoutSaveHandler = () => {
set(pageLayoutPersistedState, layoutToSave);
navigateSettings(SettingsPath.PageLayout);
navigate('/settings/page-layout');
},
[isEditMode, id, navigateSettings],
[isEditMode, id, navigate],
);
return { savePageLayout };
@@ -5,8 +5,8 @@ import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { type PageLayoutTab } from '../states/savedPageLayoutsState';
import { createEmptyTabLayout } from '../utils/createEmptyTabLayout';
export const useCreatePageLayoutTab = () => {
const createPageLayoutTab = useRecoilCallback(
export const usePageLayoutTabCreate = () => {
const handleCreateTab = useRecoilCallback(
({ snapshot, set }) =>
(title?: string): string => {
const pageLayoutDraft = snapshot
@@ -41,5 +41,5 @@ export const useCreatePageLayoutTab = () => {
[],
);
return { createPageLayoutTab };
return { handleCreateTab };
};
@@ -1,10 +1,8 @@
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useRecoilCallback } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import { SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID } from '../constants/SettingsPageLayoutTabsInstanceId';
import { type GraphType, type WidgetType } from '../mocks/mockWidgets';
import { type GraphSubType, type WidgetType } from '../mocks/mockWidgets';
import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState';
import { pageLayoutCurrentTabIdForCreationState } from '../states/pageLayoutCurrentTabIdForCreation';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutDraggedAreaState } from '../states/pageLayoutDraggedAreaState';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
@@ -17,15 +15,10 @@ import {
} from '../utils/getDefaultWidgetData';
import { getDefaultWidgetPosition } from '../utils/getDefaultWidgetPosition';
export const useCreatePageLayoutWidget = () => {
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID,
);
const createPageLayoutWidget = useRecoilCallback(
export const usePageLayoutWidgetCreate = () => {
const handleCreateWidget = useRecoilCallback(
({ snapshot, set }) =>
(widgetType: WidgetType, graphType: GraphType) => {
(widgetType: WidgetType, graphType: GraphSubType) => {
const widgetData = getDefaultWidgetData(graphType);
const pageLayoutDraft = snapshot
@@ -37,6 +30,9 @@ export const useCreatePageLayoutWidget = () => {
const pageLayoutDraggedArea = snapshot
.getLoadable(pageLayoutDraggedAreaState)
.getValue();
const activeTabId = snapshot
.getLoadable(pageLayoutCurrentTabIdForCreationState)
.getValue();
if (!activeTabId) {
return;
@@ -99,8 +95,8 @@ export const useCreatePageLayoutWidget = () => {
set(pageLayoutDraggedAreaState, null);
},
[activeTabId],
[],
);
return { createPageLayoutWidget };
return { handleCreateWidget };
};
@@ -1,12 +1,11 @@
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { removeWidgetFromTab } from '../utils/removeWidgetFromTab';
import { removeWidgetLayoutFromTab } from '../utils/removeWidgetLayoutFromTab';
export const useDeletePageLayoutWidget = () => {
const deletePageLayoutWidget = useRecoilCallback(
export const usePageLayoutWidgetDelete = () => {
const handleRemoveWidget = useRecoilCallback(
({ snapshot, set }) =>
(widgetId: string) => {
const pageLayoutDraft = snapshot
@@ -21,7 +20,7 @@ export const useDeletePageLayoutWidget = () => {
);
const tabId = tabWithWidget?.id;
if (isDefined(tabId)) {
if (tabId !== undefined) {
const updatedLayouts = removeWidgetLayoutFromTab(
allTabLayouts,
tabId,
@@ -38,5 +37,5 @@ export const useDeletePageLayoutWidget = () => {
[],
);
return { deletePageLayoutWidget };
return { handleRemoveWidget };
};
@@ -2,8 +2,8 @@ import { useRecoilCallback } from 'recoil';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
export const useUpdatePageLayoutWidget = () => {
const updatePageLayoutWidget = useRecoilCallback(
export const usePageLayoutWidgetUpdate = () => {
const handleUpdateWidget = useRecoilCallback(
({ set }) =>
(widgetId: string, updates: Partial<PageLayoutWidget>) => {
set(pageLayoutDraftState, (prev) => ({
@@ -19,5 +19,5 @@ export const useUpdatePageLayoutWidget = () => {
[],
);
return { updatePageLayoutWidget };
return { handleUpdateWidget };
};
@@ -1,14 +0,0 @@
import { useRecoilCallback } from 'recoil';
import { pageLayoutSelectedCellsState } from '../states/pageLayoutSelectedCellsState';
export const useStartPageLayoutDragSelection = () => {
const startPageLayoutDragSelection = useRecoilCallback(
({ set }) =>
() => {
set(pageLayoutSelectedCellsState, new Set());
},
[],
);
return { startPageLayoutDragSelection };
};