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

Search for apps by title #762

Merged
merged 22 commits into from
Dec 14, 2022
Merged
Show file tree
Hide file tree
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
5 changes: 5 additions & 0 deletions .changeset/slimy-roses-perform.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/app': minor
---

Allow searching for apps to connect to by title
5 changes: 5 additions & 0 deletions .changeset/stale-pianos-melt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli-kit': minor
---

Allow passing a filter function to autocomplete prompts
5 changes: 3 additions & 2 deletions packages/app/src/cli/prompts/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,10 @@ describe('selectApp', () => {
vi.mocked(ui.prompt).mockResolvedValue({apiKey: 'key2'})

// When
const got = await selectAppPrompt(apps)
const got = await selectAppPrompt(apps, ORG1.id, 'token')

// Then
expect(got).toEqual(APP2)
expect(got).toEqual({apiKey: APP2.apiKey})
expect(ui.prompt).toHaveBeenCalledWith([
{
type: 'autocomplete',
Expand All @@ -116,6 +116,7 @@ describe('selectApp', () => {
{name: 'app1', value: 'key1'},
{name: 'app2', value: 'key2'},
],
source: expect.any(Function),
},
])
})
Expand Down
54 changes: 50 additions & 4 deletions packages/app/src/cli/prompts/dev.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {Organization, MinimalOrganizationApp, OrganizationStore} from '../models/organization.js'
import {fetchOrgAndApps} from '../services/dev/fetch.js'
import {output, ui} from '@shopify/cli-kit'
import {debounce} from 'lodash-es'

export async function selectOrganizationPrompt(organizations: Organization[]): Promise<Organization> {
if (organizations.length === 1) {
Expand All @@ -17,17 +19,61 @@ export async function selectOrganizationPrompt(organizations: Organization[]): P
return organizations.find((org) => org.id === choice.id)!
}

export async function selectAppPrompt(apps: MinimalOrganizationApp[]): Promise<MinimalOrganizationApp> {
const appList = apps.map((app) => ({name: app.title, value: app.apiKey}))
const choice = await ui.prompt([
export async function selectAppPrompt(
apps: MinimalOrganizationApp[],
orgId: string,
token: string,
): Promise<MinimalOrganizationApp> {
const toAnswer = (app: MinimalOrganizationApp) => ({name: app.title, value: app.apiKey})
const appList = apps.map(toAnswer)

return ui.prompt([
{
type: 'autocomplete',
name: 'apiKey',
message: 'Which existing app is this for?',
choices: appList,
/* filterFunction is a local filter-and-search, to be applied to the
* results from the remote search for proper sorting and display.
* This source function wraps the local function in a function that
* fetches remote results when appropriate.
*/
source: (filterFunction: ui.FilterFunction): ui.FilterFunction => {
let latestInput = ''
const searchAwaiters: ((input: ui.PromptAnswer[]) => void)[] = []
const cachedResults: {[input: string]: ui.PromptAnswer[]} = {'': appList}

const performSearch = debounce(async (input: string): Promise<void> => {
if (input && !cachedResults[input]) {
const result = await fetchOrgAndApps(orgId, token, input)
// eslint-disable-next-line require-atomic-updates
cachedResults[input] = await filterFunction(result.apps.map(toAnswer), input)
}
// Only resolve results if they match the latest search term.
if (input === latestInput) searchAwaiters.forEach((func) => func(cachedResults[input]!))
}, 300)

return async (_answers: ui.PromptAnswer[], input = ''): Promise<ui.PromptAnswer[]> => {
latestInput = input

// Only perform remote search for apps if we haven't already fetched
// them all and a new search term has been entered.
if (!input) {
return appList
} else if (appList.length < 100) {
return filterFunction(appList, input)
} else if (cachedResults[input]) {
return cachedResults[input]!
}

await performSearch(input)
return new Promise((resolve, _reject) => {
searchAwaiters.push(resolve)
})
}
},
},
])
return apps.find((app) => app.apiKey === choice.apiKey)!
}

export async function selectStorePrompt(stores: OrganizationStore[]): Promise<OrganizationStore | undefined> {
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/services/app/select-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export async function selectApp(): Promise<OrganizationApp> {
const orgs = await fetchOrganizations(token)
const org = await selectOrganizationPrompt(orgs)
const {apps} = await fetchOrgAndApps(org.id, token)
const selectedApp = await selectAppPrompt(apps)
const selectedApp = await selectAppPrompt(apps, org.id, token)
const fullSelectedApp = await fetchAppFromApiKey(selectedApp.apiKey, token)
return fullSelectedApp!
}
6 changes: 4 additions & 2 deletions packages/app/src/cli/services/dev/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,11 @@ export async function fetchOrganizations(token: string) {
* @param token - Token to access partners API
* @returns Current organization details and list of apps and stores
*/
export async function fetchOrgAndApps(orgId: string, token: string): Promise<FetchResponse> {
export async function fetchOrgAndApps(orgId: string, token: string, title?: string): Promise<FetchResponse> {
const query = api.graphql.FindOrganizationQuery
const result: api.graphql.FindOrganizationQuerySchema = await api.partners.request(query, token, {id: orgId})
const params: {id: string; title?: string} = {id: orgId}
if (title) params.title = title
const result: api.graphql.FindOrganizationQuerySchema = await api.partners.request(query, token, params)
const org = result.organizations.nodes[0]
if (!org) throw NoOrgError(orgId)
const parsedOrg = {id: org.id, businessName: org.businessName, appsNext: org.appsNext}
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/services/dev/select-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ describe('selectOrCreateApp', () => {

// Then
expect(got).toEqual(APP1)
expect(selectAppPrompt).toHaveBeenCalledWith(APP_LIST)
expect(selectAppPrompt).toHaveBeenCalledWith(APP_LIST, ORG1.id, 'token')
})

it('prompts user to create if chooses to create', async () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/services/dev/select-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export async function selectOrCreateApp(
if (createNewApp) {
return createApp(org, localAppName, token)
} else {
const selectedApp = await selectAppPrompt(apps)
const selectedApp = await selectAppPrompt(apps, org.id, token)
const fullSelectedApp = await fetchAppFromApiKey(selectedApp.apiKey, token)
return fullSelectedApp!
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-kit/src/api/graphql/find_org.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import {gql} from 'graphql-request'

export const FindOrganizationQuery = gql`
query FindOrganization($id: ID!) {
query FindOrganization($id: ID!, $title: String) {
organizations(id: $id, first: 1) {
nodes {
id
businessName
website
appsNext
apps(first: 100) {
apps(first: 100, title: $title) {
nodes {
id
title
Expand Down
4 changes: 4 additions & 0 deletions packages/cli-kit/src/string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,7 @@ export const slugify = (str: string) =>
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '')

// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
// $& means the whole matched string
export const escapeRegExp = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
8 changes: 8 additions & 0 deletions packages/cli-kit/src/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ export function newListr(tasks: ListrTask[], options?: object | ListrBaseClassOp

export type ListrTasks = ConstructorParameters<typeof OriginalListr>[0]
export type {ListrTaskWrapper, ListrDefaultRenderer, ListrTask} from 'listr2'

export interface PromptAnswer {
name: string
value: string
}
export type FilterFunction = (answers: PromptAnswer[], input: string) => Promise<PromptAnswer[]>

interface BaseQuestion<TName extends string> {
name: TName
message: string
Expand All @@ -45,6 +52,7 @@ interface BaseQuestion<TName extends string> {
default?: string
result?: (value: string) => string | boolean
choices?: QuestionChoiceType[]
source?: (filter: FilterFunction) => FilterFunction
}

type TextQuestion<TName extends string> = BaseQuestion<TName> & {
Expand Down
22 changes: 14 additions & 8 deletions packages/cli-kit/src/ui/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {CustomInput} from './inquirer/input.js'
import {CustomAutocomplete} from './inquirer/autocomplete.js'
import {CustomSelect} from './inquirer/select.js'
import {CustomPassword} from './inquirer/password.js'
import {Question, QuestionChoiceType} from '../ui.js'
import {PromptAnswer, Question, QuestionChoiceType} from '../ui.js'
import inquirer, {Answers, QuestionCollection} from 'inquirer'
import fuzzy from 'fuzzy'

Expand Down Expand Up @@ -35,20 +35,22 @@ export function mapper(question: Question): unknown {
return {
...question,
type: 'custom-select',
source: getAutompleteFilterType(),
source: getAutocompleteFilterType(),
choices: question.choices ? groupAndMapChoices(question.choices) : undefined,
}
case 'autocomplete':
case 'autocomplete': {
inquirer.registerPrompt('autocomplete', CustomAutocomplete)
const filterType = getAutocompleteFilterType()
return {
...question,
type: 'autocomplete',
source: getAutompleteFilterType(),
source: question.source ? question.source(filterType) : filterType,
}
}
}
}

function fuzzyFilter(answers: {name: string; value: string}[], input = '') {
function fuzzyFilter(answers: {name: string; value: string}[], input = ''): Promise<PromptAnswer[]> {
return new Promise((resolve) => {
resolve(
fuzzy
Expand All @@ -62,13 +64,17 @@ function fuzzyFilter(answers: {name: string; value: string}[], input = '') {
})
}

function containsFilter(answers: {name: string; value: string}[], input = '') {
function containsFilter(answers: {name: string; value: string}[], input = ''): Promise<PromptAnswer[]> {
return new Promise((resolve) => {
resolve(Object.values(answers).filter((answer) => !answer.name || answer.name.includes(input)))
resolve(
Object.values(answers).filter(
(answer) => !answer.name || answer.name.toLowerCase().includes(input.toLowerCase()),
),
)
})
}

function getAutompleteFilterType() {
function getAutocompleteFilterType() {
return process.env.SHOPIFY_USE_AUTOCOMPLETE_FILTER === 'fuzzy' ? fuzzyFilter : containsFilter
}

Expand Down
16 changes: 12 additions & 4 deletions packages/cli-kit/src/ui/inquirer/autocomplete.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import colors from '../../public/node/colors.js'
import {escapeRegExp} from '../../string.js'
import AutocompletePrompt from 'inquirer-autocomplete-prompt'
import DistinctChoice from 'inquirer/lib/objects/choices'
import inquirer from 'inquirer'
Expand All @@ -22,7 +23,7 @@ export class CustomAutocomplete extends AutocompletePrompt {
let bottomContent = ''

if (this.status !== 'answered') {
content += colors.gray('… ')
content += colors.gray(this.isAutocomplete && this.firstRender ? 'Type to search… ' : '… ')
}

if (this.status === 'answered') {
Expand Down Expand Up @@ -103,10 +104,17 @@ function listRender(choices: DistinctChoice, pointer: number, searchToken?: stri
}

if (searchToken) {
const regexified = escapeRegExp(searchToken)
line = line
.split(searchToken)
.map((token) => (isSelected ? colors.magenta(token) : token))
.join(colors.magenta.dim(searchToken))
.split(new RegExp(`(${regexified})`, 'ig'))
.map((token) => {
if (token.match(new RegExp(regexified, 'ig'))) {
return colors.magenta.dim(token)
} else {
return isSelected ? colors.magenta(token) : token
}
})
.join('')
} else if (isSelected) {
line = colors.magenta(line)
}
Expand Down