feat(trading): 3114 pane context (#4442)
This commit is contained in:
parent
674baa1ee3
commit
74bf183fad
@ -58,7 +58,11 @@ const MainGrid = memo(
|
|||||||
>
|
>
|
||||||
<TradeGridChild>
|
<TradeGridChild>
|
||||||
<Tabs storageKey="console-trade-grid-main-left">
|
<Tabs storageKey="console-trade-grid-main-left">
|
||||||
<Tab id="chart" name={t('Chart')}>
|
<Tab
|
||||||
|
id="chart"
|
||||||
|
name={t('Chart')}
|
||||||
|
menu={<TradingViews.candles.menu />}
|
||||||
|
>
|
||||||
<TradingViews.candles.component marketId={marketId} />
|
<TradingViews.candles.component marketId={marketId} />
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab id="depth" name={t('Depth')}>
|
<Tab id="depth" name={t('Depth')}>
|
||||||
|
@ -37,6 +37,7 @@ export const TradePanels = ({
|
|||||||
const onOrderTypeClick = useMarketLiquidityClickHandler();
|
const onOrderTypeClick = useMarketLiquidityClickHandler();
|
||||||
|
|
||||||
const [view, setView] = useState<TradingView>('candles');
|
const [view, setView] = useState<TradingView>('candles');
|
||||||
|
|
||||||
const renderView = () => {
|
const renderView = () => {
|
||||||
const Component = memo<{
|
const Component = memo<{
|
||||||
marketId: string;
|
marketId: string;
|
||||||
@ -65,8 +66,23 @@ export const TradePanels = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderMenu = () => {
|
||||||
|
const viewCfg = TradingViews[view];
|
||||||
|
|
||||||
|
if ('menu' in viewCfg) {
|
||||||
|
const Menu = viewCfg.menu;
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1 p-1 bg-vega-clight-800 dark:bg-vega-cdark-800 border-b border-default">
|
||||||
|
<Menu />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full grid grid-rows-[min-content_1fr_min-content]">
|
<div className="h-full grid grid-rows-[min-content_min-content_1fr_min-content]">
|
||||||
<div>
|
<div>
|
||||||
{FLAGS.SUCCESSOR_MARKETS && (
|
{FLAGS.SUCCESSOR_MARKETS && (
|
||||||
<>
|
<>
|
||||||
@ -76,6 +92,7 @@ export const TradePanels = ({
|
|||||||
)}
|
)}
|
||||||
<OracleBanner marketId={market?.id || ''} />
|
<OracleBanner marketId={market?.id || ''} />
|
||||||
</div>
|
</div>
|
||||||
|
<div>{renderMenu()}</div>
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
<AutoSizer>
|
<AutoSizer>
|
||||||
{({ width, height }) => (
|
{({ width, height }) => (
|
||||||
|
@ -2,7 +2,10 @@ import type { ComponentProps } from 'react';
|
|||||||
import { Splash } from '@vegaprotocol/ui-toolkit';
|
import { Splash } from '@vegaprotocol/ui-toolkit';
|
||||||
import { TradesContainer } from '@vegaprotocol/trades';
|
import { TradesContainer } from '@vegaprotocol/trades';
|
||||||
import { DepthChartContainer } from '@vegaprotocol/market-depth';
|
import { DepthChartContainer } from '@vegaprotocol/market-depth';
|
||||||
import { CandlesChartContainer } from '@vegaprotocol/candles-chart';
|
import {
|
||||||
|
CandlesChartContainer,
|
||||||
|
CandlesMenu,
|
||||||
|
} from '@vegaprotocol/candles-chart';
|
||||||
import { Filter } from '@vegaprotocol/orders';
|
import { Filter } from '@vegaprotocol/orders';
|
||||||
import { NO_MARKET } from './constants';
|
import { NO_MARKET } from './constants';
|
||||||
import { OrderbookContainer } from '../../components/orderbook-container';
|
import { OrderbookContainer } from '../../components/orderbook-container';
|
||||||
@ -35,6 +38,7 @@ export const TradingViews = {
|
|||||||
candles: {
|
candles: {
|
||||||
label: 'Candles',
|
label: 'Candles',
|
||||||
component: requiresMarket(CandlesChartContainer),
|
component: requiresMarket(CandlesChartContainer),
|
||||||
|
menu: CandlesMenu,
|
||||||
},
|
},
|
||||||
depth: {
|
depth: {
|
||||||
label: 'Depth',
|
label: 'Depth',
|
||||||
|
@ -1,71 +0,0 @@
|
|||||||
import { CandlesChartContainer } from './candles-chart';
|
|
||||||
import { render, screen, waitFor, act } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import { MockedProvider } from '@apollo/client/testing';
|
|
||||||
import { VegaWalletContext } from '@vegaprotocol/wallet';
|
|
||||||
import { CandlesEventsDocument } from './__generated__/Candles';
|
|
||||||
import type { CandlesEventsSubscription } from './__generated__/Candles';
|
|
||||||
|
|
||||||
const candles: CandlesEventsSubscription = {
|
|
||||||
candles: {
|
|
||||||
lastUpdateInPeriod: 0,
|
|
||||||
periodStart: 0,
|
|
||||||
open: '0',
|
|
||||||
high: '0',
|
|
||||||
low: '0',
|
|
||||||
close: '0',
|
|
||||||
volume: '0',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const mocks = [
|
|
||||||
{
|
|
||||||
request: {
|
|
||||||
query: CandlesEventsDocument,
|
|
||||||
variables: { marketId: 'market-id', interval: 'INTERVAL_I15M' },
|
|
||||||
},
|
|
||||||
result: { data: candles },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
describe('TradingChart', () => {
|
|
||||||
it('should render successfully', () => {
|
|
||||||
const { baseElement } = render(
|
|
||||||
<MockedProvider>
|
|
||||||
<VegaWalletContext.Provider value={{} as never}>
|
|
||||||
<CandlesChartContainer marketId={'market-id'} />
|
|
||||||
</VegaWalletContext.Provider>
|
|
||||||
</MockedProvider>
|
|
||||||
);
|
|
||||||
expect(baseElement).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('volume study should be preselected', async () => {
|
|
||||||
act(() => {
|
|
||||||
render(
|
|
||||||
<MockedProvider mocks={mocks}>
|
|
||||||
<VegaWalletContext.Provider value={{} as never}>
|
|
||||||
<CandlesChartContainer marketId={'market-id'} />
|
|
||||||
</VegaWalletContext.Provider>
|
|
||||||
</MockedProvider>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(
|
|
||||||
screen.getByText('Studies', {
|
|
||||||
selector: '[type="button"]',
|
|
||||||
})
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
act(() => {
|
|
||||||
userEvent.click(
|
|
||||||
screen.getByText('Studies', {
|
|
||||||
selector: '[type="button"]',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByRole('menu')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
expect(screen.getByText('Volume')).toHaveAttribute('data-state', 'checked');
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,92 +1,12 @@
|
|||||||
import 'pennant/dist/style.css';
|
import 'pennant/dist/style.css';
|
||||||
import {
|
import { CandlestickChart } from 'pennant';
|
||||||
CandlestickChart,
|
|
||||||
ChartType,
|
|
||||||
Interval,
|
|
||||||
Overlay,
|
|
||||||
Study,
|
|
||||||
chartTypeLabels,
|
|
||||||
intervalLabels,
|
|
||||||
overlayLabels,
|
|
||||||
studyLabels,
|
|
||||||
} from 'pennant';
|
|
||||||
import { VegaDataSource } from './data-source';
|
import { VegaDataSource } from './data-source';
|
||||||
import { useApolloClient } from '@apollo/client';
|
import { useApolloClient } from '@apollo/client';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useVegaWallet } from '@vegaprotocol/wallet';
|
import { useVegaWallet } from '@vegaprotocol/wallet';
|
||||||
import {
|
import { useThemeSwitcher } from '@vegaprotocol/react-helpers';
|
||||||
useThemeSwitcher,
|
|
||||||
getValidItem,
|
|
||||||
getValidSubset,
|
|
||||||
} from '@vegaprotocol/react-helpers';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItemIndicator,
|
|
||||||
DropdownMenuRadioGroup,
|
|
||||||
DropdownMenuRadioItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
Icon,
|
|
||||||
} from '@vegaprotocol/ui-toolkit';
|
|
||||||
import type { IconName } from '@blueprintjs/icons';
|
|
||||||
import { IconNames } from '@blueprintjs/icons';
|
|
||||||
import { t } from '@vegaprotocol/i18n';
|
import { t } from '@vegaprotocol/i18n';
|
||||||
import { create } from 'zustand';
|
import { useCandlesChartSettings } from './use-candles-chart-settings';
|
||||||
import { persist } from 'zustand/middleware';
|
|
||||||
import { immer } from 'zustand/middleware/immer';
|
|
||||||
|
|
||||||
interface StoredSettings {
|
|
||||||
interval?: Interval;
|
|
||||||
type?: ChartType;
|
|
||||||
overlays?: Overlay[];
|
|
||||||
studies?: Study[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useCandlesChartSettings = create<
|
|
||||||
StoredSettings & {
|
|
||||||
merge: (settings: StoredSettings) => void;
|
|
||||||
setType: (type: ChartType) => void;
|
|
||||||
setInterval: (interval: Interval) => void;
|
|
||||||
setOverlays: (overlays: Overlay[]) => void;
|
|
||||||
setStudies: (studies: Study[]) => void;
|
|
||||||
}
|
|
||||||
>()(
|
|
||||||
persist(
|
|
||||||
immer((set) => ({
|
|
||||||
merge: (settings: StoredSettings) =>
|
|
||||||
set((state) => {
|
|
||||||
Object.assign(state, settings);
|
|
||||||
}),
|
|
||||||
setType: (type: ChartType) =>
|
|
||||||
set((state) => {
|
|
||||||
state.type = type;
|
|
||||||
}),
|
|
||||||
setInterval: (interval: Interval) =>
|
|
||||||
set((state) => {
|
|
||||||
state.interval = interval;
|
|
||||||
}),
|
|
||||||
setOverlays: (overlays: Overlay[]) =>
|
|
||||||
set((state) => {
|
|
||||||
state.overlays = overlays;
|
|
||||||
}),
|
|
||||||
setStudies: (studies: Study[]) =>
|
|
||||||
set((state) => {
|
|
||||||
state.studies = studies;
|
|
||||||
}),
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
name: 'console-candles',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const chartTypeIcon = new Map<ChartType, IconName>([
|
|
||||||
[ChartType.AREA, IconNames.TIMELINE_AREA_CHART],
|
|
||||||
[ChartType.CANDLE, IconNames.WATERFALL_CHART],
|
|
||||||
[ChartType.LINE, IconNames.TIMELINE_LINE_CHART],
|
|
||||||
[ChartType.OHLC, IconNames.WATERFALL_CHART],
|
|
||||||
]);
|
|
||||||
|
|
||||||
export type CandlesChartContainerProps = {
|
export type CandlesChartContainerProps = {
|
||||||
marketId: string;
|
marketId: string;
|
||||||
@ -99,161 +19,32 @@ export const CandlesChartContainer = ({
|
|||||||
const { pubKey } = useVegaWallet();
|
const { pubKey } = useVegaWallet();
|
||||||
const { theme } = useThemeSwitcher();
|
const { theme } = useThemeSwitcher();
|
||||||
|
|
||||||
const settings = useCandlesChartSettings();
|
const { interval, chartType, overlays, studies, merge } =
|
||||||
|
useCandlesChartSettings();
|
||||||
const interval: Interval = getValidItem(
|
|
||||||
settings.interval,
|
|
||||||
Object.values(Interval),
|
|
||||||
Interval.I15M
|
|
||||||
);
|
|
||||||
|
|
||||||
const chartType: ChartType = getValidItem(
|
|
||||||
settings.type,
|
|
||||||
Object.values(ChartType),
|
|
||||||
ChartType.CANDLE
|
|
||||||
);
|
|
||||||
|
|
||||||
const overlays: Overlay[] = getValidSubset(
|
|
||||||
settings.overlays,
|
|
||||||
Object.values(Overlay),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const studies: Study[] = getValidSubset(
|
|
||||||
settings.studies,
|
|
||||||
Object.values(Study),
|
|
||||||
[Study.VOLUME]
|
|
||||||
);
|
|
||||||
|
|
||||||
const dataSource = useMemo(() => {
|
const dataSource = useMemo(() => {
|
||||||
return new VegaDataSource(client, marketId, pubKey);
|
return new VegaDataSource(client, marketId, pubKey);
|
||||||
}, [client, marketId, pubKey]);
|
}, [client, marketId, pubKey]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<CandlestickChart
|
||||||
<div className="px-3 lg:px-4 py-2 flex flex-row flex-wrap gap-2 bg-vega-clight-700 dark:bg-vega-cdark-700">
|
dataSource={dataSource}
|
||||||
<DropdownMenu
|
options={{
|
||||||
trigger={
|
chartType,
|
||||||
<DropdownMenuTrigger>
|
overlays,
|
||||||
{t(`Interval: ${intervalLabels[interval]}`)}
|
studies,
|
||||||
</DropdownMenuTrigger>
|
notEnoughDataText: (
|
||||||
}
|
<span className="text-xs text-center">{t('No data')}</span>
|
||||||
>
|
),
|
||||||
<DropdownMenuContent>
|
}}
|
||||||
<DropdownMenuRadioGroup
|
interval={interval}
|
||||||
value={interval}
|
theme={theme}
|
||||||
onValueChange={(value) => {
|
onOptionsChanged={(options) => {
|
||||||
settings.setInterval(value as Interval);
|
merge({
|
||||||
}}
|
overlays: options.overlays,
|
||||||
>
|
studies: options.studies,
|
||||||
{Object.values(Interval).map((timeInterval) => (
|
});
|
||||||
<DropdownMenuRadioItem
|
}}
|
||||||
key={timeInterval}
|
/>
|
||||||
inset
|
|
||||||
value={timeInterval}
|
|
||||||
>
|
|
||||||
{intervalLabels[timeInterval]}
|
|
||||||
<DropdownMenuItemIndicator />
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuRadioGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<DropdownMenu
|
|
||||||
trigger={
|
|
||||||
<DropdownMenuTrigger>
|
|
||||||
<Icon name={chartTypeIcon.get(chartType) as IconName} />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DropdownMenuContent>
|
|
||||||
<DropdownMenuRadioGroup
|
|
||||||
value={chartType}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
settings.setType(value as ChartType);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Object.values(ChartType).map((type) => (
|
|
||||||
<DropdownMenuRadioItem key={type} inset value={type}>
|
|
||||||
{chartTypeLabels[type]}
|
|
||||||
<DropdownMenuItemIndicator />
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuRadioGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<DropdownMenu
|
|
||||||
trigger={<DropdownMenuTrigger>{t('Overlays')}</DropdownMenuTrigger>}
|
|
||||||
>
|
|
||||||
<DropdownMenuContent>
|
|
||||||
{Object.values(Overlay).map((overlay) => (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={overlay}
|
|
||||||
checked={overlays.includes(overlay)}
|
|
||||||
onCheckedChange={() => {
|
|
||||||
const newOverlays = [...overlays];
|
|
||||||
const index = overlays.findIndex((item) => item === overlay);
|
|
||||||
|
|
||||||
index !== -1
|
|
||||||
? newOverlays.splice(index, 1)
|
|
||||||
: newOverlays.push(overlay);
|
|
||||||
|
|
||||||
settings.setOverlays(newOverlays);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{overlayLabels[overlay]}
|
|
||||||
<DropdownMenuItemIndicator />
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<DropdownMenu
|
|
||||||
trigger={<DropdownMenuTrigger>{t('Studies')}</DropdownMenuTrigger>}
|
|
||||||
>
|
|
||||||
<DropdownMenuContent>
|
|
||||||
{Object.values(Study).map((study) => (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={study}
|
|
||||||
checked={studies.includes(study)}
|
|
||||||
onCheckedChange={() => {
|
|
||||||
const newStudies = [...studies];
|
|
||||||
const index = studies.findIndex((item) => item === study);
|
|
||||||
|
|
||||||
index !== -1
|
|
||||||
? newStudies.splice(index, 1)
|
|
||||||
: newStudies.push(study);
|
|
||||||
|
|
||||||
settings.setStudies(newStudies);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{studyLabels[study]}
|
|
||||||
<DropdownMenuItemIndicator />
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<CandlestickChart
|
|
||||||
dataSource={dataSource}
|
|
||||||
options={{
|
|
||||||
chartType: chartType,
|
|
||||||
overlays: overlays,
|
|
||||||
studies: studies,
|
|
||||||
notEnoughDataText: (
|
|
||||||
<span className="text-xs text-center">{t('No data')}</span>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
interval={interval}
|
|
||||||
theme={theme}
|
|
||||||
onOptionsChanged={(options) => {
|
|
||||||
settings.merge({
|
|
||||||
overlays: options.overlays,
|
|
||||||
studies: options.studies,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
17
libs/candles-chart/src/lib/candles-menu.spec.tsx
Normal file
17
libs/candles-chart/src/lib/candles-menu.spec.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { CandlesMenu } from './candles-menu';
|
||||||
|
|
||||||
|
describe('CandlesMenu', () => {
|
||||||
|
it('should render with volume study showing by default', async () => {
|
||||||
|
render(<CandlesMenu />);
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByText('Studies', {
|
||||||
|
selector: '[type="button"]',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(await screen.findByRole('menu')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Volume')).toHaveAttribute('data-state', 'checked');
|
||||||
|
});
|
||||||
|
});
|
160
libs/candles-chart/src/lib/candles-menu.tsx
Normal file
160
libs/candles-chart/src/lib/candles-menu.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import 'pennant/dist/style.css';
|
||||||
|
import {
|
||||||
|
ChartType,
|
||||||
|
Interval,
|
||||||
|
Overlay,
|
||||||
|
Study,
|
||||||
|
chartTypeLabels,
|
||||||
|
intervalLabels,
|
||||||
|
overlayLabels,
|
||||||
|
studyLabels,
|
||||||
|
} from 'pennant';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItemIndicator,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
Icon,
|
||||||
|
} from '@vegaprotocol/ui-toolkit';
|
||||||
|
import type { IconName } from '@blueprintjs/icons';
|
||||||
|
import { IconNames } from '@blueprintjs/icons';
|
||||||
|
import { t } from '@vegaprotocol/i18n';
|
||||||
|
import { useCandlesChartSettings } from './use-candles-chart-settings';
|
||||||
|
|
||||||
|
const chartTypeIcon = new Map<ChartType, IconName>([
|
||||||
|
[ChartType.AREA, IconNames.TIMELINE_AREA_CHART],
|
||||||
|
[ChartType.CANDLE, IconNames.WATERFALL_CHART],
|
||||||
|
[ChartType.LINE, IconNames.TIMELINE_LINE_CHART],
|
||||||
|
[ChartType.OHLC, IconNames.WATERFALL_CHART],
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const CandlesMenu = () => {
|
||||||
|
const {
|
||||||
|
interval,
|
||||||
|
chartType,
|
||||||
|
studies,
|
||||||
|
overlays,
|
||||||
|
setInterval,
|
||||||
|
setType,
|
||||||
|
setStudies,
|
||||||
|
setOverlays,
|
||||||
|
} = useCandlesChartSettings();
|
||||||
|
const triggerClasses = 'text-xs';
|
||||||
|
const contentAlign = 'end';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu
|
||||||
|
trigger={
|
||||||
|
<DropdownMenuTrigger className={triggerClasses}>
|
||||||
|
{t(`Interval: ${intervalLabels[interval]}`)}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenuContent align={contentAlign}>
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={interval}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setInterval(value as Interval);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Object.values(Interval).map((timeInterval) => (
|
||||||
|
<DropdownMenuRadioItem
|
||||||
|
key={timeInterval}
|
||||||
|
inset
|
||||||
|
value={timeInterval}
|
||||||
|
>
|
||||||
|
{intervalLabels[timeInterval]}
|
||||||
|
<DropdownMenuItemIndicator />
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<DropdownMenu
|
||||||
|
trigger={
|
||||||
|
<DropdownMenuTrigger className={triggerClasses}>
|
||||||
|
<Icon name={chartTypeIcon.get(chartType) as IconName} />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenuContent align={contentAlign}>
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={chartType}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setType(value as ChartType);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Object.values(ChartType).map((type) => (
|
||||||
|
<DropdownMenuRadioItem key={type} inset value={type}>
|
||||||
|
{chartTypeLabels[type]}
|
||||||
|
<DropdownMenuItemIndicator />
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<DropdownMenu
|
||||||
|
trigger={
|
||||||
|
<DropdownMenuTrigger className={triggerClasses}>
|
||||||
|
{t('Overlays')}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenuContent align={contentAlign}>
|
||||||
|
{Object.values(Overlay).map((overlay) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={overlay}
|
||||||
|
checked={overlays.includes(overlay)}
|
||||||
|
onCheckedChange={() => {
|
||||||
|
const newOverlays = [...overlays];
|
||||||
|
const index = overlays.findIndex((item) => item === overlay);
|
||||||
|
|
||||||
|
index !== -1
|
||||||
|
? newOverlays.splice(index, 1)
|
||||||
|
: newOverlays.push(overlay);
|
||||||
|
|
||||||
|
setOverlays(newOverlays);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{overlayLabels[overlay]}
|
||||||
|
<DropdownMenuItemIndicator />
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<DropdownMenu
|
||||||
|
trigger={
|
||||||
|
<DropdownMenuTrigger className={triggerClasses}>
|
||||||
|
{t('Studies')}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenuContent align={contentAlign}>
|
||||||
|
{Object.values(Study).map((study) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={study}
|
||||||
|
checked={studies.includes(study)}
|
||||||
|
onCheckedChange={() => {
|
||||||
|
const newStudies = [...studies];
|
||||||
|
const index = studies.findIndex((item) => item === study);
|
||||||
|
|
||||||
|
index !== -1
|
||||||
|
? newStudies.splice(index, 1)
|
||||||
|
: newStudies.push(study);
|
||||||
|
|
||||||
|
setStudies(newStudies);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{studyLabels[study]}
|
||||||
|
<DropdownMenuItemIndicator />
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -1,4 +1,5 @@
|
|||||||
export * from './__generated__/Candles';
|
export * from './__generated__/Candles';
|
||||||
export * from './__generated__/Chart';
|
export * from './__generated__/Chart';
|
||||||
export * from './candles-chart';
|
export * from './candles-chart';
|
||||||
|
export * from './candles-menu';
|
||||||
export * from './data-source';
|
export * from './data-source';
|
||||||
|
99
libs/candles-chart/src/lib/use-candles-chart-settings.ts
Normal file
99
libs/candles-chart/src/lib/use-candles-chart-settings.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { getValidItem, getValidSubset } from '@vegaprotocol/react-helpers';
|
||||||
|
import { ChartType, Interval, Study } from 'pennant';
|
||||||
|
import { Overlay } from 'pennant';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import { immer } from 'zustand/middleware/immer';
|
||||||
|
|
||||||
|
interface StoredSettings {
|
||||||
|
interval: Interval;
|
||||||
|
type: ChartType;
|
||||||
|
overlays: Overlay[];
|
||||||
|
studies: Study[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CHART_SETTINGS = {
|
||||||
|
interval: Interval.I15M,
|
||||||
|
type: ChartType.CANDLE,
|
||||||
|
overlays: [],
|
||||||
|
studies: [Study.VOLUME],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCandlesChartSettingsStore = create<
|
||||||
|
StoredSettings & {
|
||||||
|
merge: (settings: Partial<StoredSettings>) => void;
|
||||||
|
setType: (type: ChartType) => void;
|
||||||
|
setInterval: (interval: Interval) => void;
|
||||||
|
setOverlays: (overlays: Overlay[]) => void;
|
||||||
|
setStudies: (studies: Study[]) => void;
|
||||||
|
}
|
||||||
|
>()(
|
||||||
|
persist(
|
||||||
|
immer((set) => ({
|
||||||
|
...DEFAULT_CHART_SETTINGS,
|
||||||
|
merge: (settings: Partial<StoredSettings>) =>
|
||||||
|
set((state) => {
|
||||||
|
Object.assign(state, settings);
|
||||||
|
}),
|
||||||
|
setType: (type) =>
|
||||||
|
set((state) => {
|
||||||
|
state.type = type;
|
||||||
|
}),
|
||||||
|
setInterval: (interval) =>
|
||||||
|
set((state) => {
|
||||||
|
state.interval = interval;
|
||||||
|
}),
|
||||||
|
setOverlays: (overlays) =>
|
||||||
|
set((state) => {
|
||||||
|
state.overlays = overlays;
|
||||||
|
}),
|
||||||
|
setStudies: (studies) =>
|
||||||
|
set((state) => {
|
||||||
|
state.studies = studies;
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
name: 'vega_candles_chart_store',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useCandlesChartSettings = () => {
|
||||||
|
const settings = useCandlesChartSettingsStore();
|
||||||
|
|
||||||
|
const interval: Interval = getValidItem(
|
||||||
|
settings.interval,
|
||||||
|
Object.values(Interval),
|
||||||
|
Interval.I15M
|
||||||
|
);
|
||||||
|
|
||||||
|
const chartType: ChartType = getValidItem(
|
||||||
|
settings.type,
|
||||||
|
Object.values(ChartType),
|
||||||
|
ChartType.CANDLE
|
||||||
|
);
|
||||||
|
|
||||||
|
const overlays: Overlay[] = getValidSubset(
|
||||||
|
settings.overlays,
|
||||||
|
Object.values(Overlay),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const studies: Study[] = getValidSubset(
|
||||||
|
settings.studies,
|
||||||
|
Object.values(Study),
|
||||||
|
[Study.VOLUME]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
interval,
|
||||||
|
chartType,
|
||||||
|
overlays,
|
||||||
|
studies,
|
||||||
|
setInterval: settings.setInterval,
|
||||||
|
setType: settings.setType,
|
||||||
|
setStudies: settings.setStudies,
|
||||||
|
setOverlays: settings.setOverlays,
|
||||||
|
merge: settings.merge,
|
||||||
|
};
|
||||||
|
};
|
@ -43,17 +43,18 @@ export const DropdownMenuTrigger = forwardRef<
|
|||||||
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
|
||||||
DropdownMenuPrimitive.DropdownMenuTriggerProps
|
DropdownMenuPrimitive.DropdownMenuTriggerProps
|
||||||
>(({ className, children, ...props }, forwardedRef) => {
|
>(({ className, children, ...props }, forwardedRef) => {
|
||||||
const defaultClasses = [
|
const triggerClasses = classNames(
|
||||||
'text-sm py-1 px-2 rounded bg-transparent border whitespace-nowrap',
|
'text-sm py-1 px-2 rounded bg-transparent border whitespace-nowrap',
|
||||||
'border-vega-light-200 dark:border-vega-dark-200',
|
'border-vega-light-200 dark:border-vega-dark-200',
|
||||||
'hover:border-vega-light-300 dark:hover:border-vega-dark-300',
|
'hover:border-vega-light-300 dark:hover:border-vega-dark-300',
|
||||||
].join(' ');
|
className
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Trigger
|
<DropdownMenuPrimitive.Trigger
|
||||||
asChild={true}
|
asChild={true}
|
||||||
ref={forwardedRef}
|
ref={forwardedRef}
|
||||||
className={className || defaultClasses}
|
className={triggerClasses}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<button>{children}</button>
|
<button>{children}</button>
|
||||||
|
@ -30,4 +30,43 @@ describe('Tabs', () => {
|
|||||||
await userEvent.click(screen.getByText('Tab two'));
|
await userEvent.click(screen.getByText('Tab two'));
|
||||||
expect(await screen.getByText('Tab two content')).toBeInTheDocument();
|
expect(await screen.getByText('Tab two content')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows the menu component for the selected tab', async () => {
|
||||||
|
render(
|
||||||
|
<Tabs>
|
||||||
|
<Tab
|
||||||
|
id="one"
|
||||||
|
name="Tab one"
|
||||||
|
menu={
|
||||||
|
<>
|
||||||
|
<button>Tab 1 button 1</button>
|
||||||
|
<button>Tab 1 button 2</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p>Tab one content</p>
|
||||||
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
id="two"
|
||||||
|
name="Tab two"
|
||||||
|
menu={
|
||||||
|
<>
|
||||||
|
<button>Tab 2 button 1</button>
|
||||||
|
<button>Tab 2 button 2</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p>Tab two content</p>
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Tab one content')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Tab 1 button 1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Tab 1 button 2')).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByText('Tab two'));
|
||||||
|
expect(await screen.getByText('Tab two content')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Tab 2 button 1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Tab 2 button 2')).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -2,10 +2,11 @@ import * as TabsPrimitive from '@radix-ui/react-tabs';
|
|||||||
import {
|
import {
|
||||||
useLocalStorageSnapshot,
|
useLocalStorageSnapshot,
|
||||||
getValidItem,
|
getValidItem,
|
||||||
|
useResizeObserver,
|
||||||
} from '@vegaprotocol/react-helpers';
|
} from '@vegaprotocol/react-helpers';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import type { ReactElement, ReactNode } from 'react';
|
import type { ReactElement, ReactNode } from 'react';
|
||||||
import { Children, isValidElement, useState } from 'react';
|
import { Children, isValidElement, useRef, useState } from 'react';
|
||||||
export interface TabsProps extends TabsPrimitive.TabsProps {
|
export interface TabsProps extends TabsPrimitive.TabsProps {
|
||||||
children: (ReactElement<TabProps> | null)[];
|
children: (ReactElement<TabProps> | null)[];
|
||||||
}
|
}
|
||||||
@ -24,6 +25,19 @@ export const Tabs = ({
|
|||||||
return children.find((v) => v)?.props.id;
|
return children.find((v) => v)?.props.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bunch of refs in order to detect wrapping in side the tabs so that we
|
||||||
|
// can apply a bg color
|
||||||
|
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const tabsRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [wrapped, setWrapped] = useState(() =>
|
||||||
|
isWrapped(tabsRef.current, menuRef.current)
|
||||||
|
);
|
||||||
|
|
||||||
|
useResizeObserver(wrapperRef.current, () => {
|
||||||
|
setWrapped(isWrapped(tabsRef.current, menuRef.current));
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabsPrimitive.Root
|
<TabsPrimitive.Root
|
||||||
{...props}
|
{...props}
|
||||||
@ -31,10 +45,14 @@ export const Tabs = ({
|
|||||||
onValueChange={onValueChange || setActiveTab}
|
onValueChange={onValueChange || setActiveTab}
|
||||||
className="h-full grid grid-rows-[min-content_1fr]"
|
className="h-full grid grid-rows-[min-content_1fr]"
|
||||||
>
|
>
|
||||||
<div className="border-b border-default min-w-0">
|
<div
|
||||||
|
ref={wrapperRef}
|
||||||
|
className="flex flex-wrap justify-between border-b border-default min-w-0"
|
||||||
|
>
|
||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
className="flex flex-nowrap overflow-visible"
|
className="flex flex-nowrap overflow-visible"
|
||||||
role="tablist"
|
role="tablist"
|
||||||
|
ref={tabsRef}
|
||||||
>
|
>
|
||||||
{Children.map(children, (child) => {
|
{Children.map(children, (child) => {
|
||||||
if (!isValidElement(child) || child.props.hidden) return null;
|
if (!isValidElement(child) || child.props.hidden) return null;
|
||||||
@ -67,6 +85,27 @@ export const Tabs = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</TabsPrimitive.List>
|
</TabsPrimitive.List>
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className={classNames('flex-1 p-1', {
|
||||||
|
'bg-vega-clight-700 dark:bg-vega-cdark-700': wrapped,
|
||||||
|
'': wrapped,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{Children.map(children, (child) => {
|
||||||
|
if (!isValidElement(child) || child.props.hidden) return null;
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
value={child.props.id}
|
||||||
|
className={classNames('flex flex-nowrap gap-1', {
|
||||||
|
'justify-end': !wrapped,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{child.props.menu}
|
||||||
|
</TabsPrimitive.Content>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full overflow-auto">
|
<div className="h-full overflow-auto">
|
||||||
{Children.map(children, (child) => {
|
{Children.map(children, (child) => {
|
||||||
@ -92,6 +131,7 @@ interface TabProps {
|
|||||||
name: string;
|
name: string;
|
||||||
indicator?: ReactNode;
|
indicator?: ReactNode;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
|
menu?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Tab = ({ children, ...props }: TabProps) => {
|
export const Tab = ({ children, ...props }: TabProps) => {
|
||||||
@ -122,3 +162,13 @@ export const LocalStoragePersistTabs = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isWrapped = (
|
||||||
|
tabs: HTMLDivElement | null,
|
||||||
|
menu: HTMLDivElement | null
|
||||||
|
) => {
|
||||||
|
if (!tabs || !menu) return;
|
||||||
|
const listRect = tabs.getBoundingClientRect();
|
||||||
|
const menuRect = menu.getBoundingClientRect();
|
||||||
|
return menuRect.y > listRect.y;
|
||||||
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user