From 2a00470ad9ec197408990b674dd3e691d6e76a57 Mon Sep 17 00:00:00 2001 From: David Goss Date: Mon, 5 May 2025 13:42:03 +0100 Subject: [PATCH 1/3] Refactor search context --- src/SearchContext.ts | 17 +++ src/SearchQueryContext.spec.ts | 141 ------------------ src/SearchQueryContext.ts | 117 --------------- .../app/ControlledSearchProvider.tsx | 25 ++++ src/components/app/FilteredResults.spec.tsx | 12 +- .../app/FilteredResults.stories.tsx | 6 +- src/components/app/HighLight.tsx | 4 +- src/components/app/Highlight.spec.tsx | 6 +- .../app/InMemorySearchProvider.spec.tsx | 86 +++++++++++ src/components/app/InMemorySearchProvider.tsx | 26 ++++ src/components/app/SearchBar.spec.tsx | 131 +++++++--------- src/components/app/SearchWrapper.spec.tsx | 66 -------- src/components/app/SearchWrapper.tsx | 17 --- src/components/app/UrlSearchProvider.spec.tsx | 91 +++++++++++ src/components/app/UrlSearchProvider.tsx | 44 ++++++ src/components/app/index.ts | 4 +- src/hooks/useSearch.ts | 4 +- src/index.ts | 17 +-- 18 files changed, 362 insertions(+), 452 deletions(-) create mode 100644 src/SearchContext.ts delete mode 100644 src/SearchQueryContext.spec.ts delete mode 100644 src/SearchQueryContext.ts create mode 100644 src/components/app/ControlledSearchProvider.tsx create mode 100644 src/components/app/InMemorySearchProvider.spec.tsx create mode 100644 src/components/app/InMemorySearchProvider.tsx delete mode 100644 src/components/app/SearchWrapper.spec.tsx delete mode 100644 src/components/app/SearchWrapper.tsx create mode 100644 src/components/app/UrlSearchProvider.spec.tsx create mode 100644 src/components/app/UrlSearchProvider.tsx diff --git a/src/SearchContext.ts b/src/SearchContext.ts new file mode 100644 index 00000000..380dc042 --- /dev/null +++ b/src/SearchContext.ts @@ -0,0 +1,17 @@ +import { TestStepResultStatus as Status } from '@cucumber/messages' +import React from 'react' + +export interface SearchState { + readonly query: string + readonly hideStatuses: readonly Status[] +} + +export interface SearchContextValue extends SearchState { + update: (changes: Partial) => void +} + +export default React.createContext({ + query: '', + hideStatuses: [], + update: () => {}, +}) diff --git a/src/SearchQueryContext.spec.ts b/src/SearchQueryContext.spec.ts deleted file mode 100644 index ab3161d9..00000000 --- a/src/SearchQueryContext.spec.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { TestStepResultStatus as Status } from '@cucumber/messages' -import { expect } from 'chai' -import sinon from 'sinon' - -import { searchFromURLParams, SearchQueryCtx } from './SearchQueryContext.js' - -describe('SearchQueryCtx', () => { - it('uses the given values in its initial value', () => { - const sq = SearchQueryCtx.withDefaults({ - query: 'foo bar', - hideStatuses: [Status.PASSED], - }) - - expect(sq.query).to.eq('foo bar') - expect(sq.hideStatuses).to.deep.eq([Status.PASSED]) - }) - - it('has a blank initial query by default', () => { - const sq = SearchQueryCtx.withDefaults({}) - - expect(sq.query).to.eq('') - }) - - it('hides no statuses by default', () => { - const sq = SearchQueryCtx.withDefaults({}) - - expect(sq.hideStatuses).to.deep.eq([]) - }) - - it('does not change its value on update by default', () => { - const sq = SearchQueryCtx.withDefaults({ query: 'foo' }) - - sq.update({ - query: 'bar', - hideStatuses: [Status.PASSED], - }) - - expect(sq.query).to.eq('foo') - expect(sq.hideStatuses).to.deep.eq([]) - }) - - it('notifies its listener when the query is updated', () => { - const onSearchQueryUpdated = sinon.fake() - - const sq = SearchQueryCtx.withDefaults( - { query: 'bar', hideStatuses: [Status.FAILED] }, - onSearchQueryUpdated - ) - - sq.update({ query: 'foo' }) - - expect(onSearchQueryUpdated).to.have.been.calledWith({ - query: 'foo', - hideStatuses: [Status.FAILED], - }) - }) - - it('notifies its listener when the filters are updated', () => { - const onSearchQueryUpdated = sinon.fake() - - const sq = SearchQueryCtx.withDefaults({}, onSearchQueryUpdated) - - sq.update({ hideStatuses: [Status.PENDING] }) - - expect(onSearchQueryUpdated).to.have.been.calledWith({ - query: '', - hideStatuses: [Status.PENDING], - }) - }) - - it("notifies its listener when it's updated with blank values", () => { - const onSearchQueryUpdated = sinon.fake() - - const sq = SearchQueryCtx.withDefaults( - { query: 'foo', hideStatuses: [Status.FAILED] }, - onSearchQueryUpdated - ) - - sq.update({ query: '', hideStatuses: [] }) - - expect(onSearchQueryUpdated).to.have.been.calledWith({ - query: '', - hideStatuses: [], - }) - }) -}) - -describe('searchFromURLParams()', () => { - it('uses the search parameters from the given URL as its initial value', () => { - const ret = searchFromURLParams({ - querySearchParam: 'foo', - hideStatusesSearchParam: 'bar', - windowUrlApi: { - getURL: () => 'http://example.org/?foo=search%20text&bar=PASSED&bar=FAILED', - setURL: () => { - // Do nothing - }, - }, - }) - - expect(ret.query).to.eq('search text') - expect(ret.hideStatuses).to.deep.eq([Status.PASSED, Status.FAILED]) - }) - - it('uses null values when no search parameters are present', () => { - const ret = searchFromURLParams({ - querySearchParam: 'search', - hideStatusesSearchParam: 'hide', - windowUrlApi: { - getURL: () => 'http://example.org', - setURL: () => { - // Do nothing - }, - }, - }) - - expect(ret.query).to.eq(null) - expect(ret.hideStatuses).to.deep.eq([]) - }) - - it('creates an update function that adds parameters to the given URL', () => { - const setURL = sinon.fake() - const ret = searchFromURLParams({ - querySearchParam: 'foo', - hideStatusesSearchParam: 'bar', - windowUrlApi: { - getURL: () => 'http://example.org/?foo=search%20text&baz=sausage', - setURL, - }, - }) - - ret.onSearchQueryUpdated?.({ - query: '@slow', - hideStatuses: [Status.FAILED, Status.PENDING], - }) - - expect(setURL).to.have.been.calledWith( - 'http://example.org/?foo=%40slow&baz=sausage&bar=FAILED&bar=PENDING' - ) - }) -}) diff --git a/src/SearchQueryContext.ts b/src/SearchQueryContext.ts deleted file mode 100644 index bf24bf3e..00000000 --- a/src/SearchQueryContext.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { TestStepResultStatus as Status } from '@cucumber/messages' -import React, { useState } from 'react' - -const defaultQuerySearchParam = 'search' -const defaultHideStatusesSearchParam = 'hide' - -const defaultQuery = '' -const defaultHideStatuses: Status[] = [] - -export interface SearchQueryUpdate { - readonly query?: string | null - readonly hideStatuses?: readonly Status[] -} - -export interface SearchQuery { - readonly query: string - readonly hideStatuses: readonly Status[] -} - -export type SearchQueryUpdateFn = (query: SearchQuery) => void - -export interface SearchQueryProps extends SearchQueryUpdate { - onSearchQueryUpdated?: SearchQueryUpdateFn -} - -export interface WindowUrlApi { - getURL: () => string - setURL: (url: string) => void -} - -const defaultWindowUrlApi: WindowUrlApi = { - getURL: () => window.location.toString(), - setURL: (url) => window.history.replaceState({}, '', url), -} - -export function searchFromURLParams(opts?: { - querySearchParam?: string - hideStatusesSearchParam?: string - windowUrlApi?: WindowUrlApi -}): SearchQueryProps { - const querySearchParam = opts?.querySearchParam ?? defaultQuerySearchParam - const hideStatusesSearchParam = opts?.hideStatusesSearchParam ?? defaultHideStatusesSearchParam - const windowUrlApi = opts?.windowUrlApi ?? defaultWindowUrlApi - - function onSearchQueryUpdated(query: SearchQuery): void { - const url = new URL(windowUrlApi.getURL()) - - if (query.query !== defaultQuery) { - url.searchParams.set(querySearchParam, query.query) - } else { - url.searchParams.delete(querySearchParam) - } - url.searchParams.delete(hideStatusesSearchParam) - query.hideStatuses.forEach((s) => url.searchParams.append(hideStatusesSearchParam, s)) - - windowUrlApi.setURL(url.toString()) - } - - const url = new URL(windowUrlApi.getURL()) - - return { - query: url.searchParams.get(querySearchParam), - hideStatuses: url.searchParams - .getAll(hideStatusesSearchParam) - .filter((s) => Object.values(Status).includes(s as Status)) - .map((s) => Status[s as keyof typeof Status]), - onSearchQueryUpdated, - } -} - -function toSearchQuery(iQuery?: SearchQueryUpdate): SearchQuery { - return { - query: iQuery?.query ?? defaultQuery, - hideStatuses: iQuery?.hideStatuses ?? defaultHideStatuses, - } -} - -export class SearchQueryCtx implements SearchQuery { - readonly query: string - readonly hideStatuses: readonly Status[] - readonly update: (query: SearchQueryUpdate) => void - - constructor(value: SearchQuery, updateValue: SearchQueryUpdateFn) { - this.query = value.query - this.hideStatuses = value.hideStatuses - this.update = (values: SearchQueryUpdate) => { - updateValue({ - query: values.query ?? this.query, - hideStatuses: values.hideStatuses ?? this.hideStatuses, - }) - } - } - - static withDefaults = ( - value: SearchQueryUpdate = {}, - onSearchQueryUpdated: SearchQueryUpdateFn = () => { - //Do nothing - } - ) => { - return new SearchQueryCtx(toSearchQuery(value), onSearchQueryUpdated) - } -} - -export function useSearchQueryCtx(props: SearchQueryProps): SearchQueryCtx { - const [searchQuery, setSearchQuery] = useState(toSearchQuery(props)) - return new SearchQueryCtx( - searchQuery, - props.onSearchQueryUpdated - ? (query) => { - props.onSearchQueryUpdated && props.onSearchQueryUpdated(query) - setSearchQuery(query) - } - : setSearchQuery - ) -} - -export default React.createContext(SearchQueryCtx.withDefaults({})) diff --git a/src/components/app/ControlledSearchProvider.tsx b/src/components/app/ControlledSearchProvider.tsx new file mode 100644 index 00000000..d52c353d --- /dev/null +++ b/src/components/app/ControlledSearchProvider.tsx @@ -0,0 +1,25 @@ +import React, { FC, PropsWithChildren, useMemo } from 'react' + +import SearchQueryContext, { SearchContextValue, SearchState } from '../../SearchContext.js' + +interface Props { + value: SearchState + onChange: (newValue: SearchState) => void +} + +export const ControlledSearchProvider: FC> = ({ + value, + onChange, + children, +}) => { + const contextValue: SearchContextValue = useMemo(() => { + return { + query: value.query, + hideStatuses: value.hideStatuses, + update: (newValues: Partial) => { + onChange({ ...value, ...newValues }) + }, + } + }, [value, onChange]) + return {children} +} diff --git a/src/components/app/FilteredResults.spec.tsx b/src/components/app/FilteredResults.spec.tsx index e3a3f5df..3884b1e9 100644 --- a/src/components/app/FilteredResults.spec.tsx +++ b/src/components/app/FilteredResults.spec.tsx @@ -2,25 +2,23 @@ import { Envelope } from '@cucumber/messages' import { render, waitFor } from '@testing-library/react' import { userEvent } from '@testing-library/user-event' import { expect } from 'chai' -import React, { VoidFunctionComponent } from 'react' +import React, { FC } from 'react' import attachments from '../../../acceptance/attachments/attachments.feature.js' import examplesTables from '../../../acceptance/examples-tables/examples-tables.feature.js' import minimal from '../../../acceptance/minimal/minimal.feature.js' import targetedRun from '../../../samples/targeted-run.js' -import SearchQueryContext, { useSearchQueryCtx } from '../../SearchQueryContext.js' import { EnvelopesWrapper } from './EnvelopesWrapper.js' import { FilteredResults } from './FilteredResults.js' +import { InMemorySearchProvider } from './InMemorySearchProvider.js' describe('FilteredResults', () => { - const TestableFilteredResults: VoidFunctionComponent<{ envelopes: Envelope[] }> = ({ - envelopes, - }) => { + const TestableFilteredResults: FC<{ envelopes: Envelope[] }> = ({ envelopes }) => { return ( - + - + ) } diff --git a/src/components/app/FilteredResults.stories.tsx b/src/components/app/FilteredResults.stories.tsx index 4de9307f..5db60a4e 100644 --- a/src/components/app/FilteredResults.stories.tsx +++ b/src/components/app/FilteredResults.stories.tsx @@ -6,7 +6,7 @@ import testData from '../../../acceptance/examples-tables/examples-tables.featur import targetedRun from '../../../samples/targeted-run.js' import { EnvelopesWrapper } from './EnvelopesWrapper.js' import { FilteredResults } from './FilteredResults.js' -import { SearchWrapper } from './SearchWrapper.js' +import { InMemorySearchProvider } from './InMemorySearchProvider.js' export default { title: 'App/FilteredResults', @@ -19,9 +19,9 @@ type TemplateArgs = { const Template: Story = ({ envelopes }) => { return ( - + - + ) } diff --git a/src/components/app/HighLight.tsx b/src/components/app/HighLight.tsx index dbb10887..c3f73cf6 100644 --- a/src/components/app/HighLight.tsx +++ b/src/components/app/HighLight.tsx @@ -3,7 +3,7 @@ import highlightWords from 'highlight-words' import React from 'react' import ReactMarkdown from 'react-markdown' -import SearchQueryContext from '../../SearchQueryContext.js' +import { useSearch } from '../../hooks/index.js' import rehypePlugins from './rehypePlugins.js' import remarkPlugins from './remarkPlugins.js' @@ -30,7 +30,7 @@ export const HighLight: React.FunctionComponent = ({ markdown = false, className = '', }) => { - const searchQueryContext = React.useContext(SearchQueryContext) + const searchQueryContext = useSearch() const query = allQueryWords( searchQueryContext.query ? searchQueryContext.query.split(' ') : [] ).join(' ') diff --git a/src/components/app/Highlight.spec.tsx b/src/components/app/Highlight.spec.tsx index 064c7cfa..7e2ca207 100644 --- a/src/components/app/Highlight.spec.tsx +++ b/src/components/app/Highlight.spec.tsx @@ -2,15 +2,15 @@ import { render } from '@testing-library/react' import { expect } from 'chai' import React from 'react' -import SearchQueryContext, { SearchQueryCtx } from '../../SearchQueryContext.js' import { HighLight } from './HighLight.js' +import { InMemorySearchProvider } from './InMemorySearchProvider.js' describe('HighLight', () => { function renderHighlight(text: string, query: string, markdown: boolean) { return render( - + - + ) } diff --git a/src/components/app/InMemorySearchProvider.spec.tsx b/src/components/app/InMemorySearchProvider.spec.tsx new file mode 100644 index 00000000..1650976f --- /dev/null +++ b/src/components/app/InMemorySearchProvider.spec.tsx @@ -0,0 +1,86 @@ +import { TestStepResultStatus } from '@cucumber/messages' +import { act, render } from '@testing-library/react' +import { expect } from 'chai' +import React from 'react' +import sinon, { SinonSpy } from 'sinon' + +import SearchQueryContext, { SearchContextValue } from '../../SearchContext.js' +import { InMemorySearchProvider } from './InMemorySearchProvider.js' + +describe('', () => { + function renderAndCapture({ + defaultQuery, + defaultHideStatuses, + }: { + defaultQuery?: string + defaultHideStatuses?: readonly TestStepResultStatus[] + } = {}): SinonSpy { + const capture = sinon.fake() + render( + + + {(value) => { + capture(value) + return
+ }} + + + ) + return capture + } + + it('initialises with noop values', () => { + const capture = renderAndCapture() + expect(capture.firstCall.firstArg.query).to.deep.eq('') + expect(capture.firstCall.firstArg.hideStatuses).to.deep.eq([]) + }) + + it('initialises with defaults', () => { + const capture = renderAndCapture({ + defaultQuery: 'bar', + defaultHideStatuses: [TestStepResultStatus.PASSED], + }) + expect(capture.firstCall.firstArg.query).to.deep.eq('bar') + expect(capture.firstCall.firstArg.hideStatuses).to.deep.eq([TestStepResultStatus.PASSED]) + }) + + it('updates both values', async () => { + const capture = renderAndCapture() + + await act(() => { + capture.firstCall.firstArg.update({ + query: 'foo', + hideStatuses: [TestStepResultStatus.PASSED], + }) + }) + + expect(capture.lastCall.firstArg.query).to.eq('foo') + expect(capture.lastCall.firstArg.hideStatuses).to.deep.eq([TestStepResultStatus.PASSED]) + }) + + it('updates just the query', async () => { + const capture = renderAndCapture() + + await act(() => { + capture.firstCall.firstArg.update({ + query: 'foo', + }) + }) + + expect(capture.lastCall.firstArg.query).to.eq('foo') + expect(capture.lastCall.firstArg.hideStatuses).to.deep.eq([]) + }) + + it('updates just the hideStatuses', async () => { + const capture = renderAndCapture() + + await act(() => { + capture.firstCall.firstArg.update({ + hideStatuses: [TestStepResultStatus.PASSED], + }) + }) + + expect(capture.lastCall.firstArg.query).to.eq('') + expect(capture.lastCall.firstArg.hideStatuses).to.deep.eq([TestStepResultStatus.PASSED]) + }) +}) diff --git a/src/components/app/InMemorySearchProvider.tsx b/src/components/app/InMemorySearchProvider.tsx new file mode 100644 index 00000000..b011fa7e --- /dev/null +++ b/src/components/app/InMemorySearchProvider.tsx @@ -0,0 +1,26 @@ +import { TestStepResultStatus } from '@cucumber/messages' +import React, { FC, PropsWithChildren, useState } from 'react' + +import { SearchState } from '../../SearchContext.js' +import { ControlledSearchProvider } from './ControlledSearchProvider.js' + +interface Props { + defaultQuery?: string + defaultHideStatuses?: readonly TestStepResultStatus[] +} + +export const InMemorySearchProvider: FC> = ({ + defaultQuery = '', + defaultHideStatuses = [], + children, +}) => { + const [value, setValue] = useState({ + query: defaultQuery, + hideStatuses: defaultHideStatuses, + }) + return ( + + {children} + + ) +} diff --git a/src/components/app/SearchBar.spec.tsx b/src/components/app/SearchBar.spec.tsx index 6d40ed28..474aea23 100644 --- a/src/components/app/SearchBar.spec.tsx +++ b/src/components/app/SearchBar.spec.tsx @@ -2,63 +2,67 @@ import { TestStepResultStatus } from '@cucumber/messages' import { render } from '@testing-library/react' import { userEvent } from '@testing-library/user-event' import { expect } from 'chai' -import React from 'react' +import React, { FC, useState } from 'react' import sinon from 'sinon' import examplesTablesFeature from '../../../acceptance/examples-tables/examples-tables.feature.js' import minimalFeature from '../../../acceptance/minimal/minimal.feature.js' -import SearchQueryContext, { SearchQueryCtx } from '../../SearchQueryContext.js' +import { SearchState } from '../../SearchContext.js' +import { ControlledSearchProvider } from './ControlledSearchProvider.js' import { EnvelopesWrapper } from './EnvelopesWrapper.js' import { SearchBar } from './SearchBar.js' +const TestableSearchBar: FC<{ + defaultValue?: SearchState + onChange?: (value: SearchState) => void +}> = ({ defaultValue = { query: '', hideStatuses: [] }, onChange = () => {} }) => { + const [value, setValue] = useState(defaultValue) + return ( + { + setValue(newValue) + onChange(newValue) + }} + > + + + ) +} + describe('SearchBar', () => { describe('searching', () => { it('puts the current query as the initial search text', () => { - const searchQueryContext = SearchQueryCtx.withDefaults({ - query: 'keyword', - }) const { getByRole } = render( - - - + ) expect(getByRole('textbox', { name: 'Search' })).to.have.value('keyword') }) it('updates the search context after half a second when the user types a query', async () => { - const onUpdate = sinon.fake() - const searchQueryContext = SearchQueryCtx.withDefaults({}, onUpdate) - const { getByRole } = render( - - - - ) + const onChange = sinon.fake() + const { getByRole } = render() await userEvent.type(getByRole('textbox', { name: 'Search' }), 'search text') - expect(onUpdate).not.to.have.been.called + expect(onChange).not.to.have.been.called await new Promise((resolve) => setTimeout(resolve, 500)) - expect(onUpdate).to.have.been.calledOnceWithExactly({ + expect(onChange).to.have.been.calledOnceWithExactly({ query: 'search text', hideStatuses: [], }) }) it('updates the search context with the query when the form is submitted', async () => { - const onUpdate = sinon.fake() - const searchQueryContext = SearchQueryCtx.withDefaults({}, onUpdate) - const { getByRole } = render( - - - - ) + const onChange = sinon.fake() + const { getByRole } = render() await userEvent.clear(getByRole('textbox', { name: 'Search' })) await userEvent.type(getByRole('textbox', { name: 'Search' }), 'search text') await userEvent.keyboard('{Enter}') - expect(onUpdate).to.have.been.calledOnceWithExactly({ + expect(onChange).to.have.been.calledOnceWithExactly({ query: 'search text', hideStatuses: [], }) @@ -66,12 +70,7 @@ describe('SearchBar', () => { it("doesn't perform the default form action when submitting", async () => { const eventListener = sinon.fake() - const searchQueryContext = SearchQueryCtx.withDefaults() - const { getByRole, baseElement } = render( - - - - ) + const { getByRole, baseElement } = render() baseElement.ownerDocument.addEventListener('submit', eventListener) @@ -83,34 +82,26 @@ describe('SearchBar', () => { }) it('updates the search context with empty string when empty search is submitted', async () => { - const onUpdate = sinon.fake() - const searchQueryContext = SearchQueryCtx.withDefaults( - { - query: 'keyword', - }, - onUpdate - ) + const onChange = sinon.fake() const { getByRole } = render( - - - + ) await userEvent.clear(getByRole('textbox', { name: 'Search' })) await userEvent.keyboard('{Enter}') - expect(onUpdate).to.have.been.calledOnceWith({ query: '', hideStatuses: [] }) + expect(onChange).to.have.been.calledOnceWith({ query: '', hideStatuses: [] }) }) }) describe('filtering by status', () => { it('should not show status filters when no statuses', () => { - const searchQueryContext = SearchQueryCtx.withDefaults() const { queryByRole } = render( - - - + ) @@ -118,12 +109,9 @@ describe('SearchBar', () => { }) it('should not show status filters when just one status', () => { - const searchQueryContext = SearchQueryCtx.withDefaults() const { queryByRole } = render( - - - + ) @@ -131,12 +119,9 @@ describe('SearchBar', () => { }) it('should show named status filters, all checked by default, when multiple statuses', () => { - const searchQueryContext = SearchQueryCtx.withDefaults() const { getAllByRole, getByRole } = render( - - - + ) @@ -150,33 +135,27 @@ describe('SearchBar', () => { }) it('updates the search context with a hidden status when unchecked', async () => { - const onUpdate = sinon.fake() - const searchQueryContext = SearchQueryCtx.withDefaults({}, onUpdate) + const onChange = sinon.fake() const { getByRole } = render( - - - + ) await userEvent.click(getByRole('checkbox', { name: 'undefined 2' })) - expect(onUpdate).to.have.been.calledOnceWithExactly({ + expect(onChange).to.have.been.calledOnceWithExactly({ query: '', hideStatuses: [TestStepResultStatus.UNDEFINED], }) }) it('should show hidden statuses as unchecked', () => { - const searchQueryContext = SearchQueryCtx.withDefaults({ - hideStatuses: [TestStepResultStatus.UNDEFINED], - }) const { getByRole } = render( - - - + ) @@ -186,24 +165,22 @@ describe('SearchBar', () => { }) it('updates the search context when a status is rechecked', async () => { - const onUpdate = sinon.fake() - const searchQueryContext = SearchQueryCtx.withDefaults( - { - hideStatuses: [TestStepResultStatus.FAILED, TestStepResultStatus.UNDEFINED], - }, - onUpdate - ) + const onChange = sinon.fake() const { getByRole } = render( - - - + ) await userEvent.click(getByRole('checkbox', { name: 'failed 2' })) - expect(onUpdate).to.have.been.calledOnceWithExactly({ + expect(onChange).to.have.been.calledOnceWithExactly({ query: '', hideStatuses: [TestStepResultStatus.UNDEFINED], }) diff --git a/src/components/app/SearchWrapper.spec.tsx b/src/components/app/SearchWrapper.spec.tsx deleted file mode 100644 index 9e409420..00000000 --- a/src/components/app/SearchWrapper.spec.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { act, render } from '@testing-library/react' -import { expect } from 'chai' -import React from 'react' -import sinon, { SinonSpy } from 'sinon' - -import SearchQueryContext, { SearchQueryCtx, SearchQueryProps } from '../../SearchQueryContext.js' -import { SearchWrapper } from './SearchWrapper.js' - -describe('SearchWrapper', () => { - function renderSearchWrapper(opts?: SearchQueryProps): ReturnType & { - searchQueryCapture: SinonSpy - } { - const searchQueryCapture = sinon.fake() - const app = render( - - - {(sq) => { - searchQueryCapture(sq) - return
- }} - - - ) - return { - ...app, - searchQueryCapture, - } - } - - it('creates a search context given no query prop', () => { - const { searchQueryCapture } = renderSearchWrapper() - - expect(searchQueryCapture).to.have.been.calledOnce - expect(searchQueryCapture.firstCall.firstArg.query).to.eq('') - - const sq1 = searchQueryCapture.firstCall.firstArg - searchQueryCapture.resetHistory() - - act(() => { - // When the query is updated - sq1.update({ query: 'foo' }) - }) - - // Then... - expect(searchQueryCapture).to.have.been.calledOnce - expect(searchQueryCapture.firstCall.firstArg.query).to.eq('foo') - }) - - it('creates a search context given a string query prop', () => { - const { searchQueryCapture } = renderSearchWrapper({ query: 'foo' }) - - const sq1 = searchQueryCapture.firstCall.firstArg - searchQueryCapture.resetHistory() - - expect(sq1.query).to.eq('foo') - - act(() => { - // When the query is updated - sq1.update({ query: 'bar' }) - }) - - // Then... - expect(searchQueryCapture).to.have.been.calledOnce - expect(searchQueryCapture.firstCall.firstArg.query).to.eq('bar') - }) -}) diff --git a/src/components/app/SearchWrapper.tsx b/src/components/app/SearchWrapper.tsx deleted file mode 100644 index 2ea7d465..00000000 --- a/src/components/app/SearchWrapper.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React, { FunctionComponent, PropsWithChildren } from 'react' - -import SearchQueryContext, { - SearchQueryProps, - useSearchQueryCtx, -} from '../../SearchQueryContext.js' - -export const SearchWrapper: FunctionComponent> = ({ - children, - ...searchProps -}) => { - return ( - - {children} - - ) -} diff --git a/src/components/app/UrlSearchProvider.spec.tsx b/src/components/app/UrlSearchProvider.spec.tsx new file mode 100644 index 00000000..54ef5505 --- /dev/null +++ b/src/components/app/UrlSearchProvider.spec.tsx @@ -0,0 +1,91 @@ +import { TestStepResultStatus } from '@cucumber/messages' +import { act, render } from '@testing-library/react' +import { expect } from 'chai' +import React from 'react' +import sinon, { SinonSpy } from 'sinon' + +import SearchQueryContext, { SearchContextValue } from '../../SearchContext.js' +import { UrlSearchProvider } from './UrlSearchProvider.js' + +describe('', () => { + function renderAndCapture(): SinonSpy { + const capture = sinon.fake() + render( + + + {(value) => { + capture(value) + return
+ }} + + + ) + return capture + } + + beforeEach(() => { + window.history.replaceState({}, '', '/stuff') + }) + + it('initialises with no query string', () => { + const capture = renderAndCapture() + expect(capture.firstCall.firstArg.query).to.deep.eq('') + expect(capture.firstCall.firstArg.hideStatuses).to.deep.eq([]) + }) + + it('initialises from query string', () => { + window.history.replaceState({}, '', '/stuff?query=bar&hide=passed') + const capture = renderAndCapture() + expect(capture.firstCall.firstArg.query).to.deep.eq('bar') + expect(capture.firstCall.firstArg.hideStatuses).to.deep.eq([TestStepResultStatus.PASSED]) + }) + + it('updates both values', async () => { + const capture = renderAndCapture() + + await act(() => { + capture.firstCall.firstArg.update({ + query: 'foo', + hideStatuses: [TestStepResultStatus.UNDEFINED, TestStepResultStatus.PENDING], + }) + }) + + expect(window.location.pathname).to.eq('/stuff') + expect(window.location.search).to.eq('?query=foo&hide=undefined&hide=pending') + expect(capture.lastCall.firstArg.query).to.eq('foo') + expect(capture.lastCall.firstArg.hideStatuses).to.deep.eq([ + TestStepResultStatus.UNDEFINED, + TestStepResultStatus.PENDING, + ]) + }) + + it('updates just the query', async () => { + const capture = renderAndCapture() + + await act(() => { + capture.firstCall.firstArg.update({ + query: 'foo', + }) + }) + + expect(window.location.pathname).to.eq('/stuff') + expect(window.location.search).to.eq('?query=foo') + expect(capture.lastCall.firstArg.query).to.eq('foo') + expect(capture.lastCall.firstArg.hideStatuses).to.deep.eq([]) + }) + + it('updates just the hideStatuses', async () => { + const capture = renderAndCapture() + + await act(() => { + capture.firstCall.firstArg.update({ + hideStatuses: [TestStepResultStatus.PASSED], + }) + }) + + expect(window.location.pathname).to.eq('/stuff') + expect(window.location.search).to.eq('?hide=passed') + expect(capture.lastCall.firstArg.query).to.eq('') + expect(capture.lastCall.firstArg.hideStatuses).to.deep.eq([TestStepResultStatus.PASSED]) + }) +}) diff --git a/src/components/app/UrlSearchProvider.tsx b/src/components/app/UrlSearchProvider.tsx new file mode 100644 index 00000000..1882da40 --- /dev/null +++ b/src/components/app/UrlSearchProvider.tsx @@ -0,0 +1,44 @@ +import { TestStepResultStatus } from '@cucumber/messages' +import React, { FC, PropsWithChildren, useCallback, useState } from 'react' + +import { SearchState } from '../../SearchContext.js' +import { ControlledSearchProvider } from './ControlledSearchProvider.js' + +interface Props { + queryKey?: string + hideKey?: string +} + +export const UrlSearchProvider: FC> = ({ + queryKey = 'query', + hideKey = 'hide', + children, +}) => { + const [value, setValue] = useState(() => { + const params = new URLSearchParams(window.location.search) + return { + query: params.get(queryKey) ?? '', + hideStatuses: params.getAll(hideKey).map((s) => s.toUpperCase() as TestStepResultStatus), + } + }) + const onChange = useCallback( + (newValue: SearchState) => { + setValue(newValue) + const url = new URL(window.location.toString()) + if (newValue.query) { + url.searchParams.set(queryKey, newValue.query) + } else { + url.searchParams.delete(queryKey) + } + url.searchParams.delete(hideKey) + newValue.hideStatuses.forEach((s) => url.searchParams.append(hideKey, s.toLowerCase())) + window.history.replaceState({}, '', url) + }, + [queryKey, hideKey] + ) + return ( + + {children} + + ) +} diff --git a/src/components/app/index.ts b/src/components/app/index.ts index 2f6df496..18d29421 100644 --- a/src/components/app/index.ts +++ b/src/components/app/index.ts @@ -1,11 +1,13 @@ export * from './CICommitLink.js' +export * from './ControlledSearchProvider.js' export * from './EnvelopesWrapper.js' export * from './ExecutionSummary.js' export * from './FilteredResults.js' export * from './GherkinDocumentList.js' export * from './HighLight.js' +export * from './InMemorySearchProvider.js' export * from './NoMatchResult.js' export * from './QueriesWrapper.js' export * from './SearchBar.js' -export * from './SearchWrapper.js' export * from './StatusesSummary.js' +export * from './UrlSearchProvider.js' diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts index 8b2165aa..3fdad0a1 100644 --- a/src/hooks/useSearch.ts +++ b/src/hooks/useSearch.ts @@ -1,7 +1,7 @@ import { useContext } from 'react' -import SearchQueryContext, { SearchQueryCtx } from '../SearchQueryContext.js' +import SearchQueryContext, { SearchContextValue } from '../SearchContext.js' -export function useSearch(): SearchQueryCtx { +export function useSearch(): SearchContextValue { return useContext(SearchQueryContext) } diff --git a/src/index.ts b/src/index.ts index c5594f76..eb339ff4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,23 +1,8 @@ import CucumberQueryContext from './CucumberQueryContext.js' import filterByStatus from './filter/filterByStatus.js' import GherkinQueryContext from './GherkinQueryContext.js' -import SearchQueryContext, { - searchFromURLParams, - SearchQueryProps, - SearchQueryUpdateFn, - WindowUrlApi, -} from './SearchQueryContext.js' export * from './components/index.js' export * from './hooks/index.js' -export { - CucumberQueryContext, - filterByStatus, - GherkinQueryContext, - searchFromURLParams, - SearchQueryContext, - SearchQueryProps, - SearchQueryUpdateFn, - WindowUrlApi, -} +export { CucumberQueryContext, filterByStatus, GherkinQueryContext } From 9a80e57ebf9f5ab2d8f448053a74a2e757bd8f17 Mon Sep 17 00:00:00 2001 From: David Goss Date: Tue, 6 May 2025 15:19:53 +0100 Subject: [PATCH 2/3] update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0dab977..b19da01d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Added - Add `` to override presentation ([#382](https://github.com/cucumber/react-components/pull/382)) +- Add ``, `` and `` to provide search state ([#384](https://github.com/cucumber/react-components/pull/384)) ### Fixed - Make keyword spacing look right ([#376](https://github.com/cucumber/react-components/pull/376)) @@ -23,6 +24,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Removed - BREAKING CHANGE: Remove `EnvelopesQuery` and its React context ([#374](https://github.com/cucumber/react-components/pull/374)) - BREAKING CHANGE: Remove defunct `` component ([#382](https://github.com/cucumber/react-components/pull/382)) +- BREAKING CHANGE: Remove `SearchQueryContext` and related defunct symbols ([#384](https://github.com/cucumber/react-components/pull/384)) ## [22.4.1] - 2025-03-30 ### Fixed From f8b9e0ad09a2488912ebf51097a6ffe63b305d0c Mon Sep 17 00:00:00 2001 From: David Goss Date: Tue, 6 May 2025 15:20:18 +0100 Subject: [PATCH 3/3] update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b19da01d..9e9dfecc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Removed - BREAKING CHANGE: Remove `EnvelopesQuery` and its React context ([#374](https://github.com/cucumber/react-components/pull/374)) - BREAKING CHANGE: Remove defunct `` component ([#382](https://github.com/cucumber/react-components/pull/382)) -- BREAKING CHANGE: Remove `SearchQueryContext` and related defunct symbols ([#384](https://github.com/cucumber/react-components/pull/384)) +- BREAKING CHANGE: Remove `SearchQueryContext`, `` and related defunct symbols ([#384](https://github.com/cucumber/react-components/pull/384)) ## [22.4.1] - 2025-03-30 ### Fixed