From 4d24bfac0a2ded35a9aa13888415dcf166e0e85e Mon Sep 17 00:00:00 2001 From: Joel Sequeira Date: Sun, 29 Oct 2023 00:44:07 -0400 Subject: [PATCH] Add new useTimeQuery hook (#75) ## Context This PR relates to https://github.com/hyperdxio/hyperdx/issues/53. From a chat with @MikeShi42 a while back we were discussing the UX of the time query presets (e.g. "Past 1h") and how it's kind of confusing atm. Once you click a preset, the label will remain as "Past 1h" and add a `from` and `to` param to the url. If after some time you refresh the page, the label will still say "Past 1h" but the `from` and `to` are the old values. At the same time, the existing useTimeQuery hook has a ton of complex logic which makes it very tricky to modify without causing regressions. ## This PR Create a new version of the useTimeQuery hook (temporarily calling it useNewTimeQuery) and add detailed unit testing to it for all the different cases. This can eventually be used to replace all the existing callsites of the useTimeQuery hook. For the search page, we can move all the live tail functionality into a separate hook and integrate with this new time query hook to have better separation of concerns and make them both easier to maintain. I'll be making a separate PR for the live tail functionality. --- .changeset/five-melons-grin.md | 5 + packages/app/global-setup.js | 3 + packages/app/jest.config.js | 2 +- packages/app/package.json | 1 + packages/app/src/DashboardPage.tsx | 34 +- packages/app/src/__test__/fixtures.ts | 53 +++ packages/app/src/__test__/timeQuery.test.tsx | 361 +++++++++++++++++++ packages/app/src/timeQuery.ts | 144 +++++++- yarn.lock | 31 +- 9 files changed, 600 insertions(+), 34 deletions(-) create mode 100644 .changeset/five-melons-grin.md create mode 100644 packages/app/global-setup.js create mode 100644 packages/app/src/__test__/fixtures.ts create mode 100644 packages/app/src/__test__/timeQuery.test.tsx diff --git a/.changeset/five-melons-grin.md b/.changeset/five-melons-grin.md new file mode 100644 index 00000000..381cd9c6 --- /dev/null +++ b/.changeset/five-melons-grin.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': patch +--- + +Add new version of the useTimeQuery hook along with a testing suite diff --git a/packages/app/global-setup.js b/packages/app/global-setup.js new file mode 100644 index 00000000..e96f7d07 --- /dev/null +++ b/packages/app/global-setup.js @@ -0,0 +1,3 @@ +module.exports = async () => { + process.env.TZ = 'America/New_York'; +}; diff --git a/packages/app/jest.config.js b/packages/app/jest.config.js index 55ef0ca0..8b98c91d 100644 --- a/packages/app/jest.config.js +++ b/packages/app/jest.config.js @@ -2,7 +2,7 @@ module.exports = { preset: 'ts-jest/presets/js-with-ts', testEnvironment: '@deploysentinel/jest-rtl-debugger/environment', setupFilesAfterEnv: ['/setup-jest.ts'], - globalSetup: '@deploysentinel/jest-rtl-debugger/globalSetup', + globalSetup: '/global-setup.js', roots: ['/src'], transform: { '^.+\\.tsx?$': 'ts-jest', diff --git a/packages/app/package.json b/packages/app/package.json index 7399b55d..5fb6bc14 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -85,6 +85,7 @@ }, "devDependencies": { "@deploysentinel/jest-rtl-debugger": "^0.2.3", + "@jedmao/location": "^3.0.0", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^12.1.4", "@testing-library/user-event": "^14.4.3", diff --git a/packages/app/src/DashboardPage.tsx b/packages/app/src/DashboardPage.tsx index 71800604..80b8cfd8 100644 --- a/packages/app/src/DashboardPage.tsx +++ b/packages/app/src/DashboardPage.tsx @@ -34,7 +34,7 @@ import TabBar from './TabBar'; import HDXHistogramChart from './HDXHistogramChart'; import api from './api'; import { LogTableWithSidePanel } from './LogTableWithSidePanel'; -import { parseTimeQuery, useTimeQuery } from './timeQuery'; +import { parseTimeQuery, useNewTimeQuery, useTimeQuery } from './timeQuery'; import { EditSearchChartForm, EditMarkdownChartForm, @@ -535,7 +535,7 @@ function DashboardFilter({ } // TODO: This is a hack to set the default time range -const defaultTimeRange = parseTimeQuery('Past 1h', false); +const defaultTimeRange = parseTimeQuery('Past 1h', false) as [Date, Date]; export default function DashboardPage() { const { data: dashboardsData, isLoading: isDashboardsLoading } = api.useDashboards(); @@ -622,19 +622,17 @@ export default function DashboardPage() { const [editedChart, setEditedChart] = useState(); - const { - searchedTimeRange, - displayedTimeInputValue, - setDisplayedTimeInputValue, - onSearch, - } = useTimeQuery({ - isUTC: false, - defaultValue: 'Past 1h', - defaultTimeRange: [ - defaultTimeRange?.[0]?.getTime() ?? -1, - defaultTimeRange?.[1]?.getTime() ?? -1, - ], - }); + const { searchedTimeRange, displayedTimeInputValue, onSearch } = + useNewTimeQuery({ + isUTC: false, + initialDisplayValue: 'Past 1h', + initialTimeRange: defaultTimeRange, + }); + + const [input, setInput] = useState(displayedTimeInputValue); + useEffect(() => { + setInput(displayedTimeInputValue); + }, [displayedTimeInputValue]); const onAddChart = () => { setEditedChart({ @@ -766,13 +764,13 @@ export default function DashboardPage() { className="d-flex align-items-center" onSubmit={e => { e.preventDefault(); - onSearch(displayedTimeInputValue); + onSearch(input); }} style={{ height: 33 }} > { onSearch(range); }} diff --git a/packages/app/src/__test__/fixtures.ts b/packages/app/src/__test__/fixtures.ts new file mode 100644 index 00000000..37fa9c58 --- /dev/null +++ b/packages/app/src/__test__/fixtures.ts @@ -0,0 +1,53 @@ +import { Router } from 'next/router'; +import { LocationMock } from '@jedmao/location'; +import type { UrlObject } from 'url'; + +type PartialRouter = Partial; + +export const BASE_URL = 'https://www.hyperdx.io'; + +/** + * A Router to be used for testing which provides the bare minimum needed + * for the useQueryParam(s) hook and NextAdapter to work. + */ +export class TestRouter implements PartialRouter { + isReady = true; + pathname = '/'; + private currentUrl = ''; + private history: string[] = []; + + constructor(private locationMock: LocationMock) {} + + replace = (url: string | UrlObject) => { + this.locationMock.assign(`${BASE_URL}${url}`); + this.currentUrl = TestRouter.getURLString(url); + this.locationMock.assign(`${BASE_URL}${this.currentUrl}`); + return Promise.resolve(true); + }; + + push = (url: string | UrlObject) => { + this.history.push(this.currentUrl); + this.currentUrl = TestRouter.getURLString(url); + this.locationMock.assign(`${BASE_URL}${this.currentUrl}`); + return Promise.resolve(true); + }; + + setIsReady = (isReady: boolean) => { + this.isReady = isReady; + }; + + get asPath() { + return this.pathname; + } + + static getURLString(url: string | UrlObject): string { + if (typeof url === 'string') { + return url; + } + return `${url.pathname}${url.search}`; + } + + getParams(): URLSearchParams { + return new URL(`${BASE_URL}${this.currentUrl}`).searchParams; + } +} diff --git a/packages/app/src/__test__/timeQuery.test.tsx b/packages/app/src/__test__/timeQuery.test.tsx new file mode 100644 index 00000000..d144daae --- /dev/null +++ b/packages/app/src/__test__/timeQuery.test.tsx @@ -0,0 +1,361 @@ +import { + getLiveTailTimeRange, + useNewTimeQuery, + type UseTimeQueryInputType, + type UseTimeQueryReturnType, +} from '../timeQuery'; +import { useRouter } from 'next/router'; +import { render } from '@testing-library/react'; +import * as React from 'react'; +import { useImperativeHandle } from 'react'; +import { QueryParamProvider } from 'use-query-params'; +import { NextAdapter } from 'next-query-params'; +import { TestRouter } from './fixtures'; +import { LocationMock } from '@jedmao/location'; + +// Setting a fixed time of 10/03/23 at 12pm EDT +const INITIAL_DATE_STRING = + 'Tue Oct 03 2023 12:00:00 GMT-0400 (Eastern Daylight Time)'; + +jest.mock('next/router', () => ({ + useRouter: jest.fn(), +})); + +function TestWrapper({ children }: { children: React.ReactNode }) { + return ( + {children} + ); +} + +const TestComponent = React.forwardRef(function Component( + timeQueryInput: UseTimeQueryInputType, + ref: React.Ref, +) { + const timeQueryVal = useNewTimeQuery(timeQueryInput); + + useImperativeHandle(ref, () => timeQueryVal); + + return null; +}); + +const { location: savedLocation } = window; + +describe('useTimeQuery tests', () => { + let testRouter: TestRouter; + let locationMock: LocationMock; + + beforeAll(() => { + // @ts-ignore - This complains because we can only delete optional operands + delete window.location; + }); + + beforeEach(() => { + jest.resetAllMocks(); + locationMock = new LocationMock('https://www.hyperdx.io/'); + testRouter = new TestRouter(locationMock); + window.location = locationMock; + + (useRouter as jest.Mock).mockReturnValue(testRouter); + + jest.useFakeTimers().setSystemTime(new Date(INITIAL_DATE_STRING)); + }); + + afterAll(() => { + window.location = savedLocation; + }); + + it('initializes successfully to a non-UTC time', async () => { + const timeQueryRef = React.createRef(); + + render( + + + , + ); + + // The live tail time range is 15 mins + expect(timeQueryRef.current?.displayedTimeInputValue).toMatchInlineSnapshot( + `"Oct 3 11:45:00 - Oct 3 12:00:00"`, + ); + }); + + it('initializes successfully to a UTC time', async () => { + const timeQueryRef = React.createRef(); + + render( + + + , + ); + + // The live tail time range is 15 mins + expect(timeQueryRef.current?.displayedTimeInputValue).toMatchInlineSnapshot( + `"Oct 3 15:45:00 - Oct 3 16:00:00"`, + ); + }); + + it('can be overridden by `tq` url param', async () => { + const timeQueryRef = React.createRef(); + testRouter.replace('/search?tq=Last+4H'); + + const { rerender } = render( + + + , + ); + jest.runAllTimers(); + + rerender( + + + , + ); + jest.runAllTimers(); + + // Once the hook runs, it will unset the `tq` param and replace it with + // a `from` and `to` + expect(locationMock.searchParams.get('tq')).toBeNull(); + // `From` should be 10/03/23 at 8:00am EDT + expect(locationMock.searchParams.get('from')).toBe('1696334400000'); + // `To` should be 10/03/23 at 12:00pm EDT + expect(locationMock.searchParams.get('to')).toBe('1696348800000'); + expect(timeQueryRef.current?.displayedTimeInputValue).toMatchInlineSnapshot( + `"Oct 3 08:00:00 - Oct 3 12:00:00"`, + ); + }); + + it('browser navigation of from/to qparmas updates the searched time range', async () => { + const timeQueryRef = React.createRef(); + testRouter.setIsReady(false); + testRouter.replace('/search'); + + const result = render( + + + , + ); + jest.runAllTimers(); + + testRouter.setIsReady(true); + + result.rerender( + + + , + ); + + expect(timeQueryRef.current?.displayedTimeInputValue).toMatchInlineSnapshot( + `"Past 1h"`, + ); + expect(timeQueryRef.current?.searchedTimeRange).toMatchInlineSnapshot(` + Array [ + 2023-10-03T15:45:00.000Z, + 2023-10-03T16:00:00.000Z, + ] + `); + + // 10/03/23 from 04:00am EDT to 08:00am EDT + testRouter.replace('/search?from=1696320000000&to=1696334400000'); + + result.rerender( + + + , + ); + + result.rerender( + + + , + ); + + expect(timeQueryRef.current?.displayedTimeInputValue).toMatchInlineSnapshot( + `"Oct 3 04:00:00 - Oct 3 08:00:00"`, + ); + expect(timeQueryRef.current?.searchedTimeRange).toMatchInlineSnapshot(` + Array [ + 2023-10-03T08:00:00.000Z, + 2023-10-03T12:00:00.000Z, + ] + `); + }); + + it('overrides initial value with async updated `from` and `to` params', async () => { + const timeQueryRef = React.createRef(); + // 10/03/23 from 04:00am EDT to 08:00am EDT + testRouter.setIsReady(false); + testRouter.replace('/search'); + + const result = render( + + + , + ); + jest.runAllTimers(); + + testRouter.replace('/search?from=1696320000000&to=1696334400000'); + testRouter.setIsReady(true); + + result.rerender( + + + , + ); + + expect(timeQueryRef.current?.displayedTimeInputValue).toMatchInlineSnapshot( + `"Oct 3 04:00:00 - Oct 3 08:00:00"`, + ); + expect(timeQueryRef.current?.searchedTimeRange).toMatchInlineSnapshot(` + Array [ + 2023-10-03T08:00:00.000Z, + 2023-10-03T12:00:00.000Z, + ] + `); + }); + + it('accepts `from` and `to` url params', async () => { + const timeQueryRef = React.createRef(); + // 10/03/23 from 04:00am EDT to 08:00am EDT + testRouter.replace('/search?from=1696320000000&to=1696334400000'); + + render( + + + , + ); + jest.runAllTimers(); + + expect(timeQueryRef.current?.displayedTimeInputValue).toMatchInlineSnapshot( + `"Oct 3 04:00:00 - Oct 3 08:00:00"`, + ); + }); + + it('handles bad input in `from` and `to` url params', async () => { + const timeQueryRef = React.createRef(); + testRouter.replace('/search?from=abc&to=def'); + + render( + + + , + ); + jest.runAllTimers(); + + // Should initialize to the initial time range 11:45am - 12:00pm + expect(timeQueryRef.current?.displayedTimeInputValue).toMatchInlineSnapshot( + `"Oct 3 11:45:00 - Oct 3 12:00:00"`, + ); + }); + + it('prefers `tq` param over `from` and `to` params', async () => { + const timeQueryRef = React.createRef(); + // 10/03/23 from 04:00am EDT to 08:00am EDT, tq says last 1 hour + testRouter.replace( + '/search?from=1696320000000&to=1696334400000&tq=Past+1h', + ); + + const result = render( + + + , + ); + jest.runAllTimers(); + + result.rerender( + + + , + ); + jest.runAllTimers(); + + // The time range should be the last 1 hour even though the `from` and `to` + // params are passed in. + expect(timeQueryRef.current?.displayedTimeInputValue).toMatchInlineSnapshot( + `"Oct 3 11:00:00 - Oct 3 12:00:00"`, + ); + }); + + it('enables custom display value', async () => { + const timeQueryRef = React.createRef(); + testRouter.replace('/search'); + const initialDisplayValue = 'Live Tail'; + + render( + + + , + ); + jest.runAllTimers(); + + expect(timeQueryRef.current?.displayedTimeInputValue).toBe( + initialDisplayValue, + ); + }); +}); diff --git a/packages/app/src/timeQuery.ts b/packages/app/src/timeQuery.ts index 54d041bc..14cf4da6 100644 --- a/packages/app/src/timeQuery.ts +++ b/packages/app/src/timeQuery.ts @@ -7,8 +7,16 @@ import { NumberParam, useQueryParams, } from 'use-query-params'; -import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; -import { format, sub, startOfSecond } from 'date-fns'; +import { + useState, + useCallback, + useEffect, + useMemo, + useRef, + Dispatch, + SetStateAction, +} from 'react'; +import { format, sub, startOfSecond, isValid } from 'date-fns'; import { formatInTimeZone } from 'date-fns-tz'; import { usePrevious } from './utils'; @@ -390,3 +398,135 @@ export function useTimeQuery({ setIsLive, }; } + +export type UseTimeQueryInputType = { + /** Whether the displayed value should be in UTC */ + isUTC: boolean; + /** + * Optional initial value to be set as the `displayedTimeInputValue`. + * If no value is provided it will return a date string for the initial + * time range. + */ + initialDisplayValue?: string; + /** The initial time range to get values for */ + initialTimeRange: [Date, Date]; +}; + +export type UseTimeQueryReturnType = { + isReady: boolean; + displayedTimeInputValue: string; + setDisplayedTimeInputValue: Dispatch>; + searchedTimeRange: [Date, Date]; + onSearch: (timeQuery: string) => void; + onTimeRangeSelect: (start: Date, end: Date) => void; +}; + +export function useNewTimeQuery({ + isUTC, + initialDisplayValue, + initialTimeRange, +}: UseTimeQueryInputType): UseTimeQueryReturnType { + const router = useRouter(); + // We need to return true in SSR to prevent mismatch issues + const isReady = typeof window === 'undefined' ? true : router.isReady; + + const [displayedTimeInputValue, setDisplayedTimeInputValue] = + useState(() => { + return initialDisplayValue ?? dateRangeToString(initialTimeRange, isUTC); + }); + + const [{ from, to }, setTimeRangeQuery] = useQueryParams( + { + from: withDefault(NumberParam, undefined), + to: withDefault(NumberParam, undefined), + }, + { + updateType: 'pushIn', + enableBatching: true, + }, + ); + + const [searchedTimeRange, setSearchedTimeRange] = + useState<[Date, Date]>(initialTimeRange); + + // Allow browser back/fwd button to modify the displayed time input value + const [inputTimeQuery, setInputTimeQuery] = useQueryParam( + 'tq', + withDefault(StringParam, undefined), + { + updateType: 'pushIn', + enableBatching: true, + }, + ); + + const onSearch = useCallback( + (timeQuery: string) => { + const [start, end] = parseTimeQuery(timeQuery, isUTC); + // TODO: Add validation UI + if (start != null && end != null) { + setTimeRangeQuery({ from: start.getTime(), to: end.getTime() }); + } + }, + [isUTC, setTimeRangeQuery], + ); + + useEffect(() => { + if (from != null && to != null && inputTimeQuery == null && isReady) { + const start = new Date(from); + const end = new Date(to); + if (isValid(start) && isValid(end)) { + setSearchedTimeRange([start, end]); + const dateRangeStr = dateRangeToString([start, end], isUTC); + setDisplayedTimeInputValue(dateRangeStr); + } + } else if ( + from == null && + to == null && + inputTimeQuery == null && + isReady + ) { + setSearchedTimeRange(initialTimeRange); + const dateRangeStr = dateRangeToString(initialTimeRange, isUTC); + setDisplayedTimeInputValue(initialDisplayValue ?? dateRangeStr); + } + }, [ + isReady, + inputTimeQuery, + isUTC, + from, + to, + initialDisplayValue, + initialTimeRange, + ]); + + useEffect(() => { + // If there is a `tq` param passed in, use it to set the time range and + // then clear the param. + if (inputTimeQuery) { + onSearch(inputTimeQuery); + setInputTimeQuery(undefined); + } + }, [inputTimeQuery, onSearch, setInputTimeQuery]); + + return { + isReady, + displayedTimeInputValue, + setDisplayedTimeInputValue: () => {}, + searchedTimeRange, + onSearch, + onTimeRangeSelect: useCallback( + (start: Date, end: Date) => { + setTimeRangeQuery({ from: start.getTime(), to: end.getTime() }); + setSearchedTimeRange([start, end]); + const dateRangeStr = dateRangeToString([start, end], isUTC); + setDisplayedTimeInputValue(dateRangeStr); + }, + [isUTC, setTimeRangeQuery, setDisplayedTimeInputValue], + ), + }; +} + +export function getLiveTailTimeRange(): [Date, Date] { + const end = startOfSecond(new Date()); + return [sub(end, { minutes: 15 }), end]; +} diff --git a/yarn.lock b/yarn.lock index 07022c7d..eb17e862 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1721,6 +1721,11 @@ resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== +"@jedmao/location@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@jedmao/location/-/location-3.0.0.tgz#f2b24e937386f95252f3a1fefbf7ca2e0a4b87e9" + integrity sha512-p7mzNlgJbCioUYLUEKds3cQG4CHONVFJNYqMe6ocEtENCL/jYmMo1Q3ApwsMmU+L0ZkaDJEyv4HokaByLoPwlQ== + "@jest/console@^24.9.0": version "24.9.0" resolved "https://registry.yarnpkg.com/@jest/console/-/console-24.9.0.tgz#79b1bc06fb74a8cfb01cbdedf945584b1b9707f0" @@ -3883,17 +3888,17 @@ integrity sha512-jtkwqdP2rY2iCCDVAFuaNBH3fiEi29aTn2RhtIoky8DTTiCdc48plpHHreLwmv1PICJ4AJUUESaq3xa8fZH8+g== "@testing-library/dom@^8.0.0": - version "8.20.0" - resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.20.0.tgz#914aa862cef0f5e89b98cc48e3445c4c921010f6" - integrity sha512-d9ULIT+a4EXLX3UU8FBjauG9NnsZHkHztXoIcTsOKoOw030fyjheN9svkTULjJxtYag9DZz5Jz5qkWZDPxTFwA== + version "8.20.1" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.20.1.tgz#2e52a32e46fc88369eef7eef634ac2a192decd9f" + integrity sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g== dependencies: "@babel/code-frame" "^7.10.4" "@babel/runtime" "^7.12.5" "@types/aria-query" "^5.0.1" - aria-query "^5.0.0" + aria-query "5.1.3" chalk "^4.1.0" dom-accessibility-api "^0.5.9" - lz-string "^1.4.4" + lz-string "^1.5.0" pretty-format "^27.0.2" "@testing-library/jest-dom@^5.16.5": @@ -4602,9 +4607,9 @@ react-popper "^2.2.5" "@types/react-dom@<18.0.0": - version "17.0.19" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.19.tgz#36feef3aa35d045cacd5ed60fe0eef5272f19492" - integrity sha512-PiYG40pnQRdPHnlf7tZnp0aQ6q9tspYr72vD61saO6zFCybLfMqwUCN0va1/P+86DXn18ZWeW30Bk7xlC5eEAQ== + version "17.0.21" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.21.tgz#85d56965483ce4850f5f03f9234e54a1f47786e5" + integrity sha512-3rQEFUNUUz2MYiRwJJj6UekcW7rFLOtmK7ajQP7qJpjNdggInl3I/xM4I3Hq1yYPdCGVMgax1gZsB7BBTtayXg== dependencies: "@types/react" "^17" @@ -5141,7 +5146,7 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== -aria-query@^5.0.0, aria-query@^5.1.3: +aria-query@5.1.3, aria-query@^5.0.0, aria-query@^5.1.3: version "5.1.3" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.1.3.tgz#19db27cd101152773631396f7a95a3b58c22c35e" integrity sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ== @@ -10878,10 +10883,10 @@ luxon@^3.2.1: resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.4.3.tgz#8ddf0358a9492267ffec6a13675fbaab5551315d" integrity sha512-tFWBiv3h7z+T/tDaoxA8rqTxy1CHV6gHS//QdaH4pulbq/JuBSGgQspQQqcgnwdAx6pNI7cmvz5Sv/addzHmUg== -lz-string@^1.4.4: - version "1.4.4" - resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26" - integrity sha512-0ckx7ZHRPqb0oUm8zNr+90mtf9DQB60H1wMCjBtfi62Kl3a7JbHob6gA2bC+xRvZoOL+1hzUK8jeuEIQE8svEQ== +lz-string@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" + integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== make-dir@^3.0.0: version "3.1.0"