Загрузка данных
diff --git a/package-lock.json b/package-lock.json
index ad067c16b..36d60d8a2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -39,7 +39,7 @@
"markdown-it-link-attributes": "^4.0.1",
"markdown-it-underline": "^1.0.1",
"meow": "^8.1.2",
- "moex-chart": "^0.1.12",
+ "moex-chart": "^0.1.13",
"rc-virtual-list": "^3.14.5",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
@@ -28809,9 +28809,9 @@
}
},
"node_modules/moex-chart": {
- "version": "0.1.12",
- "resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/moex-chart/-/moex-chart-0.1.12.tgz",
- "integrity": "sha1-0uaftIpE4fqxiyRu7ZNEseyXAcM=",
+ "version": "0.1.13",
+ "resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/moex-chart/-/moex-chart-0.1.13.tgz",
+ "integrity": "sha1-DjDtaVo3SKtpu/1x9+XKb5gMJuY=",
"dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
@@ -60585,9 +60585,9 @@
"dev": true
},
"moex-chart": {
- "version": "0.1.12",
- "resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/moex-chart/-/moex-chart-0.1.12.tgz",
- "integrity": "sha1-0uaftIpE4fqxiyRu7ZNEseyXAcM=",
+ "version": "0.1.13",
+ "resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/moex-chart/-/moex-chart-0.1.13.tgz",
+ "integrity": "sha1-DjDtaVo3SKtpu/1x9+XKb5gMJuY=",
"requires": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
diff --git a/package.json b/package.json
index df913f9be..707f844d8 100644
--- a/package.json
+++ b/package.json
@@ -146,7 +146,7 @@
"markdown-it-link-attributes": "^4.0.1",
"markdown-it-underline": "^1.0.1",
"meow": "^8.1.2",
- "moex-chart": "^0.1.12",
+ "moex-chart": "^0.1.13",
"rc-virtual-list": "^3.14.5",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
diff --git a/src/components/InstrumentSearch/components/Actions/MoexChartActions.tsx b/src/components/InstrumentSearch/components/Actions/MoexChartActions.tsx
index 4fad0da7a..63170eaae 100644
--- a/src/components/InstrumentSearch/components/Actions/MoexChartActions.tsx
+++ b/src/components/InstrumentSearch/components/Actions/MoexChartActions.tsx
@@ -6,12 +6,12 @@ import { Button } from '@uikit/Button';
import styles from './styles.module.scss';
type TMoexChartActionsProps = {
- selectedInstrumentRows: [] | Contract[];
+ selectedInstrumentRows: Contract[];
isNewScaleDisabled?: boolean;
customActionsFooterHandlers: {
- handlePercent: (symbol: string) => void;
- handleNewScale: (symbol: string) => void;
- handleNewPanel: (symbol: string) => void;
+ handlePercent: (instrument: Contract) => void;
+ handleNewScale: (instrument: Contract) => void;
+ handleNewPanel: (instrument: Contract) => void;
};
};
@@ -22,16 +22,16 @@ export const MoexChartActions = ({
}: TMoexChartActionsProps) => {
const { handlePercent, handleNewScale, handleNewPanel } = customActionsFooterHandlers;
- const selectedSymbol = selectedInstrumentRows[0]?.issKey;
- const isButtonDisabled = !selectedSymbol;
+ const selectedInstrument = selectedInstrumentRows[0];
+ const isButtonDisabled = !selectedInstrument?.issKey;
return (
<div className={styles.actionWrapper}>
<Button
text="%"
onClick={() => {
- if (selectedSymbol) {
- handlePercent(selectedSymbol);
+ if (selectedInstrument?.issKey) {
+ handlePercent(selectedInstrument);
}
}}
disabled={isButtonDisabled}
@@ -40,8 +40,8 @@ export const MoexChartActions = ({
<Button
text="Новая шкала"
onClick={() => {
- if (selectedSymbol) {
- handleNewScale(selectedSymbol);
+ if (selectedInstrument?.issKey) {
+ handleNewScale(selectedInstrument);
}
}}
disabled={isButtonDisabled || isNewScaleDisabled}
@@ -50,8 +50,8 @@ export const MoexChartActions = ({
<Button
text="Новая панель"
onClick={() => {
- if (selectedSymbol) {
- handleNewPanel(selectedSymbol);
+ if (selectedInstrument?.issKey) {
+ handleNewPanel(selectedInstrument);
}
}}
disabled={isButtonDisabled}
diff --git a/src/components/InstrumentSearch/components/Actions/__tests__/MoexChartActions.test.tsx b/src/components/InstrumentSearch/components/Actions/__tests__/MoexChartActions.test.tsx
index 64e65a890..05e49ded0 100644
--- a/src/components/InstrumentSearch/components/Actions/__tests__/MoexChartActions.test.tsx
+++ b/src/components/InstrumentSearch/components/Actions/__tests__/MoexChartActions.test.tsx
@@ -1,8 +1,8 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
-import '@testing-library/jest-dom';
import { Contract } from '@modules/contracts';
+import '@testing-library/jest-dom';
import { MoexChartActions } from '../MoexChartActions';
@@ -22,6 +22,7 @@ describe('MoexChartActions', () => {
});
it('should render correctly with no selected instruments', () => {
+ // Arrange & Act
render(
<MoexChartActions
selectedInstrumentRows={[]}
@@ -29,14 +30,17 @@ describe('MoexChartActions', () => {
/>,
);
+ // Assert
expect(screen.getByText('%')).toBeInTheDocument();
expect(screen.getByText('Новая шкала')).toBeInTheDocument();
expect(screen.getByText('Новая панель')).toBeInTheDocument();
});
it('should render correctly with selected instruments', () => {
+ // Arrange
const selectedInstruments = [mockContract];
+ // Act
render(
<MoexChartActions
selectedInstrumentRows={selectedInstruments as Contract[]}
@@ -44,6 +48,7 @@ describe('MoexChartActions', () => {
/>,
);
+ // Assert
expect(screen.getByText('%')).toBeInTheDocument();
expect(screen.getByText('Новая шкала')).toBeInTheDocument();
expect(screen.getByText('Новая панель')).toBeInTheDocument();
@@ -59,6 +64,7 @@ describe('MoexChartActions', () => {
});
it('should call handlePercent when percent button is clicked and instrument is selected', () => {
+ // Arrange
const selectedInstruments = [mockContract];
render(
@@ -69,13 +75,18 @@ describe('MoexChartActions', () => {
);
const percentButton = screen.getByText('%');
+
expect(percentButton).not.toBeDisabled();
+ // Act
percentButton.click();
- expect(mockHandlers.handlePercent).toHaveBeenCalledWith('RU000A0JQ0Y0');
+
+ // Assert
+ expect(mockHandlers.handlePercent).toHaveBeenCalledWith(mockContract);
});
it('should call handleNewScale when new scale button is clicked and instrument is selected', () => {
+ // Arrange
const selectedInstruments = [mockContract];
render(
@@ -86,13 +97,18 @@ describe('MoexChartActions', () => {
);
const newScaleButton = screen.getByText('Новая шкала');
+
expect(newScaleButton).not.toBeDisabled();
+ // Act
newScaleButton.click();
- expect(mockHandlers.handleNewScale).toHaveBeenCalledWith('RU000A0JQ0Y0');
+
+ // Assert
+ expect(mockHandlers.handleNewScale).toHaveBeenCalledWith(mockContract);
});
it('should call handleNewPanel when new panel button is clicked and instrument is selected', () => {
+ // Arrange
const selectedInstruments = [mockContract];
render(
@@ -103,14 +119,26 @@ describe('MoexChartActions', () => {
);
const newPanelButton = screen.getByText('Новая панель');
+
expect(newPanelButton).not.toBeDisabled();
+ // Act
newPanelButton.click();
- expect(mockHandlers.handleNewPanel).toHaveBeenCalledWith('RU000A0JQ0Y0');
+
+ // Assert
+ expect(mockHandlers.handleNewPanel).toHaveBeenCalledWith(mockContract);
});
it('should handle multiple selected instruments (only first is used)', () => {
- const multipleContracts = [{ issKey: 'RU000A0JQ0Y0' }, { issKey: 'RU000A0JQ0Y1' }];
+ // Arrange
+ const multipleContracts = [
+ {
+ issKey: 'RU000A0JQ0Y0',
+ },
+ {
+ issKey: 'RU000A0JQ0Y1',
+ },
+ ];
render(
<MoexChartActions
@@ -127,14 +155,15 @@ describe('MoexChartActions', () => {
expect(newScaleButton).not.toBeDisabled();
expect(newPanelButton).not.toBeDisabled();
- // Click buttons and verify they call handler with first instrument's issKey
+ // Act
percentButton.click();
- expect(mockHandlers.handlePercent).toHaveBeenCalledWith('RU000A0JQ0Y0');
-
newScaleButton.click();
- expect(mockHandlers.handleNewScale).toHaveBeenCalledWith('RU000A0JQ0Y0');
-
newPanelButton.click();
- expect(mockHandlers.handleNewPanel).toHaveBeenCalledWith('RU000A0JQ0Y0');
+
+ // Assert
+ // Verify that handlers are called with the first selected instrument
+ expect(mockHandlers.handlePercent).toHaveBeenCalledWith(multipleContracts[0]);
+ expect(mockHandlers.handleNewScale).toHaveBeenCalledWith(multipleContracts[0]);
+ expect(mockHandlers.handleNewPanel).toHaveBeenCalledWith(multipleContracts[0]);
});
});
diff --git a/src/components/InstrumentSearch/types/hooks.ts b/src/components/InstrumentSearch/types/hooks.ts
index a623c5b80..d82fa0da3 100644
--- a/src/components/InstrumentSearch/types/hooks.ts
+++ b/src/components/InstrumentSearch/types/hooks.ts
@@ -74,9 +74,9 @@ export type InstrumentSearchType = {
* TODO: убрать, когда разработаем свою модалку для moex_chart
*/
customActionsFooterHandlers?: {
- handlePercent: (symbol: string) => void;
- handleNewScale: (symbol: string) => void;
- handleNewPanel: (symbol: string) => void;
+ handlePercent: (instrument: Contract) => void;
+ handleNewScale: (instrument: Contract) => void;
+ handleNewPanel: (instrument: Contract) => void;
};
isNewScaleDisabled?: boolean;
};
diff --git a/src/widgets/Chart/__tests__/CompareModal.test.tsx b/src/widgets/Chart/__tests__/CompareModal.test.tsx
new file mode 100644
index 000000000..893eb8c5d
--- /dev/null
+++ b/src/widgets/Chart/__tests__/CompareModal.test.tsx
@@ -0,0 +1,255 @@
+import { act, render } from '@testing-library/react';
+
+import { CompareMode } from 'moex-chart';
+import React from 'react';
+
+import { InstrumentSearch } from '@components/InstrumentSearch';
+
+import { CompareModal } from '../components/MoexChart/components/CompareModal';
+
+import type { Contract } from '@modules/contracts';
+import type { __CompareManager__ } from 'moex-chart';
+import type { MutableRefObject } from 'react';
+
+jest.mock('moex-chart', () => ({
+ __esModule: true,
+ CompareMode: {
+ Percentage: 'PCT',
+ NewScale: 'SCALE',
+ NewPane: 'PANE',
+ },
+}));
+
+jest.mock('@components/InstrumentSearch', () => ({
+ InstrumentSearch: jest.fn(() => null),
+}));
+
+interface CompareActions {
+ handlePercent: (instrument: Contract) => void;
+ handleNewScale: (instrument: Contract) => void;
+ handleNewPanel: (instrument: Contract) => void;
+}
+
+interface InstrumentSearchMockProps {
+ widgetId: number;
+ variant: string;
+ isOpen: boolean;
+ setOpen: (isOpen: boolean) => void;
+ isNewScaleDisabled: boolean;
+ customActionsFooterHandlers: CompareActions;
+}
+
+describe('CompareModal', () => {
+ const mockInstrumentSearch = InstrumentSearch as jest.Mock;
+
+ const mockSetOpen = jest.fn();
+ const mockSetSymbolMode = jest.fn();
+ const mockIsNewScaleDisabled = jest.fn();
+ const mockIsNewScaleDisabledObservable = jest.fn();
+ const mockSubscribe = jest.fn();
+ const mockUnsubscribe = jest.fn();
+
+ let newScaleDisabledListener: ((disabled: boolean) => void) | null;
+ let compareManager: __CompareManager__;
+ let compareManagerRef: MutableRefObject<__CompareManager__ | null>;
+
+ const getInstrumentSearchProps = (): InstrumentSearchMockProps => {
+ const lastCall = mockInstrumentSearch.mock.calls[mockInstrumentSearch.mock.calls.length - 1];
+
+ return lastCall?.[0] as InstrumentSearchMockProps;
+ };
+
+ const renderComponent = (isOpen = true) =>
+ render(
+ <CompareModal
+ widgetId={42}
+ compareManager={compareManagerRef}
+ isOpen={isOpen}
+ setOpen={mockSetOpen}
+ />,
+ );
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ newScaleDisabledListener = null;
+
+ mockIsNewScaleDisabled.mockReturnValue(false);
+ mockSetSymbolMode.mockResolvedValue(undefined);
+
+ mockSubscribe.mockImplementation((listener: (disabled: boolean) => void) => {
+ newScaleDisabledListener = listener;
+
+ return {
+ unsubscribe: mockUnsubscribe,
+ };
+ });
+
+ mockIsNewScaleDisabledObservable.mockReturnValue({
+ subscribe: mockSubscribe,
+ });
+
+ compareManager = {
+ setSymbolMode: mockSetSymbolMode,
+ isNewScaleDisabled: mockIsNewScaleDisabled,
+ isNewScaleDisabledObservable: mockIsNewScaleDisabledObservable,
+ } as unknown as __CompareManager__;
+
+ compareManagerRef = {
+ current: compareManager,
+ };
+ });
+
+ it('should pass modal properties and current scale state to instrument search', () => {
+ // Arrange
+ mockIsNewScaleDisabled.mockReturnValue(true);
+
+ // Act
+ renderComponent();
+
+ const instrumentSearchProps = getInstrumentSearchProps();
+
+ // Assert
+ expect(instrumentSearchProps.widgetId).toBe(42);
+ expect(instrumentSearchProps.variant).toBe('single');
+ expect(instrumentSearchProps.isOpen).toBe(true);
+ expect(instrumentSearchProps.setOpen).toBe(mockSetOpen);
+ expect(instrumentSearchProps.isNewScaleDisabled).toBe(true);
+ expect(instrumentSearchProps.customActionsFooterHandlers).toEqual({
+ handlePercent: expect.any(Function),
+ handleNewScale: expect.any(Function),
+ handleNewPanel: expect.any(Function),
+ });
+ });
+
+ it('should subscribe to new scale disabled state', () => {
+ // Arrange & Act
+ renderComponent();
+
+ // Assert
+ expect(mockIsNewScaleDisabled).toHaveBeenCalledTimes(1);
+ expect(mockIsNewScaleDisabledObservable).toHaveBeenCalledTimes(1);
+ expect(mockSubscribe).toHaveBeenCalledTimes(1);
+ });
+
+ it('should update new scale disabled state from manager observable', () => {
+ // Arrange
+ renderComponent();
+
+ // Act
+ act(() => {
+ newScaleDisabledListener?.(true);
+ });
+
+ // Assert
+ expect(getInstrumentSearchProps().isNewScaleDisabled).toBe(true);
+ });
+
+ it('should unsubscribe from manager observable on unmount', () => {
+ // Arrange
+ const { unmount } = renderComponent();
+
+ // Act
+ unmount();
+
+ // Assert
+ expect(mockUnsubscribe).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not subscribe when modal is closed', () => {
+ // Arrange & Act
+ renderComponent(false);
+
+ // Assert
+ expect(mockIsNewScaleDisabled).not.toHaveBeenCalled();
+ expect(mockIsNewScaleDisabledObservable).not.toHaveBeenCalled();
+ expect(getInstrumentSearchProps().isNewScaleDisabled).toBe(false);
+ });
+
+ it('should not subscribe when compare manager is unavailable', () => {
+ // Arrange
+ compareManagerRef.current = null;
+
+ // Act
+ renderComponent();
+
+ // Assert
+ expect(mockIsNewScaleDisabled).not.toHaveBeenCalled();
+ expect(mockIsNewScaleDisabledObservable).not.toHaveBeenCalled();
+ expect(getInstrumentSearchProps().isNewScaleDisabled).toBe(false);
+ });
+
+ it.each([
+ ['handlePercent', CompareMode.Percentage],
+ ['handleNewScale', CompareMode.NewScale],
+ ['handleNewPanel', CompareMode.NewPane],
+ ] as const)('should add compare instrument using %s action', (handlerName, mode) => {
+ // Arrange
+ renderComponent();
+
+ const instrument = {
+ issKey: 'MXSE:TQBR:SBER',
+ displayName: 'Сбербанк',
+ symbol: 'SBER',
+ } as Contract;
+
+ const handlers = getInstrumentSearchProps().customActionsFooterHandlers;
+
+ // Act
+ handlers[handlerName](instrument);
+
+ // Assert
+ expect(mockSetSymbolMode).toHaveBeenCalledTimes(1);
+ expect(mockSetSymbolMode).toHaveBeenCalledWith(
+ 'Line',
+ {
+ symbolId: 'MXSE:TQBR:SBER',
+ symbol: 'SBER',
+ symbolName: 'Сбербанк',
+ },
+ mode,
+ );
+ });
+
+ it('should delegate missing symbol metadata fallback to moex chart', () => {
+ // Arrange
+ renderComponent();
+
+ const instrument = {
+ issKey: 'MXSE:TQBR:SBER',
+ displayName: '',
+ symbol: '',
+ } as Contract;
+
+ // Act
+ getInstrumentSearchProps().customActionsFooterHandlers.handlePercent(instrument);
+
+ // Assert
+ expect(mockSetSymbolMode).toHaveBeenCalledWith(
+ 'Line',
+ {
+ symbolId: 'MXSE:TQBR:SBER',
+ symbol: '',
+ symbolName: '',
+ },
+ CompareMode.Percentage,
+ );
+ });
+
+ it('should not add compare instrument without issKey', () => {
+ // Arrange
+ renderComponent();
+
+ const instrument = {
+ issKey: '',
+ displayName: 'Сбербанк',
+ symbol: 'SBER',
+ } as Contract;
+
+ // Act
+ getInstrumentSearchProps().customActionsFooterHandlers.handlePercent(instrument);
+
+ // Assert
+ expect(mockSetSymbolMode).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/widgets/Chart/__tests__/MoexChart.test.tsx b/src/widgets/Chart/__tests__/MoexChart.test.tsx
new file mode 100644
index 000000000..3350dc9b8
--- /dev/null
+++ b/src/widgets/Chart/__tests__/MoexChart.test.tsx
@@ -0,0 +1,205 @@
+import { act, render } from '@testing-library/react';
+
+import React from 'react';
+
+import { CompareModal } from '../components/MoexChart/components/CompareModal';
+import { SymbolSearchModal } from '../components/MoexChart/components/SymbolSearchModal';
+import { useMoexChart } from '../components/MoexChart/hooks';
+import MoexChartComponent from '../components/MoexChart/MoexChart';
+
+import type { SelectedInstrument } from '../types';
+
+import type { __CompareManager__, SymbolInfoInput } from 'moex-chart';
+import type { MutableRefObject } from 'react';
+
+jest.mock('../components/MoexChart/hooks', () => ({
+ useMoexChart: jest.fn(),
+}));
+
+jest.mock('../components/MoexChart/components/CompareModal', () => ({
+ CompareModal: jest.fn(() => null),
+}));
+
+jest.mock('../components/MoexChart/components/SymbolSearchModal', () => ({
+ SymbolSearchModal: jest.fn(() => null),
+}));
+
+interface CompareModalMockProps {
+ widgetId: number;
+ isOpen: boolean;
+ setOpen: (isOpen: boolean) => void;
+ compareManager: MutableRefObject<__CompareManager__ | null>;
+}
+
+interface SymbolSearchModalMockProps {
+ widgetId: number;
+ isOpen: boolean;
+ setOpen: (isOpen: boolean) => void;
+ onSymbolChange: (instrument: SelectedInstrument) => void;
+}
+
+describe('MoexChart', () => {
+ const mockUseMoexChart = useMoexChart as jest.Mock;
+ const mockCompareModal = CompareModal as jest.Mock;
+ const mockSymbolSearchModal = SymbolSearchModal as jest.Mock;
+
+ const mockSetIsCompareOpen = jest.fn();
+ const mockSetIsSymbolSearchOpen = jest.fn();
+ const mockAddInstrumentFromModal = jest.fn();
+
+ const symbolInfo: SymbolInfoInput = {
+ symbolId: 'MXSE:TQBR:SBER',
+ symbol: 'SBER',
+ symbolName: 'Сбербанк',
+ };
+
+ let containerRef: MutableRefObject<HTMLDivElement | null>;
+ let compareManagerRef: MutableRefObject<__CompareManager__ | null>;
+
+ const renderComponent = () =>
+ render(
+ <MoexChartComponent
+ symbolInfo={symbolInfo}
+ widgetId={42}
+ addInstrumentFromModal={mockAddInstrumentFromModal}
+ />,
+ );
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ containerRef = {
+ current: null,
+ };
+
+ compareManagerRef = {
+ current: null,
+ };
+
+ mockUseMoexChart.mockReturnValue({
+ containerRef,
+ isCompareOpen: false,
+ isSymbolSearchOpen: false,
+ compareManagerRef,
+ setIsCompareOpen: mockSetIsCompareOpen,
+ setIsSymbolSearchOpen: mockSetIsSymbolSearchOpen,
+ saveSnapshot: jest.fn(),
+ applySnapshot: jest.fn(),
+ hasSavedSnapshot: false,
+ });
+ });
+
+ it('should initialize chart hook with symbol info', () => {
+ // Arrange & Act
+ renderComponent();
+
+ // Assert
+ expect(mockUseMoexChart).toHaveBeenCalledTimes(1);
+ expect(mockUseMoexChart).toHaveBeenCalledWith({
+ symbolInfo,
+ indicativeData: undefined,
+ });
+ });
+
+ it('should attach chart container ref', () => {
+ // Arrange & Act
+ renderComponent();
+
+ // Assert
+ expect(containerRef.current).toBeInstanceOf(HTMLDivElement);
+ });
+
+ it('should not render modals when they are closed', () => {
+ // Arrange & Act
+ renderComponent();
+
+ // Assert
+ expect(mockCompareModal).not.toHaveBeenCalled();
+ expect(mockSymbolSearchModal).not.toHaveBeenCalled();
+ });
+
+ it('should render compare modal with chart manager', () => {
+ // Arrange
+ mockUseMoexChart.mockReturnValue({
+ containerRef,
+ isCompareOpen: true,
+ isSymbolSearchOpen: false,
+ compareManagerRef,
+ setIsCompareOpen: mockSetIsCompareOpen,
+ setIsSymbolSearchOpen: mockSetIsSymbolSearchOpen,
+ saveSnapshot: jest.fn(),
+ applySnapshot: jest.fn(),
+ hasSavedSnapshot: false,
+ });
+
+ // Act
+ renderComponent();
+
+ const compareModalProps = mockCompareModal.mock.calls[0]?.[0] as CompareModalMockProps;
+
+ // Assert
+ expect(compareModalProps.widgetId).toBe(42);
+ expect(compareModalProps.isOpen).toBe(true);
+ expect(compareModalProps.setOpen).toBe(mockSetIsCompareOpen);
+ expect(compareModalProps.compareManager).toBe(compareManagerRef);
+ });
+
+ it('should render symbol search modal', () => {
+ // Arrange
+ mockUseMoexChart.mockReturnValue({
+ containerRef,
+ isCompareOpen: false,
+ isSymbolSearchOpen: true,
+ compareManagerRef,
+ setIsCompareOpen: mockSetIsCompareOpen,
+ setIsSymbolSearchOpen: mockSetIsSymbolSearchOpen,
+ saveSnapshot: jest.fn(),
+ applySnapshot: jest.fn(),
+ hasSavedSnapshot: false,
+ });
+
+ // Act
+ renderComponent();
+
+ const symbolSearchModalProps = mockSymbolSearchModal.mock.calls[0]?.[0] as SymbolSearchModalMockProps;
+
+ // Assert
+ expect(symbolSearchModalProps.widgetId).toBe(42);
+ expect(symbolSearchModalProps.isOpen).toBe(true);
+ expect(symbolSearchModalProps.setOpen).toBe(mockSetIsSymbolSearchOpen);
+ });
+
+ it('should pass selected instrument from symbol search modal', () => {
+ // Arrange
+ mockUseMoexChart.mockReturnValue({
+ containerRef,
+ isCompareOpen: false,
+ isSymbolSearchOpen: true,
+ compareManagerRef,
+ setIsCompareOpen: mockSetIsCompareOpen,
+ setIsSymbolSearchOpen: mockSetIsSymbolSearchOpen,
+ saveSnapshot: jest.fn(),
+ applySnapshot: jest.fn(),
+ hasSavedSnapshot: false,
+ });
+
+ renderComponent();
+
+ const symbolSearchModalProps = mockSymbolSearchModal.mock.calls[0]?.[0] as SymbolSearchModalMockProps;
+
+ const instrument: SelectedInstrument = {
+ issKey: 'MXSE:TQBR:GAZP',
+ displayName: 'Газпром',
+ symbol: 'GAZP',
+ };
+
+ // Act
+ act(() => {
+ symbolSearchModalProps.onSymbolChange(instrument);
+ });
+
+ // Assert
+ expect(mockAddInstrumentFromModal).toHaveBeenCalledTimes(1);
+ expect(mockAddInstrumentFromModal).toHaveBeenCalledWith([instrument]);
+ });
+});
diff --git a/src/widgets/Chart/__tests__/SymbolSearchModal.test.tsx b/src/widgets/Chart/__tests__/SymbolSearchModal.test.tsx
index b61b7fc18..e8d2587f0 100644
--- a/src/widgets/Chart/__tests__/SymbolSearchModal.test.tsx
+++ b/src/widgets/Chart/__tests__/SymbolSearchModal.test.tsx
@@ -1,4 +1,5 @@
import { render } from '@testing-library/react';
+
import React from 'react';
import { InstrumentSearch } from '@components/InstrumentSearch';
@@ -54,12 +55,14 @@ describe('SymbolSearchModal', () => {
expect(instrumentSearchProps.addInstruments).toEqual(expect.any(Function));
});
- it('should change symbol after instrument selection', () => {
+ it('should pass selected instrument after instrument selection', () => {
// Arrange
const instrumentSearchProps = renderComponent();
const instrument = {
- issKey: 'MOEX:SBER',
+ issKey: 'MXSE:TQBR:SBER',
+ displayName: 'Сбербанк',
+ symbol: 'SBER',
} as Contract;
// Act
@@ -67,7 +70,30 @@ describe('SymbolSearchModal', () => {
// Assert
expect(mockOnSymbolChange).toHaveBeenCalledTimes(1);
- expect(mockOnSymbolChange).toHaveBeenCalledWith('MOEX:SBER');
+ expect(mockOnSymbolChange).toHaveBeenCalledWith(instrument);
+ });
+
+ it('should pass all required symbol metadata after instrument selection', () => {
+ // Arrange
+ const instrumentSearchProps = renderComponent();
+
+ const instrument = {
+ issKey: 'MXSE:TQBR:GAZP',
+ displayName: 'Газпром',
+ symbol: 'GAZP',
+ } as Contract;
+
+ // Act
+ instrumentSearchProps.addInstruments([instrument]);
+
+ // Assert
+ expect(mockOnSymbolChange).toHaveBeenCalledWith(
+ expect.objectContaining({
+ issKey: 'MXSE:TQBR:GAZP',
+ displayName: 'Газпром',
+ symbol: 'GAZP',
+ }),
+ );
});
it('should close modal after instrument selection', () => {
@@ -75,7 +101,9 @@ describe('SymbolSearchModal', () => {
const instrumentSearchProps = renderComponent();
const instrument = {
- issKey: 'MOEX:SBER',
+ issKey: 'MXSE:TQBR:SBER',
+ displayName: 'Сбербанк',
+ symbol: 'SBER',
} as Contract;
// Act
@@ -102,8 +130,13 @@ describe('SymbolSearchModal', () => {
// Arrange
const instrumentSearchProps = renderComponent();
+ const instrument = {
+ displayName: 'Сбербанк',
+ symbol: 'SBER',
+ } as Contract;
+
// Act
- instrumentSearchProps.addInstruments([{} as Contract]);
+ instrumentSearchProps.addInstruments([instrument]);
// Assert
expect(mockOnSymbolChange).not.toHaveBeenCalled();
@@ -116,6 +149,8 @@ describe('SymbolSearchModal', () => {
const instrument = {
issKey: '',
+ displayName: 'Сбербанк',
+ symbol: 'SBER',
} as Contract;
// Act
@@ -125,4 +160,23 @@ describe('SymbolSearchModal', () => {
expect(mockOnSymbolChange).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
+
+ it('should pass instrument with empty optional display metadata', () => {
+ // Arrange
+ const instrumentSearchProps = renderComponent();
+
+ const instrument = {
+ issKey: 'MXSE:TQBR:SBER',
+ displayName: '',
+ symbol: '',
+ } as Contract;
+
+ // Act
+ instrumentSearchProps.addInstruments([instrument]);
+
+ // Assert
+ expect(mockOnSymbolChange).toHaveBeenCalledTimes(1);
+ expect(mockOnSymbolChange).toHaveBeenCalledWith(instrument);
+ expect(mockSetOpen).toHaveBeenCalledWith(false);
+ });
});
diff --git a/src/widgets/Chart/__tests__/dataSourceProvide.test.ts b/src/widgets/Chart/__tests__/dataSourceProvide.test.ts
index 68529685a..1082c242d 100644
--- a/src/widgets/Chart/__tests__/dataSourceProvide.test.ts
+++ b/src/widgets/Chart/__tests__/dataSourceProvide.test.ts
@@ -1,3 +1,5 @@
+import { DEFAULT_SYMBOL } from '@widgets/Chart/const';
+
import type { Timeframes as TimeframesType } from 'moex-chart';
type DataSourceProvideModule = typeof import('@widgets/Chart/components/MoexChart/dataSourceProvide');
@@ -120,7 +122,6 @@ describe('DataSourceProvider', () => {
// Arrange
const mockTimeframeCallback = jest.fn();
- // mockMoexChartTimeConverter.mockReturnValue('1');
mockRequestBars.mockResolvedValue([mockBar]);
const provider = new DataSourceProvider();
@@ -148,6 +149,36 @@ describe('DataSourceProvider', () => {
expect(result).toEqual([mockBar]);
});
+ it('should not request chart history data for default symbol', async () => {
+ // Arrange
+ const mockTimeframeCallback = jest.fn();
+
+ const provider = new DataSourceProvider();
+
+ const dataSource = provider.getDataSource(undefined, mockTimeframeCallback);
+
+ // Act
+ const result = await dataSource(Timeframes['1m'], DEFAULT_SYMBOL);
+
+ // Assert
+ expect(result).toBeNull();
+ expect(mockRequestBars).not.toHaveBeenCalled();
+ });
+
+ it('should not request chart history data for empty symbol', async () => {
+ // Arrange
+ const provider = new DataSourceProvider();
+
+ const dataSource = provider.getDataSource();
+
+ // Act
+ const result = await dataSource(Timeframes['1m'], ' ');
+
+ // Assert
+ expect(result).toBeNull();
+ expect(mockRequestBars).not.toHaveBeenCalled();
+ });
+
it('should request chart history data with indicative data', async () => {
// Arrange
const indicativeData = {
@@ -160,7 +191,6 @@ describe('DataSourceProvider', () => {
key: 'SBER_TBQR',
};
- // mockMoexChartTimeConverter.mockReturnValue('1');
mockRequestBars.mockResolvedValue([mockBar]);
const provider = new DataSourceProvider();
@@ -204,7 +234,6 @@ describe('DataSourceProvider', () => {
it('should return null when history data is empty', async () => {
// Arrange
- // mockMoexChartTimeConverter.mockReturnValue('1');
mockRequestBars.mockResolvedValue([]);
const provider = new DataSourceProvider();
@@ -223,7 +252,6 @@ describe('DataSourceProvider', () => {
const provider = new DataSourceProvider();
const mockUpdate = jest.fn();
- // mockMoexChartTimeConverter.mockReturnValue('1');
const localMockedBar = {
time: 1640995200,
open: 100,
@@ -232,6 +260,7 @@ describe('DataSourceProvider', () => {
low: 90,
volume: Math.floor(Math.random() * 1000),
};
+
mockRequestRealtimeBars.mockImplementation(() => localMockedBar);
provider.startRealtime({
@@ -247,18 +276,65 @@ describe('DataSourceProvider', () => {
// Assert
expect(mockRequestRealtimeBars).toHaveBeenCalledWith({
- currencyPair: 'MOEX.SBER',
+ currencyPair: 'moex.sber',
interval: '1',
- ticker: 'MOEX:SBER',
+ ticker: 'moex:sber',
indicativeData: undefined,
});
- expect(mockUpdate).toHaveBeenCalledWith('MOEX:SBER', localMockedBar);
+ expect(mockUpdate).toHaveBeenCalledWith('moex:sber', localMockedBar);
+ });
+
+ it('should not request realtime data for default symbol', async () => {
+ // Arrange
+ const provider = new DataSourceProvider();
+ const mockUpdate = jest.fn();
+
+ const unsubscribe = provider.startRealtime({
+ getSymbols: () => [DEFAULT_SYMBOL],
+ getTimeframe: () => Timeframes['1m'],
+ update: mockUpdate,
+ periodMs: 1000,
+ });
+
+ // Act
+ jest.advanceTimersByTime(1000);
+ await flushPromises();
+
+ unsubscribe();
+
+ // Assert
+ expect(mockRequestRealtimeBars).not.toHaveBeenCalled();
+ expect(mockUpdate).not.toHaveBeenCalled();
+ });
+
+ it('should not request realtime data for default symbol with surrounding spaces', async () => {
+ // Arrange
+ const provider = new DataSourceProvider();
+ const mockUpdate = jest.fn();
+
+ const unsubscribe = provider.startRealtime({
+ getSymbols: () => [` ${DEFAULT_SYMBOL} `],
+ getTimeframe: () => Timeframes['1m'],
+ update: mockUpdate,
+ periodMs: 1000,
+ });
+
+ // Act
+ jest.advanceTimersByTime(1000);
+ await flushPromises();
+
+ unsubscribe();
+
+ // Assert
+ expect(mockRequestRealtimeBars).not.toHaveBeenCalled();
+ expect(mockUpdate).not.toHaveBeenCalled();
});
it('should request realtime data with indicative data', async () => {
// Arrange
const provider = new DataSourceProvider();
const mockUpdate = jest.fn();
+
const indicativeData = {
id: 1,
title: 'Test instrument',
@@ -269,7 +345,6 @@ describe('DataSourceProvider', () => {
key: 'SBER_TBQR',
};
- // mockMoexChartTimeConverter.mockReturnValue('1');
mockRequestRealtimeBars.mockResolvedValue(mockBar);
provider.startRealtime({
@@ -297,7 +372,6 @@ describe('DataSourceProvider', () => {
const provider = new DataSourceProvider();
const mockUpdate = jest.fn();
- // mockMoexChartTimeConverter.mockReturnValue('1');
mockRequestRealtimeBars.mockResolvedValue(undefined);
provider.startRealtime({
@@ -319,7 +393,6 @@ describe('DataSourceProvider', () => {
// Arrange
const provider = new DataSourceProvider();
- // mockMoexChartTimeConverter.mockReturnValue('1');
mockRequestRealtimeBars.mockResolvedValue(mockBar);
provider.startRealtime({
@@ -341,7 +414,6 @@ describe('DataSourceProvider', () => {
// Arrange
const provider = new DataSourceProvider();
- // mockMoexChartTimeConverter.mockReturnValue('1');
mockRequestRealtimeBars.mockResolvedValue(mockBar);
const unsubscribe = provider.startRealtime({
@@ -353,6 +425,7 @@ describe('DataSourceProvider', () => {
// Act
unsubscribe();
+
jest.advanceTimersByTime(1000);
await flushPromises();
@@ -369,6 +442,7 @@ describe('DataSourceProvider', () => {
mockRequestBars.mockResolvedValue(historyData);
const provider = new DataSourceProvider();
+
const dataSource = provider.getDataSource();
// Act
@@ -396,6 +470,7 @@ describe('DataSourceProvider', () => {
mockRequestBars.mockResolvedValue(historyData);
const provider = new DataSourceProvider();
+
const dataSource = provider.getDataSource();
// Act
@@ -432,6 +507,7 @@ describe('DataSourceProvider', () => {
mockRequestBars.mockResolvedValue(historyData);
const provider = new DataSourceProvider();
+
const dataSource = provider.getDataSource();
// Act
@@ -458,6 +534,7 @@ describe('DataSourceProvider', () => {
mockRequestBars.mockResolvedValue([createBar(0), createBar(1)]);
const provider = new DataSourceProvider();
+
const dataSource = provider.getDataSource();
// Act
diff --git a/src/widgets/Chart/__tests__/useChartComponentFacade.test.tsx b/src/widgets/Chart/__tests__/useChartComponentFacade.test.tsx
new file mode 100644
index 000000000..b6341a06b
--- /dev/null
+++ b/src/widgets/Chart/__tests__/useChartComponentFacade.test.tsx
@@ -0,0 +1,393 @@
+import { act, renderHook } from '@testing-library/react';
+
+import { useDispatch } from 'react-redux';
+
+import { communicator } from '@core/comm';
+import { useAppSelect } from '@hooks/useAppSelector';
+import { CORPACTIONS_OPEN_EXESTED_WIDGET_EVENT, HIGHLIGHT_WIDGET_EVENT } from '@modules/widgets/shared';
+import { addContentPropsToWidget, unbindWidgets } from '@store/slices/widgets';
+import { getState } from '@store/store';
+import { useWidgetsBind } from '@utils/hooks/useWidgetsBind';
+
+import useChartComponentFacade from '../hooks/useChartComponentFacade';
+import { useChartPublicContext } from '../hooks/useChartPublicContext';
+
+import type { WidgetProperties } from '../properties/types';
+import type { ChartContainerProps } from '../types';
+
+import type { SymbolInfoInput } from 'moex-chart';
+
+jest.mock('@store/store', () => ({
+ getState: jest.fn(),
+}));
+
+jest.mock('@store/slices/widgets', () => ({
+ addContentPropsToWidget: jest.fn(),
+ unbindWidgets: jest.fn(),
+}));
+
+jest.mock('react-redux', () => ({
+ useDispatch: jest.fn(),
+}));
+
+jest.mock('@core/comm', () => ({
+ communicator: {
+ listen: jest.fn(),
+ },
+}));
+
+jest.mock('@hooks/useAppSelector', () => ({
+ useAppSelect: jest.fn(),
+}));
+
+jest.mock('@utils/hooks/useWidgetsBind', () => ({
+ useWidgetsBind: jest.fn(),
+}));
+
+jest.mock('../hooks/useChartPublicContext', () => ({
+ useChartPublicContext: jest.fn(),
+}));
+
+interface PublicContextMockParams {
+ setCurrInstrument: (symbolInfo: SymbolInfoInput) => void;
+}
+
+describe('useChartComponentFacade', () => {
+ const mockUseDispatch = useDispatch as jest.Mock;
+ const mockUseAppSelect = useAppSelect as jest.Mock;
+ const mockAddContentPropsToWidget = addContentPropsToWidget as unknown as jest.Mock;
+ const mockUnbindWidgets = unbindWidgets as unknown as jest.Mock;
+ const mockGetState = getState as jest.Mock;
+ const mockUseWidgetsBind = useWidgetsBind as jest.Mock;
+ const mockUseChartPublicContext = useChartPublicContext as jest.Mock;
+ const mockCommunicatorListen = communicator.listen as jest.Mock;
+
+ const mockDispatch = jest.fn();
+ const mockTriggerRelatedWidgetsToUpdate = jest.fn();
+ const mockGetMasterInstrumentFromPublicContext = jest.fn();
+
+ const mockUnsubscribeCorpActions = jest.fn();
+ const mockUnsubscribeHighlighter = jest.fn();
+
+ const widgetProperties = {
+ chartState: {
+ savedInstrument: 'MOEX:SBER',
+ savedSymbol: 'SBER',
+ savedSymbolName: 'Сбербанк',
+ interval: '1m',
+ },
+ indicativeData: {
+ id: 1,
+ key: 'INDICATIVE',
+ secId: 'INAV',
+ instrumentName: 'Индикатив',
+ settlement: 'Расчётный',
+ firmName: 'Тестовая фирма',
+ },
+ } as WidgetProperties;
+
+ const renderFacade = () =>
+ renderHook(() =>
+ useChartComponentFacade({
+ widgetId: 42,
+ } as ChartContainerProps),
+ );
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockUseDispatch.mockReturnValue(mockDispatch);
+ mockUseAppSelect.mockReturnValue(widgetProperties);
+
+ mockGetState.mockReturnValue({
+ widgets: {
+ widgets: [
+ {
+ id: 42,
+ widgetContentProps: widgetProperties,
+ },
+ ],
+ },
+ });
+
+ mockUseWidgetsBind.mockReturnValue({
+ triggerRelatedWidgetsToUpdate: mockTriggerRelatedWidgetsToUpdate,
+ getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
+ });
+
+ mockUseChartPublicContext.mockReturnValue({
+ issKey: undefined,
+ });
+
+ mockAddContentPropsToWidget.mockImplementation((payload) => ({
+ type: 'widgets/addContentPropsToWidget',
+ payload,
+ }));
+
+ mockUnbindWidgets.mockImplementation((payload) => ({
+ type: 'widgets/unbindWidgets',
+ payload,
+ }));
+
+ mockCommunicatorListen.mockImplementation(({ messageType }) => {
+ if (messageType === CORPACTIONS_OPEN_EXESTED_WIDGET_EVENT) {
+ return mockUnsubscribeCorpActions;
+ }
+
+ if (messageType === HIGHLIGHT_WIDGET_EVENT) {
+ return mockUnsubscribeHighlighter;
+ }
+
+ return jest.fn();
+ });
+ });
+
+ it('should initialize symbol info from widget properties', () => {
+ // Arrange & Act
+ const { result } = renderFacade();
+
+ // Assert
+ expect(result.current.currentSymbolInfo).toEqual({
+ symbolId: 'MOEX:SBER',
+ symbol: 'SBER',
+ symbolName: 'Сбербанк',
+ });
+ });
+
+ it('should update and save instrument selected from modal', () => {
+ // Arrange
+ const { result } = renderFacade();
+
+ jest.clearAllMocks();
+
+ // Act
+ act(() => {
+ result.current.addInstrumentFromModal([
+ {
+ issKey: 'MOEX:GAZP',
+ displayName: 'Газпром',
+ symbol: 'GAZP',
+ },
+ ]);
+ });
+
+ // Assert
+ expect(result.current.currentSymbolInfo).toEqual({
+ symbolId: 'MOEX:GAZP',
+ symbol: 'GAZP',
+ symbolName: 'Газпром',
+ });
+
+ expect(mockUnbindWidgets).toHaveBeenCalledWith({
+ widgetId: 42,
+ });
+
+ expect(mockAddContentPropsToWidget).toHaveBeenCalledWith({
+ id: 42,
+ withoutSend: true,
+ widgetContentProps: {
+ chartState: {
+ ...widgetProperties.chartState,
+ savedInstrument: 'MOEX:GAZP',
+ savedSymbol: 'GAZP',
+ savedSymbolName: 'Газпром',
+ },
+ indicativeData: undefined,
+ },
+ });
+
+ expect(mockTriggerRelatedWidgetsToUpdate).toHaveBeenCalledWith('MOEX:GAZP');
+ });
+
+ it('should update symbol metadata without unbinding when symbol id is unchanged', () => {
+ // Arrange
+ const { result } = renderFacade();
+
+ jest.clearAllMocks();
+
+ // Act
+ act(() => {
+ result.current.addInstrumentFromModal([
+ {
+ issKey: 'MOEX:SBER',
+ displayName: 'Сбербанк ПАО',
+ symbol: 'SBERP',
+ },
+ ]);
+ });
+
+ // Assert
+ expect(result.current.currentSymbolInfo).toEqual({
+ symbolId: 'MOEX:SBER',
+ symbol: 'SBERP',
+ symbolName: 'Сбербанк ПАО',
+ });
+
+ expect(mockUnbindWidgets).not.toHaveBeenCalled();
+ expect(mockTriggerRelatedWidgetsToUpdate).not.toHaveBeenCalled();
+
+ expect(mockAddContentPropsToWidget).toHaveBeenCalledWith({
+ id: 42,
+ withoutSend: true,
+ widgetContentProps: {
+ chartState: {
+ ...widgetProperties.chartState,
+ savedInstrument: 'MOEX:SBER',
+ savedSymbol: 'SBERP',
+ savedSymbolName: 'Сбербанк ПАО',
+ },
+ indicativeData: widgetProperties.indicativeData,
+ },
+ });
+ });
+
+ it('should send dropped instrument update immediately', () => {
+ // Arrange
+ const { result } = renderFacade();
+
+ jest.clearAllMocks();
+
+ // Act
+ act(() => {
+ result.current.onDropInstrument('MOEX:LKOH', true);
+ });
+
+ // Assert
+ expect(result.current.currentSymbolInfo).toEqual({
+ symbolId: 'MOEX:LKOH',
+ symbol: undefined,
+ symbolName: undefined,
+ });
+
+ expect(mockAddContentPropsToWidget).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 42,
+ withoutSend: false,
+ widgetContentProps: expect.objectContaining({
+ chartState: expect.objectContaining({
+ savedInstrument: 'MOEX:LKOH',
+ savedSymbol: undefined,
+ savedSymbolName: undefined,
+ }),
+ }),
+ }),
+ );
+ });
+
+ it('should update instrument from public context without unbinding widget', () => {
+ // Arrange
+ renderFacade();
+
+ const publicContextParams = mockUseChartPublicContext.mock.calls[0]?.[0] as PublicContextMockParams;
+
+ jest.clearAllMocks();
+
+ // Act
+ act(() => {
+ publicContextParams.setCurrInstrument({
+ symbolId: 'MOEX:ROSN',
+ symbol: 'ROSN',
+ symbolName: 'Роснефть',
+ });
+ });
+
+ // Assert
+ expect(mockUnbindWidgets).not.toHaveBeenCalled();
+
+ expect(mockAddContentPropsToWidget).toHaveBeenCalledWith(
+ expect.objectContaining({
+ withoutSend: false,
+ widgetContentProps: expect.objectContaining({
+ chartState: expect.objectContaining({
+ savedInstrument: 'MOEX:ROSN',
+ savedSymbol: 'ROSN',
+ savedSymbolName: 'Роснефть',
+ }),
+ }),
+ }),
+ );
+ });
+
+ it('should delegate missing symbol metadata fallback to moex chart', () => {
+ // Arrange
+ const { result } = renderFacade();
+
+ const corpActionsListener = mockCommunicatorListen.mock.calls.find(
+ ([options]) => options.messageType === CORPACTIONS_OPEN_EXESTED_WIDGET_EVENT,
+ )?.[1] as (message: Record<number, string>) => void;
+
+ // Act
+ act(() => {
+ corpActionsListener({
+ 42: 'MOEX:UNKNOWN',
+ });
+ });
+
+ // Assert
+ expect(result.current.currentSymbolInfo).toEqual({
+ symbolId: 'MOEX:UNKNOWN',
+ symbol: undefined,
+ symbolName: undefined,
+ });
+
+ expect(mockAddContentPropsToWidget).toHaveBeenCalledWith(
+ expect.objectContaining({
+ widgetContentProps: expect.objectContaining({
+ chartState: expect.objectContaining({
+ savedInstrument: 'MOEX:UNKNOWN',
+ savedSymbol: undefined,
+ savedSymbolName: undefined,
+ }),
+ }),
+ }),
+ );
+ });
+
+ it('should not update instrument when modal selection is empty', () => {
+ // Arrange
+ const { result } = renderFacade();
+
+ jest.clearAllMocks();
+
+ // Act
+ act(() => {
+ result.current.addInstrumentFromModal([]);
+ });
+
+ // Assert
+ expect(mockAddContentPropsToWidget).not.toHaveBeenCalled();
+ expect(mockUnbindWidgets).not.toHaveBeenCalled();
+ expect(mockTriggerRelatedWidgetsToUpdate).not.toHaveBeenCalled();
+ });
+
+ it('should update widget highlight state', () => {
+ // Arrange
+ const { result } = renderFacade();
+
+ const highlighterListener = mockCommunicatorListen.mock.calls.find(
+ ([options]) => options.messageType === HIGHLIGHT_WIDGET_EVENT,
+ )?.[1] as (message: Record<number, boolean>) => void;
+
+ // Act
+ act(() => {
+ highlighterListener({
+ 42: true,
+ });
+ });
+
+ // Assert
+ expect(result.current.isOver).toBe(true);
+ });
+
+ it('should unsubscribe communicator listeners on unmount', () => {
+ // Arrange
+ const { unmount } = renderFacade();
+
+ // Act
+ unmount();
+
+ // Assert
+ expect(mockUnsubscribeCorpActions).toHaveBeenCalledTimes(1);
+ expect(mockUnsubscribeHighlighter).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/widgets/Chart/__tests__/useChartPublicContext.test.tsx b/src/widgets/Chart/__tests__/useChartPublicContext.test.tsx
index 666aa3073..0b0f20572 100644
--- a/src/widgets/Chart/__tests__/useChartPublicContext.test.tsx
+++ b/src/widgets/Chart/__tests__/useChartPublicContext.test.tsx
@@ -7,7 +7,8 @@ import { DEFAULT_SYMBOL } from '@widgets/Chart/const';
import { useChartPublicContext } from '../hooks/useChartPublicContext';
-// Mock the dependencies
+import type { Contract } from '@modules/contracts';
+
jest.mock('@hooks/useAppSelector');
jest.mock('@modules/contracts');
jest.mock('@utils/filterByUniqIssKey');
@@ -24,6 +25,7 @@ jest.mock('@api/index', () => ({
update: jest.fn(),
},
}));
+
jest.mock('@api/controllers/workspace', () => ({
workspaceController: {
update: jest.fn(),
@@ -36,32 +38,43 @@ describe('useChartPublicContext', () => {
const mockFilterByUniqIssKey = filterByUniqIssKey as jest.Mock;
const mockContracts = [
+ {
+ issKey: DEFAULT_SYMBOL,
+ displayName: 'Инструмент по умолчанию',
+ symbol: 'DEFAULT',
+ // ... other contract properties
+ },
{
issKey: 'MOEX:TEST1',
- instrName: 'Test Instrument 1',
+ displayName: 'Test Instrument 1',
symbol: 'TEST1',
// ... other contract properties
},
{
issKey: 'MOEX:TEST2',
- instrName: 'Test Instrument 2',
+ displayName: 'Test Instrument 2',
symbol: 'TEST2',
// ... other contract properties
},
{
issKey: 'MOEX:TEST3',
- instrName: 'Test Instrument 3',
+ displayName: 'Test Instrument 3',
symbol: 'TEST3',
// ... other contract properties
},
- ];
+ ] as Contract[];
const mockWidget = {
id: 1,
name: 'Test Widget',
type: 'graphic',
master: 123,
- externalProperties: [{ key: 'instrument', value: 'MOEX:TEST1' }],
+ externalProperties: [
+ {
+ key: 'instrument',
+ value: 'MOEX:TEST1',
+ },
+ ],
// ... other widget properties
};
@@ -77,9 +90,11 @@ describe('useChartPublicContext', () => {
if (selector.toString().includes('publicContext')) {
return mockPublicContext;
}
+
if (selector.toString().includes('widgets')) {
return mockWidget;
}
+
return {};
});
@@ -89,7 +104,7 @@ describe('useChartPublicContext', () => {
});
// Mock filterByUniqIssKey to return the same contracts
- mockFilterByUniqIssKey.mockImplementation((contracts, keys) => contracts);
+ mockFilterByUniqIssKey.mockImplementation((contracts: Contract[]) => contracts);
});
it('should return the correct issKey when a matching instrument is found', () => {
@@ -110,13 +125,13 @@ describe('useChartPublicContext', () => {
expect(result.current.issKey).toBe('MOEX:TEST1');
});
- it('should return undefined when no matching instrument is found', () => {
+ it('should pass symbol info when a matching instrument is found', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
- const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue('MOEX:NONEXISTENT');
+ const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue('MOEX:TEST2');
// Act
- const { result } = renderHook(() =>
+ renderHook(() =>
useChartPublicContext({
widgetId: 1,
setCurrInstrument: mockSetCurrInstrument,
@@ -125,13 +140,18 @@ describe('useChartPublicContext', () => {
);
// Assert
- expect(result.current.issKey).toBeUndefined();
+ expect(mockSetCurrInstrument).toHaveBeenCalledTimes(1);
+ expect(mockSetCurrInstrument).toHaveBeenCalledWith({
+ symbolId: 'MOEX:TEST2',
+ symbol: 'TEST2',
+ symbolName: 'Test Instrument 2',
+ });
});
- it('should return undefined when getMasterInstrumentFromPublicContext returns null', () => {
+ it('should return undefined when no matching instrument is found', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
- const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue(null);
+ const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue('MOEX:NONEXISTENT');
// Act
const { result } = renderHook(() =>
@@ -144,12 +164,13 @@ describe('useChartPublicContext', () => {
// Assert
expect(result.current.issKey).toBeUndefined();
+ expect(mockSetCurrInstrument).not.toHaveBeenCalled();
});
- it('should return undefined when getMasterInstrumentFromPublicContext returns undefined', () => {
+ it('should return undefined when getMasterInstrumentFromPublicContext returns null', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
- const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue(undefined);
+ const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue(null);
// Act
const { result } = renderHook(() =>
@@ -164,13 +185,13 @@ describe('useChartPublicContext', () => {
expect(result.current.issKey).toBeUndefined();
});
- it('should set current instrument to DEFAULT_SYMBOL when no field value and widget has master', () => {
+ it('should return undefined when getMasterInstrumentFromPublicContext returns undefined', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
- const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue(null);
+ const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue(undefined);
// Act
- renderHook(() =>
+ const { result } = renderHook(() =>
useChartPublicContext({
widgetId: 1,
setCurrInstrument: mockSetCurrInstrument,
@@ -179,13 +200,13 @@ describe('useChartPublicContext', () => {
);
// Assert
- expect(mockSetCurrInstrument).toHaveBeenCalledWith(DEFAULT_SYMBOL);
+ expect(result.current.issKey).toBeUndefined();
});
- it('should set current instrument to the found issKey when a matching instrument exists', () => {
+ it('should set default symbol info when context value is empty', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
- const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue('MOEX:TEST2');
+ const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue(null);
// Act
renderHook(() =>
@@ -197,29 +218,35 @@ describe('useChartPublicContext', () => {
);
// Assert
- expect(mockSetCurrInstrument).toHaveBeenCalledWith('MOEX:TEST2');
+ expect(mockSetCurrInstrument).toHaveBeenCalledTimes(1);
+ expect(mockSetCurrInstrument).toHaveBeenCalledWith({
+ symbolId: DEFAULT_SYMBOL,
+ symbol: 'DEFAULT',
+ symbolName: 'Инструмент по умолчанию',
+ });
});
- it('should handle case when widget is not found in widgets array', () => {
+ it('should resolve instrument when widget is not found', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue('MOEX:TEST1');
- // Mock useAppSelect to return null for widget (widget not found)
mockUseAppSelect.mockImplementation((selector) => {
if (selector.toString().includes('publicContext')) {
return mockPublicContext;
}
+
if (selector.toString().includes('widgets')) {
- return null; // Widget not found
+ return null;
}
+
return {};
});
// Act
const { result } = renderHook(() =>
useChartPublicContext({
- widgetId: 999, // Non-existent widget ID
+ widgetId: 999,
setCurrInstrument: mockSetCurrInstrument,
getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
}),
@@ -227,14 +254,18 @@ describe('useChartPublicContext', () => {
// Assert
expect(result.current.issKey).toBe('MOEX:TEST1');
+ expect(mockSetCurrInstrument).toHaveBeenCalledWith({
+ symbolId: 'MOEX:TEST1',
+ symbol: 'TEST1',
+ symbolName: 'Test Instrument 1',
+ });
});
- it('should handle case when contracts array is empty', () => {
+ it('should not set instrument when contracts array is empty', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue('MOEX:TEST1');
- // Mock useContracts to return empty contracts
mockUseContracts.mockReturnValue({
contracts: [],
});
@@ -250,5 +281,6 @@ describe('useChartPublicContext', () => {
// Assert
expect(result.current.issKey).toBeUndefined();
+ expect(mockSetCurrInstrument).not.toHaveBeenCalled();
});
});
diff --git a/src/widgets/Chart/__tests__/useMoexchart.test.tsx b/src/widgets/Chart/__tests__/useMoexchart.test.tsx
index 2396e0910..f60feb2d8 100644
--- a/src/widgets/Chart/__tests__/useMoexchart.test.tsx
+++ b/src/widgets/Chart/__tests__/useMoexchart.test.tsx
@@ -4,11 +4,12 @@ import { MoexChart, Timeframes } from 'moex-chart';
import React from 'react';
import { useChangeProperties, useSelectProperties } from '@modules/widgetProperties';
-import { ChartIndicativeData } from '@widgets/Chart/types';
import { useMoexChart } from '../components/MoexChart/hooks';
-// Mock the dependencies
+import type { ChartIndicativeData } from '@widgets/Chart/types';
+import type { SymbolInfoInput } from 'moex-chart';
+
jest.mock('@modules/widgetProperties');
jest.mock('../components/MoexChart/dataSourceProvide', () => ({
@@ -59,13 +60,37 @@ const mockedDataSourceProvide = jest.requireMock('../components/MoexChart/dataSo
type TimeframeValue = (typeof Timeframes)[keyof typeof Timeframes];
+interface MockCompareSeries {
+ name: string;
+ seriesOptions?: {
+ priceScaleId?: string;
+ color?: string;
+ };
+}
+
+interface MockIndicatorConfig {
+ symbolInfo?: SymbolInfoInput;
+ label?: string;
+ newPane?: boolean;
+ series: MockCompareSeries[];
+}
+
+interface MockIndicatorSnapshot {
+ id?: string;
+ indicatorType?: string;
+ dataSource?: unknown;
+ config?: MockIndicatorConfig;
+}
+
interface MockPaneSnapshot {
- indicators: unknown[];
+ indicators: MockIndicatorSnapshot[];
}
interface MockChartSnapshot {
charts: {
+ symbolId: string;
symbol: string;
+ symbolName: string;
timeframe: TimeframeValue;
chartSeriesType: string;
panes: MockPaneSnapshot[];
@@ -88,7 +113,8 @@ interface MockMoexChartConfig {
}
interface MockMoexChartState {
- tf?: TimeframeValue;
+ timeframe: TimeframeValue;
+ initialInterval?: string;
savedData?: string;
}
@@ -101,13 +127,20 @@ type UpdatePropertiesCallback = (state: MockPropertiesState) => void;
type TimeframeChangeCallback = (timeframe: TimeframeValue) => void;
interface TestComponentProps {
- symbol?: string;
+ symbolInfo?: SymbolInfoInput;
+ indicativeData?: ChartIndicativeData;
}
+const defaultSymbolInfo: SymbolInfoInput = {
+ symbolId: 'MOEX:SBER',
+ symbol: 'SBER',
+ symbolName: 'Сбербанк',
+};
+
describe('useMoexChart', () => {
const mockUseSelectProperties = useSelectProperties as jest.Mock;
const mockUseChangeProperties = useChangeProperties as jest.Mock;
- const mockMoexChart = MoexChart as jest.Mock;
+ const mockMoexChart = MoexChart as unknown as jest.Mock;
const mockDataSourceProvider = mockedDataSourceProvide.DataSourceProvider;
const mockUpdateProperties = jest.fn();
@@ -117,6 +150,7 @@ describe('useMoexChart', () => {
const mockSetSnapshot = jest.fn();
const mockSetSymbol = jest.fn();
const mockGetCompareManager = jest.fn();
+ const mockSetSettings = jest.fn();
const mockDataSource = jest.fn();
const mockStartRealtime = jest.fn();
@@ -130,7 +164,9 @@ describe('useMoexChart', () => {
const mockSnapshot: MockChartSnapshot = {
charts: [
{
- symbol: 'OLD:SYMBOL',
+ symbolId: 'OLD:SYMBOL',
+ symbol: 'OLD',
+ symbolName: 'Old instrument',
timeframe: Timeframes['5m'],
chartSeriesType: 'Candlestick',
panes: [
@@ -146,10 +182,13 @@ describe('useMoexChart', () => {
let lastMoexChartConfig: MockMoexChartConfig | null = null;
let mockMoexChartState: MockMoexChartState | undefined;
- const TestComponent = ({ symbol = 'MOEX:SBER' }: TestComponentProps): React.ReactElement => {
+ const TestComponent = ({
+ symbolInfo = defaultSymbolInfo,
+ indicativeData,
+ }: TestComponentProps): React.ReactElement => {
hookResult = useMoexChart({
- symbol,
- indicativeData: undefined,
+ symbolInfo,
+ indicativeData,
});
return <div ref={hookResult.containerRef} />;
@@ -157,12 +196,13 @@ describe('useMoexChart', () => {
beforeEach(() => {
jest.clearAllMocks();
+ jest.useFakeTimers();
hookResult = null;
lastMoexChartConfig = null;
mockMoexChartState = {
- tf: Timeframes['1m'],
+ timeframe: Timeframes['1m'],
savedData: undefined,
};
@@ -177,8 +217,8 @@ describe('useMoexChart', () => {
});
mockGetDataSource.mockImplementation(
- (_indicativeData?: ChartIndicativeData, callback?: (timeframe: Timeframes) => void) =>
- (timeframe: Timeframes) => {
+ (_indicativeData?: ChartIndicativeData, callback?: (timeframe: TimeframeValue) => void) =>
+ (timeframe: TimeframeValue) => {
callback?.(timeframe);
return mockDataSource();
@@ -203,46 +243,70 @@ describe('useMoexChart', () => {
getSnapshot: mockGetSnapshot,
setSnapshot: mockSetSnapshot,
setSymbol: mockSetSymbol,
+ setSettings: mockSetSettings,
getCompareManager: mockGetCompareManager,
};
});
});
- it('should create moex chart with current symbol and timeframe', () => {
+ afterEach(() => {
+ jest.clearAllTimers();
+ jest.useRealTimers();
+ });
+
+ it('should create moex chart with current symbol info and timeframe', () => {
// Arrange & Act
- render(<TestComponent symbol="MOEX:SBER" />);
+ render(<TestComponent />);
// Assert
expect(mockMoexChart).toHaveBeenCalledTimes(1);
expect(mockDataSourceProvider).toHaveBeenCalledTimes(1);
-
expect(lastMoexChartConfig?.container).toBeInstanceOf(HTMLDivElement);
- expect(lastMoexChartConfig?.snapshot.charts[0]?.symbol).toBe('MOEX:SBER');
- expect(lastMoexChartConfig?.snapshot.charts[0]?.timeframe).toBe(Timeframes['1m']);
-
+ expect(lastMoexChartConfig?.snapshot.charts[0]).toEqual(
+ expect.objectContaining({
+ symbolId: 'MOEX:SBER',
+ symbol: 'SBER',
+ symbolName: 'Сбербанк',
+ timeframe: Timeframes['1m'],
+ }),
+ );
expect(hookResult?.compareManagerRef.current).toBe(mockCompareManager);
+ expect(hookResult?.hasSavedSnapshot).toBe(false);
});
- it('should create chart with saved snapshot when saved data exists', () => {
+ it('should create chart with saved snapshot and current symbol info', () => {
// Arrange
mockMoexChartState = {
- tf: Timeframes['5m'],
+ timeframe: Timeframes['5m'],
savedData: JSON.stringify(mockSnapshot),
};
// Act
- render(<TestComponent symbol="MOEX:GAZP" />);
+ render(
+ <TestComponent
+ symbolInfo={{
+ symbolId: 'MOEX:GAZP',
+ symbol: 'GAZP',
+ symbolName: 'Газпром',
+ }}
+ />,
+ );
// Assert
- expect(lastMoexChartConfig?.snapshot.charts[0]?.symbol).toBe('MOEX:GAZP');
- expect(lastMoexChartConfig?.snapshot.charts[0]?.timeframe).toBe(Timeframes['5m']);
-
+ expect(lastMoexChartConfig?.snapshot.charts[0]).toEqual(
+ expect.objectContaining({
+ symbolId: 'MOEX:GAZP',
+ symbol: 'GAZP',
+ symbolName: 'Газпром',
+ timeframe: Timeframes['5m'],
+ }),
+ );
expect(hookResult?.hasSavedSnapshot).toBe(true);
});
it('should save chart snapshot to widget properties', () => {
// Arrange
- render(<TestComponent symbol="MOEX:SBER" />);
+ render(<TestComponent />);
// Act
act(() => {
@@ -257,23 +321,138 @@ describe('useMoexChart', () => {
const mockState: MockPropertiesState = {
moexChartState: {
- tf: Timeframes['1m'],
+ timeframe: Timeframes['1m'],
},
};
updateCallback(mockState);
expect(mockState.moexChartState).toEqual({
- tf: Timeframes['1m'],
+ timeframe: Timeframes['1m'],
savedData: JSON.stringify(mockSnapshot),
});
});
+ it('should serialize compare symbol info in snapshot', () => {
+ // Arrange
+ const snapshotWithIndicators: MockChartSnapshot = {
+ charts: [
+ {
+ symbolId: 'MOEX:SBER',
+ symbol: 'SBER',
+ symbolName: 'Сбербанк',
+ timeframe: Timeframes['1m'],
+ chartSeriesType: 'Candlestick',
+ panes: [
+ {
+ indicators: [
+ {
+ id: 'compare-gazp',
+ indicatorType: undefined,
+ dataSource: {
+ subscription: {},
+ },
+ config: {
+ symbolInfo: {
+ symbolId: 'MOEX:GAZP',
+ symbol: 'GAZP',
+ symbolName: 'Газпром',
+ },
+ label: 'Газпром',
+ newPane: false,
+ series: [
+ {
+ name: 'Line',
+ seriesOptions: {
+ priceScaleId: 'left',
+ color: '#FFFFFF',
+ },
+ },
+ ],
+ },
+ },
+ {
+ id: 'rsi',
+ indicatorType: 'RSI',
+ dataSource: {
+ subscription: {},
+ },
+ config: {
+ label: 'RSI',
+ newPane: true,
+ series: [
+ {
+ name: 'Line',
+ seriesOptions: {
+ priceScaleId: 'right',
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+
+ mockGetSnapshot.mockReturnValue(snapshotWithIndicators);
+
+ render(<TestComponent />);
+
+ // Act
+ act(() => {
+ hookResult?.saveSnapshot();
+ });
+
+ const updateCallback = mockUpdateProperties.mock.calls[0]?.[0] as UpdatePropertiesCallback;
+
+ const mockState: MockPropertiesState = {
+ moexChartState: {
+ timeframe: Timeframes['1m'],
+ },
+ };
+
+ updateCallback(mockState);
+
+ const savedSnapshot = JSON.parse(mockState.moexChartState?.savedData ?? '{}') as MockChartSnapshot;
+
+ const [compareIndicator, regularIndicator] = savedSnapshot.charts[0]?.panes[0]?.indicators ?? [];
+
+ // Assert
+ expect(compareIndicator).toEqual({
+ id: 'compare-gazp',
+ config: {
+ symbolInfo: {
+ symbolId: 'MOEX:GAZP',
+ symbol: 'GAZP',
+ symbolName: 'Газпром',
+ },
+ label: 'Газпром',
+ newPane: false,
+ series: [
+ {
+ name: 'Line',
+ seriesOptions: {
+ priceScaleId: 'left',
+ },
+ },
+ ],
+ },
+ });
+
+ expect(regularIndicator).toEqual({
+ id: 'rsi',
+ indicatorType: 'RSI',
+ });
+ });
+
it('should not save snapshot when chart does not return snapshot', () => {
// Arrange
mockGetSnapshot.mockReturnValue(undefined);
- render(<TestComponent symbol="MOEX:SBER" />);
+ render(<TestComponent />);
// Act
act(() => {
@@ -285,14 +464,14 @@ describe('useMoexChart', () => {
expect(mockUpdateProperties).not.toHaveBeenCalled();
});
- it('should apply saved snapshot with current symbol and timeframe', () => {
+ it('should apply saved snapshot with current symbol info and timeframe', () => {
// Arrange
mockMoexChartState = {
- tf: Timeframes['5m'],
+ timeframe: Timeframes['5m'],
savedData: JSON.stringify(mockSnapshot),
};
- render(<TestComponent symbol="MOEX:SBER" />);
+ render(<TestComponent />);
// Act
act(() => {
@@ -305,18 +484,19 @@ describe('useMoexChart', () => {
charts: [
{
...mockSnapshot.charts[0],
- symbol: 'MOEX:SBER',
+ symbolId: 'MOEX:SBER',
+ symbol: 'SBER',
+ symbolName: 'Сбербанк',
timeframe: Timeframes['5m'],
},
],
});
-
expect(hookResult?.compareManagerRef.current).toBe(mockCompareManager);
});
it('should update compare modal state', () => {
// Arrange
- render(<TestComponent symbol="MOEX:SBER" />);
+ render(<TestComponent />);
// Act
act(() => {
@@ -329,7 +509,7 @@ describe('useMoexChart', () => {
it('should open compare modal from chart preset callback', () => {
// Arrange
- render(<TestComponent symbol="MOEX:SBER" />);
+ render(<TestComponent />);
// Act
act(() => {
@@ -342,7 +522,7 @@ describe('useMoexChart', () => {
it('should update symbol search modal state', () => {
// Arrange
- render(<TestComponent symbol="MOEX:SBER" />);
+ render(<TestComponent />);
// Act
act(() => {
@@ -355,7 +535,7 @@ describe('useMoexChart', () => {
it('should open symbol search modal from chart preset callback', () => {
// Arrange
- render(<TestComponent symbol="MOEX:SBER" />);
+ render(<TestComponent />);
// Act
act(() => {
@@ -366,82 +546,172 @@ describe('useMoexChart', () => {
expect(hookResult?.isSymbolSearchOpen).toBe(true);
});
- it('should normalize and change main symbol', () => {
+ it('should update chart when external symbol info changes', () => {
// Arrange
- render(<TestComponent symbol="MOEX:SBER" />);
+ const { rerender } = render(<TestComponent />);
// Act
- act(() => {
- hookResult?.setMainSymbol(' MOEX:GAZP ');
- });
+ rerender(
+ <TestComponent
+ symbolInfo={{
+ symbolId: 'MOEX:GAZP',
+ symbol: 'GAZP',
+ symbolName: 'Газпром',
+ }}
+ />,
+ );
// Assert
expect(mockSetSymbol).toHaveBeenCalledTimes(1);
- expect(mockSetSymbol).toHaveBeenCalledWith('MOEX:GAZP');
+ expect(mockSetSymbol).toHaveBeenCalledWith({
+ symbolId: 'MOEX:GAZP',
+ symbol: 'GAZP',
+ symbolName: 'Газпром',
+ });
});
- it('should not change main symbol when value is empty', () => {
+ it('should update chart when only symbol name changes', () => {
// Arrange
- render(<TestComponent symbol="MOEX:SBER" />);
+ const { rerender } = render(<TestComponent />);
// Act
- act(() => {
- hookResult?.setMainSymbol('');
- hookResult?.setMainSymbol(' ');
- });
+ rerender(
+ <TestComponent
+ symbolInfo={{
+ symbolId: 'MOEX:SBER',
+ symbol: 'SBER',
+ symbolName: 'Сбербанк ПАО',
+ }}
+ />,
+ );
// Assert
- expect(mockSetSymbol).not.toHaveBeenCalled();
+ expect(mockSetSymbol).toHaveBeenCalledTimes(1);
+ expect(mockSetSymbol).toHaveBeenCalledWith({
+ symbolId: 'MOEX:SBER',
+ symbol: 'SBER',
+ symbolName: 'Сбербанк ПАО',
+ });
});
- it('should not change main symbol when it is already selected', () => {
+ it('should update chart when only symbol changes', () => {
// Arrange
- render(<TestComponent symbol="MOEX:SBER" />);
+ const { rerender } = render(<TestComponent />);
// Act
- act(() => {
- hookResult?.setMainSymbol('MOEX:SBER');
+ rerender(
+ <TestComponent
+ symbolInfo={{
+ symbolId: 'MOEX:SBER',
+ symbol: 'SBERP',
+ symbolName: 'Сбербанк',
+ }}
+ />,
+ );
+
+ // Assert
+ expect(mockSetSymbol).toHaveBeenCalledTimes(1);
+ expect(mockSetSymbol).toHaveBeenCalledWith({
+ symbolId: 'MOEX:SBER',
+ symbol: 'SBERP',
+ symbolName: 'Сбербанк',
});
+ });
+
+ it('should delegate missing symbol name fallback to moex chart', () => {
+ // Arrange
+ const { rerender } = render(<TestComponent />);
+
+ // Act
+ rerender(
+ <TestComponent
+ symbolInfo={{
+ symbolId: 'MOEX:SBER',
+ symbol: 'SBER',
+ }}
+ />,
+ );
// Assert
- expect(mockSetSymbol).not.toHaveBeenCalled();
+ expect(mockSetSymbol).toHaveBeenCalledTimes(1);
+ expect(mockSetSymbol).toHaveBeenCalledWith({
+ symbolId: 'MOEX:SBER',
+ symbol: 'SBER',
+ symbolName: undefined,
+ });
});
- it('should update chart when external symbol changes', () => {
+ it('should delegate missing symbol fallback to moex chart', () => {
// Arrange
- const { rerender } = render(<TestComponent symbol="MOEX:SBER" />);
+ const { rerender } = render(<TestComponent />);
// Act
- rerender(<TestComponent symbol="MOEX:GAZP" />);
+ rerender(
+ <TestComponent
+ symbolInfo={{
+ symbolId: 'MOEX:SBER',
+ symbolName: 'Сбербанк',
+ }}
+ />,
+ );
// Assert
expect(mockSetSymbol).toHaveBeenCalledTimes(1);
- expect(mockSetSymbol).toHaveBeenCalledWith('MOEX:GAZP');
+ expect(mockSetSymbol).toHaveBeenCalledWith({
+ symbolId: 'MOEX:SBER',
+ symbol: undefined,
+ symbolName: 'Сбербанк',
+ });
+ });
+
+ it('should not update chart when symbol info is unchanged', () => {
+ // Arrange
+ const { rerender } = render(<TestComponent />);
+
+ // Act
+ rerender(<TestComponent />);
+
+ // Assert
+ expect(mockSetSymbol).not.toHaveBeenCalled();
});
it('should not recreate chart when external symbol changes', () => {
// Arrange
- const { rerender } = render(<TestComponent symbol="MOEX:SBER" />);
+ const { rerender } = render(<TestComponent />);
// Act
- rerender(<TestComponent symbol="MOEX:GAZP" />);
+ rerender(
+ <TestComponent
+ symbolInfo={{
+ symbolId: 'MOEX:GAZP',
+ symbol: 'GAZP',
+ symbolName: 'Газпром',
+ }}
+ />,
+ );
// Assert
expect(mockMoexChart).toHaveBeenCalledTimes(1);
});
- it('should apply saved snapshot with symbol selected from search modal', () => {
+ it('should apply saved snapshot with externally selected symbol', () => {
// Arrange
mockMoexChartState = {
- tf: Timeframes['5m'],
+ timeframe: Timeframes['5m'],
savedData: JSON.stringify(mockSnapshot),
};
- render(<TestComponent symbol="MOEX:SBER" />);
+ const { rerender } = render(<TestComponent />);
- act(() => {
- hookResult?.setMainSymbol('MOEX:GAZP');
- });
+ rerender(
+ <TestComponent
+ symbolInfo={{
+ symbolId: 'MOEX:GAZP',
+ symbol: 'GAZP',
+ symbolName: 'Газпром',
+ }}
+ />,
+ );
// Act
act(() => {
@@ -454,16 +724,53 @@ describe('useMoexChart', () => {
charts: [
{
...mockSnapshot.charts[0],
- symbol: 'MOEX:GAZP',
+ symbolId: 'MOEX:GAZP',
+ symbol: 'GAZP',
+ symbolName: 'Газпром',
timeframe: Timeframes['5m'],
},
],
});
});
+ it('should initialize indicative instrument with symbol info', () => {
+ // Arrange
+ const indicativeData: ChartIndicativeData = {
+ id: 1,
+ title: 'Indicative instrument',
+ secId: 'INAV',
+ instrumentName: 'Индикатив',
+ settlement: 'Расчётный',
+ firmName: 'Тестовая фирма',
+ key: '2xOFZ:INAV',
+ };
+
+ // Act
+ render(
+ <TestComponent
+ symbolInfo={{
+ symbolId: '2xOFZ:INAV',
+ symbol: 'INAV',
+ symbolName: 'Индикатив Расчётный',
+ }}
+ indicativeData={indicativeData}
+ />,
+ );
+
+ // Assert
+ expect(mockGetDataSource).toHaveBeenCalledWith(indicativeData, expect.any(Function));
+ expect(lastMoexChartConfig?.snapshot.charts[0]).toEqual(
+ expect.objectContaining({
+ symbolId: '2xOFZ:INAV',
+ symbol: 'INAV',
+ symbolName: 'Индикатив Расчётный',
+ }),
+ );
+ });
+
it('should pass realtime params to data source provider', () => {
// Arrange
- render(<TestComponent symbol="MOEX:SBER" />);
+ render(<TestComponent />);
const getSymbols = jest.fn(() => ['MOEX:SBER']);
const getTimeframe = jest.fn(() => Timeframes['1m']);
@@ -478,13 +785,12 @@ describe('useMoexChart', () => {
getTimeframe,
update,
});
-
expect(unsubscribe).toBe(mockRealtimeUnsubscribe);
});
it('should update timeframe from data source callback', () => {
// Arrange
- render(<TestComponent symbol="MOEX:SBER" />);
+ render(<TestComponent />);
const timeframeChangeCallback = mockGetDataSource.mock.calls[0]?.[1] as TimeframeChangeCallback;
@@ -500,18 +806,18 @@ describe('useMoexChart', () => {
const mockState: MockPropertiesState = {
moexChartState: {
- tf: Timeframes['1m'],
+ timeframe: Timeframes['1m'],
},
};
updateCallback(mockState);
- expect(mockState.moexChartState?.tf).toBe(Timeframes['5m']);
+ expect(mockState.moexChartState?.timeframe).toBe(Timeframes['5m']);
});
it('should not update timeframe when it is the same as current timeframe', () => {
// Arrange
- render(<TestComponent symbol="MOEX:SBER" />);
+ render(<TestComponent />);
const timeframeChangeCallback = mockGetDataSource.mock.calls[0]?.[1] as TimeframeChangeCallback;
@@ -526,7 +832,7 @@ describe('useMoexChart', () => {
it('should destroy chart on unmount', () => {
// Arrange
- const { unmount } = render(<TestComponent symbol="MOEX:SBER" />);
+ const { unmount } = render(<TestComponent />);
// Act
unmount();
diff --git a/src/widgets/Chart/components/MoexChart/MoexChart.tsx b/src/widgets/Chart/components/MoexChart/MoexChart.tsx
index 86e7bb981..a1dc9291d 100644
--- a/src/widgets/Chart/components/MoexChart/MoexChart.tsx
+++ b/src/widgets/Chart/components/MoexChart/MoexChart.tsx
@@ -1,23 +1,24 @@
import React from 'react';
-import { Contract } from '@modules/contracts';
import { SymbolSearchModal } from '@widgets/Chart/components/MoexChart/components/SymbolSearchModal';
-import { ChartIndicativeData } from '../../types';
-
import { CompareModal } from './components/CompareModal';
import { useMoexChart } from './hooks';
+import type { ChartIndicativeData, SelectedInstrument } from '../../types';
+
+import type { SymbolInfoInput } from 'moex-chart';
+
import 'moex-chart/dist/styles.css';
-type TRProps = {
- fullName: string;
- indicativeData?: ChartIndicativeData | undefined;
+interface TRProps {
+ symbolInfo: SymbolInfoInput;
+ indicativeData?: ChartIndicativeData;
widgetId: number;
- addInstrumentFromModal: (instruments: Pick<Contract, 'issKey'>[]) => void;
-};
+ addInstrumentFromModal: (instruments: SelectedInstrument[]) => void;
+}
-export default React.memo(({ fullName, indicativeData, widgetId, addInstrumentFromModal }: TRProps) => {
+function MoexChart({ symbolInfo, indicativeData, widgetId, addInstrumentFromModal }: TRProps) {
const {
containerRef,
isCompareOpen,
@@ -25,15 +26,13 @@ export default React.memo(({ fullName, indicativeData, widgetId, addInstrumentFr
compareManagerRef,
setIsCompareOpen,
setIsSymbolSearchOpen,
- setMainSymbol,
} = useMoexChart({
+ symbolInfo,
indicativeData,
- symbol: fullName,
});
- const handleSymbolChange = (symbol: string): void => {
- addInstrumentFromModal([{ issKey: symbol }]);
- setMainSymbol(symbol);
+ const handleSymbolChange = (instrument: SelectedInstrument): void => {
+ addInstrumentFromModal([instrument]);
};
return (
@@ -48,7 +47,6 @@ export default React.memo(({ fullName, indicativeData, widgetId, addInstrumentFr
{isCompareOpen && (
<CompareModal
- onClose={() => setIsCompareOpen(false)}
compareManager={compareManagerRef}
widgetId={widgetId}
isOpen={isCompareOpen}
@@ -66,4 +64,6 @@ export default React.memo(({ fullName, indicativeData, widgetId, addInstrumentFr
)}
</div>
);
-});
+}
+
+export default React.memo(MoexChart);
diff --git a/src/widgets/Chart/components/MoexChart/components/CompareModal.tsx b/src/widgets/Chart/components/MoexChart/components/CompareModal.tsx
index 092f6df1a..2ffba9e87 100644
--- a/src/widgets/Chart/components/MoexChart/components/CompareModal.tsx
+++ b/src/widgets/Chart/components/MoexChart/components/CompareModal.tsx
@@ -1,21 +1,20 @@
-import { __CompareManager__, CompareMode } from 'moex-chart';
-import React, { MutableRefObject, useEffect, useState } from 'react';
+import { CompareMode } from 'moex-chart';
+import React, { useEffect, useState } from 'react';
import { InstrumentSearch } from '@components/InstrumentSearch';
+import { Contract } from '@modules/contracts';
-export const CompareModal = ({
- compareManager,
- widgetId,
- isOpen,
- setOpen,
-}: {
- onClose: () => void;
+import type { __CompareManager__ } from 'moex-chart';
+import type { MutableRefObject } from 'react';
+
+interface CompareModalProps {
widgetId: number;
compareManager: MutableRefObject<__CompareManager__ | null>;
-
isOpen: boolean;
setOpen: (isOpen: boolean) => void;
-}) => {
+}
+
+export const CompareModal = ({ compareManager, widgetId, isOpen, setOpen }: CompareModalProps): React.ReactElement => {
const [isNewScaleDisabled, setIsNewScaleDisabled] = useState(false);
useEffect(() => {
@@ -33,16 +32,32 @@ export const CompareModal = ({
return () => subscription.unsubscribe();
}, [compareManager, isOpen]);
- const handlePercent = (symbol: string) => {
- compareManager?.current?.setSymbolMode('Line', symbol, CompareMode.Percentage);
+ const setCompareMode = (instrument: Contract, mode: CompareMode): void => {
+ if (!instrument.issKey) {
+ return;
+ }
+
+ compareManager.current?.setSymbolMode(
+ 'Line',
+ {
+ symbolId: instrument.issKey,
+ symbol: instrument.symbol,
+ symbolName: instrument.displayName,
+ },
+ mode,
+ );
+ };
+
+ const handlePercent = (instrument: Contract): void => {
+ setCompareMode(instrument, CompareMode.Percentage);
};
- const handleNewScale = (symbol: string) => {
- compareManager?.current?.setSymbolMode('Line', symbol, CompareMode.NewScale);
+ const handleNewScale = (instrument: Contract): void => {
+ setCompareMode(instrument, CompareMode.NewScale);
};
- const handleNewPanel = (symbol: string) => {
- compareManager?.current?.setSymbolMode('Line', symbol, CompareMode.NewPane);
+ const handleNewPanel = (instrument: Contract): void => {
+ setCompareMode(instrument, CompareMode.NewPane);
};
return (
diff --git a/src/widgets/Chart/components/MoexChart/components/SymbolSearchModal.tsx b/src/widgets/Chart/components/MoexChart/components/SymbolSearchModal.tsx
index efb08af16..bed8d94db 100644
--- a/src/widgets/Chart/components/MoexChart/components/SymbolSearchModal.tsx
+++ b/src/widgets/Chart/components/MoexChart/components/SymbolSearchModal.tsx
@@ -3,23 +3,29 @@ import React from 'react';
import { InstrumentSearch } from '@components/InstrumentSearch';
import type { Contract } from '@modules/contracts';
+import type { SelectedInstrument } from '@widgets/Chart/types';
interface SymbolSearchModalProps {
widgetId: number;
isOpen: boolean;
setOpen: (isOpen: boolean) => void;
- onSymbolChange: (symbol: string) => void;
+ onSymbolChange: (instrument: SelectedInstrument) => void;
}
export const SymbolSearchModal = ({ widgetId, isOpen, setOpen, onSymbolChange }: SymbolSearchModalProps) => {
- const handleAddInstruments = (instruments: Contract[]) => {
- const symbol = instruments[0]?.issKey;
+ const handleAddInstruments = (instruments: Contract[]): void => {
+ const selectedInstrument = instruments[0];
- if (!symbol) {
+ if (!selectedInstrument?.issKey) {
return;
}
- onSymbolChange(symbol);
+ onSymbolChange({
+ issKey: selectedInstrument.issKey,
+ symbol: selectedInstrument.symbol,
+ displayName: selectedInstrument.displayName,
+ });
+
setOpen(false);
};
diff --git a/src/widgets/Chart/components/MoexChart/constants.ts b/src/widgets/Chart/components/MoexChart/constants.ts
index 288036c50..24a0dcab9 100644
--- a/src/widgets/Chart/components/MoexChart/constants.ts
+++ b/src/widgets/Chart/components/MoexChart/constants.ts
@@ -1,11 +1,11 @@
import { DateFormat, IndicatorsIds, Locale, Timeframes } from 'moex-chart';
-import type { ChartCollectionPreset, IMoexChart, MoexChartSnapshot } from 'moex-chart';
+import type { ChartCollectionPreset, IMoexChart, MoexChartSnapshotInput } from 'moex-chart';
type ChartCollectionPresetConfig = Omit<ChartCollectionPreset, 'getDataSource' | 'startRealtime'>;
-type ChartSnapshotItemConfig = Omit<MoexChartSnapshot['charts'][number], 'symbol'>;
-type MoexChartSnapshotConfig = Omit<MoexChartSnapshot, 'charts'> & {
- charts: ChartSnapshotItemConfig[];
+type ChartSnapshotConfig = Omit<MoexChartSnapshotInput['charts'][number], 'symbolId'>;
+type MoexChartSnapshotConfig = Omit<MoexChartSnapshotInput, 'charts'> & {
+ charts: ChartSnapshotConfig[];
};
type MoexChartConfig = Omit<IMoexChart, 'container' | 'chartCollectionPreset' | 'snapshot'> & {
diff --git a/src/widgets/Chart/components/MoexChart/dataSourceProvide.ts b/src/widgets/Chart/components/MoexChart/dataSourceProvide.ts
index 2bc6851d5..929f2b851 100644
--- a/src/widgets/Chart/components/MoexChart/dataSourceProvide.ts
+++ b/src/widgets/Chart/components/MoexChart/dataSourceProvide.ts
@@ -8,6 +8,7 @@ import {
moexChartTimeConverter,
moexChartToIssTimeframe,
} from '@utils/chartToReqTimeConverter';
+import { DEFAULT_SYMBOL } from '@widgets/Chart/const';
import { requestBars, requestRealtimeBars } from '../../requestBars';
import { ChartIndicativeData } from '../../types';
@@ -17,9 +18,14 @@ import type { Candle, Timeframes } from 'moex-chart';
dayjs.extend(duration);
-function normalizeSymbol(symbolRaw?: string): string {
- return String(symbolRaw ?? '')
- .trim();
+function getRequestSymbol(symbolRaw?: string): string | undefined {
+ const symbol = String(symbolRaw ?? '').trim();
+
+ if (!symbol || symbol === DEFAULT_SYMBOL) {
+ return undefined;
+ }
+
+ return symbol;
}
const collectCandleGroup = ({
@@ -181,7 +187,13 @@ class DataSourceProvider {
public getDataSource =
(indicativeData?: ChartIndicativeData, cb?: (timeframe: Timeframes) => void) =>
- async (timeframe: Timeframes, symbol: string, until?: Candle) => {
+ async (timeframe: Timeframes, symbolId: string, until?: Candle) => {
+ const symbol = getRequestSymbol(symbolId);
+
+ if (!symbol) {
+ return null;
+ }
+
cb?.(timeframe);
const interval = moexChartTimeConverter(timeframe);
@@ -226,7 +238,7 @@ class DataSourceProvider {
}: {
getSymbols: () => string[];
getTimeframe: () => Timeframes;
- update: (symbol: string, candle: Candle) => void;
+ update: (symbolId: string, candle: Candle) => void;
periodMs?: number;
indicativeData?: ChartIndicativeData;
}): () => void {
@@ -236,11 +248,11 @@ class DataSourceProvider {
this.realtimeTimer = setInterval(() => {
const timeframe = getTimeframe();
- const symbols = getSymbols();
+ const symbolIds = getSymbols();
Promise.all(
- symbols.map(async (value) => {
- const symbol = normalizeSymbol(value);
+ symbolIds.map(async (symbolId) => {
+ const symbol = getRequestSymbol(symbolId);
if (!symbol) {
return;
diff --git a/src/widgets/Chart/components/MoexChart/hooks/useMoexchart.ts b/src/widgets/Chart/components/MoexChart/hooks/useMoexchart.ts
index 58c504900..37641688f 100644
--- a/src/widgets/Chart/components/MoexChart/hooks/useMoexchart.ts
+++ b/src/widgets/Chart/components/MoexChart/hooks/useMoexchart.ts
@@ -1,24 +1,25 @@
import { MoexChart, Timeframes } from 'moex-chart';
-import { useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import { useChangeProperties, useSelectProperties } from '@modules/widgetProperties';
+import { WidgetProperties } from '@widgets/Chart/properties/types';
import { ChartIndicativeData } from '@widgets/Chart/types';
-import { WidgetProperties } from '../../../properties/types';
-
import { MOEX_CHART_CONFIG } from '../constants';
import { DataSourceProvider } from '../dataSourceProvide';
-import type { __CompareManager__, IMoexChart } from 'moex-chart';
+import type { __CompareManager__, IMoexChart, SymbolInfoInput } from 'moex-chart';
interface TUseMoexChartProps {
- symbol: string;
+ symbolInfo: SymbolInfoInput;
indicativeData?: ChartIndicativeData;
}
-export const useMoexChart = ({ symbol, indicativeData }: TUseMoexChartProps) => {
- const moexChartState = useSelectProperties((wProps: Partial<WidgetProperties>) => wProps.moexChartState);
+export const useMoexChart = ({ symbolInfo, indicativeData }: TUseMoexChartProps) => {
+ const moexChartState = useSelectProperties(
+ (widgetProperties: Partial<WidgetProperties>) => widgetProperties.moexChartState,
+ );
const { updateProperties } = useChangeProperties<WidgetProperties>();
@@ -27,32 +28,23 @@ export const useMoexChart = ({ symbol, indicativeData }: TUseMoexChartProps) =>
const containerRef = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<MoexChart | null>(null);
- const compareManagerRef = useRef<null | __CompareManager__>(null);
- const currentSymbolRef = useRef(symbol);
+ const compareManagerRef = useRef<__CompareManager__ | null>(null);
+ const currentSymbolInfoRef = useRef<SymbolInfoInput>(symbolInfo);
const timeframeRef = useRef<Timeframes | undefined>(moexChartState?.timeframe);
const savedDataRef = useRef<string | undefined>(moexChartState?.savedData);
- const updateTimeframeRef = useRef<((tf: Timeframes) => void) | null>(null);
+ const updateTimeframeRef = useRef<((timeframe: Timeframes) => void) | null>(null);
useEffect(() => {
- if (!symbol || currentSymbolRef.current === symbol) {
- return;
- }
-
- currentSymbolRef.current = symbol;
- chartRef.current?.setSymbol(symbol);
- }, [symbol]);
-
- const setMainSymbol = (nextSymbol: string) => {
- const normalizedSymbol = nextSymbol.trim();
+ const nextSymbolInfo: SymbolInfoInput = {
+ symbolId: symbolInfo.symbolId,
+ symbol: symbolInfo.symbol,
+ symbolName: symbolInfo.symbolName,
+ };
- if (!normalizedSymbol || currentSymbolRef.current === normalizedSymbol) {
- return;
- }
-
- currentSymbolRef.current = normalizedSymbol;
- chartRef.current?.setSymbol(normalizedSymbol);
- };
+ currentSymbolInfoRef.current = nextSymbolInfo;
+ chartRef.current?.setSymbol(nextSymbolInfo);
+ }, [symbolInfo.symbolId, symbolInfo.symbol, symbolInfo.symbolName]);
useEffect(() => {
timeframeRef.current = moexChartState?.timeframe;
@@ -74,7 +66,7 @@ export const useMoexChart = ({ symbol, indicativeData }: TUseMoexChartProps) =>
});
};
- const saveSnapshot = () => {
+ const saveSnapshot = useCallback((): void => {
const snapshot = chartRef.current?.getSnapshot();
if (!snapshot) {
@@ -89,13 +81,15 @@ export const useMoexChart = ({ symbol, indicativeData }: TUseMoexChartProps) =>
...pane,
indicators: pane.indicators.map((indicator) => {
const compareSeries = indicator.config?.series[0];
+ const compareSymbolInfo = indicator.config?.symbolInfo;
return {
...indicator,
dataSource: undefined, // dataSource пока не среиализуем
config:
- indicator.indicatorType === undefined && indicator.config?.label && compareSeries
+ indicator.indicatorType === undefined && compareSymbolInfo && indicator.config?.label && compareSeries
? {
+ symbolInfo: compareSymbolInfo,
label: indicator.config.label,
newPane: indicator.config.newPane,
series: [
@@ -123,9 +117,21 @@ export const useMoexChart = ({ symbol, indicativeData }: TUseMoexChartProps) =>
savedData,
};
});
- };
-
- const applySnapshot = () => {
+ }, [updateProperties]);
+
+ const getSnapshotWithCurrentSymbol = (
+ snapshot: typeof MOEX_CHART_CONFIG.snapshot | IMoexChart['snapshot'],
+ timeframe: Timeframes,
+ ): IMoexChart['snapshot'] => ({
+ ...snapshot,
+ charts: snapshot.charts.map((chartSnapshot) => ({
+ ...chartSnapshot,
+ ...currentSymbolInfoRef.current,
+ timeframe,
+ })),
+ });
+
+ const applySnapshot = (): void => {
if (!savedDataRef.current || !chartRef.current) {
return;
}
@@ -133,14 +139,7 @@ export const useMoexChart = ({ symbol, indicativeData }: TUseMoexChartProps) =>
const savedSnapshot = JSON.parse(savedDataRef.current) as IMoexChart['snapshot'];
const timeframe = timeframeRef.current || Timeframes['1m'];
- chartRef.current.setSnapshot({
- ...savedSnapshot,
- charts: savedSnapshot.charts.map((chartSnapshot) => ({
- ...chartSnapshot,
- symbol: currentSymbolRef.current,
- timeframe,
- })),
- });
+ chartRef.current.setSnapshot(getSnapshotWithCurrentSymbol(savedSnapshot, timeframe));
compareManagerRef.current = chartRef.current.getCompareManager();
};
@@ -161,20 +160,13 @@ export const useMoexChart = ({ symbol, indicativeData }: TUseMoexChartProps) =>
const chart = new MoexChart({
...MOEX_CHART_CONFIG,
container,
- snapshot: {
- ...savedSnapshot,
- charts: savedSnapshot.charts.map((chartSnapshot) => ({
- ...chartSnapshot,
- symbol: currentSymbolRef.current,
- timeframe,
- })),
- },
+ snapshot: getSnapshotWithCurrentSymbol(savedSnapshot, timeframe),
chartCollectionPreset: {
...MOEX_CHART_CONFIG.chartCollectionPreset,
openCompareModal: () => setIsCompareOpen(true),
openSymbolSearchModal: () => setIsSymbolSearchOpen(true),
- getDataSource: dataProvider.getDataSource(indicativeData, (tf) => {
- updateTimeframeRef.current?.(tf);
+ getDataSource: dataProvider.getDataSource(indicativeData, (nextTimeframe) => {
+ updateTimeframeRef.current?.(nextTimeframe);
}),
startRealtime: (getSymbols, getTimeframe, update) =>
dataProvider.startRealtime({
@@ -208,7 +200,6 @@ export const useMoexChart = ({ symbol, indicativeData }: TUseMoexChartProps) =>
compareManagerRef,
setIsCompareOpen,
setIsSymbolSearchOpen,
- setMainSymbol,
saveSnapshot,
applySnapshot,
hasSavedSnapshot: Boolean(moexChartState?.savedData),
diff --git a/src/widgets/Chart/hooks/useChartComponentFacade.tsx b/src/widgets/Chart/hooks/useChartComponentFacade.tsx
index ece1436b8..4892f3aa7 100644
--- a/src/widgets/Chart/hooks/useChartComponentFacade.tsx
+++ b/src/widgets/Chart/hooks/useChartComponentFacade.tsx
@@ -13,20 +13,20 @@ import { DEFAULT_SYMBOL } from '../const';
import { useChartPublicContext } from './useChartPublicContext';
import type { WidgetProperties } from '../properties/types';
-import type { ChartContainerProps } from '../types';
+import type { ChartContainerProps, SelectedInstrument } from '../types';
-import type { Contract } from '@modules/contracts';
+import type { SymbolInfoInput } from 'moex-chart';
import type { Dispatch, SetStateAction } from 'react';
import type { Widget } from 'types/Widgets';
interface UseChartComponentFacadeReturn {
dropDownOpen: boolean;
setDropdownOpen: Dispatch<SetStateAction<boolean>>;
- currentInstrument: string;
+ currentSymbolInfo: SymbolInfoInput;
isWidgetHeaderContextMenuOpen: boolean;
setIsWidgetHeaderContextMenuOpen: Dispatch<SetStateAction<boolean>>;
- onDropInstruments: (val: string, withUpdate?: boolean) => void;
- addInstrumentFromModal: (instruments: Pick<Contract, 'issKey'>[]) => void;
+ onDropInstrument: (symbolId: string, withUpdate?: boolean) => void;
+ addInstrumentFromModal: (instruments: SelectedInstrument[]) => void;
isOver: boolean;
}
@@ -48,12 +48,13 @@ export default function useChartComponentFacade(props: ChartContainerProps): Use
(state) => state.widgets.widgets.find(({ id }) => id === widgetId)?.widgetContentProps,
) as WidgetProperties | undefined;
- const initialInstrument = widgetProperties?.chartState?.savedInstrument ?? DEFAULT_SYMBOL;
+ const currentSymbolInfoRef = useRef<SymbolInfoInput>({
+ symbolId: widgetProperties?.chartState?.savedInstrument ?? DEFAULT_SYMBOL,
+ symbol: widgetProperties?.chartState?.savedSymbol,
+ symbolName: widgetProperties?.chartState?.savedSymbolName,
+ });
- // TODO временно реф из-за непредсказуемого изменения если используется useState,
- // нужен рефакторинг и вернуть обратно useState
- const currentInstrumentRef = useRef(initialInstrument);
- const [currentInstrument, setCurrentInstrument] = useState(initialInstrument);
+ const [currentSymbolInfo, setCurrentSymbolInfo] = useState<SymbolInfoInput>(currentSymbolInfoRef.current);
const { triggerRelatedWidgetsToUpdate, getMasterInstrumentFromPublicContext } = useWidgetsBind({
widgetId,
@@ -66,8 +67,8 @@ export default function useChartComponentFacade(props: ChartContainerProps): Use
}, [triggerRelatedWidgetsToUpdate]);
const saveContentProps = useCallback(
- (val: SaveContentPropsOptions): void => {
- const { withUpdate, cleanIndicativeData, ...chartStateVal } = val;
+ (value: SaveContentPropsOptions): void => {
+ const { withUpdate, cleanIndicativeData, ...chartStateValue } = value;
const oldProps = (getState().widgets.widgets.find(({ id }) => id === widgetId) as Widget | undefined)
?.widgetContentProps;
@@ -83,7 +84,7 @@ export default function useChartComponentFacade(props: ChartContainerProps): Use
widgetContentProps: {
chartState: {
...oldProps.chartState,
- ...chartStateVal,
+ ...chartStateValue,
},
// при смене инструмента очищаем индикативные данные, переданные из виджета Индикативные котировки
indicativeData: cleanIndicativeData ? undefined : oldProps.indicativeData,
@@ -95,60 +96,85 @@ export default function useChartComponentFacade(props: ChartContainerProps): Use
);
const onInstrumentChange = useCallback(
- (newVal: string, withUpdate?: boolean, unbind = true): void => {
- if (!newVal) {
+ (symbolInfo: SymbolInfoInput, withUpdate?: boolean, unbind = true): void => {
+ const symbolId = symbolInfo.symbolId.trim();
+
+ if (!symbolId) {
return;
}
setIsWidgetHeaderContextMenuOpen(false);
- if (currentInstrumentRef.current === newVal) {
+ const currentValue = currentSymbolInfoRef.current;
+ const symbolIdChanged = currentValue.symbolId !== symbolId;
+
+ const symbol = symbolInfo.symbol?.trim() || (symbolIdChanged ? undefined : currentValue.symbol);
+ const symbolName = symbolInfo.symbolName?.trim() || (symbolIdChanged ? undefined : currentValue.symbolName);
+
+ const nextSymbolInfo: SymbolInfoInput = {
+ symbolId,
+ symbol,
+ symbolName,
+ };
+
+ const symbolChanged = currentValue.symbol !== symbol;
+ const symbolNameChanged = currentValue.symbolName !== symbolName;
+
+ if (!symbolIdChanged && !symbolChanged && !symbolNameChanged) {
return;
}
- currentInstrumentRef.current = newVal;
- setCurrentInstrument(newVal);
+ currentSymbolInfoRef.current = nextSymbolInfo;
+ setCurrentSymbolInfo(nextSymbolInfo);
- if (unbind) {
+ if (symbolIdChanged && unbind) {
dispatch(unbindWidgets({ widgetId }));
}
// при смене инструмента очищаем индикативные данные виджета график
// т.к. логика для графика индикатива построена на наличии в widgetContentProps данных indicativeData
saveContentProps({
- savedInstrument: newVal,
+ savedInstrument: symbolId,
+ savedSymbol: symbol,
+ savedSymbolName: symbolName,
withUpdate,
- cleanIndicativeData: true,
+ cleanIndicativeData: symbolIdChanged,
});
- triggerRelatedWidgetsToUpdateRef.current(newVal);
+ if (symbolIdChanged) {
+ triggerRelatedWidgetsToUpdateRef.current(symbolId);
+ }
},
[dispatch, saveContentProps, widgetId],
);
const addInstrumentFromModal = useCallback(
- (instruments: Pick<Contract, 'issKey'>[]) => {
- const issKey = instruments[0]?.issKey;
+ (instruments: SelectedInstrument[]): void => {
+ const instrument = instruments[0];
- if (!issKey) {
+ if (!instrument?.issKey) {
return;
}
- onInstrumentChange(issKey);
+ onInstrumentChange({
+ symbolId: instrument.issKey,
+ symbol: instrument.symbol,
+ symbolName: instrument.displayName,
+ });
},
[onInstrumentChange],
);
const onInstrumentChangeFromBind = useCallback(
- (instrumentId: string) => {
- onInstrumentChange(instrumentId, true, false);
+ (symbolInfo: SymbolInfoInput): void => {
+ onInstrumentChange(symbolInfo, true, false);
},
[onInstrumentChange],
);
- const onDropInstruments = useCallback(
- (val: string, withUpdate?: boolean): void => {
- onInstrumentChange(val, withUpdate);
+ const onDropInstrument = useCallback(
+ (symbolId: string, withUpdate?: boolean): void => {
+ onInstrumentChange({ symbolId }, withUpdate);
},
[onInstrumentChange],
);
@@ -160,7 +186,7 @@ export default function useChartComponentFacade(props: ChartContainerProps): Use
});
useEffect(() => {
- triggerRelatedWidgetsToUpdateRef.current(currentInstrumentRef.current);
+ triggerRelatedWidgetsToUpdateRef.current(currentSymbolInfoRef.current.symbolId);
}, []);
useEffect(() => {
@@ -169,10 +195,10 @@ export default function useChartComponentFacade(props: ChartContainerProps): Use
messageType: CORPACTIONS_OPEN_EXESTED_WIDGET_EVENT,
},
(message) => {
- const issKey = (message as Record<number, string>)[widgetId];
+ const symbolId = (message as Record<number, string>)[widgetId];
- if (issKey) {
- addInstrumentFromModal([{ issKey }]);
+ if (symbolId) {
+ onInstrumentChange({ symbolId });
}
},
);
@@ -194,15 +220,15 @@ export default function useChartComponentFacade(props: ChartContainerProps): Use
unsubscribe();
unsubscribeHighlighter();
};
- }, [addInstrumentFromModal, widgetId]);
+ }, [onInstrumentChange, widgetId]);
return {
dropDownOpen,
setDropdownOpen,
- currentInstrument,
+ currentSymbolInfo,
isWidgetHeaderContextMenuOpen,
setIsWidgetHeaderContextMenuOpen,
- onDropInstruments,
+ onDropInstrument,
addInstrumentFromModal,
isOver,
};
diff --git a/src/widgets/Chart/hooks/useChartPublicContext.ts b/src/widgets/Chart/hooks/useChartPublicContext.ts
index 0edfadcae..fe6184965 100644
--- a/src/widgets/Chart/hooks/useChartPublicContext.ts
+++ b/src/widgets/Chart/hooks/useChartPublicContext.ts
@@ -7,8 +7,10 @@ import { filterByUniqIssKey } from '@utils/filterByUniqIssKey';
import { DEFAULT_SYMBOL } from '@widgets/Chart/const';
import { Widget } from 'types/Widgets';
+import type { SymbolInfoInput } from 'moex-chart';
+
interface UseChartPublicContextArg {
- setCurrInstrument: (instrumentId: string) => void;
+ setCurrInstrument: (symbolInfo: SymbolInfoInput) => void;
widgetId: Widget['id'];
getMasterInstrumentFromPublicContext: () => string | number | null | undefined;
}
@@ -27,29 +29,45 @@ export function useChartPublicContext({
const { contracts } = useContracts();
- const instruments = useMemo(() => [...(contracts && filterByUniqIssKey(contracts, ['issKey']))], [contracts]);
+ const instruments = useMemo(() => (contracts ? filterByUniqIssKey(contracts, ['issKey']) : []), [contracts]);
const issKey = useMemo(() => {
const fieldValue = getMasterInstrumentFromPublicContext();
- const instrKey = instruments.find((item) => item.issKey === fieldValue)?.issKey;
- return instrKey;
+
+ return instruments.find((instrument) => instrument.issKey === fieldValue)?.issKey;
// eslint-disable-next-line react-hooks/exhaustive-deps -- Посмотреть этот момент.
}, [publicContext, instruments]);
useEffect(() => {
const fieldValue = getMasterInstrumentFromPublicContext();
+
if (!fieldValue && widget?.master) {
- setCurrInstrument(DEFAULT_SYMBOL);
+ const defaultInstrument = instruments.find((instrument) => instrument.issKey === DEFAULT_SYMBOL);
+
+ setCurrInstrument({
+ symbolId: DEFAULT_SYMBOL,
+ symbol: defaultInstrument?.symbol ?? undefined,
+ symbolName: defaultInstrument?.displayName ?? undefined,
+ });
+
return;
}
- const instrKey = instruments.find((item) => item.issKey === fieldValue)?.issKey;
- if (instrKey) {
- setCurrInstrument(instrKey);
+
+ const instrument = instruments.find((item) => item.issKey === fieldValue);
+
+ if (!instrument?.issKey) {
+ return;
}
+
+ setCurrInstrument({
+ symbolId: instrument.issKey,
+ symbol: instrument.symbol ?? undefined,
+ symbolName: instrument.displayName ?? undefined,
+ });
// eslint-disable-next-line react-hooks/exhaustive-deps -- Посмотреть этот момент.
}, [publicContext, instruments, widget?.externalProperties, setCurrInstrument]);
return {
- issKey: issKey ?? undefined,
+ issKey,
};
}
diff --git a/src/widgets/Chart/index.tsx b/src/widgets/Chart/index.tsx
index e38b1552a..7121ad997 100644
--- a/src/widgets/Chart/index.tsx
+++ b/src/widgets/Chart/index.tsx
@@ -21,10 +21,10 @@ export const Chart: FC<ChartContainerProps> = function (props): JSX.Element {
const {
dropDownOpen,
setDropdownOpen,
- currentInstrument,
+ currentSymbolInfo,
setIsWidgetHeaderContextMenuOpen,
isWidgetHeaderContextMenuOpen,
- onDropInstruments,
+ onDropInstrument,
addInstrumentFromModal,
isOver,
} = useChartComponentFacade(props);
@@ -33,7 +33,7 @@ export const Chart: FC<ChartContainerProps> = function (props): JSX.Element {
и делаем апдейт на бэк, чтобы сохранить изменения
при обновлении страницы */
const { dropRef } = useDropInstrument((dragData) => {
- onDropInstruments(dragData.properties.issKey, true);
+ onDropInstrument(dragData.properties.issKey, true);
});
const [isOpenEmptyAction, setIsOpenEmptyAction] = useState<boolean>(false);
@@ -45,11 +45,17 @@ export const Chart: FC<ChartContainerProps> = function (props): JSX.Element {
// TODO: Зависит от сеток
const { isDraggedOver } = useContext(WidgetEnvContext);
- const contractsInstrumentName = useWidgetHeaderName(currentInstrument);
+ const contractsInstrumentName = useWidgetHeaderName(currentSymbolInfo.symbolId);
- const instrumentName = indicativeData
- ? `${indicativeData.instrumentName} ${indicativeData.settlement} - ${indicativeData.firmName}`
- : contractsInstrumentName;
+ const symbolInfo = indicativeData
+ ? {
+ symbolId: indicativeData.key,
+ symbol: indicativeData.secId,
+ symbolName: `${indicativeData.instrumentName} ${indicativeData.settlement} - ${indicativeData.firmName}`,
+ }
+ : currentSymbolInfo;
+
+ const instrumentName = symbolInfo.symbolName || contractsInstrumentName || symbolInfo.symbol || symbolInfo.symbolId;
return (
<DNDWrapper
@@ -78,7 +84,7 @@ export const Chart: FC<ChartContainerProps> = function (props): JSX.Element {
}}
>
<MoexChart
- fullName={currentInstrument}
+ symbolInfo={symbolInfo}
indicativeData={indicativeData}
widgetId={props.widgetId}
addInstrumentFromModal={addInstrumentFromModal}
diff --git a/src/widgets/Chart/properties/types.ts b/src/widgets/Chart/properties/types.ts
index afdbdf473..720bcec6f 100644
--- a/src/widgets/Chart/properties/types.ts
+++ b/src/widgets/Chart/properties/types.ts
@@ -1,12 +1,15 @@
-import { Contract } from '@modules/contracts/types';
+import type { ChartIndicativeData } from '../types';
-import { ChartIndicativeData } from '../types';
+import type { Contract } from '@modules/contracts/types';
+import type { WidgetProperties as BaseWidgetProperties } from '@modules/widgetProperties/types';
import type { Timeframes } from 'moex-chart';
-export type WidgetProperties = {
+export interface WidgetProperties extends BaseWidgetProperties {
chartState: {
savedInstrument: Contract['issKey'] | null;
+ savedSymbol?: Contract['symbol'];
+ savedSymbolName?: Contract['displayName'];
interval: string;
savedData?: string;
};
@@ -15,4 +18,4 @@ export type WidgetProperties = {
timeframe: Timeframes;
savedData?: string;
};
-};
+}
diff --git a/src/widgets/Chart/types.ts b/src/widgets/Chart/types.ts
index bbc0155d5..2a4a5aae3 100644
--- a/src/widgets/Chart/types.ts
+++ b/src/widgets/Chart/types.ts
@@ -1,12 +1,22 @@
-import type { WidgetContentBasicProps } from 'types/Widgets';
import type { WidgetProperties } from './properties/types';
-export type ChartIndicativeData = {
+import type { Contract } from '@modules/contracts';
+
+import type { WidgetProperties as BaseWidgetProperties } from '@modules/widgetProperties/types';
+import type { WidgetContentBasicProps } from 'types/Widgets';
+
+export interface ChartIndicativeData extends BaseWidgetProperties {
secId: string;
instrumentName: string;
settlement: string;
firmName: string;
key: string;
-};
+}
+
+export interface SelectedInstrument {
+ issKey: Contract['issKey'];
+ displayName: Contract['displayName'];
+ symbol: Contract['symbol'];
+}
export type ChartContainerProps = WidgetContentBasicProps<WidgetProperties>;