Skip to content

Commit

Permalink
Add whole-word matching option in search bar (#13777)
Browse files Browse the repository at this point in the history
* Implement whole-word matching option

* Update Playwright Snapshots

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
  • Loading branch information
krassowski and github-actions[bot] committed Jan 17, 2023
1 parent ce655d9 commit 31886fd
Show file tree
Hide file tree
Showing 16 changed files with 174 additions and 6 deletions.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
30 changes: 25 additions & 5 deletions packages/documentsearch/src/searchmodel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,20 @@ export class SearchDocumentModel
}
}

/**
* Whether to match whole words or not.
*/
get wholeWords(): boolean {
return this._wholeWords;
}
set wholeWords(v: boolean) {
if (this._wholeWords !== v) {
this._wholeWords = v;
this.stateChanged.emit();
this.refresh();
}
}

/**
* Dispose the model.
*/
Expand Down Expand Up @@ -262,7 +276,8 @@ export class SearchDocumentModel
? Private.parseQuery(
this.searchExpression,
this.caseSensitive,
this.useRegex
this.useRegex,
this.wholeWords
)
: null;
if (query) {
Expand All @@ -288,6 +303,7 @@ export class SearchDocumentModel
private _searchDebouncer: Debouncer;
private _searchExpression = '';
private _useRegex = false;
private _wholeWords = false;
}

