Загрузка данных
import { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { workspaceController } from '@api/controllers/workspace';
import { CREATE_NEW_WORKSPACE_ID, DEFAULT_NEGOTIATION_WORKSPACE } from '@configs/appConfig';
import { useAppSelect } from '@hooks/useAppSelector';
import { widgetsSelector } from '@store/selectors/widgets';
import { currentWorkspaceIdSelector, workspacesSelector } from '@store/selectors/workspaces';
import { removeWidget } from '@store/slices/widgets';
import {
addToFavorites,
addWorkspace,
removeFromFavorites,
removeWorkspace,
setClosedWorkspace,
setCurrentWorkspace,
setWorkspaceName,
updateWorkspace,
} from '@store/slices/workspaces';
import { Workspace } from 'types/Workspace';
import { toasts } from '../services/toasts';
export const useWorkspaceActions = () => {
const dispatch = useDispatch();
const widgets = useAppSelect(widgetsSelector);
const currentWorkspaceId = useAppSelect(currentWorkspaceIdSelector);
const workspaces = useAppSelect(workspacesSelector);
const deleteWorkspace = useCallback(
async (id: number): Promise<void> => {
if (id === currentWorkspaceId) {
const openWorkspaces = workspaces.filter((elem) => elem.id !== DEFAULT_NEGOTIATION_WORKSPACE && elem.saved);
const removedWorkspaceIndex = openWorkspaces.findIndex((workspace) => id === workspace.id);
const previosWorkspace = openWorkspaces[removedWorkspaceIndex - 1];
const nextWorkspace = openWorkspaces[removedWorkspaceIndex + 1];
if (previosWorkspace) {
dispatch(setCurrentWorkspace(previosWorkspace.id));
await workspaceController.update({ ...previosWorkspace, isCurrent: true });
} else if (nextWorkspace) {
dispatch(setCurrentWorkspace(nextWorkspace.id));
await workspaceController.update({ ...nextWorkspace, isCurrent: true });
}
}
dispatch(removeWorkspace(id));
dispatch(removeWidget(widgets.filter(({ workspaceId }) => workspaceId === id).map((widget) => widget.id)));
},
[currentWorkspaceId, dispatch, widgets, workspaces],
);
const closeWorkspace = useCallback(
async (id: number): Promise<void> => {
const openWorkspaces = workspaces.filter((elem) => elem.id !== DEFAULT_NEGOTIATION_WORKSPACE && elem.saved);
if (id === currentWorkspaceId) {
const removedWorkspaceIndex = openWorkspaces.findIndex((workspace) => id === workspace.id);
const previosWorkspace = openWorkspaces[removedWorkspaceIndex - 1];
const nextWorkspace = openWorkspaces[removedWorkspaceIndex + 1];
if (previosWorkspace) {
dispatch(setCurrentWorkspace(previosWorkspace.id));
await workspaceController.update({ ...previosWorkspace, isCurrent: true });
} else if (nextWorkspace) {
dispatch(setCurrentWorkspace(nextWorkspace.id));
await workspaceController.update({ ...nextWorkspace, isCurrent: true });
}
}
dispatch(setClosedWorkspace({ id, saved: false }));
const workspace = workspaces.find((el) => el.id === id);
if (workspace && workspace.id !== CREATE_NEW_WORKSPACE_ID) {
try {
const res = await workspaceController.update({ ...workspace, saved: false, isCurrent: false });
if (res.data.id) {
dispatch(updateWorkspace(res.data));
}
} catch {
/** */
}
}
},
[currentWorkspaceId, dispatch, workspaces],
);
const makeNewWorkspace = useCallback((): void => {
const newWorkspace: Workspace = {
name: 'Новый рабочий стол',
id: CREATE_NEW_WORKSPACE_ID,
isCurrent: true,
saved: true,
pinned: false,
position: workspaces.length,
isNew: true,
isAddWidgets: true,
};
dispatch(addWorkspace(newWorkspace));
dispatch(setCurrentWorkspace(CREATE_NEW_WORKSPACE_ID));
}, [dispatch, workspaces.length]);
const renameWorkspace = useCallback(
async (id: number, name: string) => {
const workspace = workspaces.find((w) => w.id === id);
if (workspace) {
try {
await workspaceController.update({ ...workspace, name });
dispatch(setWorkspaceName({ id, name }));
toasts.renameWrokspace.success();
} catch (error) {
toasts.renameWrokspace.error();
}
}
},
[dispatch, workspaces],
);
const addWorkspaceToFavorites = useCallback(
async (id: number, name: string) => {
const workspace = workspaces.find((w) => w.id === id);
if (workspace) {
try {
await workspaceController.update({ ...workspace, name, favorite: true });
dispatch(addToFavorites({ id, name }));
toasts.addToFavorites.success(name);
} catch (error) {
toasts.addToFavorites.error(name);
}
}
},
[dispatch, workspaces],
);
const removeWorkspaceFormFavorites = useCallback(
(id: number) => {
dispatch(removeFromFavorites(id));
},
[dispatch],
);
return {
deleteWorkspace,
closeWorkspace,
makeNewWorkspace,
renameWorkspace,
addWorkspaceToFavorites,
removeWorkspaceFormFavorites,
};
};
import { DragEndEvent } from '@dnd-kit/core';
import { arrayMove } from '@dnd-kit/sortable';
import { useCallback, useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import { workspaceController } from '@api/controllers/workspace';
import { CREATE_NEW_WORKSPACE_ID, DEFAULT_NEGOTIATION_WORKSPACE } from '@configs/appConfig';
import { useAppSelect } from '@hooks/useAppSelector';
import { useLocalisation } from '@hooks/useLocalisation';
import { changeCurrentWorkspace, removeWorkspace, setWorkspaces } from '@store/slices/workspaces';
import { useSaveProfileConfirm } from '@terminal/hooks/useSaveProfileConfirm';
import { useWorkspaceActions } from '@terminal/hooks/useWorkspaceActions';
type UseWorkspaceTabParams = {
onClick?: (id: number) => void;
};
export const useWorkspaceTabs = ({ onClick }: UseWorkspaceTabParams) => {
const [hoveredIndex, setHoveredIndex] = useState<number>(-1);
const dispatch = useDispatch();
const workspacesFromStore = useAppSelect((state) => state.workspaces.workspaces);
const currentWorkspace = useAppSelect((state) => state.workspaces.workspaces?.find((item) => !!item.isCurrent));
const { makeNewWorkspace } = useWorkspaceActions();
const { confirm } = useSaveProfileConfirm();
const localisation = useLocalisation();
const workspaces = useMemo(
() => workspacesFromStore.filter((w) => w.saved && w.name !== localisation.helpCenter),
[localisation.helpCenter, workspacesFromStore],
);
const workspaceTabs = useMemo(() => {
const editableWorkspaces = workspaces.filter((elem) => elem.id !== DEFAULT_NEGOTIATION_WORKSPACE);
const pinnedWorkspaces = editableWorkspaces.filter((elem) => elem.pinned);
const alsoWorkspaces = editableWorkspaces.filter((elem) => !elem.pinned).sort((a, b) => a.position - b.position);
// Элементы для отображения во вкладках
return [...pinnedWorkspaces, ...alsoWorkspaces];
}, [workspaces]);
const defaultWorkspace = workspaces.find((elem) => elem.id === DEFAULT_NEGOTIATION_WORKSPACE);
const handleClickTab = useCallback(
async (id?: number) => {
const findedWorkspace = workspaces.find((el) => el.id === id);
if (id && currentWorkspace && findedWorkspace?.id) {
dispatch(changeCurrentWorkspace(id));
// Если переходим со стартовой страницы, то после перехода закрываем ее с задержкой,
// чтобы правильно обработать двойной клик по вкладке
if (currentWorkspace.id === CREATE_NEW_WORKSPACE_ID) {
setTimeout(() => dispatch(removeWorkspace(CREATE_NEW_WORKSPACE_ID)), 300);
}
onClick?.(id);
await Promise.all([
currentWorkspace.id !== CREATE_NEW_WORKSPACE_ID &&
workspaceController.update({ ...currentWorkspace, isCurrent: false }),
workspaceController.update({ ...findedWorkspace, isCurrent: true }),
]);
}
},
[workspaces, currentWorkspace, dispatch, onClick],
);
const moveWorkspaceTab = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (active.id !== over?.id) {
const oldIndex = workspacesFromStore.findIndex((w) => w.id === active.id);
const newIndex = workspacesFromStore.findIndex((w) => w.id === over?.id);
const movedWorkspaces = arrayMove(workspacesFromStore, oldIndex, newIndex);
const editableWorkspaces = movedWorkspaces.filter((w) => w.id !== DEFAULT_NEGOTIATION_WORKSPACE);
const pinnedWorkspaces = editableWorkspaces.filter((w) => w.pinned);
const otherWorkspaces = editableWorkspaces.filter((w) => !w.pinned);
const reorderedWorkspaces = [...pinnedWorkspaces, ...otherWorkspaces].map((w, i) => ({
...w,
position: i,
}));
const newWorkspaces = defaultWorkspace ? [defaultWorkspace, ...reorderedWorkspaces] : reorderedWorkspaces;
dispatch(setWorkspaces(newWorkspaces));
}
},
[defaultWorkspace, dispatch, workspacesFromStore],
);
const handlePlusBtnClick = async () => {
const confirmed = await confirm();
if (confirmed) {
makeNewWorkspace();
}
};
const showPlusBtn = workspaces.findIndex((elem) => elem.isNew) === -1;
return {
currentWorkspace,
workspaceTabs,
defaultWorkspace,
showPlusBtn,
moveWorkspaceTab,
handleClickTab,
hoveredIndex,
changeHoveredIndex: setHoveredIndex,
handlePlusBtnClick,
};
};
import classNames from 'classnames';
import React, { useEffect } from 'react';
import { IconButton } from '@components/IconButton';
import { ChevronLeftRounded } from '@components/Icons/ChevronLeftRounded';
import { ChevronRightRounded } from '@components/Icons/ChevronRightRounded';
import { useWorkspaceActions } from '@terminal/hooks/useWorkspaceActions';
import { Button } from '@uikit/Button';
import { WorkspaceTab } from '../WorkspaceTab';
import { DndWrapper } from './components/DndWrapper';
import { useScroll } from './hooks/useScroll';
import { useWorkspaceTabs } from './hooks/useWorkspaceTabs';
import styles from './workspacePanel.module.scss';
// TODO: убрать, т.к. здесь он не используется, но импортируется в модуле NewsFeedsHeader
export type Coords = {
left: number;
top: number;
};
export const WorkspacePanel = () => {
const { setTabRef, scroll, scrollRef, showLeft, showRight, handleWheel, isWheeling, scrollToTab } = useScroll();
const { closeWorkspace } = useWorkspaceActions();
const {
currentWorkspace,
workspaceTabs,
defaultWorkspace,
hoveredIndex,
showPlusBtn,
handleClickTab,
moveWorkspaceTab,
changeHoveredIndex,
handlePlusBtnClick,
} = useWorkspaceTabs({ onClick: scrollToTab });
useEffect(() => {
scrollToTab(currentWorkspace?.id);
}, [scrollToTab, currentWorkspace?.id, currentWorkspace?.pinned]);
return (
<div
className={styles.workspaceTabPanel}
data-unit="workspace-panel"
onWheel={handleWheel}
>
{showLeft && (
<div className={classNames(styles.nav, styles['nav-left'])}>
<IconButton
size="large"
icon={<ChevronLeftRounded />}
onClick={() => scroll('left')}
className={styles.navButton}
/>
</div>
)}
<div
className={styles.tabsContainer}
ref={scrollRef}
>
{defaultWorkspace && (
<WorkspaceTab
id={defaultWorkspace?.id}
name={defaultWorkspace?.name}
index={-1}
onClick={handleClickTab}
selected={defaultWorkspace?.isCurrent}
onCloseWorkspace={closeWorkspace}
setTabRef={setTabRef}
/>
)}
<DndWrapper
items={workspaceTabs}
scrollContainerRef={scrollRef}
onDragEnd={moveWorkspaceTab}
>
{workspaceTabs.map((elem, index, items) => (
<WorkspaceTab
key={elem.id}
id={elem.id}
name={elem.name}
index={index}
onClick={handleClickTab}
editable
selected={elem.isCurrent}
onCloseWorkspace={closeWorkspace}
pinned={elem.pinned}
withDivider={
index !== 0 &&
!elem.isCurrent &&
!items[index - 1].isCurrent &&
index !== hoveredIndex &&
index !== hoveredIndex + 1
}
onChangeHoverIndex={changeHoveredIndex}
withTooltip={!isWheeling}
setTabRef={setTabRef}
/>
))}
</DndWrapper>
{showPlusBtn && (
<div className={classNames(styles.mover)}>
<Button
variant="unfilled-secondary"
Icon="plus"
iconEnabled="start"
onClick={handlePlusBtnClick}
className={styles.plusIcon}
/>
</div>
)}
</div>
{showRight && (
<div className={classNames(styles.nav, styles['nav-right'])}>
<IconButton
size="large"
icon={<ChevronRightRounded />}
onClick={() => scroll('right')}
className={styles.navButton}
/>
{showPlusBtn && (
<Button
variant="unfilled-secondary"
Icon="plus"
iconEnabled="start"
onClick={handlePlusBtnClick}
className={styles.plusIcon}
/>
)}
</div>
)}
</div>
);
};
import React, { FC, forwardRef, useEffect, useRef } from 'react';
import { newsSettingsController } from '@api/controllers/newsController';
import { ChevronRight } from '@components/Icons/ChevronRight';
import { useDragInstrument } from '@hooks/dnd';
import { useAppSelect } from '@hooks/useAppSelector';
import { useLocalisation } from '@hooks/useLocalisation';
import { useManageSidebar } from '@hooks/useManageSidebar';
import { useValueAtPreviousRender } from '@hooks/usePrevious';
import { setNewsFeedSettings } from '@store/slices/newsSettings';
import { setOpenNewsId } from '@store/slices/newsSlice';
import { setClosedWorkspace, setCurrentWorkspace } from '@store/slices/workspaces';
import { getStore } from '@store/store';
import { highlight } from '@utils/highlight';
import createWidget from '@utils/createWidget';
import { newWorkspaceName } from '@utils/newWorkspaceName';
import { WidgetContentType } from 'types/Widgets';
import createWorkspace from '@utils/createWorkspace';
import { ItemListType } from '../type/type';
import styles from './itemList.module.scss';
export interface ItemListProps {
setCurrentDivSource: React.Dispatch<React.SetStateAction<HTMLDivElement | undefined>>;
currentDivSource: HTMLDivElement | undefined;
shiftRight: boolean;
className?: string;
closeList?: () => void;
selectItemHandler: (item: ItemListType | null) => void;
itemProp: ItemListType;
}
export const ItemList: FC<ItemListProps> = forwardRef(
({
selectItemHandler,
className = '',
currentDivSource,
setCurrentDivSource,
shiftRight,
closeList,
itemProp,
}: ItemListProps): JSX.Element => {
const { name, type, widget, findKey, workspace, instrument, instrumentProps, key: newsId } = itemProp;
const refSource = useRef(null);
const workspaceStore = useAppSelect((state) => state.workspaces);
const newsSettings = useAppSelect((state) => state.newsSettings.settings);
const isExtended = useAppSelect((state) => state.newsSettings.settings?.viewMode === 'EXTENDED');
const workspaceId = workspaceStore.workspaces.find(({ isCurrent }) => isCurrent)?.id as number;
const localisation = useLocalisation();
const isCurrentWorkspaceNews: boolean = useAppSelect(
(state) => !!state.workspaces.workspaces?.find((item) => item.name === localisation.news && item.isCurrent),
);
const currentWorkspace = useAppSelect((state) => state.workspaces.workspaces?.find((item) => item.isCurrent));
const workspaceNews = useAppSelect((state) =>
state.workspaces.workspaces?.find((item) => item.name === localisation.news && item.isAddWidgets === false),
);
async function findWorkspace() {
if (workspaceId === -1) {
const { id } = await createWorkspace({
name: newWorkspaceName(localisation.newWorkspace, workspaceStore),
isNew: false,
pinned: false,
});
return id;
}
return workspaceId;
}
const onClickHandler = async () => {
const avalibleWorkspace = workspaceStore.workspaces.find((item) => item.id === workspace);
if (workspace) {
if (avalibleWorkspace) {
getStore().dispatch(setCurrentWorkspace(avalibleWorkspace.id));
} else {
// eslint-disable-next-line no-console
console.warn('Workspace not avalibal');
}
if (closeList) {closeList();}
} else if (widget) {
const id = await findWorkspace();
createWidget([
{
type: widget as WidgetContentType,
workspaceId: id,
},
]);
if (closeList) {closeList();}
} else if (type === 'news' && newsId) {
if (!newsSettings) {
(async () => {
const { data } = await newsSettingsController.getSettings();
getStore().dispatch(setNewsFeedSettings(data));
})();
}
getStore().dispatch(setOpenNewsId(newsId));
if (!isExtended) {
if (!isCurrentWorkspaceNews && workspaceNews) {
getStore().dispatch(setCurrentWorkspace(workspaceNews.id));
getStore().dispatch(
setClosedWorkspace({
id: workspaceNews.id,
saved: true,
}),
);
} else if (!isCurrentWorkspaceNews) {
createWorkspace({
name: localisation.news,
isAddWidgets: false,
isNew: false,
});
}
} else if (isCurrentWorkspaceNews) {
createWorkspace({
name: newWorkspaceName(localisation.newWorkspace, workspaceStore),
isNew: false,
pinned: false,
}).then((res) => {
createWidget([
{
workspaceId: res.id as unknown as number,
type: WidgetContentType.news,
position: { x: 0, y: 0 },
},
]);
});
} else if (currentWorkspace?.id) {
createWidget([
{
workspaceId: currentWorkspace.id,
type: WidgetContentType.news,
},
]);
}
if (closeList) {closeList();}
} else {
setCurrentDivSource(refSource.current as unknown as HTMLDivElement);
selectItemHandler(itemProp);
}
};
const { dragRef, opacity, isDragging } = useDragInstrument({
...instrumentProps,
});
const { close: closeSidebar } = useManageSidebar();
// Закрытие списка если начато перетаскивание инструмента
const previousRenderDraggingStatus = useValueAtPreviousRender(isDragging);
useEffect(() => {
// начато перетаскивание инструмента
if (isDragging && !previousRenderDraggingStatus) {
closeList?.();
closeSidebar();
}
}, [isDragging, previousRenderDraggingStatus, closeList, closeSidebar]);
return (
<div
className={styles.itemList}
onClick={onClickHandler}
ref={refSource}
>
<div
style={{ opacity }}
className={`${styles.wrapper} ${
refSource.current === currentDivSource ? styles['wrapper-checked'] : ''
} ${className} ${shiftRight ? styles['wrapper-shift'] : ''}`}
ref={dragRef}
>
<div className={styles['wrapper-name']}>
<div className={`${styles.name} ${type === 'news' ? styles['name-news'] : ''}`}>
{highlight(name, findKey)}
</div>
{type !== 'news' && <div className={styles.type}>{highlight(type, findKey)}</div>}
</div>
{instrument && (
<div className={styles['wrapper-instrument']}>
<div className={styles.instrument}>{highlight(instrument, findKey)}</div>
<div className={styles['chevron-icon']}>
<ChevronRight />
</div>
</div>
)}
{itemProp.issuerId && (
<div className={styles['wrapper-instrument']}>
<div className={styles['chevron-icon']}>
<ChevronRight />
</div>
</div>
)}
</div>
</div>
);
},
);
import React, { FC } from 'react';
import { workspaceController } from '@api/controllers/workspace';
import Icons from '@components/Icons';
import { DEFAULT_NEGOTIATION_WORKSPACE } from '@configs/appConfig';
import { useAppSelect } from '@hooks/useAppSelector';
import { useLocalisation } from '@hooks/useLocalisation';
import { setClosedWorkspace, setCurrentWorkspace } from '@store/slices/workspaces';
import { getStore } from '@store/store';
import { DropdownItems, WorkspaceItem } from '../MenuItems/WorkspaceItem';
const { LaptopChromebookIcon } = Icons;
const menuItemsTest = [
{
name: 'Мои рабочие столы',
icon: <LaptopChromebookIcon style={{ color: '#C7C7D1' }} />,
dropdownItems: [
{
name: 'Новый рабочий стол',
},
],
},
// {
// name: "Рекомендуемые рабочие столы",
// icon: <LaptopChromebookIcon style={{ color: "#C7C7D1" }} />,
// dropdownItems: [
// {
// name: "Новости Prime",
// },
// {
// name: "Акции",
// },
// {
// name: "Облигации",
// },
// {
// name: "Валютный рынок",
// },
// {
// name: "Рынок метталлов",
// },
// {
// name: "Фьючерсы",
// },
// ],
// },
];
interface WorkspaceMenuProps {
selectedItem: string;
setSelectedItem: React.Dispatch<React.SetStateAction<string>>;
}
export const WorkspaceMenu: FC<WorkspaceMenuProps> = function ({ selectedItem, setSelectedItem }) {
const localisation = useLocalisation();
const workspaces = useAppSelect((state) => state.workspaces.workspaces);
const workspacesMenu: DropdownItems[] = workspaces
.filter((w) => w.id !== DEFAULT_NEGOTIATION_WORKSPACE)
// TRADERADAR-2032. Возможны проблемы при работе с английским языком
.filter((w) => w.name !== localisation.helpCenter)
.map((workspace) => ({
id: workspace.id,
name: workspace.name,
onClick: async (id) => {
if (id) {
getStore().dispatch(setCurrentWorkspace(id));
getStore().dispatch(setClosedWorkspace({ id, saved: true }));
await workspaceController.update({ ...workspace, isCurrent: true, saved: true });
}
},
}));
return (
<>
{menuItemsTest.map((menuItem) => {
const isMyWorkSpaceMenu = menuItem.name === 'Мои рабочие столы';
return (
<WorkspaceItem
key={menuItem.name}
contextMenu
name={menuItem.name}
icon={menuItem.icon}
selected={selectedItem === menuItem.name}
setSelectedItem={setSelectedItem}
dropdownItems={isMyWorkSpaceMenu ? workspacesMenu : menuItem.dropdownItems}
chevronIcon={isMyWorkSpaceMenu ? !!workspacesMenu.length : true}
disabled={isMyWorkSpaceMenu ? !workspacesMenu.length : false}
/>
);
})}
</>
);
};
import { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { LIBRARY_WORKSPACES_NAMES } from '@configs/systemWorkspaceConfigs';
import { useAppSelect } from '@hooks/useAppSelector';
import { useLocalisation } from '@hooks/useLocalisation';
import { useWorkspaceCreator } from '@hooks/useWorkspaceCreator';
import { workspacesNamesSelector } from '@store/selectors/workspaces';
import { setCurrentWorkspace } from '@store/slices/workspaces';
import { createNameForNewWorkspace } from '@terminal/desktop/components/Header/utils';
import createWidget from '@utils/createWidget';
import createWorkspace from '@utils/createWorkspace';
import getDefaultWorkspaceConfig from '@utils/getDefaultWorkspaceConfig';
import { newWorkspaceName } from '@utils/newWorkspaceName';
import { LibraryWorkspaceType, SystemWorkspaceConfig } from 'types/Workspace';
export const useWorkspaceCreate = () => {
const dispatch = useDispatch();
const localisation = useLocalisation();
const workspaceStore = useAppSelect((state) => state.workspaces);
const workspacesNames = useAppSelect(workspacesNamesSelector);
const createWorkspaceFromConfig = (type: SystemWorkspaceConfig, name: string) => {
createWorkspace({
name: newWorkspaceName(name, workspaceStore),
isNew: false,
}).then((res) => {
dispatch(setCurrentWorkspace(res.id));
const widgets = getDefaultWorkspaceConfig(type, res.id);
createWidget(widgets);
});
};
const createEmptyWorkspace = async () => {
try {
const workspace = await createWorkspace({
name: createNameForNewWorkspace(localisation.newWorkspace, workspacesNames),
isNew: false,
pinned: false,
});
return workspace;
} catch (error) {
console.log(error);
}
};
const createWorkspaceThroughImport = useWorkspaceCreator(null);
const createWorkspaceFromJson = useCallback(
(id: LibraryWorkspaceType) => {
fetch(`/workspaces/${id}.json`)
.then((result) => result.text())
.then((workspace) => {
const dataJSON = JSON.parse(workspace);
createWorkspaceThroughImport(
dataJSON.workspace,
dataJSON.widgets_properties,
LIBRARY_WORKSPACES_NAMES.get(id),
);
});
},
[createWorkspaceThroughImport],
);
return { createWorkspaceFromConfig, createEmptyWorkspace, createWorkspaceFromJson, workspaceStore };
};
import { useMemo } from 'react';
import { LIBRARY_WORKSPACES_NAMES } from '@configs/systemWorkspaceConfigs';
import { useAppSelect } from '@hooks/useAppSelector';
import { useProtectedWorkspaces } from '@hooks/useProtectedWorkspaces';
import { isSPFIBrokerSelector, isSPFITraderSelector } from '@store/selectors/user';
import { workspacesSelector } from '@store/selectors/workspaces';
import { useFavoriteWorkspaces } from './hooks/useFavoriteWorkspaces';
import { useWorkspaceCreate } from './hooks/useWorkspaceCreate';
import { DefaultWorkspaceTile } from './types';
export function useStartPage() {
const { isAvailableWorkspaceByWorkspaceType } = useProtectedWorkspaces();
const isSpfiTrader = useAppSelect(isSPFITraderSelector);
const isSpfiBroker = useAppSelect(isSPFIBrokerSelector);
const workspaces = useAppSelect(workspacesSelector);
const currencyWorkspace = useMemo(() => {
if (isSpfiBroker) {
return 'spfiBroker';
}
if (isSpfiTrader) {
return 'spfiScreen';
}
return 'spfiView';
}, [isSpfiTrader, isSpfiBroker]);
const { createEmptyWorkspace, createWorkspaceFromJson } = useWorkspaceCreate();
// TODO: Обновлять при изменении конфигов рабочих столов
const startPageDefaultWorkspaces = [
isAvailableWorkspaceByWorkspaceType('depositsCcp') && {
label: LIBRARY_WORKSPACES_NAMES.get('depositsCcp'),
onClick: () => createWorkspaceFromJson('depositsCcp'),
empty: false,
variant: 'quadBlock',
},
isAvailableWorkspaceByWorkspaceType('Currency') && {
label: LIBRARY_WORKSPACES_NAMES.get(currencyWorkspace),
onClick: () => createWorkspaceFromJson(currencyWorkspace),
empty: false,
variant: 'solid',
},
isAvailableWorkspaceByWorkspaceType('Bonds') && {
label: 'Облигации',
onClick: () => createWorkspaceFromJson('bondsLargeScreen'),
empty: false,
variant: 'leftTripleBlock',
},
!isAvailableWorkspaceByWorkspaceType('NTBStock') && {
label: LIBRARY_WORKSPACES_NAMES.get('corporateInformationCenter'),
onClick: () => createWorkspaceFromJson('corporateInformationCenter'),
empty: false,
variant: 'solid',
},
isAvailableWorkspaceByWorkspaceType('NTBStock') && {
label: 'Товарный рынок',
onClick: () => createWorkspaceFromJson('ntb'),
empty: false,
variant: 'rightTripleBlock',
},
isAvailableWorkspaceByWorkspaceType('NTBStock') && {
label: 'Товарные индексы',
onClick: () => createWorkspaceFromJson('ntbIndexes'),
empty: false,
variant: 'rightTripleBlock',
},
isAvailableWorkspaceByWorkspaceType('NTBStock') && {
label: 'Логистика',
onClick: () => createWorkspaceFromJson('ntbLogistic'),
empty: false,
variant: 'rightTripleBlock',
},
isAvailableWorkspaceByWorkspaceType('Stocks') && {
label: 'Акции и Индексы',
onClick: () => createWorkspaceFromJson('stocksAndIndexes'),
empty: false,
variant: 'quadBlock',
},
isAvailableWorkspaceByWorkspaceType('Currency') && {
label: 'Валютный рынок',
empty: false,
onClick: () => createWorkspaceFromJson('currencyMarket'),
variant: 'doubleBlock',
},
isAvailableWorkspaceByWorkspaceType('Talks') && {
label: 'MOEX Dealing',
onClick: () => createWorkspaceFromJson('dealingFunctionality'),
empty: false,
variant: 'rightTripleBlock',
},
].filter(Boolean) as DefaultWorkspaceTile[];
const favoriteWorkspaces = useFavoriteWorkspaces();
const isRecentWorkspacesExist = useMemo(() => !workspaces.some(({ saved }) => !saved), [workspaces]);
return {
startPageDefaultWorkspaces,
onAddEmptyClick: createEmptyWorkspace,
isRecentWorkspacesExist,
favoriteWorkspaces,
};
}
import React from 'react';
import { useDispatch } from 'react-redux';
import { TOURS_DATA } from '@modules/tours';
import { openStartPageWidgetsModal } from '@store/slices/modals';
import { Button } from '@uikit/Button';
import { MiniTile } from './components/MiniTile';
import { StartPageModal } from './components/StartPageModal/types';
import styles from './startPage.module.scss';
import { useStartPage } from './useStartPage';
const StartPage = function () {
const { startPageDefaultWorkspaces, onAddEmptyClick, favoriteWorkspaces, isRecentWorkspacesExist } = useStartPage();
const dispatch = useDispatch();
const recentWorkspacesButton = (
<Button
tooltipProps={{
title: isRecentWorkspacesExist ? 'Нет закрытых рабочих столов' : null,
}}
disabled={isRecentWorkspacesExist}
onClick={() => dispatch(openStartPageWidgetsModal(StartPageModal.RECENT_WORKSPACES))}
className={styles['startpage-block-subtitle-button']}
variant="unfilled-primary"
text="Недавно закрытые"
/>
);
return (
<div className={styles['startpage-block']}>
<div className={styles['startpage-block-create-container']}>
<div
className={styles['startpage-block-create']}
onClick={onAddEmptyClick}
data-intro-tour={TOURS_DATA[0].steps[0].target.split('-')[1]}
>
<div className={styles['startpage-block-create-circle']}>+</div>
<div className={styles['startpage-block-create-bottom']}>
<div className={styles['startpage-block-create-bottom-title']}>Создать свой рабочий стол</div>
<div className={styles['startpage-block-create-bottom-text']}>
Создайте рабочий стол со своими настройками
</div>
</div>
</div>
{favoriteWorkspaces.length === 0 && recentWorkspacesButton}
</div>
{favoriteWorkspaces.length > 0 && (
<div className={styles['startpage-block-item']}>
<div className={styles['startpage-block-subtitle']}>
<p className={styles['startpage-block-subtitle-text']}>Избранные рабочие столы</p>
{recentWorkspacesButton}
</div>
<div className={styles['startpage-block-body']}>
<div className={styles['body-tile-row']}>
{favoriteWorkspaces.map((item) => (
<MiniTile
key={item.id}
onClick={item.onClick}
label={item.label}
variant={item.variant}
/>
))}
</div>
</div>
</div>
)}
<div className={styles['startpage-block-item']}>
<div className={styles['startpage-block-subtitle']}>
<p className={styles['startpage-block-subtitle-text']}>Шаблонные рабочие столы</p>
<Button
onClick={() => dispatch(openStartPageWidgetsModal(StartPageModal.LIBRARY_WORKSPACES))}
className={styles['startpage-block-subtitle-button']}
variant="unfilled-primary"
text="Открыть библиотеку"
/>
</div>
<div className={styles['startpage-block-body']}>
<div className={styles['body-tile-row']}>
{startPageDefaultWorkspaces.map((item) =>
!item.empty ? (
<MiniTile
id={item.id ?? ''}
key={item.label}
onClick={item.onClick}
label={item.label}
variant={item.variant}
/>
) : null,
)}
</div>
</div>
</div>
</div>
);
};
export default StartPage;
import isEqual from 'lodash/isEqual';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import {
CREATE_NEW_WORKSPACE_ID,
DISTANCE_BETWEEN_WIDGET,
DISTANCE_MAGNETIZATION_WIDGET_START,
} from '@configs/appConfig';
import features from '@features';
import { useAppSelect } from '@hooks/useAppSelector';
import { useLocalisation } from '@hooks/useLocalisation';
import { useSetBiggestZIndex } from '@hooks/useSetBiggestZIndex';
import { savedWorkspacesSelector, workspaceWidgetsSelector } from '@store/selectors/workspaces';
import { setPositionForWidget, setSizesForWidget, WidgetChangePayload } from '@store/slices/widgets';
import { addWorkspace, setCurrentWorkspace } from '@store/slices/workspaces';
import { getStore } from '@store/store';
import getWorkspaceSizes from '@utils/getWorkspaceSizes';
import { type Widget, type WidgetSizes } from 'types/Widgets';
import { Workspace } from 'types/Workspace';
import { exportJSONFile } from '../../../utils/exportJSONFile';
import { widgetDistanceConnect } from './widgetDistanceConnect';
import { widgetResizeConnect } from './widgetResizeConnect';
import type { DraggableData, Position, RndDragEvent, RndResizeCallback } from 'react-rnd';
type OnResizeCb = (...args: Parameters<RndResizeCallback>) => boolean;
interface WidgetConnectorCoordinates {
x: number;
y: number;
}
interface UseWorkspaceFacadeResult {
widgets: Widget[];
setZIndexForWidget(widgetId: Widget['id']): void;
setDraggingWidgetId: (widgetId: number) => void;
onWidgetDrag(event: RndDragEvent, data: DraggableData): void;
onWidgetDragStop(e: DraggableData): void;
onWidgetResize: OnResizeCb;
widgetConnectorCoordinates: Partial<WidgetConnectorCoordinates> | undefined;
onWidgetResizeStop: RndResizeCallback;
onWidgetResizeStart(widgetId: Widget['id']): void;
isCurrentWorkspaceNew: boolean | undefined;
isShowCurrentWidgets: boolean;
exportJSONFile(item: number, name: string): void;
workspacesArr: Workspace[];
idOfMovingOrResizingWidget: number | null;
}
export function useWorkspaceFacade(): UseWorkspaceFacadeResult {
const dispatch = useDispatch();
const workspacesArr = useAppSelect(savedWorkspacesSelector);
const isCurrentWorkspaceNew = useAppSelect(
({ workspaces }) => workspaces.workspaces.find(({ isCurrent }) => isCurrent)?.isNew,
);
const { setZIndexForWidget } = useSetBiggestZIndex();
const isShowCurrentWidgets: boolean = useAppSelect(
(state) => state.workspaces.workspaces.find(({ isCurrent, code }) => isCurrent && !code)?.isAddWidgets ?? false,
);
const widgets = useAppSelect(workspaceWidgetsSelector);
// Использование одновременно и стейта и реф для:
// 1) Уменьшения пересоздания коллбеков (не надо прокидывать в зависимости useCallback)
// 2) стейт - для перерендеров там где нужно (вне фасада)
const [draggingWidgetId, setDraggingWidgetId] = useState<Widget['id'] | undefined>();
const draggingWidgetIdRef = useRef<number | undefined>(undefined);
const [widgetConnectorCoordinates, setWidgetConnectorCoordinates] = useState<
Partial<WidgetConnectorCoordinates> | undefined
>();
const newSizesForWidget = useRef<{
id: number;
sizes: WidgetSizes;
}>();
const newPositionForWidget = useRef<{
id: number;
position: Position;
}>();
const [idOfWidgetInResizeProcess, setIdOfWidgetInResizeProcess] = useState<number | null>(null);
const setOnlyNewWidgetConnectorCoordinates = useCallback(
(connectorCoordinates: Partial<WidgetConnectorCoordinates> | undefined) => {
if (!isEqual(widgetConnectorCoordinates, connectorCoordinates)) {
setWidgetConnectorCoordinates(connectorCoordinates);
}
},
[widgetConnectorCoordinates],
);
useEffect(() => {
if (workspacesArr.length === 0) {
const newWorkspace: Workspace = {
name: 'Новый рабочий стол',
id: CREATE_NEW_WORKSPACE_ID,
isCurrent: true,
saved: true,
pinned: false,
position: getStore().getState().workspaces.workspaces.length,
isNew: true,
isAddWidgets: true,
};
if (features.meshLayout) {
newWorkspace.layout = {
version: '1.0',
rootId: 'pane-0',
items: [{ id: 'pane-0', widgetId: '', size: { width: 0, height: 0 } }],
};
}
dispatch(addWorkspace(newWorkspace));
dispatch(setCurrentWorkspace(CREATE_NEW_WORKSPACE_ID));
}
}, [dispatch, workspacesArr]);
const { getConnectionSideCoordinateFromDC } = useMemo(widgetDistanceConnect, []);
const { getParams } = useMemo(widgetResizeConnect, []);
const onWidgetDrag = useCallback(
(event: RndDragEvent, data: DraggableData): void => {
const { x, y } = data;
const { height, width } = data.node.getBoundingClientRect();
event.preventDefault();
const coord = getConnectionSideCoordinateFromDC(data);
const workspaceSizes = getWorkspaceSizes();
// прилипание виджетов между виджетом и стороной
if (
y >= 0 &&
y <= DISTANCE_MAGNETIZATION_WIDGET_START &&
coord?.y !== undefined &&
coord?.y < DISTANCE_MAGNETIZATION_WIDGET_START
) {
if (widgetConnectorCoordinates) {
setOnlyNewWidgetConnectorCoordinates({ x: coord.x, y: 1 });
}
return;
}
if (
y + height >= workspaceSizes.height - DISTANCE_MAGNETIZATION_WIDGET_START &&
y + height <= workspaceSizes.height &&
coord &&
coord?.y !== undefined &&
workspaceSizes.height - (coord?.y as number) <= DISTANCE_MAGNETIZATION_WIDGET_START
) {
setOnlyNewWidgetConnectorCoordinates({
x: coord.x,
y: workspaceSizes.height - 1,
});
return;
}
if (
x + width >= workspaceSizes.width - DISTANCE_MAGNETIZATION_WIDGET_START &&
x + width <= workspaceSizes.width &&
coord &&
coord?.x !== undefined &&
workspaceSizes.width - (coord?.x as number) <= DISTANCE_MAGNETIZATION_WIDGET_START
) {
setOnlyNewWidgetConnectorCoordinates({
x: workspaceSizes.width - 1,
y: coord.y,
});
return;
}
if (
x >= 0 &&
x <= DISTANCE_MAGNETIZATION_WIDGET_START &&
coord?.x !== undefined &&
coord?.x <= DISTANCE_MAGNETIZATION_WIDGET_START
) {
setOnlyNewWidgetConnectorCoordinates({
x: 1,
y: coord.y,
});
return;
}
if (x >= 0 && x <= DISTANCE_MAGNETIZATION_WIDGET_START && y >= 0 && y <= DISTANCE_MAGNETIZATION_WIDGET_START) {
// проверка на прилипание по углам
setOnlyNewWidgetConnectorCoordinates({ x: 1, y: 1 });
return;
}
if (
x >= 0 &&
x <= DISTANCE_MAGNETIZATION_WIDGET_START &&
y + height >= workspaceSizes.height - DISTANCE_MAGNETIZATION_WIDGET_START &&
y + height <= workspaceSizes.height
) {
setOnlyNewWidgetConnectorCoordinates({
x: 1,
y: workspaceSizes.height - 1,
});
return;
}
if (
x + width >= workspaceSizes.width - DISTANCE_MAGNETIZATION_WIDGET_START &&
x + width <= workspaceSizes.width &&
y >= 0 &&
y <= DISTANCE_MAGNETIZATION_WIDGET_START
) {
setOnlyNewWidgetConnectorCoordinates({
x: workspaceSizes.width - 1,
y: 1,
});
return;
}
if (
x + width >= workspaceSizes.width - DISTANCE_MAGNETIZATION_WIDGET_START &&
x + width <= workspaceSizes.width &&
y + height >= workspaceSizes.height - DISTANCE_MAGNETIZATION_WIDGET_START &&
y + height <= workspaceSizes.height
) {
setOnlyNewWidgetConnectorCoordinates({
x: workspaceSizes.width - 1,
y: workspaceSizes.height - 1,
});
return;
}
if (x >= 0 && x <= DISTANCE_MAGNETIZATION_WIDGET_START) {
setOnlyNewWidgetConnectorCoordinates({ x: 1 });
return;
}
if (
x + width >= workspaceSizes.width - DISTANCE_MAGNETIZATION_WIDGET_START &&
x + width <= workspaceSizes.width
) {
setOnlyNewWidgetConnectorCoordinates({ x: workspaceSizes.width - 1 });
return;
}
if (y >= 0 && y <= DISTANCE_MAGNETIZATION_WIDGET_START) {
setOnlyNewWidgetConnectorCoordinates({ y: 1 });
return;
}
if (
y + height >= workspaceSizes.height - DISTANCE_MAGNETIZATION_WIDGET_START &&
y + height <= workspaceSizes.height
) {
setOnlyNewWidgetConnectorCoordinates({ y: workspaceSizes.height - 1 });
return;
}
// проверка на прилипание к другим виджетам
if (coord) {
const coordConnector = Object.entries(coord).filter((item) => item[0] === 'x' || item[0] === 'y');
const coordRes = coordConnector.reduce((acc, cur) => ({ ...acc, [cur[0]]: cur[1] }), {});
setOnlyNewWidgetConnectorCoordinates(coordRes);
return;
}
if (
(x > DISTANCE_MAGNETIZATION_WIDGET_START ||
x + width < workspaceSizes.width - DISTANCE_MAGNETIZATION_WIDGET_START ||
y > DISTANCE_MAGNETIZATION_WIDGET_START ||
y + height < workspaceSizes.height - DISTANCE_MAGNETIZATION_WIDGET_START) &&
!coord
) {
setOnlyNewWidgetConnectorCoordinates(undefined);
}
},
[getConnectionSideCoordinateFromDC, setOnlyNewWidgetConnectorCoordinates, widgetConnectorCoordinates],
);
// Использование ref чтобы избежать ререндера коллбека из-за изменения зависимости widgets
const dragWidgetRef = useRef<Widget | undefined>(undefined);
const draggableWidgetId = draggingWidgetIdRef.current;
useEffect(() => {
const draggedWidget = widgets.find(({ id }) => draggableWidgetId === id);
dragWidgetRef.current = draggedWidget;
}, [widgets, draggableWidgetId]);
const onWidgetDragStop = useCallback(
(data: DraggableData): void => {
const { width, height } = getWorkspaceSizes();
if (
draggingWidgetIdRef.current &&
widgetConnectorCoordinates &&
widgetConnectorCoordinates.x &&
widgetConnectorCoordinates.x !== 1 &&
widgetConnectorCoordinates.x !== width - 1
) {
const position = getConnectionSideCoordinateFromDC(data)
? getConnectionSideCoordinateFromDC(data)?.position
: undefined;
if (position) {
dispatch(setPositionForWidget({ id: draggingWidgetIdRef.current, position, moved: true }));
}
} else if (
draggingWidgetIdRef.current &&
widgetConnectorCoordinates &&
widgetConnectorCoordinates.y &&
widgetConnectorCoordinates.y !== 1 &&
widgetConnectorCoordinates.y !== height - 1
) {
const position = getConnectionSideCoordinateFromDC(data)
? getConnectionSideCoordinateFromDC(data)?.position
: undefined;
if (position) {
dispatch(setPositionForWidget({ id: draggingWidgetIdRef.current, position, moved: true }));
}
} else if (draggingWidgetIdRef.current && widgetConnectorCoordinates) {
const position = {
x: data.x,
y: data.y,
};
const currentDraggableWidget = dragWidgetRef.current;
if (currentDraggableWidget) {
if (widgetConnectorCoordinates?.x) {
const newX =
widgetConnectorCoordinates.x > 1
? width - parseFloat(currentDraggableWidget.sizes.width) - DISTANCE_BETWEEN_WIDGET
: DISTANCE_BETWEEN_WIDGET;
position.x = newX;
}
if (widgetConnectorCoordinates?.y) {
const newY =
widgetConnectorCoordinates.y > 1
? height - parseFloat(currentDraggableWidget.sizes.height) - DISTANCE_BETWEEN_WIDGET
: DISTANCE_BETWEEN_WIDGET;
position.y = newY;
}
dispatch(setPositionForWidget({ id: draggingWidgetIdRef.current, position, moved: true }));
}
}
// Убираем текущий виджет из активного драг состояния
draggingWidgetIdRef.current = undefined;
setDraggingWidgetId(undefined);
setWidgetConnectorCoordinates(undefined);
},
[dispatch, getConnectionSideCoordinateFromDC, widgetConnectorCoordinates],
);
const onWidgetResize: OnResizeCb = useCallback(
(e, direction, refToElem, delta, pos) => {
const resizeId = parseInt(refToElem.id, 10);
const directions = ['top', 'right', 'bottom', 'left', 'topLeft', 'topRight', 'bottomLeft', 'bottomRight'];
if (!directions.includes(direction)) {
if (widgetConnectorCoordinates) {
setWidgetConnectorCoordinates(undefined);
}
return false;
}
const params = getParams({
dir: direction,
refToElem,
pos,
});
const { x, y, width, height } = refToElem.getBoundingClientRect();
const transformSizeParam = (param: number): string => `${param}px`;
newSizesForWidget.current = {
id: resizeId,
sizes: {
width: transformSizeParam(width),
height: transformSizeParam(height),
},
};
newPositionForWidget.current = {
id: resizeId,
position: { x, y },
};
if (!params) {
if (widgetConnectorCoordinates) {
setWidgetConnectorCoordinates(undefined);
}
return false;
}
const { position, sizes, closest, typeX, typeY } = params;
newSizesForWidget.current = {
id: resizeId,
sizes,
};
newPositionForWidget.current = {
id: resizeId,
position,
};
const getLineCoords = (direct: typeof direction): { x?: number; y?: number } | undefined => {
let result: { x?: number | undefined; y?: number | undefined } | undefined;
const lineTop = () => {
if (typeY) {
result = {
...result,
y: typeY === 'closest' ? closest.position.y + parseFloat(closest.sizes.height) + 2 : closest.position.y,
};
}
};
const lineBottom = () => {
if (typeY) {
result = {
...result,
y: typeY === 'closest' ? closest.position.y : closest.position.y + parseFloat(closest.sizes.height),
};
}
};
const lineLeft = () => {
if (typeX) {
result = {
...result,
x: typeX === 'closest' ? closest.position.x + parseFloat(closest.sizes.width) : closest.position.x,
};
}
};
const lineRight = () => {
if (typeX) {
result = {
...result,
x: typeX === 'closest' ? closest.position.x : closest.position.x + parseFloat(closest.sizes.width),
};
}
};
switch (direct) {
case 'top':
lineTop();
break;
case 'bottom':
lineBottom();
break;
case 'left':
lineLeft();
break;
case 'right':
lineRight();
break;
case 'topLeft':
lineLeft();
lineTop();
break;
case 'topRight':
lineTop();
lineRight();
break;
case 'bottomLeft':
lineBottom();
lineLeft();
break;
case 'bottomRight':
lineBottom();
lineRight();
break;
default:
break;
}
return result;
};
const variant = getLineCoords(direction);
if (variant) {
setOnlyNewWidgetConnectorCoordinates(variant);
} else {
setOnlyNewWidgetConnectorCoordinates(undefined);
}
return false;
},
[getParams, setOnlyNewWidgetConnectorCoordinates, widgetConnectorCoordinates],
);
const onWidgetResizeStop: RndResizeCallback = useCallback(() => {
setIdOfWidgetInResizeProcess(null);
if (widgetConnectorCoordinates) {
dispatch(setSizesForWidget(newSizesForWidget.current as unknown as WidgetChangePayload<'sizes'>));
dispatch(
setPositionForWidget({
...(newPositionForWidget.current as unknown as WidgetChangePayload<'position'>),
moved: true,
}),
);
setWidgetConnectorCoordinates(undefined);
}
}, [dispatch, widgetConnectorCoordinates]);
const onWidgetResizeStart = useCallback((widgetId: number) => {
setIdOfWidgetInResizeProcess(widgetId);
}, []);
const setDraggingWidgetIdCallback = useCallback((widgetId: number) => {
setDraggingWidgetId(widgetId);
draggingWidgetIdRef.current = widgetId;
}, []);
return {
widgets,
setZIndexForWidget,
setDraggingWidgetId: setDraggingWidgetIdCallback,
onWidgetDrag,
onWidgetDragStop,
onWidgetResize,
widgetConnectorCoordinates,
onWidgetResizeStop,
isCurrentWorkspaceNew,
isShowCurrentWidgets,
exportJSONFile,
workspacesArr,
onWidgetResizeStart,
idOfMovingOrResizingWidget: draggingWidgetId ?? idOfWidgetInResizeProcess,
};
}
import React, { FC, PropsWithChildren, useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { MAX_FAVORITE_WORKSPACES } from '@configs/appConfig';
import { useAppSelect } from '@hooks/useAppSelector';
import { isFavoritesLimitExceedSelector } from '@store/selectors/modals';
import { workspaceByIdSelector } from '@store/selectors/workspaces';
import { openSaveWorkspaceModal } from '@store/slices/modals';
import { togglePinWorkspace } from '@store/slices/workspaces';
import { createNameForNewWorkspace } from '@terminal/desktop/components/Header/utils';
import { useWorkspaceActions } from '@terminal/hooks/useWorkspaceActions';
import { toasts } from '@terminal/services/toasts';
import { exportJSONFile } from '@terminal/utils/exportJSONFile';
import { ContextMenu } from '@uikit/ContextMenu';
import { ContextMenuItem } from '@uikit/ContextMenu/types';
import createWidget from '@utils/createWidget';
import createWorkspace from '@utils/createWorkspace';
import { deepClone } from '@utils/deepClone';
import { SaveWorkspaceActionType } from '../SaveWorkspaceModal/types';
import { WorkspaceMenuItemsKeys } from './const';
import { useWidgetsByWorkspaceId } from './hooks/useWidgetsByWorkspaceId';
import type { Widget } from 'types/Widgets';
import type { Workspace } from 'types/Workspace';
export type WorkspaceContextMenuProps = PropsWithChildren<{
workspaceId: Workspace['id'];
disabled?: boolean;
onOpenChange?: (open: boolean) => void;
hiddenItems?: WorkspaceMenuItemsKeys[];
open?: boolean;
}>;
export const WorkspaceContextMenu: FC<WorkspaceContextMenuProps> = function ({
workspaceId,
disabled,
onOpenChange,
children,
hiddenItems,
open,
}): JSX.Element | null {
const workspace = useAppSelect(workspaceByIdSelector(workspaceId));
const workspacesNames = useAppSelect((state) => state.workspaces.workspaces).map((item) => item.name);
const dispatch = useDispatch();
const isFavoritesLimitExceed = useAppSelect(isFavoritesLimitExceedSelector(MAX_FAVORITE_WORKSPACES));
const { deleteWorkspace, closeWorkspace, removeWorkspaceFormFavorites } = useWorkspaceActions();
const widgets = useWidgetsByWorkspaceId(workspaceId);
const name = workspace?.name ?? '';
const code = workspace?.code;
const pinned = workspace?.pinned ?? false;
const favorite = workspace?.favorite ?? false;
const onDuplicateBtnClick = useCallback(() => {
const newWorkspaceName = createNameForNewWorkspace(name, workspacesNames);
createWorkspace({
name: newWorkspaceName,
isNew: false,
})
.then(({ id }) => {
const newWidgets = widgets.map((item) => {
const newWidget = deepClone(item) as Omit<Widget, 'id'> & Partial<Pick<Widget, 'id'>>;
delete newWidget.id;
newWidget.workspaceId = id;
// Удаляет id списка инструментов при дублировании рабочего стола
delete newWidget?.widgetContentProps?.widgetQuotesState?.instrumentListId;
return newWidget;
});
createWidget(newWidgets);
toasts.duplicateWorkspace.success(newWorkspaceName);
})
.catch(() => {
toasts.duplicateWorkspace.error(newWorkspaceName);
});
}, [widgets, name, workspacesNames]);
const onPinnedWorkspaceClick = useCallback(() => {
dispatch(togglePinWorkspace({ id: workspaceId }));
}, [dispatch, workspaceId]);
const addToFavorites = useCallback(() => {
dispatch(openSaveWorkspaceModal({ workspaceId, type: SaveWorkspaceActionType.Favorite }));
}, [dispatch, workspaceId]);
const removeFromFavorites = useCallback(() => {
removeWorkspaceFormFavorites(workspaceId);
}, [removeWorkspaceFormFavorites, workspaceId]);
const renameWorkspace = useCallback(() => {
dispatch(openSaveWorkspaceModal({ workspaceId, type: SaveWorkspaceActionType.Rename }));
}, [dispatch, workspaceId]);
const menuItems: ContextMenuItem[] = useMemo(
() =>
[
!code && {
key: WorkspaceMenuItemsKeys.Rename,
label: 'Редактировать название',
onClick: renameWorkspace,
},
{ key: WorkspaceMenuItemsKeys.Copy, label: 'Дублировать вкладку', onClick: onDuplicateBtnClick },
!code && {
key: WorkspaceMenuItemsKeys.Pin,
label: pinned ? 'Открепить вкладку' : 'Закрепить вкладку',
onClick: onPinnedWorkspaceClick,
},
{
key: WorkspaceMenuItemsKeys.Favorite,
label: favorite ? 'Удалить из избранных' : 'Добавить в избранные',
onClick: favorite ? removeFromFavorites : addToFavorites,
disabled: !favorite && isFavoritesLimitExceed,
},
{
key: WorkspaceMenuItemsKeys.Delete,
label: 'Удалить рабочий стол',
onClick: () => deleteWorkspace(workspaceId),
},
{
key: WorkspaceMenuItemsKeys.Export,
label: 'Выгрузить рабочий стол в файл',
onClick: () => exportJSONFile(workspaceId, name),
},
{
key: WorkspaceMenuItemsKeys.Close,
label: 'Закрыть',
onClick: () => closeWorkspace?.(workspaceId),
},
]
.filter((item) => !!item)
.filter((item) => !hiddenItems?.includes(item.key)),
[
addToFavorites,
closeWorkspace,
code,
deleteWorkspace,
favorite,
hiddenItems,
isFavoritesLimitExceed,
name,
onDuplicateBtnClick,
onPinnedWorkspaceClick,
pinned,
removeFromFavorites,
renameWorkspace,
workspaceId,
],
);
if (!workspace) {
return null;
}
return (
<ContextMenu
onOpenChange={onOpenChange}
items={menuItems}
disabled={disabled}
zIndex={10001}
open={open}
>
{children}
</ContextMenu>
);
};
import React from 'react';
import { useAppSelect } from '@hooks/useAppSelector';
import { useSystemWorkspaces } from '@hooks/useSystemWorkspaces';
import { workspaceByIdSelector } from '@store/selectors/workspaces';
import { WorkspaceContextMenu, WorkspaceContextMenuProps } from './WorkspaceContextMenu';
type WorkspaceContextMenuWrapperProps = Omit<WorkspaceContextMenuProps, 'workspaceId'> & {
workspaceId?: number;
disabled?: boolean;
};
export const WorkspaceContextMenuWrapper = ({
children,
workspaceId,
disabled,
...otherProps
}: WorkspaceContextMenuWrapperProps) => {
const { isSystemWorkspace } = useSystemWorkspaces({ workspaceId });
const disableContextMenu = disabled || isSystemWorkspace;
if (!workspaceId || disableContextMenu) {
return children;
}
return (
<WorkspaceContextMenu
workspaceId={workspaceId}
{...otherProps}
>
{children}
</WorkspaceContextMenu>
);
};
import classNames from 'classnames/bind';
import React, { FC, useEffect, useRef, useState } from 'react';
import { WorkspaceContextMenuWrapper, WorkspaceMenuItemsKeys } from '@terminal/desktop/components/WorkspaceContextMenu';
import { wrapBetaInSpan } from '@utils/wrapBetaInSpan';
import { WidgetContentType } from 'types/Widgets';
import styles from './dropdownitem.module.scss';
const cx = classNames.bind(styles);
interface ItemProps {
name: string;
title?: string;
subtitle?: string;
descripIcon?: FC;
id?: number;
updated?: string;
onItemClick?: (id?: number, type?: WidgetContentType) => void;
type?: WidgetContentType;
itemStyles?: React.CSSProperties;
selected?: boolean;
contextMenu?: boolean;
disabled?: boolean;
needTip?: boolean;
}
export const DropdownItem: FC<ItemProps> = function ({
name,
title = '',
subtitle,
descripIcon,
id,
updated = '',
onItemClick,
type,
itemStyles = {} as React.CSSProperties,
selected = false,
contextMenu = false,
disabled = false,
needTip,
}) {
const [tooLong, setTooLong] = useState(false);
const nameRef = useRef<HTMLSpanElement | null>(null);
const DescripIcon = descripIcon as FC;
useEffect(() => {
if (needTip && nameRef.current) {
const nameWidth = nameRef.current.offsetWidth;
if (nameWidth > 220) {
setTooLong(true);
}
}
}, [needTip]);
const ref = useRef<HTMLDivElement | null>(null);
return (
<WorkspaceContextMenuWrapper
workspaceId={id}
disabled={!contextMenu}
hiddenItems={[WorkspaceMenuItemsKeys.Close]}
>
<div
className={cx({
'dropdown-menu-item': !selected,
'dropdown-menu-item-selected': selected,
'dropdown-menu-item-disabled': disabled,
})}
onClick={onItemClick && !disabled ? () => onItemClick(id, type) : undefined}
style={itemStyles}
title={tooLong ? name : ''}
ref={ref}
>
<div
style={{
display: 'flex',
paddingBottom: '4px',
pointerEvents: 'none',
}}
>
<span className={cx('menu-item-name', [type])}>
<span className={styles['menu-item-title']}>{title}</span>
<div className={styles['menu-item-main-info-container']}>
{descripIcon && <DescripIcon />}
<div className={styles['menu-item-main-info']}>
<span
ref={nameRef}
className={cx(styles.name, { [styles.tooLong]: tooLong })}
>
{wrapBetaInSpan(name)}
</span>
{subtitle && <div className={styles['menu-item-subtitle']}>{subtitle}</div>}
</div>
</div>
</span>
</div>
<span className={styles['menu-item-updated']}>{updated}</span>
</div>
</WorkspaceContextMenuWrapper>
);
};
import dayjs, { Dayjs } from 'dayjs';
import React, { useMemo, useState } from 'react';
import { DatePickerMad } from '@components/DatePicker';
import { useAppSelect } from '@hooks/useAppSelector';
import { ModalContent } from '../ModalContent';
import { WorkspaceCard } from '../WorkspaceCard';
import styles from './recentWorkspaces.module.scss';
import { useWorkspace } from './useWorkspace';
import { getDateDescription, getDateTitle } from './utils/dates';
import { formatDate } from './utils/formatDate';
import { groupWorkspacesByDate } from './utils/groupWorkspacesByDate';
import { closedWorkspacesSelector } from '@store/selectors/workspaces';
export const RecentWorkspaces = () => {
const [date, setDate] = useState<Dayjs | null>(null);
const workspaces = useAppSelect(closedWorkspacesSelector);
const { handleSelectWorkspace } = useWorkspace();
const groupedWorkspaces = useMemo(() => groupWorkspacesByDate(workspaces), [workspaces]);
const filteredWorkspaces = useMemo(
() => groupedWorkspaces.filter(([closedDate]) => !date || closedDate === formatDate(date)),
[date, groupedWorkspaces],
);
return (
<ModalContent
headerTitle="Выберите дату"
headerContent={
<DatePickerMad<Dayjs | null>
value={date}
onChange={setDate}
format="DD.MM.YYYY"
popupClassName={styles.picker}
disabledDate={(current) => current.isAfter(dayjs(), 'day')}
/>
}
sections={filteredWorkspaces}
getDataFromSection={([groupDate, group]) => ({
key: groupDate,
title: getDateTitle(groupDate),
content: group.map((workspace) => (
<WorkspaceCard
key={workspace.id}
title={workspace.name}
onClick={() => handleSelectWorkspace(workspace.id, workspace)}
description={getDateDescription(groupDate, workspace.closedAt ?? '')}
/>
)),
})}
/>
);
};
import { useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { workspaceController } from '@api/controllers/workspace';
import { CREATE_NEW_WORKSPACE_ID } from '@configs/appConfig';
import { useAppSelect } from '@hooks/useAppSelector';
import { openWorkspace, setClosedWorkspace } from '@store/slices/workspaces';
import { TILE_VARIANTS } from '../const';
import { FavoriteWorkspaceTile } from '../types';
import { workspacesSelector } from '@store/selectors/workspaces';
import { widgetsSelector } from '@store/selectors/widgets';
export const useFavoriteWorkspaces = () => {
const dispatch = useDispatch();
const widgets = useAppSelect(widgetsSelector);
const workspaces = useAppSelect(workspacesSelector);
const favoriteWorkspaces: FavoriteWorkspaceTile[] = useMemo(() => {
const getTileVariant = (workspaceId: number) => {
const widgetsQty = widgets.filter((w) => w.workspaceId === workspaceId).length;
let tileVariantIndex = 0;
if (widgetsQty <= 1) {
tileVariantIndex = 0;
} else if (widgetsQty === 3) {
// 2 и 3 варианты имеют три блока,
// поэтому для раб. столов с тремя виджетами вариант определяем на основании четности id
tileVariantIndex = workspaceId % 2 ? 2 : 3;
} else if (widgetsQty < TILE_VARIANTS.length) {
tileVariantIndex = widgetsQty;
} else {
tileVariantIndex = TILE_VARIANTS.length - 1;
}
return TILE_VARIANTS[tileVariantIndex];
};
const currentWorkspace = workspaces.find((w) => w.isCurrent);
return workspaces
.filter((w) => w.favorite)
.map((w) => ({
id: w.id,
label: w.name,
onClick: () => {
dispatch(openWorkspace(w.id));
dispatch(setClosedWorkspace({ id: CREATE_NEW_WORKSPACE_ID, saved: false }));
Promise.all([
currentWorkspace &&
currentWorkspace.id !== CREATE_NEW_WORKSPACE_ID &&
workspaceController.update({ ...currentWorkspace, isCurrent: false }),
workspaceController.update({ ...w, isCurrent: true }),
]);
},
variant: getTileVariant(w.id),
}));
}, [dispatch, widgets, workspaces]);
return favoriteWorkspaces;
};