feat: [console-lite] - market selector (#726)

* feat: [console-lite] - market selector - first commit

* feat: [console-lite] - market selector - add strongly different mobile version

* feat: [console-lite] - market selector - fix deal-market lint fail

* feat: [console-lite] - market selector - add a bunch of improvements

* feat: [console-lite] - market selector - add some int tests

* feat: [console-lite] - market selector - fix dialog dimmensions

Co-authored-by: maciek <maciek@vegaprotocol.io>
This commit is contained in:
macqbat 2022-07-11 15:27:28 +02:00 committed by GitHub
parent 73c059718b
commit 9cfcadd39b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 557 additions and 10 deletions

View File

@ -0,0 +1,87 @@
describe('market selector', () => {
let markets;
before(() => {
cy.intercept('POST', '/query', (req) => {
const { body } = req;
if (body.operationName === 'SimpleMarkets') {
req.alias = `gqlSimpleMarketsQuery`;
}
});
cy.visit('/markets');
cy.wait('@gqlSimpleMarketsQuery').then((response) => {
if (response.response?.body?.data?.markets?.length) {
markets = response.response?.body?.data?.markets;
}
});
});
it('should be properly rendered', () => {
if (markets) {
cy.visit(`/trading/${markets[0].id}`);
cy.get('input[placeholder="Search"]').should(
'have.value',
markets[0].name
);
cy.getByTestId('arrow-button').click();
cy.getByTestId('market-pane').should('be.visible');
cy.getByTestId('market-pane')
.children()
.find('[role="button"]')
.should('contain.text', markets[0].name);
cy.getByTestId('market-pane').children().find('[role="button"]').click();
cy.getByTestId('market-pane').should('not.be.visible');
}
});
it('typing should change list', () => {
if (markets) {
cy.visit(`/trading/${markets[0].id}`);
cy.get('input[placeholder="Search"]').type('{backspace}');
cy.getByTestId('market-pane')
.children()
.find('[role="button"]')
.should('have.length', 1);
cy.get('input[placeholder="Search"]').clear();
cy.get('input[placeholder="Search"]').type('a');
const filtered = markets.filter((market) => market.name.match(/a/i));
cy.getByTestId('market-pane')
.children()
.find('[role="button"]')
.should('have.length', filtered.length);
cy.getByTestId('market-pane')
.children()
.find('[role="button"]')
.last()
.click();
cy.location('pathname').should(
'eq',
`/trading/${filtered[filtered.length - 1].id}`
);
cy.get('input[placeholder="Search"]').should(
'have.value',
filtered[filtered.length - 1].name
);
}
});
it('mobile view', () => {
if (markets) {
cy.viewport('iphone-xr');
cy.visit(`/trading/${markets[0].id}`);
cy.get('[role="dialog"]').should('not.exist');
cy.getByTestId('arrow-button').click();
cy.get('[role="dialog"]').should('be.visible');
cy.get('input[placeholder="Search"]').clear();
cy.getByTestId('market-pane')
.children()
.find('[role="button"]')
.should('have.length', markets.length);
cy.pause();
cy.getByTestId('dialog-close').click();
cy.get('input[placeholder="Search"]').should(
'have.value',
markets[0].name
);
}
});
});

View File

@ -13,6 +13,7 @@ import {
useOrderValidation,
useOrderSubmit,
DealTicketAmount,
MarketSelector,
} from '@vegaprotocol/deal-ticket';
import {
OrderTimeInForce,
@ -20,12 +21,23 @@ import {
VegaTxStatus,
} from '@vegaprotocol/wallet';
import { t, addDecimal, toDecimal } from '@vegaprotocol/react-helpers';
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import MarketNameRenderer from '../simple-market-list/simple-market-renderer';
interface DealTicketMarketProps {
market: DealTicketQuery_market;
}
export const DealTicketSteps = ({ market }: DealTicketMarketProps) => {
const navigate = useNavigate();
const setMarket = useCallback(
(marketId) => {
navigate(`/trading/${marketId}`);
},
[navigate]
);
const {
register,
control,
@ -70,7 +82,13 @@ export const DealTicketSteps = ({ market }: DealTicketMarketProps) => {
{
label: 'Select Asset',
description: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`,
component: <h1 className="font-bold mb-16">{market.name}</h1>,
component: (
<MarketSelector
market={market}
setMarket={setMarket}
ItemRenderer={MarketNameRenderer}
/>
),
},
{
label: 'Select Order Type',

View File

@ -1,23 +1,23 @@
import React from 'react';
import type { SimpleMarkets_markets } from './__generated__/SimpleMarkets';
import type { MarketNames_markets } from '@vegaprotocol/deal-ticket';
import SimpleMarketExpires from './simple-market-expires';
interface Props {
data: SimpleMarkets_markets;
market: MarketNames_markets;
}
const MarketNameRenderer = ({ data }: Props) => {
const MarketNameRenderer = ({ market }: Props) => {
return (
<div className="flex h-full items-center grid grid-rows-2 grid-flow-col gap-x-8 gap-y-0 grid-cols-[min-content,1fr,1fr]">
<div className="w-60 row-span-2 bg-pink rounded-full w-44 h-44 bg-gradient-to-br from-white-60 to--white-80 opacity-30" />
<div className="col-span-2 uppercase justify-start text-black dark:text-white text-market self-end">
{data.name}{' '}
{market.name}{' '}
<SimpleMarketExpires
tags={data.tradableInstrument.instrument.metadata.tags}
tags={market.tradableInstrument.instrument.metadata.tags}
/>
</div>
<div className="col-span-2 ui-small text-deemphasise dark:text-midGrey self-start leading-3">
{data.tradableInstrument.instrument.product.quoteName}
<div className="col-span-2 text-ui-small text-deemphasise dark:text-midGrey self-start leading-3">
{market.tradableInstrument.instrument.product.quoteName}
</div>
</div>
);

View File

@ -21,7 +21,7 @@ const useColumnDefinitions = ({ onClick }: Props) => {
minWidth: 300,
field: 'name',
cellRenderer: ({ data }: { data: SimpleMarketsType }) => (
<MarketNameRenderer data={data} />
<MarketNameRenderer market={data} />
),
},
{

View File

@ -2,6 +2,7 @@ const { join } = require('path');
const { createGlobPatternsForDependencies } = require('@nrwl/next/tailwind');
const theme = require('../../libs/tailwindcss-config/src/theme-lite');
const vegaCustomClasses = require('../../libs/tailwindcss-config/src/vega-custom-classes');
const vegaCustomClassesLite = require('../../libs/tailwindcss-config/src/vega-custom-classes-lite');
module.exports = {
content: [
@ -11,5 +12,5 @@ module.exports = {
],
darkMode: 'class',
theme,
plugins: [vegaCustomClasses],
plugins: [vegaCustomClasses, vegaCustomClassesLite],
};

View File

@ -0,0 +1,67 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL query operation: MarketNames
// ====================================================
export interface MarketNames_markets_tradableInstrument_instrument_metadata {
__typename: "InstrumentMetadata";
/**
* An arbitrary list of tags to associated to associate to the Instrument (string list)
*/
tags: string[] | null;
}
export interface MarketNames_markets_tradableInstrument_instrument_product {
__typename: "Future";
/**
* String representing the quote (e.g. BTCUSD -> USD is quote)
*/
quoteName: string;
}
export interface MarketNames_markets_tradableInstrument_instrument {
__typename: "Instrument";
/**
* Metadata for this instrument
*/
metadata: MarketNames_markets_tradableInstrument_instrument_metadata;
/**
* A reference to or instance of a fully specified product, including all required product parameters for that product (Product union)
*/
product: MarketNames_markets_tradableInstrument_instrument_product;
}
export interface MarketNames_markets_tradableInstrument {
__typename: "TradableInstrument";
/**
* An instance of or reference to a fully specified instrument.
*/
instrument: MarketNames_markets_tradableInstrument_instrument;
}
export interface MarketNames_markets {
__typename: "Market";
/**
* Market ID
*/
id: string;
/**
* Market full name
*/
name: string;
/**
* An instance of or reference to a tradable instrument.
*/
tradableInstrument: MarketNames_markets_tradableInstrument;
}
export interface MarketNames {
/**
* One or more instruments that are trading on the VEGA network
*/
markets: MarketNames_markets[] | null;
}

View File

@ -1,2 +1,3 @@
export * from './DealTicketQuery';
export * from './MarketInfoQuery';
export * from './MarketNames';

View File

@ -10,3 +10,4 @@ export * from './info-market';
export * from './side-selector';
export * from './time-in-force-selector';
export * from './type-selector';
export * from './market-selector';

View File

@ -0,0 +1,292 @@
import React, {
useCallback,
useState,
useEffect,
useRef,
useMemo,
} from 'react';
import { gql, useQuery } from '@apollo/client';
import classNames from 'classnames';
import type { DealTicketQuery_market } from './__generated__';
import {
Button,
Dialog,
Icon,
Input,
Loader,
Splash,
} from '@vegaprotocol/ui-toolkit';
import {
t,
useScreenDimensions,
useOutsideClick,
} from '@vegaprotocol/react-helpers';
import type {
MarketNames,
MarketNames_markets,
} from './__generated__/MarketNames';
import { IconNames } from '@blueprintjs/icons';
export const MARKET_NAMES_QUERY = gql`
query MarketNames {
markets {
id
name
tradableInstrument {
instrument {
metadata {
tags
}
product {
... on Future {
quoteName
}
}
}
}
}
}
`;
interface Props {
market: DealTicketQuery_market;
setMarket: (marketId: string) => void;
ItemRenderer?: React.FC<{ market: MarketNames_markets }>;
}
function escapeRegExp(str: string) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export const MarketSelector = ({ market, setMarket, ItemRenderer }: Props) => {
const { isMobile } = useScreenDimensions();
const contRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const arrowButtonRef = useRef<HTMLButtonElement | null>(null);
const [skip, setSkip] = useState(true);
const [results, setResults] = useState<MarketNames_markets[]>([]);
const [showPane, setShowPane] = useState(false);
const [lookup, setLookup] = useState(market.name || '');
const [dialogContent, setDialogContent] = useState<React.ReactNode | null>(
null
);
const { data, loading, error } = useQuery<MarketNames>(MARKET_NAMES_QUERY, {
skip,
});
const outsideClickCb = useCallback(() => {
if (!isMobile) {
setShowPane(false);
}
}, [setShowPane, isMobile]);
useOutsideClick({ refs: [contRef, arrowButtonRef], func: outsideClickCb });
const handleOnChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const {
target: { value },
} = event;
setLookup(value);
setShowPane(true);
if (value) {
setSkip(false);
}
},
[setLookup, setShowPane, setSkip]
);
const handleMarketSelect = useCallback(
({ id, name }) => {
setLookup(name);
setShowPane(false);
setMarket(id);
inputRef.current?.focus();
},
[setLookup, setShowPane, setMarket, inputRef]
);
const handleItemKeyDown = useCallback(
(
event: React.KeyboardEvent,
market: MarketNames_markets,
index: number
) => {
switch (event.key) {
case 'ArrowDown':
if (index < results.length - 1) {
(contRef.current?.children[index + 1] as HTMLDivElement).focus();
}
break;
case 'ArrowUp':
if (!index) {
inputRef.current?.focus();
inputRef.current?.setSelectionRange(
inputRef.current?.value.length,
inputRef.current?.value.length
);
return;
}
(contRef.current?.children[index - 1] as HTMLDivElement).focus();
break;
case 'Enter':
handleMarketSelect(market);
break;
}
},
[results, handleMarketSelect]
);
const handleInputKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === 'ArrowDown') {
(contRef.current?.children[0] as HTMLDivElement).focus();
}
},
[contRef]
);
const handleOnBlur = useCallback(() => {
console.log('lookup, showPane', lookup, showPane);
if (!lookup && !showPane) {
console.log(
'2 lookup, showPane, market.name',
lookup,
showPane,
market.name
);
setLookup(market.name);
}
}, [market, lookup, showPane, setLookup]);
const openPane = useCallback(() => {
setShowPane(!showPane);
setSkip(false);
inputRef.current?.focus();
}, [showPane, setShowPane, setSkip, inputRef]);
const handleDialogOnchange = useCallback(
(isOpen) => {
setShowPane(isOpen);
if (!isOpen) {
setLookup(lookup || market.name);
inputRef.current?.focus();
}
},
[setShowPane, lookup, setLookup, market.name, inputRef]
);
const selectorContent = useMemo(() => {
return (
<div className="relative flex flex-col">
<div className="relative w-full min-h-[30px] dark:bg-black">
<Input
className="h-[30px] w-[calc(100%-20px)] border-none dark:bg-black"
ref={inputRef}
tabIndex={0}
value={lookup}
placeholder={t('Search')}
onChange={handleOnChange}
onKeyDown={handleInputKeyDown}
onBlur={handleOnBlur}
/>
<Button
className="absolute self-end top-[7px] right-0 z-10"
variant="inline-link"
onClick={openPane}
ref={arrowButtonRef}
data-testid="arrow-button"
>
<Icon
name={IconNames.ARROW_DOWN}
className={classNames('fill-current transition-transform', {
'rotate-180': showPane,
})}
size={16}
/>
</Button>
</div>
<hr className="md:hidden mb-5" />
<div
className={classNames(
'md:absolute flex flex-col top-[30px] z-10 md:drop-shadow-md md:border-1 md:border-black md:dark:border-white bg-white dark:bg-black text-black dark:text-white min-w-full md:max-h-[200px] overflow-y-auto',
showPane ? 'block' : 'hidden'
)}
data-testid="market-pane"
>
{loading && <Loader />}
{error && (
<Splash>{t(`Something went wrong: ${error.message}`)}</Splash>
)}
<div ref={contRef} className="w-full">
{results.map((market, i) => (
<div
role="button"
tabIndex={0}
key={market.id}
className="cursor-pointer focus:bg-white-95 focus:outline-0 dark:focus:bg-black-80 px-20 py-5"
onClick={() => handleMarketSelect(market)}
onKeyDown={(e) => handleItemKeyDown(e, market, i)}
>
{ItemRenderer ? <ItemRenderer market={market} /> : market.name}
</div>
))}
</div>
</div>
</div>
);
}, [
ItemRenderer,
error,
handleInputKeyDown,
handleItemKeyDown,
handleMarketSelect,
handleOnBlur,
handleOnChange,
loading,
lookup,
openPane,
results,
showPane,
]);
useEffect(() => {
setResults(
data?.markets?.filter((item: MarketNames_markets) =>
item.name.match(new RegExp(escapeRegExp(lookup), 'i'))
) || []
);
}, [data, lookup]);
useEffect(() => {
inputRef.current?.focus();
}, [inputRef]);
useEffect(() => {
if (showPane && isMobile) {
setDialogContent(selectorContent);
inputRef.current?.focus();
window.scrollTo(0, 0);
} else {
setDialogContent(null);
}
}, [selectorContent, showPane, isMobile, setDialogContent]);
return (
<>
{!dialogContent && selectorContent}
<Dialog
titleClassNames="uppercase font-alpha"
contentClassNames="left-[0px] top-[99px] h-[calc(100%-99px)] border-0 translate-x-[0] translate-y-[0] border-none overflow-y-auto"
title={t('Select Market')}
open={Boolean(dialogContent)}
onChange={handleDialogOnchange}
>
{dialogContent}
</Dialog>
</>
);
};

View File

@ -3,3 +3,5 @@ export * from './use-data-provider';
export * from './use-theme-switcher';
export * from './use-fetch';
export * from './use-resize';
export * from './use-outside-click';
export * from './use-screen-dimensions';

View File

@ -0,0 +1,27 @@
import { useEffect } from 'react';
import type { RefObject } from 'react';
interface Props {
refs: RefObject<HTMLElement>[];
func: (event: Event) => void;
}
export const useOutsideClick = ({ refs, func }: Props) => {
useEffect(() => {
const handleClickOutside = (event: Event) => {
const found = refs.reduce((agg: boolean, item) => {
if (item.current && item.current.contains(event.target as Node)) {
agg = true;
}
return agg;
}, false);
if (!found) {
func(event);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [refs, func]);
};

View File

@ -0,0 +1,28 @@
import { useMemo } from 'react';
import { theme } from '@vegaprotocol/tailwindcss-config';
import { useResize } from './use-resize';
type Screen = keyof typeof theme.screens;
interface Props {
isMobile: boolean;
screen: Screen;
width: number;
}
export const useScreenDimensions = (): Props => {
const { width } = useResize();
return useMemo(
() => ({
width,
isMobile: width < parseInt(theme.screens.md),
screen: Object.entries(theme.screens).reduce((agg: Screen, entry) => {
if (width > parseInt(entry[1])) {
agg = entry[0] as Screen;
}
return agg;
}, 'xs'),
}),
[width]
);
};

View File

@ -33,5 +33,11 @@ module.exports = {
...theme.boxShadow,
'inset-black': '',
'inset-white': '',
input: 'none',
'input-focus': 'none',
'input-dark': 'none',
'input-focus-dark': 'none',
'input-focus-error': 'none',
'input-focus-error-dark': 'none',
},
};

View File

@ -0,0 +1,17 @@
const plugin = require('tailwindcss/plugin');
const vegaCustomClassesLite = plugin(function ({ addUtilities }) {
addUtilities({
'.input-border': {
borderWidth: '0',
},
'.input-border-dark': {
borderWidth: '0',
},
'.shadow-input': {
boxShadow: 'none',
},
});
});
module.exports = vegaCustomClassesLite;