Skip to content

Commit

Permalink
feat: add fuzzy search to projects list dropdown (#208)
Browse files Browse the repository at this point in the history
  • Loading branch information
L-Joli authored Oct 16, 2023
1 parent 24e3647 commit cdd926c
Show file tree
Hide file tree
Showing 5 changed files with 143 additions and 52 deletions.
93 changes: 62 additions & 31 deletions src/components/fuzzySearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,23 @@ import { WebviewApi } from "vscode-webview";

export enum SearchType {
variables = 'variables',
features = 'features'
features = 'features',
projects = 'projects',
}

type LocalStorageKey = `${SearchType}${string}` | SearchType

//View Provider
export const getCustomDropdown = (optionElements: string, selectedValue: string) => {
export const getCustomDropdown = (optionElements: string, selectedValue: string, folderIndex?: number, dataType?: string) => {
const folderAttribute = folderIndex !== undefined ? `data-folder="${folderIndex}"` : ''
const dataTypeAttribute = dataType ? `data-type="${dataType}"` : ''
const inputId = folderIndex !== undefined ? `id="dropdown-input-${folderIndex}"` : ''
const optionsListId = folderIndex !== undefined ? `id="dropdown-optionsList-${folderIndex}"` : ''
return `
<div class="custom-dropdown">
<input type="text" class="dropdown-input" placeholder="Search..." value="${selectedValue}">
<input ${inputId} type="text" class="dropdown-input" placeholder="Search..." value="${selectedValue}">
<div class="dropdown-arrow">^</div>
<div class="dropdown-options">
<div ${optionsListId} class="dropdown-options" ${folderAttribute} ${dataTypeAttribute}>
${optionElements}
</div>
</div>`
Expand All @@ -28,11 +36,13 @@ export const getCustomDropdown = (optionElements: string, selectedValue: string)
*/
export function handleFuzzySearchDropdown(
vscode: WebviewApi<unknown>,
searchType: SearchType,
localStorageKey: LocalStorageKey,
fuseOptions: Fuse.IFuseOptions<any> = {
keys: ['label', 'value'],
threshold: 0.5,
}
},
inputSelector: string = '.dropdown-input',
optionsListSelector: string = '.dropdown-options'
) {
window.addEventListener('message', (event) => {
const message = event.data
Expand All @@ -42,17 +52,10 @@ export function handleFuzzySearchDropdown(
}
})

const localStorageData = localStorage.getItem(searchType)

if (!localStorageData) {
return
}

const input = document.querySelector('.dropdown-input') as HTMLInputElement
const optionsList = document.querySelector('.dropdown-options') as HTMLDivElement

const searchData: { label: string, value?: string }[] = JSON.parse(localStorageData)
const fuse = new Fuse([...searchData], fuseOptions)
const input = document.querySelector(inputSelector) as HTMLInputElement
const optionsList = document.querySelector(optionsListSelector) as HTMLDivElement
const folderIndex = optionsList?.getAttribute('data-folder')
const selectedValueKey = `selectedValue${folderIndex || ''}`

const createAndAppendOption = (displayValue: string, dataAttributeValue?: string) => {
const option = document.createElement('div')
Expand All @@ -63,15 +66,57 @@ export function handleFuzzySearchDropdown(

// Event listeners to handle dropdown behavior
document.addEventListener('click', () => {
if (!optionsList?.classList.contains('visible')) {
return
}
const selectedValue = localStorage.getItem(selectedValueKey)
if (selectedValue) {
input.value = selectedValue
}
optionsList?.classList.remove('visible')
})

optionsList?.addEventListener('click', (event) => {
const selectedOption = event.target as HTMLElement
if (selectedOption.tagName === 'DIV') {
const selectedValue = selectedOption.getAttribute('data-value')
const selectedType = optionsList.getAttribute('data-type')
if (selectedValue) {
input.value = selectedOption.innerText
vscode.postMessage({
type: selectedType || 'key',
value: selectedValue,
...(optionsList.dataset.folder && { folderIndex: optionsList.dataset.folder })
})
optionsList.classList.remove('visible')
}
}
})

input?.addEventListener('click', (event) => {
const { value } = event.target as HTMLInputElement
localStorage.setItem(selectedValueKey, value)
event.stopPropagation()
optionsList?.classList.toggle('visible')
})


let localStorageData: string | null = localStorage.getItem(localStorageKey)
const refreshLocalStorageData = () => {
localStorageData = localStorage.getItem(localStorageKey)
}
input?.addEventListener('input', () => {
if (!localStorageData) {
vscode.postMessage({
type: 'setFuseData',
...(folderIndex && { folderIndex: folderIndex })
})
refreshLocalStorageData()
return
}

const searchData: { label: string, value?: string }[] = JSON.parse(localStorageData)
const fuse = new Fuse([...searchData], fuseOptions)
const inputValue = input?.value.toLowerCase().trim()
optionsList.innerHTML = ''

Expand All @@ -95,18 +140,4 @@ export function handleFuzzySearchDropdown(
}
})

optionsList.addEventListener('click', (event) => {
const selectedOption = event.target as HTMLElement
if (selectedOption.tagName === 'DIV') {
const selectedValue = selectedOption.getAttribute('data-value')
if (selectedValue) {
vscode.postMessage({
type: 'key',
value: selectedValue,
})
optionsList.classList.remove('visible')
}
}
})

}
55 changes: 44 additions & 11 deletions src/views/home/HomeViewProvider.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
import * as vscode from 'vscode'
import { getNonce } from '../../utils/getNonce'
import { BaseCLIController, OrganizationsCLIController, ProjectsCLIController } from '../../cli'
import { BaseCLIController, OrganizationsCLIController, Project, ProjectsCLIController } from '../../cli'
import path from 'path'
import { COMMAND_LOGOUT } from '../../commands/logout'
import { KEYS, StateManager } from '../../StateManager'
import { updateRepoConfig } from '../../utils/updateRepoConfigProject'
import { executeRefreshAllCommand } from '../../commands'
import { loginAndRefresh } from '../../utils/loginAndRefresh'
import { SearchType, getCustomDropdown } from '../../components/fuzzySearch'

type HomeViewMessage =
| { type: 'project' | 'organization', value: string, folderIndex: number }
| { type: 'config', folderIndex: number }
| { type: 'login', folderIndex: number }
| { type: 'logout' }
| { type: 'setFuseData', folderIndex: number }

export class HomeViewProvider implements vscode.WebviewViewProvider {
_view?: vscode.WebviewView
_doc?: vscode.TextDocument
isRefreshing: boolean = false
webviewIsDisposed: boolean = false
projectOptions: Record<number, { label: string, value: string }[]> = []

constructor(private readonly _extensionUri: vscode.Uri) {}

Expand Down Expand Up @@ -46,6 +49,11 @@ export class HomeViewProvider implements vscode.WebviewViewProvider {
const folder = vscode.workspace.workspaceFolders?.[data.folderIndex]
if (!folder) { return }

if (data.type === 'setFuseData') {
this.setFuseSearchData(this.projectOptions[folder.index], folder)
return
}

if (data.type === 'login') {
await loginAndRefresh([folder])
webviewView.webview.html = await this._getHtmlForWebview(webviewView.webview)
Expand Down Expand Up @@ -116,6 +124,21 @@ export class HomeViewProvider implements vscode.WebviewViewProvider {
await this.refreshAll()
}

private postMessageToWebview(message: unknown) {
this._view?.webview.postMessage(message)
}

private setFuseSearchData<Data>(data: Data[], folder: vscode.WorkspaceFolder) {
this.postMessageToWebview({ type: 'searchData', searchType: `${SearchType.projects}${folder.index}`, value: JSON.stringify(data)})
}

private getSelectedProject(activeProjectKey: string | undefined, projects: Record<string, Project>) {
if (!activeProjectKey) {
return ''
}
return projects[activeProjectKey]?.name || projects[activeProjectKey]?.key
}

private async getBodyHtml(folder: vscode.WorkspaceFolder, showHeader?: boolean): Promise<string> {
const organizationsController = new OrganizationsCLIController(folder)
const projectsController = new ProjectsCLIController(folder)
Expand All @@ -125,21 +148,24 @@ export class HomeViewProvider implements vscode.WebviewViewProvider {
const activeOrganizationName = StateManager.getFolderState(folder.name, KEYS.ORGANIZATION)?.name
const isLoggedIn = StateManager.getFolderState(folder.name, KEYS.LOGGED_IN)

const projectId = `project${folder.index}`
const projectOptions = Object.values(projects).map((project) => ({
label: project.name || project.key,
value: project.key,
}))
this.projectOptions[folder.index] = projectOptions
this.setFuseSearchData(projectOptions, folder)

const organizationId = `organization${folder.index}`
const editButtonId = `editConfigButton${folder.index}`


const orgOptions = Object.values(organizations).map((organization) =>
`<vscode-option value="${organization.name}"${organization.name === activeOrganizationName ? ' selected' : '' }>${organization.display_name || organization.name}</vscode-option>`
)
const projectOptions = Object.values(projects).map((project) =>
`<vscode-option value="${project.key}"${project.key === activeProjectKey ? ' selected' : ''}>${project.name}</vscode-option>`
const projectOptionElements = Object.values(projects).map((project) =>
`<div data-value="${project.key}">${project.name}</div>`
)
if (!activeProjectKey) {
projectOptions.unshift(`<vscode-option class="placeholder" selected>Select a project...</vscode-option>`)
}

const selectedProject = this.getSelectedProject(activeProjectKey, projects)
return `
${
showHeader ? `
Expand All @@ -162,9 +188,7 @@ export class HomeViewProvider implements vscode.WebviewViewProvider {
</vscode-dropdown>
<label class="home-view-dropdown-label">
<i class="codicon codicon-star-empty"></i>Project</label>
<vscode-dropdown id="${projectId}" class="home-dropdown" data-folder="${folder.index}" data-type="project">
${projectOptions.join('')}
</vscode-dropdown>
${getCustomDropdown(projectOptionElements.join(''), selectedProject, folder.index, 'project')}
<button id="${editButtonId}" class="icon-button edit-config-button" data-folder="${folder.index}">
<i class="codicon codicon-edit"></i>Edit Config
</button>
Expand Down Expand Up @@ -207,6 +231,14 @@ export class HomeViewProvider implements vscode.WebviewViewProvider {
const homeViewStylesUri = webview.asWebviewUri(
vscode.Uri.joinPath(this._extensionUri, 'media', 'styles', 'homeView.css'),
)
const dropdownStylesUri = webview.asWebviewUri(
vscode.Uri.joinPath(
this._extensionUri,
'media',
'styles',
'customDropdown.css',
),
)
const webViewUri = webview.asWebviewUri(
vscode.Uri.joinPath(this._extensionUri, 'out', 'homeView.js'),
)
Expand Down Expand Up @@ -236,6 +268,7 @@ export class HomeViewProvider implements vscode.WebviewViewProvider {
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="${styleVSCodeUri}" rel="stylesheet">
<link href="${homeViewStylesUri}" rel="stylesheet">
<link href="${dropdownStylesUri}" rel="stylesheet">
<link href="${codiconsUri}" rel="stylesheet"/>
</head>
<body>
Expand Down
30 changes: 20 additions & 10 deletions src/views/inspector/InspectorViewProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type InspectorViewMessage =
}
| { type: 'folder'; value: number }
| { type: 'jsonReadonly'; value: string }
| { type: 'setFuseData' }

const htmlMessage = (message: string) => `<!DOCTYPE html>
<html lang="en">
Expand Down Expand Up @@ -96,16 +97,8 @@ export class InspectorViewProvider implements vscode.WebviewViewProvider {

this.selectedKey = ''

// Send variables data to the webview to initialize fuse
const variableOptions = this.orderedVariables.map(variable => ({
label: variable.key
}))
const featureOptions = this.orderedFeatures.map(feature => ({
label: feature.name || feature.key,
value: feature._id
}))
this.postMessageToWebview({type: 'searchData', searchType: SearchType.variables, value: JSON.stringify(variableOptions)})
this.postMessageToWebview({type: 'searchData', searchType: SearchType.features, value: JSON.stringify(featureOptions)})
// Send data to the webview to initialize Fuse
this.setFuseSearchData()
} catch (e) {
vscode.window.showErrorMessage(
`Error initializing features and variables in inspector: ${e}`,
Expand Down Expand Up @@ -133,6 +126,11 @@ export class InspectorViewProvider implements vscode.WebviewViewProvider {

webviewView.webview.onDidReceiveMessage(
async (data: InspectorViewMessage) => {
if (data.type === 'setFuseData') {
this.setFuseSearchData()
return
}

if (data.type === 'variableOrFeature') {
this.selectedType = data.value
this.selectedKey = ''
Expand Down Expand Up @@ -175,6 +173,18 @@ export class InspectorViewProvider implements vscode.WebviewViewProvider {
this.webviewIsDisposed = false
}

private setFuseSearchData() {
const variableOptions = this.orderedVariables.map(variable => ({
label: variable.key
}))
const featureOptions = this.orderedFeatures.map(feature => ({
label: feature.name || feature.key,
value: feature._id
}))
this.postMessageToWebview({ type: 'searchData', searchType: SearchType.variables, value: JSON.stringify(variableOptions) })
this.postMessageToWebview({ type: 'searchData', searchType: SearchType.features, value: JSON.stringify(featureOptions) })
}

private async refreshInspectorView() {
if (!this._view?.webview) {
return
Expand Down
13 changes: 13 additions & 0 deletions src/webview/homeView.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { provideVSCodeDesignSystem, vsCodeDropdown, vsCodeOption, Dropdown } from "@vscode/webview-ui-toolkit";
import { SearchType, handleFuzzySearchDropdown } from "../components/fuzzySearch";

provideVSCodeDesignSystem().register(vsCodeDropdown(), vsCodeOption());

Expand Down Expand Up @@ -46,6 +47,18 @@ function main() {
dropdown.addEventListener('change', handleDropdownValueChange);
}

const optionsLists = document.querySelectorAll('.dropdown-options') as NodeListOf<HTMLDivElement>
optionsLists.forEach(optionsList => {
const folderIndexAttribute = optionsList.getAttribute('data-folder')
const folderIndex = folderIndexAttribute ? folderIndexAttribute : null

if (folderIndex) {
handleFuzzySearchDropdown(vscode, `${SearchType.projects}${folderIndex}`, undefined, `#dropdown-input-${folderIndex}`, `#dropdown-optionsList-${folderIndex}`)
} else {
handleFuzzySearchDropdown(vscode, SearchType.projects)
}
})

const editConfigButtons = document.getElementsByClassName("edit-config-button")
for (let i = 0; i < editConfigButtons.length; i++) {
// Edit config button
Expand Down
4 changes: 4 additions & 0 deletions src/webview/inspectorView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ if (focusedElement) {

window.addEventListener('message', (event) => {
const message = event.data
// if message type is 'searchData' then we don't want to post it back to the extension
if (message.type === 'searchData') {
return
}
vscode.postMessage(message)
})

Expand Down

0 comments on commit cdd926c

Please sign in to comment.