Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Searchbar #7865

Merged
merged 1 commit into from
Aug 11, 2021
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 96 additions & 133 deletions plugins/plugin-core-support/src/test/core-standalone/text-search.ts
Original file line number Diff line number Diff line change
@@ -14,8 +14,6 @@
* limitations under the License.
*/

import * as assert from 'assert'

import { Common, CLI, Keys, ReplExpect, Selectors } from '@kui-shell/test'

Common.localDescribe('Text search', function(this: Common.ISuite) {
@@ -37,189 +35,154 @@ Common.localDescribe('Text search', function(this: Common.ISuite) {
.catch(Common.oops(this, true)))

it('should open the search bar when cmd+f is pressed', async () => {
await this.app.client.keys([Keys.ctrlOrMeta, 'f'])
await this.app.client.$('#search-bar').then(_ => _.waitForDisplayed())
await this.app.client.keys([Keys.ctrlOrMeta, 'F'])
await this.app.client.$('#search-bar').then(_ => _.waitForDisplayed({ timeout: CLI.waitTimeout }))

await this.app.client.waitUntil(
async () => {
return this.app.client.$('#search-input').then(_ => _.isFocused())
return this.app.client.$('#search-bar input').then(_ => _.isFocused())
},
{ timeout: CLI.waitTimeout }
)
})

xit('should not close the search bar if pressing esc outside of search input', async () => {
it('should not close the search bar if pressing esc outside of search bar', async () => {
await this.app.client.$(Selectors.CURRENT_PROMPT_BLOCK).then(_ => _.click())
await this.app.client.keys(Keys.ESCAPE)
await this.app.client.$('#search-bar').then(_ => _.waitForDisplayed())
await this.app.client.$('#search-bar').then(_ => _.waitForDisplayed({ timeout: 3000 }))
})

xit('should focus on search input when search input is pressed', async () => {
it('should focus on search bar when search bar is pressed', async () => {
await this.app.client.$('#search-bar').then(_ => _.click())
await this.app.client.waitUntil(
async () => {
await this.app.client.$('#search-input').then(_ => _.click())
const hasFocus = await this.app.client.$('#search-input').then(_ => _.isFocused())
const hasFocus = await this.app.client.$('#search-bar input').then(_ => _.isFocused())
return hasFocus
},
{ timeout: CLI.waitTimeout }
{ timeout: 3000 }
)
})

it('should close the search bar via ctrl+f', async () => {
await this.app.client.keys(['NULL', Keys.ctrlOrMeta, 'f'])
await this.app.client.$('#search-bar').then(_ => _.waitForDisplayed({ timeout: 20000, reverse: true }))
})
const closeSearchBar = async () => {
await this.app.client.keys([Keys.ctrlOrMeta, 'F'])
await this.app.client.$('#search-bar').then(_ => _.waitForDisplayed({ timeout: 3000, reverse: true }))
}

// re-open, so that we can test the close button
// !!! Notes: some odd chrome or chromedriver bugs: if you click on
// the close button, then chrome/chromedriver/whatever refuses to
// accept any input; both setValue on the INPUT and the ctrlOrMeta+F
// fail
/* it('should open the search bar when cmd+f is pressed', async () => {
await this.app.client.keys([Keys.ctrlOrMeta, 'f'])
await this.app.client.waitForVisible('#search-bar')
it('should close the search bar via ctrl+f', async () => {
closeSearchBar()
})

it('should close the search bar if clicking the close button', async () => {
await new Promise(resolve => setTimeout(resolve, 5000))
await this.app.client.click('#search-close-button')
await this.app.client.waitForVisible('#search-bar', 2000, true) // reverse: true
await this.app.client.waitUntil(async () => {
const hasFocus = await this.app.client.hasFocus(ui.Selectors.CURRENT_PROMPT)
return hasFocus
})
}) */

/*
####################################################################################
# THE FOLLOWING ARE ALL MATCHING TESTS. WE TEST IF THE NUMBER OF MATCHES OF A
# PARTICULAR INPUT MATCHES THAT OUTPUTTED ON THE SEARCH BAR
####################################################################################
*/
const type = async (text: string) => {
await this.app.client.execute(
(text: string) =>
navigator.clipboard.writeText(text).then(() => {
document.execCommand('paste')
}),
text
)

let idx = 0
// deleting any existing text in search bar input field
await this.app.client.$('#search-bar input').then(_ => _.setValue(''))
// pasting the input text into the search bar input field
await this.app.client.$('#search-bar input').then(_ => _.setValue(text))
// making sure the word in the input field is the same word we want to search for
await this.app.client.waitUntil(
async () => {
const actualText = await this.app.client.$('#search-input').then(_ => _.getValue())
console.error('3T', actualText)

if (++idx > 5) {
console.error(`still waiting for search result actualText=${actualText} expectedText=${text}`)
}

const actualText = await this.app.client.$('#search-bar input').then(_ => _.getValue())
return actualText === text
},
{ timeout: CLI.waitTimeout }
)
}

const waitForSearchFoundText = async (searchFoundText: string) => {
let idx = 0
await this.app.client.waitUntil(
async () => {
await this.app.client.$('#search-found-text').then(_ => _.waitForExist())
const txt = await this.app.client.$('#search-found-text').then(_ => _.getText())

if (++idx > 5) {
console.error(`still waiting for search result actualText=${txt} expectedText=${searchFoundText}`)
}

console.error('4a', txt)
return txt === searchFoundText
},
{ timeout: CLI.waitTimeout }
{ timeout: 3000 }
)
}

const findMatch = (typeText: string, searchFoundText: string) => {
it(`should find ${searchFoundText} for ${typeText}`, async () => {
it(`should find ${searchFoundText} matches for ${typeText}`, async () => {
try {
console.error('1', typeText)
// opening the search bar
await this.app.client.waitUntil(
async () => {
await this.app.client.keys(['NULL', Keys.ctrlOrMeta, 'f'])
console.error('1a')
await this.app.client.$('#search-bar').then(_ => _.waitForDisplayed({ timeout: 4000 }))
return true
await this.app.client.keys([Keys.ctrlOrMeta, 'F'])
await this.app.client.$('#search-bar').then(_ => _.waitForDisplayed({ timeout: 3000 }))
return this.app.client.$('#search-bar input').then(_ => _.isFocused())
},
{ timeout: CLI.waitTimeout }
)

console.error('2')
await this.app.client.waitUntil(() => this.app.client.$('#search-input').then(_ => _.isFocused()), {
timeout: CLI.waitTimeout
})

console.error('3')
// typing the word to find matches for into the search bar
await type(typeText)

console.error('4', searchFoundText)
await waitForSearchFoundText(searchFoundText)
// finding number of matches
await this.app.client.waitUntil(
async () => {
await this.app.client.$('#search-bar input').then(_ => _.waitForExist({ timeout: 3000 }))
const txt = await this.app.client.$('#search-bar').then(_ => _.getText())
return txt === searchFoundText
},
{ timeout: 3000 }
)
} catch (err) {
await Common.oops(this, true)(err)
}
})
}

findMatch('grumble', '4 matches') // two executions plus two 'Command not found: grumble' matches, and no tab title match!
// 4 match test: two executions plus two 'Command not found: grumble' matches
findMatch('grumble', '4')

// 1 match test
it('should close the search bar via ctrl+f', () =>
this.app.client
.keys(['NULL', Keys.ctrlOrMeta, 'f'])
.then(() => this.app.client.$('#search-bar'))
.then(_ => _.waitForDisplayed({ timeout: 2000, reverse: true }))
.catch(Common.oops(this, true)))

findMatch('bojangles', '2 matches') // one execution, plus one "Command not found: bojangles" match (not with carbon themes: plus one tab title match)
// 1 match test: one execution plus one 'Command not found: bojangles' match
closeSearchBar()
findMatch('bojangles', '2')

// no matches test
it('should close the search bar via ctrl+f', async () => {
return this.app.client
.keys(['NULL', Keys.ctrlOrMeta, 'f'])
.then(() => this.app.client.$('#search-bar'))
.then(_ => _.waitForDisplayed({ timeout: 2000, reverse: true }))
.catch(Common.oops(this, true))
})
// re-open, so that we can test entering text and hitting enter
it('should find nothing when searching for waldo', () =>
this.app.client
.keys(['NULL', Keys.ctrlOrMeta, 'f'])
.then(() => this.app.client.$('#search-bar'))
.then(_ => _.waitForDisplayed())
.then(() =>
this.app.client.waitUntil(() => this.app.client.$('#search-input').then(_ => _.isFocused()), {
timeout: CLI.waitTimeout
})
)
.then(async () => {
console.error('5')
await type(`waldo`)
closeSearchBar()
findMatch('waldo', '0')

console.error('6')
await waitForSearchFoundText('No matches')
})
.catch(Common.oops(this, true)))
// ############### !!!!!!!!!!!!!!!!!!!! TODO: test entering text and hitting enter !!!!!!!!!!!!!!!!!!!! ###############

// paste test; reload first to start with a clean slate in the text search box
it('should reload the app', () => Common.refresh(this))

// testing paste and making sure nothing else in Kui intercepts the paste
it('should paste into the text search box', async () => {
return this.app.client
.keys(['NULL', Keys.ctrlOrMeta, 'f'])
.then(() => this.app.client.$('#search-bar'))
.then(_ => _.waitForDisplayed())
.then(() =>
this.app.client.waitUntil(() => this.app.client.$('#search-input').then(_ => _.isFocused()), {
timeout: CLI.waitTimeout
})
// open the search bar and focus it
await this.app.client.keys([Keys.ctrlOrMeta, 'F'])
await this.app.client.$('#search-bar').then(_ => _.waitForDisplayed({ timeout: CLI.waitTimeout }))
await this.app.client.waitUntil(
async () => {
return this.app.client.$('#search-bar input').then(_ => _.isFocused())
},
{ timeout: CLI.waitTimeout }
)

// write text using electron
await this.app.electron.clipboard.writeText('grumble')
await this.app.client.execute(() => document.execCommand('paste'))

// get text from the search bar
const actualText = await this.app.client.$('#search-bar input').then(_ => _.getValue())
return actualText === 'grumble'
})

/*
####################################################################################
# TESTING THE CLOSE BUTTON
####################################################################################
*/
it('should close the search bar if clicking the close button', async () => {
await this.app.client.$('#search-bar button').then(_ => _.click())
await this.app.client.waitUntil(async () => {
// checking that search bar isn't displayed
const displayResults = await this.app.client
.$('#search-bar')
.then(_ => _.waitForDisplayed({ timeout: 3000, reverse: true }))
// open the search bar and focus it
await this.app.client.keys([Keys.ctrlOrMeta, 'F'])
await this.app.client.$('#search-bar').then(_ => _.waitForDisplayed({ timeout: CLI.waitTimeout }))
await this.app.client.waitUntil(
async () => {
return this.app.client.$('#search-bar input').then(_ => _.isFocused())
},
{ timeout: CLI.waitTimeout }
)
.then(() => this.app.electron.clipboard.writeText('grumble'))
.then(() => this.app.client.execute(() => document.execCommand('paste')))
.then(() => this.app.client.$('#search-input'))
.then(_ => _.getValue())
.then(actual => assert.strictEqual(actual, 'grumble')) // paste made it to #search-input?
.catch(Common.oops(this, true))
// checking that there's no text in the input field after it's been closed
const textInSearchBar = await this.app.client.$('#search-bar input').then(_ => _.getValue())
return textInSearchBar === '' && displayResults
})
})
})
128 changes: 49 additions & 79 deletions plugins/plugin-electron-components/src/components/Search.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2020 The Kubernetes Authors
* Copyright 2021 The Kubernetes Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -13,15 +13,14 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import React from 'react'
import { i18n } from '@kui-shell/core'
import { Event, FoundInPageResult } from 'electron'
import { Icons } from '@kui-shell/plugin-client-common'
import { SearchInput } from '@patternfly/react-core'
// import { i18n } from '@kui-shell/core'
import { FoundInPageResult } from 'electron'

import '../../web/scss/components/Search/Search.scss'

const strings = i18n('plugin-client-common', 'search')
// const strings = i18n('plugin-client-common', 'search')

type Props = {}

@@ -30,7 +29,7 @@ interface State {
result: FoundInPageResult
}

export default class Search extends React.PureComponent<Props, State> {
export default class Search extends React.Component<Props, State> {
// to help with focus
private _input: HTMLInputElement

@@ -46,14 +45,24 @@ export default class Search extends React.PureComponent<Props, State> {
}
}

/** stop findInPage, and clear selections in page */
private async stopFindInPage() {
return import('electron').then(async ({ remote }) => {
// note: with 'clearSelection', the focus of the input is very
// odd; it is focused, but typing text does nothing until some
// global refresh occurs. maybe this is just a bug in electron 6?
await remote.getCurrentWebContents().stopFindInPage('activateSelection')
})
}

private initEvents() {
document.body.addEventListener('keydown', evt => {
if (
!evt.defaultPrevented &&
evt.code === 'KeyF' &&
((evt.ctrlKey && process.platform !== 'darwin') || evt.metaKey)
) {
if (this.state.isActive && !!this._input && document.activeElement !== this._input) {
if (this.state.isActive && document.activeElement !== this._input) {
this.doFocus()
} else {
this.setState(curState => {
@@ -69,15 +78,7 @@ export default class Search extends React.PureComponent<Props, State> {
})
}

/** stop findInPage, and clear selections in page */
private async stopFindInPage() {
return import('electron').then(async ({ remote }) => {
// note: with 'clearSelection', the focus of the input is very
// odd; it is focused, but typing text does nothing until some
// global refresh occurs. maybe this is just a bug in electron 6?
await remote.getCurrentWebContents().stopFindInPage('activateSelection')
})
}
private readonly _onChange = this.onChange.bind(this)

private async onChange() {
if (this._input) {
@@ -86,7 +87,7 @@ export default class Search extends React.PureComponent<Props, State> {
this.setState({ result: undefined })
} else {
const { remote } = await import('electron')

// Registering a callback handler
remote.getCurrentWebContents().once('found-in-page', async (event: Event, result: FoundInPageResult) => {
this.setState(curState => {
if (curState.isActive) {
@@ -95,93 +96,62 @@ export default class Search extends React.PureComponent<Props, State> {
}
})
})

// this is where we call the electron API to initiate a new find
remote.getCurrentWebContents().findInPage(this._input.value)
}
}
}

/**
* This bit of ugliness works around us not using a proper
* webview to encapsulate the <input> element; without this
* encapsulation, chrome does some funky things with
* focus. For example, when there is no text found, the
* input element oddly ... maintains focus but is not
* typeable until a global refresh. Weird. This also has the
* nice side-effect of (albeit with a small visual glitch)
* having no yellow/red highlight text around the text
* inside the input element.
*
*/
private hack() {
const v = this._input.value
this._input.value = ''
this._input.value = v
this._input.focus()
}

private doFocus(input?: HTMLInputElement) {
if (!!input && !this._input) {
this._input = input
}

private doFocus() {
if (this.state.isActive && this._input) {
this._input.focus()
}
}

/** Summarize results of find, e.g. "3 of 3" */
private matchCount() {
const { result } = this.state
if (result) {
// exclude the text search itself; TODO move the input element to a webview
const N = result.matches - 1
const text = N === 0 ? strings('noMatches') : N === 1 ? strings('1Match') : strings('nMatches', N)
private onClear = async () => {
await this.stopFindInPage()
if (this._input) {
this._input.value = ''
}
this.setState({
result: undefined,
isActive: false
})
}

// re: id: text-search test needs this
return (
<span id="search-found-text" className="kui--search-match-count sub-text even-smaller-text nowrap">
{text}
</span>
)
private readonly _onClear = this.onClear.bind(this)

private readonly _onRef = (c: HTMLInputElement) => {
if (c) {
this._input = c
this.doFocus()
}
}

public render() {
if (!this.state.isActive) {
this._input = undefined
return <React.Fragment />
} else {
/**
* NOTE: we need the ref input to manage the focus.
* We want the search input to focus when user hits ctrl+f,
* and stay focused when electron finds match.
* With PatternFly’s SearchInput (function component), we can’t access the refs and manage the focus.
* So, we crafted the search input by ourselves. See issue: https://github.com/IBM/kui/issues/4364
*
*/

// re: id, text-search test needs this
return (
<div className="pf-c-search-input kui--search flex-layout" id="search-bar">
<span className="pf-c-search-input__text">
<span className="pf-c-search-input__icon">
<Icons icon="Search" />
</span>
<input
className="pf-c-search-input__text-input"
id="search-input"
placeholder={strings('placeHolderText')}
aria-label="Search"
onChange={this.onChange.bind(this)}
spellCheck={false}
ref={input => {
this.doFocus(input)
}}
/>
</span>
{this.matchCount()}
</div>
<SearchInput
id="search-bar"
className="kui--search"
placeholder="Find by name"
value={this._input && this._input.value}
aria-label="Search"
onChange={this._onChange}
onClear={this._onClear}
spellCheck={false}
resultsCount={this.state.result && (this.state.result.matches - 1).toString()}
ref={this._onRef}
/>
)
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
.kui--search {
.kui--search-match-count {
position: absolute;
right: 3em;
}
.pf-c-search-input__icon {
svg {
vertical-align: -0.125em;
}
z-index: 1000;
color: var(--color-text-01);
}
.pf-c-search-input__text-input {
font-family: var(--font-sans-serif);
//font-family: var(--font-sans-serif);
color: var(--color-text-01);
background-color: transparent;
}

.pf-c-badge.pf-m-read {
color: var(--color-text-01);
position: unset;
background-color: var(--color-base02);
}
}