feature(home-plugin): Creates the <VisitListener/> component
This patch creates the <VisitListener/> component to be used on the app to register user visits enabling the homepage Recently Visited and Top Visited experience (<VisitedByType/>) to be functional. Signed-off-by: Renan Mendes Carvalho <aitherios@gmail.com>
This commit is contained in:
committed by
Camila Belo
parent
cac684376e
commit
c0700220c9
@@ -14,13 +14,15 @@ import { CardSettings as CardSettings_2 } from '@backstage/plugin-home-react';
|
||||
import { ComponentParts as ComponentParts_2 } from '@backstage/plugin-home-react';
|
||||
import { ComponentRenderer as ComponentRenderer_2 } from '@backstage/plugin-home-react';
|
||||
import { createCardExtension as createCardExtension_2 } from '@backstage/plugin-home-react';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { Dispatch } from 'react';
|
||||
import { JSX as JSX_2 } from 'react';
|
||||
import { default as React_2 } from 'react';
|
||||
import { ReactElement } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import { RendererProps as RendererProps_2 } from '@backstage/plugin-home-react';
|
||||
import { RouteRef } from '@backstage/core-plugin-api';
|
||||
import { SetStateAction } from 'react';
|
||||
import { stringifyEntityRef } from '@backstage/catalog-model';
|
||||
|
||||
// @public
|
||||
export type Breakpoint = 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
@@ -99,6 +101,25 @@ export type CustomHomepageGridProps = {
|
||||
preventCollision?: boolean;
|
||||
};
|
||||
|
||||
// @public
|
||||
export const DoNotTrack: ({
|
||||
children,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
}) => JSX.Element;
|
||||
|
||||
// @public
|
||||
export const getToEntityRef: ({
|
||||
rootPath,
|
||||
stringifyEntityRefImpl,
|
||||
}?: {
|
||||
rootPath?: string | undefined;
|
||||
stringifyEntityRefImpl?: typeof stringifyEntityRef | undefined;
|
||||
}) => ({ pathname }: { pathname: string }) => string | undefined;
|
||||
|
||||
// @public
|
||||
export const getVisitName: (document: Document) => () => string;
|
||||
|
||||
// @public
|
||||
export const HeaderWorldClock: (props: {
|
||||
clockConfigs: ClockConfig[];
|
||||
@@ -193,6 +214,9 @@ export type ToolkitContentProps = {
|
||||
tools: Tool[];
|
||||
};
|
||||
|
||||
// @public
|
||||
export const useVisitListener: () => VisitListenerContextValue;
|
||||
|
||||
// @public
|
||||
export type Visit = {
|
||||
id: string;
|
||||
@@ -215,24 +239,46 @@ export type VisitedByTypeProps = {
|
||||
kind: VisitedByTypeKind;
|
||||
};
|
||||
|
||||
// @public
|
||||
export const VisitListener: ({
|
||||
children,
|
||||
toEntityRef,
|
||||
visitName,
|
||||
}: {
|
||||
children?: React_2.ReactNode;
|
||||
toEntityRef?:
|
||||
| (({ pathname }: { pathname: string }) => string | undefined)
|
||||
| undefined;
|
||||
visitName?: (({ pathname }: { pathname: string }) => string) | undefined;
|
||||
}) => JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export type VisitFilter = {
|
||||
field: string;
|
||||
operator: '<' | '<=' | '==' | '>' | '>=' | 'contains';
|
||||
value: JsonValue;
|
||||
export const VisitListenerContext: React_2.Context<VisitListenerContextValue>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type VisitListenerContextValue = {
|
||||
doNotTrack: boolean;
|
||||
setDoNotTrack: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
// @public
|
||||
export interface VisitsApi {
|
||||
listUserVisits(queryParams?: VisitsApiQueryParams): Promise<Visit[]>;
|
||||
saveVisit(saveParams: VisitsApiSaveParams): Promise<void>;
|
||||
saveVisit(saveParams: VisitsApiSaveParams): Promise<Visit>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type VisitsApiQueryParams = {
|
||||
limit?: number;
|
||||
orderBy?: Record<string, 'asc' | 'desc'>;
|
||||
filterBy?: VisitFilter[];
|
||||
orderBy?: Array<{
|
||||
field: keyof Visit;
|
||||
direction: 'asc' | 'desc';
|
||||
}>;
|
||||
filterBy?: Array<{
|
||||
field: keyof Visit;
|
||||
operator: '<' | '<=' | '==' | '>' | '>=' | 'contains';
|
||||
value: string | number;
|
||||
}>;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"@testing-library/dom": "^8.0.0",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^12.1.3",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@testing-library/user-event": "^14.0.0",
|
||||
"@types/react-grid-layout": "^1.3.2",
|
||||
"msw": "^1.0.0"
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
*/
|
||||
|
||||
import { createApiRef } from '@backstage/core-plugin-api';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
|
||||
/**
|
||||
* @public
|
||||
@@ -48,13 +47,6 @@ export type Visit = {
|
||||
entityRef?: string;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export type VisitFilter = {
|
||||
field: string;
|
||||
operator: '<' | '<=' | '==' | '>' | '>=' | 'contains';
|
||||
value: JsonValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
* This data structure represents the parameters associated with search queries for visits.
|
||||
@@ -65,24 +57,36 @@ export type VisitsApiQueryParams = {
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* A record for which the key is a field name to sort on, and the value is the sort direction.
|
||||
* For a multi-field sorting query, add multi entries to the record.
|
||||
* Allows ordering visits on entity properties.
|
||||
* @example
|
||||
* Sort ascending by the timestamp field.
|
||||
* ```
|
||||
* { orderBy: { timestamp: 'asc' } }
|
||||
* { orderBy: [{ field: 'timestamp', direction: 'asc' }] }
|
||||
* ```
|
||||
*/
|
||||
orderBy?: Record<string, 'asc' | 'desc'>;
|
||||
orderBy?: Array<{
|
||||
field: keyof Visit;
|
||||
direction: 'asc' | 'desc';
|
||||
}>;
|
||||
/**
|
||||
* Allows filtering visits on number of hits, timestamp and/or entityRef attributes.
|
||||
* Allows filtering visits on entity properties.
|
||||
* @example
|
||||
* Most popular docs on the past 7 days
|
||||
* ```
|
||||
* { orderBy: { hits: 'desc' }, filterBy: [{ field: 'timestamp', operator: '>=', value: <date> }, { field: 'entityRef', operator: 'contains', value: 'docs' }] }
|
||||
* {
|
||||
* orderBy: [{ field: 'hits', direction: 'desc' }],
|
||||
* filterBy: [
|
||||
* { field: 'timestamp', operator: '>=', value: <date> },
|
||||
* { field: 'entityRef', operator: 'contains', value: 'docs' }
|
||||
* ]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
filterBy?: VisitFilter[];
|
||||
filterBy?: Array<{
|
||||
field: keyof Visit;
|
||||
operator: '<' | '<=' | '==' | '>' | '>=' | 'contains';
|
||||
value: string | number;
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -102,7 +106,7 @@ export interface VisitsApi {
|
||||
* Persist a new visit.
|
||||
* @param pageVisit - a new visit data
|
||||
*/
|
||||
saveVisit(saveParams: VisitsApiSaveParams): Promise<void>;
|
||||
saveVisit(saveParams: VisitsApiSaveParams): Promise<Visit>;
|
||||
/**
|
||||
* Get the logged user visits.
|
||||
* @param queryParams - optional search query params.
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { TestApiProvider, renderInTestApp } from '@backstage/test-utils';
|
||||
import { Visit, visitsApiRef } from '../api';
|
||||
import { DoNotTrack, VisitListener, useVisitListener } from './VisitListener';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { act, renderHook } from '@testing-library/react-hooks';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
const visits: Array<Visit> = [
|
||||
{
|
||||
id: 'tech-radar',
|
||||
name: 'Tech Radar',
|
||||
pathname: '/tech-radar',
|
||||
hits: 40,
|
||||
timestamp: Date.now() - 360_000,
|
||||
},
|
||||
{
|
||||
id: 'explore',
|
||||
name: 'Explore Backstage',
|
||||
pathname: '/explore',
|
||||
hits: 35,
|
||||
timestamp: Date.now() - 86400_000 * 1,
|
||||
},
|
||||
{
|
||||
id: 'user-1',
|
||||
name: 'Guest',
|
||||
pathname: '/catalog/default/user/guest',
|
||||
hits: 30,
|
||||
timestamp: Date.now() - 86400_000 * 2,
|
||||
entityRef: 'User:default/guest',
|
||||
},
|
||||
];
|
||||
|
||||
const mockVisitsApi = {
|
||||
saveVisit: jest.fn(async () => visits[0]),
|
||||
listVisits: jest.fn(async () => visits),
|
||||
};
|
||||
|
||||
describe('<VisitListener/>', () => {
|
||||
afterEach(jest.resetAllMocks);
|
||||
|
||||
it('registers a visit', async () => {
|
||||
jest.spyOn(document, 'title', 'get').mockReturnValue('MockedTitle');
|
||||
const pathname = '/catalog/default/component/playback-order';
|
||||
|
||||
await renderInTestApp(
|
||||
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
|
||||
<VisitListener />
|
||||
</TestApiProvider>,
|
||||
{ routeEntries: [pathname] },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockVisitsApi.saveVisit).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
expect(mockVisitsApi.saveVisit).toHaveBeenCalledWith({
|
||||
visit: {
|
||||
pathname,
|
||||
entityRef: 'component:default/playback-order',
|
||||
name: 'MockedTitle',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('renders its children', async () => {
|
||||
const { getByTestId } = await renderInTestApp(
|
||||
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
|
||||
<VisitListener>
|
||||
<div data-testid="child">child</div>
|
||||
</VisitListener>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(getByTestId('child')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('is able to override how visit names are defined', async () => {
|
||||
jest.spyOn(document, 'title', 'get').mockReturnValue('MockedTitle');
|
||||
const pathname = '/catalog/default/component/playback-order';
|
||||
|
||||
const visitNameOverride = ({ pathname: path }: { pathname: string }) =>
|
||||
path;
|
||||
|
||||
await renderInTestApp(
|
||||
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
|
||||
<VisitListener visitName={visitNameOverride} />
|
||||
</TestApiProvider>,
|
||||
{ routeEntries: [pathname] },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockVisitsApi.saveVisit).toHaveBeenCalledWith({
|
||||
visit: {
|
||||
pathname,
|
||||
entityRef: 'component:default/playback-order',
|
||||
name: pathname,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('is able to override how entityRefs are defined', async () => {
|
||||
jest.spyOn(document, 'title', 'get').mockReturnValue('MockedTitle');
|
||||
const pathname = '/catalog/default/component/playback-order';
|
||||
|
||||
const toEntityRefOverride = ({ pathname: path }: { pathname: string }) =>
|
||||
path;
|
||||
|
||||
await renderInTestApp(
|
||||
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
|
||||
<VisitListener toEntityRef={toEntityRefOverride} />
|
||||
</TestApiProvider>,
|
||||
{ routeEntries: [pathname] },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockVisitsApi.saveVisit).toHaveBeenCalledWith({
|
||||
visit: {
|
||||
pathname,
|
||||
entityRef: pathname,
|
||||
name: 'MockedTitle',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('<DoNotTrack/>', () => {
|
||||
afterEach(jest.resetAllMocks);
|
||||
|
||||
it("doesn't register a visit", async () => {
|
||||
const requestAnimationFrameSpy = jest.spyOn(
|
||||
window,
|
||||
'requestAnimationFrame',
|
||||
);
|
||||
await renderInTestApp(
|
||||
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
|
||||
<VisitListener>
|
||||
<DoNotTrack />
|
||||
</VisitListener>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
await waitFor(() => expect(requestAnimationFrameSpy).toHaveBeenCalled());
|
||||
expect(mockVisitsApi.saveVisit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders its children', async () => {
|
||||
const requestAnimationFrameSpy = jest.spyOn(
|
||||
window,
|
||||
'requestAnimationFrame',
|
||||
);
|
||||
const { getByTestId } = await renderInTestApp(
|
||||
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
|
||||
<VisitListener>
|
||||
<DoNotTrack>
|
||||
<div data-testid="child">child</div>
|
||||
</DoNotTrack>
|
||||
</VisitListener>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
await waitFor(() => expect(requestAnimationFrameSpy).toHaveBeenCalled());
|
||||
expect(getByTestId('child')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVisitListener()', () => {
|
||||
it('returns the default context', () => {
|
||||
const { result } = renderHook(() => useVisitListener());
|
||||
expect(result.current.doNotTrack).toBeFalsy();
|
||||
expect(result.current.setDoNotTrack).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('changes the doNotTrack flag', () => {
|
||||
const { result } = renderHook(() => useVisitListener(), {
|
||||
wrapper: ({ children }) => (
|
||||
<MemoryRouter>
|
||||
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
|
||||
<VisitListener>{children}</VisitListener>
|
||||
</TestApiProvider>
|
||||
</MemoryRouter>
|
||||
),
|
||||
});
|
||||
act(() => {
|
||||
result.current.setDoNotTrack(true);
|
||||
});
|
||||
expect(result.current.doNotTrack).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React, {
|
||||
createContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useContext,
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { visitsApiRef } from '../api';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { stringifyEntityRef } from '@backstage/catalog-model';
|
||||
|
||||
/** @public */
|
||||
export type VisitListenerContextValue = {
|
||||
doNotTrack: boolean;
|
||||
setDoNotTrack: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
const defaultVisitListenerContext: VisitListenerContextValue = {
|
||||
doNotTrack: false,
|
||||
setDoNotTrack: () => {},
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export const VisitListenerContext = createContext<VisitListenerContextValue>(
|
||||
defaultVisitListenerContext,
|
||||
);
|
||||
|
||||
/**
|
||||
* @public
|
||||
* This function returns an implementation of toEntityRef which is responsible
|
||||
* for receiving a pathname and maybe returning an entityRef compatible with the
|
||||
* catalog-model.
|
||||
* By default this function uses the url root "/catalog" and the
|
||||
* stringifyEntityRef implementation from catalog-model.
|
||||
* Example:
|
||||
* const toEntityRef = getToEntityRef();
|
||||
* toEntityRef(\{ pathname: "/catalog/default/component/playback-order" \})
|
||||
* // returns "component:default/playback-order"
|
||||
*/
|
||||
export const getToEntityRef =
|
||||
({
|
||||
rootPath = 'catalog',
|
||||
stringifyEntityRefImpl = stringifyEntityRef,
|
||||
} = {}) =>
|
||||
({ pathname }: { pathname: string }): string | undefined => {
|
||||
const regex = new RegExp(
|
||||
`^\/${rootPath}\/(?<namespace>[^\/]+)\/(?<kind>[^\/]+)\/(?<name>[^\/]+)`,
|
||||
);
|
||||
const result = regex.exec(pathname);
|
||||
if (!result || !result?.groups) return undefined;
|
||||
const entity = {
|
||||
namespace: result.groups.namespace,
|
||||
kind: result.groups.kind,
|
||||
name: result.groups.name,
|
||||
};
|
||||
return stringifyEntityRefImpl(entity);
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
* This function returns an implementation of visitName which is responsible
|
||||
* for receiving a pathname and returning a string (name). The default
|
||||
* implementation ignores the pathname and uses the document.title .
|
||||
*/
|
||||
export const getVisitName = (document: Document) => () => document.title;
|
||||
|
||||
/**
|
||||
* @public
|
||||
* Component responsible for listening to location changes and calling
|
||||
* the visitsApi to save visits.
|
||||
*/
|
||||
export const VisitListener = ({
|
||||
children,
|
||||
toEntityRef,
|
||||
visitName,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
toEntityRef?: ({ pathname }: { pathname: string }) => string | undefined;
|
||||
visitName?: ({ pathname }: { pathname: string }) => string;
|
||||
}): JSX.Element => {
|
||||
const [doNotTrack, setDoNotTrack] = useState<boolean>(
|
||||
defaultVisitListenerContext.doNotTrack,
|
||||
);
|
||||
const visitsApi = useApi(visitsApiRef);
|
||||
const { pathname } = useLocation();
|
||||
const toEntityRefImpl = toEntityRef ?? getToEntityRef();
|
||||
const visitNameImpl = visitName ?? getVisitName(document);
|
||||
useEffect(() => {
|
||||
// Wait for the browser to finish with paint with the assumption react
|
||||
// has finished with dom reconciliation and the doNotTrack state update.
|
||||
const requestId = requestAnimationFrame(() => {
|
||||
if (!doNotTrack)
|
||||
visitsApi.saveVisit({
|
||||
visit: {
|
||||
name: visitNameImpl({ pathname }),
|
||||
pathname,
|
||||
entityRef: toEntityRefImpl({ pathname }),
|
||||
},
|
||||
});
|
||||
});
|
||||
return () => cancelAnimationFrame(requestId);
|
||||
}, [doNotTrack, visitsApi, pathname, toEntityRefImpl, visitNameImpl]);
|
||||
|
||||
return (
|
||||
<VisitListenerContext.Provider value={{ doNotTrack, setDoNotTrack }}>
|
||||
{children}
|
||||
</VisitListenerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
* Hook used to access visit listener context. Is able to control if tracking
|
||||
* should be disabled.
|
||||
*/
|
||||
export const useVisitListener = () => {
|
||||
const value = useContext(VisitListenerContext);
|
||||
|
||||
if (value === undefined)
|
||||
throw new Error(
|
||||
'useVisitListener found an undefined context, <VisitListener/> could be missing',
|
||||
);
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
* Use this component to warn VisitListener to disable tracking.
|
||||
*/
|
||||
export const DoNotTrack = ({
|
||||
children,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
}): JSX.Element => {
|
||||
const { setDoNotTrack } = useVisitListener();
|
||||
useEffect(() => {
|
||||
setDoNotTrack(true);
|
||||
return () => setDoNotTrack(false);
|
||||
}, [setDoNotTrack]);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -16,3 +16,4 @@
|
||||
|
||||
export { HomepageCompositionRoot } from './HomepageCompositionRoot';
|
||||
export * from './CustomHomepage';
|
||||
export * from './VisitListener';
|
||||
|
||||
@@ -32,7 +32,7 @@ const visits = [
|
||||
];
|
||||
|
||||
const mockVisitsApi = {
|
||||
saveVisit: async () => {},
|
||||
saveVisit: async () => visits[0],
|
||||
listUserVisits: async () => visits,
|
||||
};
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ export const Content = ({
|
||||
return await visitsApi
|
||||
.listUserVisits({
|
||||
limit: numVisitsTotal ?? 8,
|
||||
orderBy: { timestamp: 'desc' },
|
||||
orderBy: [{ field: 'timestamp', direction: 'desc' }],
|
||||
})
|
||||
.then(setVisits);
|
||||
}
|
||||
@@ -75,7 +75,7 @@ export const Content = ({
|
||||
return await visitsApi
|
||||
.listUserVisits({
|
||||
limit: numVisitsTotal ?? 8,
|
||||
orderBy: { hits: 'desc' },
|
||||
orderBy: [{ field: 'hits', direction: 'desc' }],
|
||||
})
|
||||
.then(setVisits);
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ const visits: Array<Visit> = [
|
||||
];
|
||||
|
||||
const mockVisitsApi = {
|
||||
saveVisit: async () => {},
|
||||
saveVisit: async () => visits[0],
|
||||
listUserVisits: async () => visits,
|
||||
};
|
||||
|
||||
|
||||
@@ -7406,6 +7406,7 @@ __metadata:
|
||||
"@testing-library/dom": ^8.0.0
|
||||
"@testing-library/jest-dom": ^5.10.1
|
||||
"@testing-library/react": ^12.1.3
|
||||
"@testing-library/react-hooks": ^8.0.1
|
||||
"@testing-library/user-event": ^14.0.0
|
||||
"@types/react": ^16.13.1 || ^17.0.0
|
||||
"@types/react-grid-layout": ^1.3.2
|
||||
|
||||
Reference in New Issue
Block a user