namespace Private {
Expand All @@ -302,15 +318,19 @@ namespace Private {
export function parseQuery(
queryString: string,
caseSensitive: boolean,
regex: boolean
regex: boolean,
wholeWords: boolean
): RegExp | null {
const flag = caseSensitive ? 'g' : 'gi';
// escape regex characters in query if its a string search
const queryText = regex
let queryText = regex
? queryString
: queryString.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
let ret;
ret = new RegExp(queryText, flag);

if (wholeWords) {
queryText = '\\b' + queryText + '\\b';
}
const ret = new RegExp(queryText, flag);

// If the empty string is hit, the search logic will freeze the browser tab
// Trying /^/ or /$/ on the codemirror search demo, does not find anything.
Expand Down
31 changes: 30 additions & 1 deletion packages/documentsearch/src/searchview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import {
closeIcon,
ellipsesIcon,
regexIcon,
VDomRenderer
VDomRenderer,
wordIcon
} from '@jupyterlab/ui-components';
import { ISignal, Signal } from '@lumino/signaling';
import { Message } from '@lumino/messaging';
Expand Down Expand Up @@ -55,10 +56,12 @@ interface ISearchEntryProps {
inputRef: React.RefObject<HTMLInputElement>;
onCaseSensitiveToggled: () => void;
onRegexToggled: () => void;
onWordToggled: () => void;
onKeydown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
caseSensitive: boolean;
useRegex: boolean;
wholeWords: boolean;
searchText: string;
translator?: ITranslator;
}
Expand All @@ -74,6 +77,10 @@ function SearchEntry(props: ISearchEntryProps): JSX.Element {
props.useRegex ? INPUT_BUTTON_CLASS_ON : INPUT_BUTTON_CLASS_OFF,
BUTTON_CONTENT_CLASS
);
const wordButtonToggleClass = classes(
props.wholeWords ? INPUT_BUTTON_CLASS_ON : INPUT_BUTTON_CLASS_OFF,
BUTTON_CONTENT_CLASS
);

const wrapperClass = INPUT_WRAPPER_CLASS;

Expand All @@ -99,6 +106,14 @@ function SearchEntry(props: ISearchEntryProps): JSX.Element {
>
<caseSensitiveIcon.react className={caseButtonToggleClass} tag="span" />
</button>
<button
className={BUTTON_WRAPPER_CLASS}
onClick={() => props.onWordToggled()}
tabIndex={0}
title={trans.__('Match Whole Word')}
>
<wordIcon.react className={wordButtonToggleClass} tag="span" />
</button>
<button
className={BUTTON_WRAPPER_CLASS}
onClick={() => props.onRegexToggled()}
Expand Down Expand Up @@ -335,6 +350,10 @@ interface ISearchOverlayProps {
* Whether the search defines a regular expression or not.
*/
useRegex: boolean;
/**
* Whether the search matches entire words or any substring.
*/
wholeWords: boolean;
/**
* Callback on case sensitive toggled.
*/
Expand All @@ -361,6 +380,10 @@ interface ISearchOverlayProps {
* Callback on use regular expression toggled
*/
onRegexToggled: () => void;
/**
* Callback on use whole word toggled.
*/
onWordToggled: () => void;
/**
* Callback on replace all button click.
*/
Expand Down Expand Up @@ -515,8 +538,10 @@ class SearchOverlay extends React.Component<
inputRef={this.props.searchInputRef}
useRegex={this.props.useRegex}
caseSensitive={this.props.caseSensitive}
wholeWords={this.props.wholeWords}
onCaseSensitiveToggled={this.props.onCaseSensitiveToggled}
onRegexToggled={this.props.onRegexToggled}
onWordToggled={this.props.onWordToggled}
onKeydown={(e: React.KeyboardEvent<HTMLInputElement>) =>
this._onSearchKeydown(e)
}
Expand Down Expand Up @@ -676,12 +701,16 @@ export class SearchDocumentView extends VDomRenderer<SearchDocumentModel> {
totalMatches={this.model.totalMatches}
translator={this.translator}
useRegex={this.model.useRegex}
wholeWords={this.model.wholeWords}
onCaseSensitiveToggled={() => {
this.model.caseSensitive = !this.model.caseSensitive;
}}
onRegexToggled={() => {
this.model.useRegex = !this.model.useRegex;
}}
onWordToggled={() => {
this.model.wholeWords = !this.model.wholeWords;
}}
onFilterChanged={async (name: string, value: boolean) => {
await this.model.setFilter(name, value);
}}
Expand Down
5 changes: 5 additions & 0 deletions packages/documentsearch/style/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@
height: 100%;
}

.jp-DocumentSearch-button-content svg {
width: 100%;
height: 100%;
}

.jp-DocumentSearch-input-wrapper {
border: var(--jp-border-width) solid var(--jp-border-color0);
display: flex;
Expand Down
97 changes: 97 additions & 0 deletions packages/documentsearch/test/searchmodel.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
import {
GenericSearchProvider,
SearchDocumentModel
} from '@jupyterlab/documentsearch';
import { Widget } from '@lumino/widgets';
import { PromiseDelegate } from '@lumino/coreutils';

class LogSearchProvider extends GenericSearchProvider {
private _queryReceived: PromiseDelegate<RegExp | null>;

constructor(widget: Widget) {
super(widget);
this._queryReceived = new PromiseDelegate();
}
get queryReceived(): Promise<RegExp | null> {
return this._queryReceived.promise;
}

async startQuery(query: RegExp | null, filters = {}): Promise<void> {
this._queryReceived.resolve(query);
this._queryReceived = new PromiseDelegate();
}
}

describe('documentsearch/searchmodel', () => {
describe('SearchDocumentModel', () => {
let provider: LogSearchProvider;
let widget: Widget;
let model: SearchDocumentModel;

beforeEach(() => {
widget = new Widget();
provider = new LogSearchProvider(widget);
model = new SearchDocumentModel(provider, 0);
});

afterEach(async () => {
widget.dispose();
});

describe('#searchExpression', () => {
it('should notify provider of new query when set', async () => {
model.searchExpression = 'query';
expect(model.searchExpression).toEqual('query');
const query = (await provider.queryReceived)!;
expect(query.test('query')).toEqual(true);
query.lastIndex = 0;
expect(query.test('test')).toEqual(false);
query.lastIndex = 0;
});
});

describe('#caseSensitive', () => {
it('should start a case-sensitive query', async () => {
model.searchExpression = 'query';
model.caseSensitive = true;
expect(model.caseSensitive).toEqual(true);
let query = (await provider.queryReceived)!;
expect(query.test('query')).toEqual(true);
query.lastIndex = 0;
expect(query.test('QUERY')).toEqual(false);
query.lastIndex = 0;

model.caseSensitive = false;
expect(model.caseSensitive).toEqual(false);
query = (await provider.queryReceived)!;
expect(query.test('query')).toEqual(true);
query.lastIndex = 0;
expect(query.test('QUERY')).toEqual(true);
query.lastIndex = 0;
});
});

describe('#wholeWords', () => {
it('should start a whole-words query', async () => {
model.searchExpression = 'query';
model.wholeWords = true;
expect(model.wholeWords).toEqual(true);
let query = (await provider.queryReceived)!;
expect(query.test(' query ')).toEqual(true);
query.lastIndex = 0;
expect(query.test('XqueryX')).toEqual(false);
query.lastIndex = 0;

model.wholeWords = false;
expect(model.wholeWords).toEqual(false);
query = (await provider.queryReceived)!;
expect(query.test(' query ')).toEqual(true);
query.lastIndex = 0;
expect(query.test('XqueryX')).toEqual(true);
query.lastIndex = 0;
});
});
});
});
2 changes: 2 additions & 0 deletions packages/ui-components/src/icon/iconimports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ import undoSvgstr from '../../style/icons/toolbar/undo.svg';
import userSvgstr from '../../style/icons/sidebar/user.svg';
import usersSvgstr from '../../style/icons/sidebar/users.svg';
import vegaSvgstr from '../../style/icons/filetype/vega.svg';
import wordSvgstr from '../../style/icons/search/word.svg';
import yamlSvgstr from '../../style/icons/filetype/yaml.svg';

// LabIcon instance construction
Expand Down Expand Up @@ -201,4 +202,5 @@ export const undoIcon = new LabIcon({ name: 'ui-components:undo', svgstr: undoSv
export const userIcon = new LabIcon({ name: 'ui-components:user', svgstr: userSvgstr });
export const usersIcon = new LabIcon({ name: 'ui-components:users', svgstr: usersSvgstr });
export const vegaIcon = new LabIcon({ name: 'ui-components:vega', svgstr: vegaSvgstr });
export const wordIcon = new LabIcon({ name: 'ui-components:word', svgstr: wordSvgstr });
export const yamlIcon = new LabIcon({ name: 'ui-components:yaml', svgstr: yamlSvgstr });
5 changes: 5 additions & 0 deletions packages/ui-components/style/deprecated.css
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
--jp-icon-user: url('icons/sidebar/user.svg');
--jp-icon-users: url('icons/sidebar/users.svg');
--jp-icon-vega: url('icons/filetype/vega.svg');
--jp-icon-word: url('icons/search/word.svg');
--jp-icon-yaml: url('icons/filetype/yaml.svg');
}

Expand Down Expand Up @@ -492,6 +493,10 @@
background-image: var(--jp-icon-vega);
}

.jp-WordIcon {
background-image: var(--jp-icon-word);
}

.jp-YamlIcon {
background-image: var(--jp-icon-yaml);
}
10 changes: 10 additions & 0 deletions packages/ui-components/style/icons/search/word.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 31886fd

Please sign in to comment.