introduce new search-react package

Signed-off-by: Emma Indal <emma.indahl@gmail.com>
This commit is contained in:
Emma Indal
2022-04-11 11:29:56 +02:00
parent 06140486ac
commit c4baa24009
11 changed files with 695 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+1
View File
@@ -0,0 +1 @@
# search react
+64
View File
@@ -0,0 +1,64 @@
## API Report File for "@backstage/plugin-search-react"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ApiRef } from '@backstage/core-plugin-api';
import { AsyncState } from 'react-use/lib/useAsync';
import { ComponentProps } from 'react';
import { JsonObject } from '@backstage/types';
import { PropsWithChildren } from 'react';
import { default as React_2 } from 'react';
import { SearchQuery } from '@backstage/plugin-search-common';
import { SearchResultSet } from '@backstage/plugin-search-common';
// @public (undocumented)
export interface SearchApi {
// (undocumented)
query(query: SearchQuery): Promise<SearchResultSet>;
}
// Warning: (ae-forgotten-export) The symbol "QueryResultProps" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "SearchApiProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export function SearchApiProviderForStorybook(
props: PropsWithChildren<QueryResultProps>,
): JSX.Element;
// @public (undocumented)
export const searchApiRef: ApiRef<SearchApi>;
// Warning: (ae-forgotten-export) The symbol "SearchContextValue" needs to be exported by the entry point index.d.ts
//
// @public (undocumented)
export const SearchContext: React_2.Context<SearchContextValue | undefined>;
// @public (undocumented)
export const SearchContextProvider: ({
initialState,
children,
}: React_2.PropsWithChildren<{
initialState?: SearchContextState | undefined;
}>) => JSX.Element;
// Warning: (ae-missing-release-tag) "SearchContextProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export const SearchContextProviderForStorybook: (
props: ComponentProps<typeof SearchContextProvider> & QueryResultProps,
) => JSX.Element;
// @public
export type SearchContextState = {
term: string;
types: string[];
filters: JsonObject;
pageCursor?: string;
};
// @public (undocumented)
export const useSearch: () => SearchContextValue;
// (No @packageDocumentation comment for this package)
```
+53
View File
@@ -0,0 +1,53 @@
{
"name": "@backstage/plugin-search-react",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "web-library"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/search-react"
},
"keywords": [
"backstage"
],
"scripts": {
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
"clean": "backstage-cli package clean",
"start": "backstage-cli package start"
},
"dependencies": {
"@backstage/plugin-search-common": "^0.3.2",
"@backstage/core-plugin-api": "^1.0.0",
"@backstage/core-app-api": "^1.0.1-next.0",
"react-use": "^17.3.2",
"@backstage/types": "^1.0.0"
},
"peerDependencies": {
"@types/react": "^16.13.1 || ^17.0.0",
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/test-utils": "^1.0.0",
"@testing-library/react": "^13.0.0",
"@testing-library/react-hooks": "^8.0.0",
"@testing-library/jest-dom": "^5.16.4"
},
"files": [
"dist"
]
}
+32
View File
@@ -0,0 +1,32 @@
/*
* Copyright 2022 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 { SearchQuery, SearchResultSet } from '@backstage/plugin-search-common';
import { createApiRef } from '@backstage/core-plugin-api';
/**
* @public
*/
export const searchApiRef = createApiRef<SearchApi>({
id: 'plugin.search.queryservice',
});
/**
* @public
*/
export interface SearchApi {
query(query: SearchQuery): Promise<SearchResultSet>;
}
@@ -0,0 +1,287 @@
/*
* Copyright 2022 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 { useApi } from '@backstage/core-plugin-api';
import { render, screen, waitFor } from '@testing-library/react';
import { act, renderHook } from '@testing-library/react-hooks';
import React from 'react';
import { SearchContextProvider, useSearch } from './SearchContext';
jest.mock('@backstage/core-plugin-api', () => ({
...jest.requireActual('@backstage/core-plugin-api'),
useApi: jest.fn(),
}));
describe('SearchContext', () => {
const query = jest.fn();
const wrapper = ({ children, initialState }: any) => (
<SearchContextProvider initialState={initialState}>
{children}
</SearchContextProvider>
);
const initialState = {
term: '',
filters: {},
types: ['*'],
};
beforeEach(() => {
query.mockResolvedValue({});
(useApi as jest.Mock).mockReturnValue({ query: query });
});
afterAll(() => {
jest.resetAllMocks();
});
it('Passes children', async () => {
const text = 'text';
render(
<SearchContextProvider initialState={initialState}>
{text}
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(text)).toBeInTheDocument();
});
});
it('Throws error when no context is set', () => {
const { result } = renderHook(() => useSearch());
expect(result.error).toEqual(
Error('useSearch must be used within a SearchContextProvider'),
);
});
it('Uses initial state values', async () => {
const { result, waitForNextUpdate } = renderHook(() => useSearch(), {
wrapper,
initialProps: {
initialState,
},
});
await waitForNextUpdate();
expect(result.current).toEqual(expect.objectContaining(initialState));
});
it('Resets cursor when term is set (and different from previous)', async () => {
const { result, waitForNextUpdate } = renderHook(() => useSearch(), {
wrapper,
initialProps: {
initialState: {
...initialState,
pageCursor: 'SOMEPAGE',
},
},
});
await waitForNextUpdate();
expect(result.current.pageCursor).toEqual('SOMEPAGE');
act(() => {
result.current.setTerm('first term');
});
act(() => {
result.current.setPageCursor('OTHERPAGE');
});
await waitForNextUpdate();
expect(result.current.pageCursor).toEqual('OTHERPAGE');
act(() => {
result.current.setTerm('second term');
});
await waitForNextUpdate();
expect(result.current.pageCursor).toEqual(undefined);
});
describe('Performs search (and sets results)', () => {
it('When term is set', async () => {
const { result, waitForNextUpdate } = renderHook(() => useSearch(), {
wrapper,
initialProps: {
initialState,
},
});
await waitForNextUpdate();
const term = 'term';
act(() => {
result.current.setTerm(term);
});
await waitForNextUpdate();
expect(query).toHaveBeenLastCalledWith({
filters: {},
types: ['*'],
term,
});
});
it('When filters are set', async () => {
const { result, waitForNextUpdate } = renderHook(() => useSearch(), {
wrapper,
initialProps: {
initialState,
},
});
await waitForNextUpdate();
const filters = { filter: 'filter' };
act(() => {
result.current.setFilters(filters);
});
await waitForNextUpdate();
expect(query).toHaveBeenLastCalledWith({
filters,
types: ['*'],
term: '',
});
});
it('When page is set', async () => {
const { result, waitForNextUpdate } = renderHook(() => useSearch(), {
wrapper,
initialProps: {
initialState,
},
});
await waitForNextUpdate();
act(() => {
result.current.setPageCursor('SOMEPAGE');
});
await waitForNextUpdate();
expect(query).toHaveBeenLastCalledWith({
filters: {},
types: ['*'],
pageCursor: 'SOMEPAGE',
term: '',
});
});
it('When types is set', async () => {
const { result, waitForNextUpdate } = renderHook(() => useSearch(), {
wrapper,
initialProps: {
initialState,
},
});
await waitForNextUpdate();
const types = ['type'];
act(() => {
result.current.setTypes(types);
});
await waitForNextUpdate();
expect(query).toHaveBeenLastCalledWith({
types,
filters: {},
term: '',
});
});
it('provides function for fetch the next page', async () => {
query.mockResolvedValue({
results: [],
nextPageCursor: 'NEXT',
});
const { result, waitForNextUpdate } = renderHook(() => useSearch(), {
wrapper,
initialProps: {
initialState,
},
});
await waitForNextUpdate();
expect(result.current.fetchNextPage).toBeDefined();
expect(result.current.fetchPreviousPage).toBeUndefined();
act(() => {
result.current.fetchNextPage!();
});
await waitForNextUpdate();
expect(query).toHaveBeenLastCalledWith({
types: ['*'],
filters: {},
term: '',
pageCursor: 'NEXT',
});
});
it('provides function for fetch the previous page', async () => {
query.mockResolvedValue({
results: [],
previousPageCursor: 'PREVIOUS',
});
const { result, waitForNextUpdate } = renderHook(() => useSearch(), {
wrapper,
initialProps: {
initialState,
},
});
await waitForNextUpdate();
expect(result.current.fetchNextPage).toBeUndefined();
expect(result.current.fetchPreviousPage).toBeDefined();
act(() => {
result.current.fetchPreviousPage!();
});
await waitForNextUpdate();
expect(query).toHaveBeenLastCalledWith({
types: ['*'],
filters: {},
term: '',
pageCursor: 'PREVIOUS',
});
});
});
});
@@ -0,0 +1,144 @@
/*
* Copyright 2022 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 { JsonObject } from '@backstage/types';
import { useApi, AnalyticsContext } from '@backstage/core-plugin-api';
import { SearchResultSet } from '@backstage/plugin-search-common';
import React, {
createContext,
PropsWithChildren,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import useAsync, { AsyncState } from 'react-use/lib/useAsync';
import usePrevious from 'react-use/lib/usePrevious';
import { searchApiRef } from '../api';
type SearchContextValue = {
result: AsyncState<SearchResultSet>;
setTerm: React.Dispatch<React.SetStateAction<string>>;
setTypes: React.Dispatch<React.SetStateAction<string[]>>;
setFilters: React.Dispatch<React.SetStateAction<JsonObject>>;
setPageCursor: React.Dispatch<React.SetStateAction<string | undefined>>;
fetchNextPage?: React.DispatchWithoutAction;
fetchPreviousPage?: React.DispatchWithoutAction;
} & SearchContextState;
/**
* The initial state of `SearchContextProvider`.
*
* @public
*/
export type SearchContextState = {
term: string;
types: string[];
filters: JsonObject;
pageCursor?: string;
};
/**
* @public
*/
export const SearchContext = createContext<SearchContextValue | undefined>(
undefined,
);
const searchInitialState: SearchContextState = {
term: '',
pageCursor: undefined,
filters: {},
types: [],
};
/**
* @public
*/
export const SearchContextProvider = ({
initialState = searchInitialState,
children,
}: PropsWithChildren<{ initialState?: SearchContextState }>) => {
const searchApi = useApi(searchApiRef);
const [pageCursor, setPageCursor] = useState<string | undefined>(
initialState.pageCursor,
);
const [filters, setFilters] = useState<JsonObject>(initialState.filters);
const [term, setTerm] = useState<string>(initialState.term);
const [types, setTypes] = useState<string[]>(initialState.types);
const prevTerm = usePrevious(term);
const result = useAsync(
() =>
searchApi.query({
term,
filters,
pageCursor,
types,
}),
[term, filters, types, pageCursor],
);
const hasNextPage =
!result.loading && !result.error && result.value?.nextPageCursor;
const hasPreviousPage =
!result.loading && !result.error && result.value?.previousPageCursor;
const fetchNextPage = useCallback(() => {
setPageCursor(result.value?.nextPageCursor);
}, [result.value?.nextPageCursor]);
const fetchPreviousPage = useCallback(() => {
setPageCursor(result.value?.previousPageCursor);
}, [result.value?.previousPageCursor]);
useEffect(() => {
// Any time a term is reset, we want to start from page 0.
if (term && prevTerm && term !== prevTerm) {
setPageCursor(undefined);
}
}, [term, prevTerm, initialState.pageCursor]);
const value: SearchContextValue = {
result,
filters,
setFilters,
term,
setTerm,
types,
setTypes,
pageCursor,
setPageCursor,
fetchNextPage: hasNextPage ? fetchNextPage : undefined,
fetchPreviousPage: hasPreviousPage ? fetchPreviousPage : undefined,
};
return (
<AnalyticsContext attributes={{ searchTypes: types.sort().join(',') }}>
<SearchContext.Provider value={value} children={children} />
</AnalyticsContext>
);
};
/**
* @public
*/
export const useSearch = () => {
const context = useContext(SearchContext);
if (context === undefined) {
throw new Error('useSearch must be used within a SearchContextProvider');
}
return context;
};
@@ -0,0 +1,52 @@
/*
* Copyright 2022 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 { ApiProvider } from '@backstage/core-app-api';
import { SearchResultSet } from '@backstage/plugin-search-common';
import { TestApiRegistry } from '@backstage/test-utils';
import React, { ComponentProps, PropsWithChildren } from 'react';
import { searchApiRef } from '../api';
import { SearchContextProvider as RealSearchContextProvider } from './SearchContext';
type QueryResultProps = {
mockedResults?: SearchResultSet;
};
/**
* Utility context provider only for use in Storybook stories. You should use
* the real `<SearchContextProvider>` exported by `@backstage/plugin-search-react` in
* your app instead of this! In some cases (like the search page) it may
* already be provided on your behalf.
*/
export const SearchContextProvider = (
props: ComponentProps<typeof RealSearchContextProvider> & QueryResultProps,
) => {
return (
<SearchApiProvider {...props}>
<RealSearchContextProvider children={props.children} />
</SearchApiProvider>
);
};
/**
* Utility api provider only for use in Storybook stories.
*
*/
export function SearchApiProvider(props: PropsWithChildren<QueryResultProps>) {
const { mockedResults, children } = props;
const query: any = () => Promise.resolve(mockedResults || {});
const apiRegistry = TestApiRegistry.from([searchApiRef, { query }]);
return <ApiProvider apis={apiRegistry} children={children} />;
}
@@ -0,0 +1,26 @@
/*
* Copyright 2022 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.
*/
export {
SearchContextProvider,
SearchContext,
useSearch,
} from './SearchContext';
export type { SearchContextState } from './SearchContext';
export {
SearchContextProvider as SearchContextProviderForStorybook,
SearchApiProvider as SearchApiProviderForStorybook,
} from './SearchContextForStorybook.stories';
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2021 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.
*/
export * from './api';
export * from './context';
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2021 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 '@testing-library/jest-dom';