feat: 1655 persist deal ticket, 1694 GTT in the past (#1865)
This commit is contained in:
parent
82c8b4a0e5
commit
0ec511a72f
@ -121,6 +121,43 @@ const testOrder = (order: Order, expected?: Partial<Order>) => {
|
||||
cy.getByTestId('dialog-close').click();
|
||||
};
|
||||
|
||||
const clearPersistedOrder = () => {
|
||||
cy.clearLocalStorage().should((ls) => {
|
||||
expect(ls.getItem('deal-ticket-order-market-0')).to.be.null;
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => clearPersistedOrder());
|
||||
afterEach(() => clearPersistedOrder());
|
||||
|
||||
describe('time in force default values', () => {
|
||||
before(() => {
|
||||
cy.mockTradingPage();
|
||||
cy.mockGQLSubscription();
|
||||
cy.visit('/#/markets/market-0');
|
||||
cy.wait('@Market');
|
||||
connectVegaWallet();
|
||||
});
|
||||
|
||||
it('must have market order set up to IOC by default', function () {
|
||||
//7002-SORD-031
|
||||
cy.getByTestId(toggleMarket).click();
|
||||
cy.get(`[data-testid=${orderTIFDropDown}] option:selected`).should(
|
||||
'have.text',
|
||||
TIFlist.filter((item) => item.code === 'IOC')[0].text
|
||||
);
|
||||
});
|
||||
|
||||
it('must have time in force set to GTC for limit order', function () {
|
||||
//7002-SORD-031
|
||||
cy.getByTestId(toggleLimit).click();
|
||||
cy.get(`[data-testid=${orderTIFDropDown}] option:selected`).should(
|
||||
'have.text',
|
||||
TIFlist.filter((item) => item.code === 'GTC')[0].text
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('must submit order', { tags: '@smoke' }, () => {
|
||||
// 7002-SORD-039
|
||||
before(() => {
|
||||
@ -181,13 +218,14 @@ describe('must submit order', { tags: '@smoke' }, () => {
|
||||
|
||||
it('successfully places GTT limit buy order', () => {
|
||||
cy.mockVegaCommandSync(mockTx);
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
const order: Order = {
|
||||
type: 'TYPE_LIMIT',
|
||||
side: 'SIDE_SELL',
|
||||
size: '100',
|
||||
price: '1.00',
|
||||
timeInForce: 'TIME_IN_FORCE_GTT',
|
||||
expiresAt: '2022-01-01T00:00',
|
||||
expiresAt: expiresAt.toISOString().substring(0, 16),
|
||||
};
|
||||
testOrder(order, {
|
||||
price: '100000',
|
||||
@ -436,6 +474,7 @@ describe('deal ticket size validation', { tags: '@smoke' }, function () {
|
||||
});
|
||||
it('must warn if order size input has too many digits after the decimal place', function () {
|
||||
//7002-SORD-016
|
||||
cy.getByTestId('order-type-TYPE_MARKET').click();
|
||||
cy.getByTestId(orderSizeField).clear().type('1.234');
|
||||
cy.getByTestId(placeOrderBtn).should('not.be.disabled');
|
||||
cy.getByTestId(placeOrderBtn).click();
|
||||
@ -447,6 +486,7 @@ describe('deal ticket size validation', { tags: '@smoke' }, function () {
|
||||
});
|
||||
|
||||
it('must warn if order size is set to 0', function () {
|
||||
cy.getByTestId('order-type-TYPE_MARKET').click();
|
||||
cy.getByTestId(orderSizeField).clear().type('0');
|
||||
cy.getByTestId(placeOrderBtn).should('not.be.disabled');
|
||||
cy.getByTestId(placeOrderBtn).click();
|
||||
@ -462,6 +502,7 @@ describe('limit order validations', { tags: '@smoke' }, () => {
|
||||
before(() => {
|
||||
cy.mockTradingPage();
|
||||
cy.visit('/#/markets/market-0');
|
||||
connectVegaWallet();
|
||||
cy.wait('@Market');
|
||||
cy.getByTestId(toggleLimit).click();
|
||||
});
|
||||
@ -473,9 +514,23 @@ describe('limit order validations', { tags: '@smoke' }, () => {
|
||||
.should('have.text', 'Price (BTC)');
|
||||
});
|
||||
|
||||
it.skip('must see warning when placing an order with expiry date in past', function () {
|
||||
// Test to be created after the bug below is fixed
|
||||
// https://github.com/vegaprotocol/frontend-monorepo/issues/1694
|
||||
it('must see warning when placing an order with expiry date in past', function () {
|
||||
const expiresAt = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const expiresAtInputValue = expiresAt.toISOString().substring(0, 16);
|
||||
cy.getByTestId(toggleLimit).click();
|
||||
cy.getByTestId(orderPriceField).clear().type('0.1');
|
||||
cy.getByTestId(orderSizeField).clear().type('1');
|
||||
cy.getByTestId(orderTIFDropDown).select('TIME_IN_FORCE_GTT');
|
||||
|
||||
cy.log('choosing yesterday');
|
||||
cy.getByTestId('date-picker-field').type(expiresAtInputValue);
|
||||
|
||||
cy.getByTestId(placeOrderBtn).click();
|
||||
|
||||
cy.getByTestId('dealticket-error-message-force').should(
|
||||
'have.text',
|
||||
'The expiry date that you have entered appears to be in the past'
|
||||
);
|
||||
});
|
||||
|
||||
it.skip('must receive warning if price has too many digits after decimal place', function () {
|
||||
@ -484,14 +539,6 @@ describe('limit order validations', { tags: '@smoke' }, () => {
|
||||
});
|
||||
|
||||
describe('time in force validations', function () {
|
||||
it('must have limit order set to GTC by default', function () {
|
||||
//7002-SORD-031
|
||||
cy.get(`[data-testid=${orderTIFDropDown}] option:selected`).should(
|
||||
'have.text',
|
||||
TIFlist.filter((item) => item.code === 'GTC')[0].text
|
||||
);
|
||||
});
|
||||
|
||||
const validTIF = TIFlist;
|
||||
validTIF.forEach((tif) => {
|
||||
//7002-SORD-023
|
||||
@ -545,14 +592,6 @@ describe('market order validations', { tags: '@smoke' }, () => {
|
||||
});
|
||||
|
||||
describe('time in force validations', function () {
|
||||
it('must have market order set up to IOC by default', function () {
|
||||
//7002-SORD-031
|
||||
cy.get(`[data-testid=${orderTIFDropDown}] option:selected`).should(
|
||||
'have.text',
|
||||
TIFlist.filter((item) => item.code === 'IOC')[0].text
|
||||
);
|
||||
});
|
||||
|
||||
const validTIF = TIFlist.filter((tif) => ['FOK', 'IOC'].includes(tif.code));
|
||||
const invalidTIF = TIFlist.filter(
|
||||
(tif) => !['FOK', 'IOC'].includes(tif.code)
|
||||
@ -607,6 +646,8 @@ describe('suspended market validation', { tags: '@regression' }, () => {
|
||||
});
|
||||
it('should show info for allowed TIF', function () {
|
||||
cy.getByTestId(toggleLimit).click();
|
||||
cy.getByTestId(orderPriceField).clear().type('0.1');
|
||||
cy.getByTestId(orderSizeField).clear().type('1');
|
||||
cy.getByTestId(placeOrderBtn).should('be.enabled');
|
||||
cy.getByTestId(errorMessage).should(
|
||||
'have.text',
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { toDecimal } from '@vegaprotocol/react-helpers';
|
||||
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
|
||||
import { Schema } from '@vegaprotocol/types';
|
||||
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
|
||||
|
||||
export const getDefaultOrder = (market: {
|
||||
id: string;
|
||||
|
@ -1,3 +1,5 @@
|
||||
export * from './get-default-order';
|
||||
export * from './use-order-validation';
|
||||
export * from './validate-size';
|
||||
export * from './use-persisted-order';
|
||||
export * from './validate-expiration';
|
||||
|
@ -18,6 +18,7 @@ import type { DealTicketMarketFragment } from '../deal-ticket/__generated__/Deal
|
||||
import { ValidateMargin } from './validate-margin';
|
||||
import type { OrderMargin } from '../../hooks/use-order-margin';
|
||||
import { useOrderMarginValidation } from './use-order-margin-validation';
|
||||
import { ERROR_EXPIRATION_IN_THE_PAST } from './validate-expiration';
|
||||
import { DEAL_TICKET_SECTION, ERROR_SIZE_DECIMAL } from '../constants';
|
||||
|
||||
export const isMarketInAuction = (market: DealTicketMarketFragment) => {
|
||||
@ -129,13 +130,28 @@ export const useOrderValidation = ({
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
fieldErrors?.expiresAt?.type === 'validate' &&
|
||||
fieldErrors?.expiresAt.message === ERROR_EXPIRATION_IN_THE_PAST
|
||||
) {
|
||||
return {
|
||||
isDisabled: false,
|
||||
message: t(
|
||||
'The expiry date that you have entered appears to be in the past'
|
||||
),
|
||||
section: DEAL_TICKET_SECTION.EXPIRY,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [
|
||||
fieldErrors?.size?.type,
|
||||
fieldErrors?.size?.message,
|
||||
fieldErrors?.price?.type,
|
||||
minSize,
|
||||
fieldErrors?.expiresAt?.type,
|
||||
fieldErrors?.expiresAt?.message,
|
||||
orderType,
|
||||
minSize,
|
||||
market.positionDecimalPlaces,
|
||||
]);
|
||||
|
||||
@ -356,9 +372,9 @@ export const useOrderValidation = ({
|
||||
pubKey,
|
||||
market,
|
||||
fieldErrorChecking,
|
||||
isInvalidOrderMargin,
|
||||
orderType,
|
||||
orderTimeInForce,
|
||||
isInvalidOrderMargin,
|
||||
]);
|
||||
|
||||
return { message, isDisabled, section };
|
||||
|
@ -0,0 +1,16 @@
|
||||
import { useLocalStorage } from '@vegaprotocol/react-helpers';
|
||||
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
type OrderData = OrderSubmissionBody['orderSubmission'] | null;
|
||||
|
||||
export const usePersistedOrder = (market: {
|
||||
id: string;
|
||||
}): [OrderData, (value: OrderData) => void] => {
|
||||
const [value, setValue] = useLocalStorage(`deal-ticket-order-${market.id}`);
|
||||
const order = value != null ? (JSON.parse(value) as OrderData) : null;
|
||||
return useMemo<[OrderData, (value: OrderData) => void]>(
|
||||
() => [order, (order: OrderData) => setValue(JSON.stringify(order))],
|
||||
[order, setValue]
|
||||
);
|
||||
};
|
@ -0,0 +1,14 @@
|
||||
import type { Validate } from 'react-hook-form';
|
||||
|
||||
export const ERROR_EXPIRATION_IN_THE_PAST = 'ERROR_EXPIRATION_IN_THE_PAST';
|
||||
|
||||
export const validateExpiration: Validate<string | undefined> = (
|
||||
value?: string
|
||||
) => {
|
||||
const now = new Date();
|
||||
const valueAsDate = value ? new Date(value) : now;
|
||||
if (now > valueAsDate) {
|
||||
return ERROR_EXPIRATION_IN_THE_PAST;
|
||||
}
|
||||
return true;
|
||||
};
|
@ -84,6 +84,8 @@ function generateJsx(order?: OrderSubmissionBody['orderSubmission']) {
|
||||
}
|
||||
|
||||
describe('DealTicket', () => {
|
||||
beforeEach(() => window.localStorage.clear());
|
||||
afterEach(() => window.localStorage.clear());
|
||||
it('should display ticket defaults', () => {
|
||||
render(generateJsx());
|
||||
|
||||
@ -148,20 +150,20 @@ describe('DealTicket', () => {
|
||||
it('handles TIF select box dependent on order type', () => {
|
||||
render(generateJsx());
|
||||
|
||||
// Check only IOC and
|
||||
// Only FOK and IOC should be present by default (type market order)
|
||||
expect(
|
||||
Array.from(screen.getByTestId('order-tif').children).map(
|
||||
(o) => o.textContent
|
||||
)
|
||||
).toEqual(['Fill or Kill (FOK)', 'Immediate or Cancel (IOC)']);
|
||||
|
||||
// Switch to limit order and check all TIF options shown
|
||||
// Switch to type limit order -> all TIF options should be shown
|
||||
fireEvent.click(screen.getByTestId('order-type-TYPE_LIMIT'));
|
||||
expect(screen.getByTestId('order-tif').children).toHaveLength(
|
||||
Object.keys(Schema.OrderTimeInForce).length
|
||||
);
|
||||
|
||||
// Change to GTC
|
||||
// Select GTC -> GTC should be selected
|
||||
fireEvent.change(screen.getByTestId('order-tif'), {
|
||||
target: { value: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC },
|
||||
});
|
||||
@ -169,24 +171,38 @@ describe('DealTicket', () => {
|
||||
Schema.OrderTimeInForce.TIME_IN_FORCE_GTC
|
||||
);
|
||||
|
||||
// Switch back to market order and TIF should now be IOC
|
||||
// Switch to type market order -> IOC should be selected (default)
|
||||
fireEvent.click(screen.getByTestId('order-type-TYPE_MARKET'));
|
||||
expect(screen.getByTestId('order-tif')).toHaveValue(
|
||||
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
|
||||
);
|
||||
|
||||
// Switch tif to FOK
|
||||
// Select IOC -> IOC should be selected
|
||||
fireEvent.change(screen.getByTestId('order-tif'), {
|
||||
target: { value: Schema.OrderTimeInForce.TIME_IN_FORCE_FOK },
|
||||
target: { value: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC },
|
||||
});
|
||||
expect(screen.getByTestId('order-tif')).toHaveValue(
|
||||
Schema.OrderTimeInForce.TIME_IN_FORCE_FOK
|
||||
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
|
||||
);
|
||||
|
||||
// Change back to limit and check we are still on FOK
|
||||
// Switch to type limit order -> GTC should be selected
|
||||
fireEvent.click(screen.getByTestId('order-type-TYPE_LIMIT'));
|
||||
expect(screen.getByTestId('order-tif')).toHaveValue(
|
||||
Schema.OrderTimeInForce.TIME_IN_FORCE_GTC
|
||||
);
|
||||
|
||||
// Select GTT -> GTT should be selected
|
||||
fireEvent.change(screen.getByTestId('order-tif'), {
|
||||
target: { value: Schema.OrderTimeInForce.TIME_IN_FORCE_GTT },
|
||||
});
|
||||
expect(screen.getByTestId('order-tif')).toHaveValue(
|
||||
Schema.OrderTimeInForce.TIME_IN_FORCE_GTT
|
||||
);
|
||||
|
||||
// Switch to type market order -> IOC should be selected
|
||||
fireEvent.click(screen.getByTestId('order-type-TYPE_MARKET'));
|
||||
expect(screen.getByTestId('order-tif')).toHaveValue(
|
||||
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
getFeeDetailsValues,
|
||||
useFeeDealTicketDetails,
|
||||
} from '../../hooks/use-fee-deal-ticket-details';
|
||||
import { getDefaultOrder } from '../deal-ticket-validation';
|
||||
import { getDefaultOrder, usePersistedOrder } from '../deal-ticket-validation';
|
||||
import {
|
||||
isMarketInAuction,
|
||||
useOrderValidation,
|
||||
@ -23,6 +23,7 @@ import { TypeSelector } from './type-selector';
|
||||
import type { DealTicketMarketFragment } from './__generated__/DealTicket';
|
||||
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
|
||||
import type { DealTicketErrorMessage } from './deal-ticket-error';
|
||||
import { DEAL_TICKET_SECTION } from '../constants';
|
||||
|
||||
export type TransactionStatus = 'default' | 'pending';
|
||||
|
||||
@ -41,6 +42,7 @@ export const DealTicket = ({
|
||||
const [errorMessage, setErrorMessage] = useState<
|
||||
DealTicketErrorMessage | undefined
|
||||
>(undefined);
|
||||
const [persistedOrder, setOrder] = usePersistedOrder(market);
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
@ -52,16 +54,18 @@ export const DealTicket = ({
|
||||
formState: { errors, isSubmitted },
|
||||
} = useForm<OrderSubmissionBody['orderSubmission']>({
|
||||
mode: 'onChange',
|
||||
defaultValues: getDefaultOrder(market),
|
||||
defaultValues: persistedOrder || getDefaultOrder(market),
|
||||
});
|
||||
const order = watch();
|
||||
|
||||
const feeDetails = useFeeDealTicketDetails(order, market);
|
||||
const details = getFeeDetailsValues(feeDetails);
|
||||
|
||||
useEffect(() => setOrder(order), [order, setOrder]);
|
||||
|
||||
const {
|
||||
message,
|
||||
isDisabled: disabled,
|
||||
message,
|
||||
section: errorSection,
|
||||
} = useOrderValidation({
|
||||
market,
|
||||
@ -80,7 +84,7 @@ export const DealTicket = ({
|
||||
}, [disabled, setError, clearErrors]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmitted) {
|
||||
if (isSubmitted || errorSection === DEAL_TICKET_SECTION.SUMMARY) {
|
||||
setErrorMessage({ message, isDisabled: disabled, errorSection });
|
||||
} else {
|
||||
setErrorMessage(undefined);
|
||||
@ -180,6 +184,7 @@ export const DealTicket = ({
|
||||
value={field.value}
|
||||
onSelect={field.onChange}
|
||||
errorMessage={errorMessage}
|
||||
register={register}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
@ -1,6 +1,9 @@
|
||||
import { FormGroup, Input } from '@vegaprotocol/ui-toolkit';
|
||||
import { formatForInput } from '@vegaprotocol/react-helpers';
|
||||
import { t } from '@vegaprotocol/react-helpers';
|
||||
import type { UseFormRegister } from 'react-hook-form';
|
||||
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
|
||||
import { validateExpiration } from '../deal-ticket-validation/validate-expiration';
|
||||
import type { DealTicketErrorMessage } from './deal-ticket-error';
|
||||
import { DealTicketError } from './deal-ticket-error';
|
||||
import { DEAL_TICKET_SECTION } from '../constants';
|
||||
@ -9,12 +12,14 @@ interface ExpirySelectorProps {
|
||||
value?: string;
|
||||
onSelect: (expiration: string | null) => void;
|
||||
errorMessage?: DealTicketErrorMessage;
|
||||
register?: UseFormRegister<OrderSubmissionBody['orderSubmission']>;
|
||||
}
|
||||
|
||||
export const ExpirySelector = ({
|
||||
value,
|
||||
onSelect,
|
||||
errorMessage,
|
||||
register,
|
||||
}: ExpirySelectorProps) => {
|
||||
const date = value ? new Date(value) : new Date();
|
||||
const dateFormatted = formatForInput(date);
|
||||
@ -24,11 +29,13 @@ export const ExpirySelector = ({
|
||||
<Input
|
||||
data-testid="date-picker-field"
|
||||
id="expiration"
|
||||
name="expiration"
|
||||
type="datetime-local"
|
||||
value={dateFormatted}
|
||||
onChange={(e) => onSelect(e.target.value)}
|
||||
min={minDate}
|
||||
{...register?.('expiresAt', {
|
||||
validate: validateExpiration,
|
||||
})}
|
||||
/>
|
||||
<DealTicketError
|
||||
errorMessage={errorMessage}
|
||||
|
@ -14,12 +14,13 @@ interface TimeInForceSelectorProps {
|
||||
errorMessage?: DealTicketErrorMessage;
|
||||
}
|
||||
|
||||
type PossibleOrderKeys = Exclude<
|
||||
Schema.OrderType,
|
||||
Schema.OrderType.TYPE_NETWORK
|
||||
>;
|
||||
type PrevSelectedValue = {
|
||||
[key in PossibleOrderKeys]: Schema.OrderTimeInForce;
|
||||
type OrderType = Schema.OrderType.TYPE_MARKET | Schema.OrderType.TYPE_LIMIT;
|
||||
type PreviousTimeInForce = {
|
||||
[key in OrderType]: Schema.OrderTimeInForce;
|
||||
};
|
||||
const DEFAULT_TIME_IN_FORCE: PreviousTimeInForce = {
|
||||
[Schema.OrderType.TYPE_MARKET]: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC,
|
||||
[Schema.OrderType.TYPE_LIMIT]: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC,
|
||||
};
|
||||
|
||||
export const TimeInForceSelector = ({
|
||||
@ -28,10 +29,6 @@ export const TimeInForceSelector = ({
|
||||
onSelect,
|
||||
errorMessage,
|
||||
}: TimeInForceSelectorProps) => {
|
||||
const [prevValue, setPrevValue] = useState<PrevSelectedValue>({
|
||||
[Schema.OrderType.TYPE_LIMIT]: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC,
|
||||
[Schema.OrderType.TYPE_MARKET]: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC,
|
||||
});
|
||||
const options =
|
||||
orderType === Schema.OrderType.TYPE_LIMIT
|
||||
? Object.entries(Schema.OrderTimeInForce)
|
||||
@ -40,20 +37,40 @@ export const TimeInForceSelector = ({
|
||||
timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_FOK ||
|
||||
timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
|
||||
);
|
||||
const [previousOrderType, setPreviousOrderType] = useState(
|
||||
Schema.OrderType.TYPE_MARKET
|
||||
);
|
||||
const [previousTimeInForce, setPreviousTimeInForce] =
|
||||
useState<PreviousTimeInForce>({
|
||||
...DEFAULT_TIME_IN_FORCE,
|
||||
[orderType]: value,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onSelect(prevValue[orderType as PossibleOrderKeys]);
|
||||
}, [onSelect, prevValue, orderType]);
|
||||
if (previousOrderType !== orderType) {
|
||||
setPreviousOrderType(orderType);
|
||||
const prev = previousTimeInForce[orderType as OrderType];
|
||||
onSelect(prev);
|
||||
}
|
||||
}, [
|
||||
onSelect,
|
||||
orderType,
|
||||
previousTimeInForce,
|
||||
previousOrderType,
|
||||
setPreviousOrderType,
|
||||
]);
|
||||
|
||||
return (
|
||||
<FormGroup label={t('Time in force')} labelFor="select-time-in-force">
|
||||
<Select
|
||||
id="select-time-in-force"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const selectedValue = e.target.value as Schema.OrderTimeInForce;
|
||||
setPrevValue({
|
||||
...prevValue,
|
||||
[orderType]: selectedValue,
|
||||
setPreviousTimeInForce({
|
||||
...previousTimeInForce,
|
||||
[orderType]: e.target.value,
|
||||
});
|
||||
onSelect(e.target.value as Schema.OrderTimeInForce);
|
||||
}}
|
||||
className="w-full"
|
||||
data-testid="order-tif"
|
||||
|
@ -1,29 +1 @@
|
||||
// TODO: fine for now however will leak state between tests (we don't really have) in future. Ideally should use a provider
|
||||
export const LocalStorage = {
|
||||
getItem: (key: string) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
setItem: (key: string, value: string) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(key, value);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.removeItem(key);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
export * from './local-storage';
|
||||
|
52
libs/react-helpers/src/lib/storage/local-storage.spec.ts
Normal file
52
libs/react-helpers/src/lib/storage/local-storage.spec.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useLocalStorage } from './local-storage';
|
||||
|
||||
describe('useLocalStorage', () => {
|
||||
afterEach(() => window.localStorage.clear());
|
||||
it("should return null if there's no value set", () => {
|
||||
const { result } = renderHook(() => useLocalStorage('test'));
|
||||
const [value] = result.current;
|
||||
expect(value).toBeNull();
|
||||
});
|
||||
it('should return already saved value', () => {
|
||||
window.localStorage.setItem('test', '123');
|
||||
const { result } = renderHook(() => useLocalStorage('test'));
|
||||
const [value] = result.current;
|
||||
expect(value).toEqual('123');
|
||||
});
|
||||
it('should save given value', () => {
|
||||
const { result } = renderHook(() => useLocalStorage('test'));
|
||||
const setValue = result.current[1];
|
||||
expect(result.current[0]).toBeNull();
|
||||
act(() => setValue('123'));
|
||||
expect(result.current[0]).toEqual('123');
|
||||
act(() => setValue('456'));
|
||||
expect(result.current[0]).toEqual('456');
|
||||
});
|
||||
it('should remove given value', () => {
|
||||
const { result } = renderHook(() => useLocalStorage('test'));
|
||||
const setValue = result.current[1];
|
||||
const removeValue = result.current[2];
|
||||
act(() => setValue('123'));
|
||||
expect(result.current[0]).toEqual('123');
|
||||
act(() => removeValue());
|
||||
expect(result.current[0]).toBeNull();
|
||||
});
|
||||
it('should return value set by storage event (by another tab)', () => {
|
||||
const { result: A } = renderHook(() => useLocalStorage('test-a'));
|
||||
const { result: B } = renderHook(() => useLocalStorage('test-b'));
|
||||
act(() => {
|
||||
window.localStorage.setItem('test-a', '123');
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key: 'test-a',
|
||||
oldValue: window.localStorage.getItem('test-a'),
|
||||
storageArea: window.localStorage,
|
||||
newValue: '123',
|
||||
})
|
||||
);
|
||||
});
|
||||
expect(A.current[0]).toEqual('123');
|
||||
expect(B.current[0]).toBeNull();
|
||||
});
|
||||
});
|
86
libs/react-helpers/src/lib/storage/local-storage.ts
Normal file
86
libs/react-helpers/src/lib/storage/local-storage.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react';
|
||||
|
||||
export const LocalStorage = {
|
||||
getItem: (key: string) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
setItem: (key: string, value: string) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(key, value);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.removeItem(key);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
type LocalStorageCallback = (key: string) => void;
|
||||
const LOCAL_STORAGE_CALLBACKS = new Set<LocalStorageCallback>();
|
||||
const registerCallback = (callback: LocalStorageCallback) =>
|
||||
LOCAL_STORAGE_CALLBACKS.add(callback);
|
||||
const unregisterCallback = (callback: LocalStorageCallback) =>
|
||||
LOCAL_STORAGE_CALLBACKS.delete(callback);
|
||||
const triggerCallbacks = (key: string) =>
|
||||
LOCAL_STORAGE_CALLBACKS.forEach((cb) => cb(key));
|
||||
|
||||
export const useLocalStorage = (key: string) => {
|
||||
const subscribe = useCallback(
|
||||
(onStoreChange: () => void) => {
|
||||
const callback = (cbKey: string) => {
|
||||
if (cbKey === key) onStoreChange();
|
||||
};
|
||||
registerCallback(callback);
|
||||
return () => unregisterCallback(callback);
|
||||
},
|
||||
[key]
|
||||
);
|
||||
const getSnapshot = () => {
|
||||
const item = LocalStorage.getItem(key);
|
||||
return item;
|
||||
};
|
||||
const setValue = useCallback(
|
||||
(value: string) => {
|
||||
LocalStorage.setItem(key, value);
|
||||
triggerCallbacks(key);
|
||||
},
|
||||
[key]
|
||||
);
|
||||
const removeValue = useCallback(() => {
|
||||
LocalStorage.removeItem(key);
|
||||
triggerCallbacks(key);
|
||||
}, [key]);
|
||||
const value = useSyncExternalStore(subscribe, getSnapshot, () => null);
|
||||
|
||||
// sync in all tabs
|
||||
const onStorage = useCallback(
|
||||
(ev: StorageEvent) => {
|
||||
if (ev.storageArea === window.localStorage && ev.key === key) {
|
||||
triggerCallbacks(key);
|
||||
}
|
||||
},
|
||||
[key]
|
||||
);
|
||||
useEffect(() => {
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}, [key, onStorage]);
|
||||
|
||||
return useMemo<
|
||||
[string | null | undefined, (value: string) => void, () => void]
|
||||
>(() => [value, setValue, removeValue], [removeValue, setValue, value]);
|
||||
};
|
Loading…
Reference in New Issue
Block a user