From 868b67a9437e4f084cd6a3a101b8b87a16afd1c1 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Tue, 13 Jun 2023 15:11:49 +0200 Subject: [PATCH] Add API based filtering for organizations (#1472) * Build abstract api filtering quick pick * Use new APIQuickPick for project filter * Move TerraformCloudHost We need this to open the browser for organziation creation * Add search query parameter to organizations API * Improve APIQuickPick hide and show behavior * Implement OrganizationAPIResource * Use OrganizationAPIResource to pick an organization * Move uiHelpers file --- src/features/terraformCloud.ts | 54 ++++++++------- src/providers/authenticationProvider.ts | 5 +- src/providers/tfc/organizationPicker.ts | 89 +++++++++++++++++++++++++ src/providers/tfc/uiHelpers.ts | 64 ++++++++++++++++++ src/providers/tfc/workspaceFilters.ts | 72 +++++++------------- src/providers/tfc/workspaceProvider.ts | 8 ++- src/terraformCloud/filter.ts | 9 +++ src/terraformCloud/index.ts | 3 + src/terraformCloud/organization.ts | 23 ++++--- src/terraformCloud/project.ts | 12 +--- 10 files changed, 238 insertions(+), 101 deletions(-) create mode 100644 src/providers/tfc/organizationPicker.ts create mode 100644 src/providers/tfc/uiHelpers.ts diff --git a/src/features/terraformCloud.ts b/src/features/terraformCloud.ts index d721e24be5..e95ff689d2 100644 --- a/src/features/terraformCloud.ts +++ b/src/features/terraformCloud.ts @@ -7,7 +7,12 @@ import * as vscode from 'vscode'; import { WorkspaceTreeDataProvider, WorkspaceTreeItem } from '../providers/tfc/workspaceProvider'; import { RunTreeDataProvider } from '../providers/tfc/runProvider'; import { TerraformCloudAuthenticationProvider } from '../providers/authenticationProvider'; -import { apiClient } from '../terraformCloud'; +import { + CreateOrganizationItem, + OrganizationAPIResource, + RefreshOrganizationItem, +} from '../providers/tfc/organizationPicker'; +import { APIQuickPick } from '../providers/tfc/uiHelpers'; export class TerraformCloudFeature implements vscode.Disposable { private statusBar: OrganizationStatusBar; @@ -86,33 +91,32 @@ export class TerraformCloudFeature implements vscode.Disposable { this.context.subscriptions.push( vscode.commands.registerCommand('terraform.cloud.organization.picker', async () => { - const response = await apiClient.listOrganizations(); - const orgs = response.data; - - const items: vscode.QuickPickItem[] = []; - for (let index = 0; index < orgs.length; index++) { - const element = orgs[index]; - items.push({ - label: element.attributes.name, - }); - } - - const answer = await vscode.window.showQuickPick(items, { - canPickMany: false, - ignoreFocusOut: true, - placeHolder: 'Choose an organization. Hit enter to select the first organization.', - title: 'Welcome to Terraform Cloud', - }); - - if (answer === undefined) { - // user exited without answering, so don't change - return; + const organizationAPIResource = new OrganizationAPIResource(); + const organizationQuickPick = new APIQuickPick(organizationAPIResource); + let choice: vscode.QuickPickItem | undefined; + + // eslint-disable-next-line no-constant-condition + while (true) { + choice = await organizationQuickPick.pick(false); + + if (choice === undefined) { + // user exited without answering, so don't do anything + return; + } else if (choice instanceof CreateOrganizationItem) { + // open the browser an re-run the loop + choice.open(); + continue; + } else if (choice instanceof RefreshOrganizationItem) { + // re-run the loop + continue; + } + + break; } // user chose an organization so update the statusbar and make sure its visible - this.statusBar.show(answer.label); - - // store the organization so other parts can use it + organizationQuickPick.hide(); + this.statusBar.show(choice.label); // refresh workspaces so they pick up the change workspaceDataProvider.refresh(); diff --git a/src/providers/authenticationProvider.ts b/src/providers/authenticationProvider.ts index ed91e791be..6f47c6d704 100644 --- a/src/providers/authenticationProvider.ts +++ b/src/providers/authenticationProvider.ts @@ -7,10 +7,7 @@ import * as os from 'os'; import * as path from 'path'; import * as vscode from 'vscode'; import axios from 'axios'; -import { earlyApiClient } from '../terraformCloud'; - -// TODO: replace with production URL -const TerraformCloudHost = 'app.staging.terraform.io'; +import { earlyApiClient, TerraformCloudHost } from '../terraformCloud'; class TerraformCloudSession implements vscode.AuthenticationSession { // This id isn't used for anything yet, so we set it to a constant diff --git a/src/providers/tfc/organizationPicker.ts b/src/providers/tfc/organizationPicker.ts new file mode 100644 index 0000000000..314032cc90 --- /dev/null +++ b/src/providers/tfc/organizationPicker.ts @@ -0,0 +1,89 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import * as vscode from 'vscode'; +import { TerraformCloudHost, apiClient } from '../../terraformCloud'; +import { APIResource } from './uiHelpers'; +import { Organization } from '../../terraformCloud/organization'; + +export class CreateOrganizationItem implements vscode.QuickPickItem { + get label() { + return '$(add) Create new organization'; + } + get description() { + return 'Open the browser to create a new organization'; + } + async open() { + await vscode.env.openExternal(vscode.Uri.parse(`https://${TerraformCloudHost}/app/organizations`)); + } + get alwaysShow() { + return true; + } +} + +export class RefreshOrganizationItem implements vscode.QuickPickItem { + get label() { + return '$(refresh) Refresh organizations'; + } + get description() { + return 'Refetch all organizations'; + } + get alwaysShow() { + return true; + } +} + +class OrganizationItem implements vscode.QuickPickItem { + constructor(protected organization: Organization) {} + get label() { + return this.organization.attributes.name; + } +} + +export class OrganizationAPIResource implements APIResource { + name = 'organizations'; + title = 'Welcome to Terraform Cloud'; + placeholder = 'Choose an organization. Hit enter to select the first organization. (type to search)'; + ignoreFocusOut = true; + + private async createOrganizationItems(search?: string): Promise { + const organizations = await apiClient.listOrganizations({ + // Include query parameter only if search argument is passed + ...(search && { + queries: { + q: search, + }, + }), + }); + + return organizations.data.map((organization) => new OrganizationItem(organization)); + } + + async fetchItems(query?: string): Promise { + const createItem = new CreateOrganizationItem(); + const refreshItem = new RefreshOrganizationItem(); + const picks: vscode.QuickPickItem[] = [ + createItem, + refreshItem, + { label: '', kind: vscode.QuickPickItemKind.Separator }, + ]; + + try { + picks.push(...(await this.createOrganizationItems(query))); + } catch (error) { + let message = 'Failed to fetch organizations'; + if (error instanceof Error) { + message = error.message; + } else if (typeof error === 'string') { + message = error; + } + + picks.push({ label: `$(error) Error: ${message}`, alwaysShow: true }); + console.error(error); + } + + return picks; + } +} diff --git a/src/providers/tfc/uiHelpers.ts b/src/providers/tfc/uiHelpers.ts new file mode 100644 index 0000000000..ab782de09b --- /dev/null +++ b/src/providers/tfc/uiHelpers.ts @@ -0,0 +1,64 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import * as vscode from 'vscode'; + +export interface APIResource { + readonly name: string; + + readonly title: string; + readonly placeholder: string; + readonly ignoreFocusOut?: boolean; + + fetchItems(query?: string): Promise; +} + +export class APIQuickPick { + private quickPick: vscode.QuickPick; + private fetchTimerKey: NodeJS.Timeout | undefined; + + constructor(private resource: APIResource) { + this.quickPick = vscode.window.createQuickPick(); + this.quickPick.title = resource.title; + this.quickPick.placeholder = resource.placeholder; + this.quickPick.onDidChangeValue(this.onDidChangeValue, this); + this.quickPick.ignoreFocusOut = resource.ignoreFocusOut ?? false; + } + + private onDidChangeValue() { + clearTimeout(this.fetchTimerKey); + // Only starts fetching after a user stopped typing for 300ms + this.fetchTimerKey = setTimeout(() => this.fetchResource.apply(this), 300); + } + + private async fetchResource() { + this.quickPick.busy = true; + this.quickPick.show(); + + this.quickPick.items = await this.resource.fetchItems(this.quickPick.value); + + this.quickPick.busy = false; + } + + async pick(autoHide = true) { + await this.fetchResource(); + + const result = await new Promise((c) => { + this.quickPick.onDidAccept(() => c(this.quickPick.selectedItems[0])); + this.quickPick.onDidHide(() => c(undefined)); + this.quickPick.show(); + }); + + if (autoHide) { + this.quickPick.hide(); + } + + return result; + } + + hide() { + this.quickPick.hide(); + } +} diff --git a/src/providers/tfc/workspaceFilters.ts b/src/providers/tfc/workspaceFilters.ts index 8f26640b4f..295da08d25 100644 --- a/src/providers/tfc/workspaceFilters.ts +++ b/src/providers/tfc/workspaceFilters.ts @@ -6,6 +6,7 @@ import * as vscode from 'vscode'; import { apiClient } from '../../terraformCloud'; import { Project } from '../../terraformCloud/project'; +import { APIResource } from './uiHelpers'; export class ResetProjectItem implements vscode.QuickPickItem { get label() { @@ -29,48 +30,35 @@ class ProjectItem implements vscode.QuickPickItem { } } -async function createProjectItems(organization: string, search?: string): Promise { - const projects = await apiClient.listProjects({ - params: { - organization_name: organization, - }, - // Include query parameter only if search argument is passed - ...(search && { - queries: { - q: search, - }, - }), - }); - - return projects.data.map((project) => new ProjectItem(project)); -} +export class ProjectsAPIResource implements APIResource { + name = 'projects'; + title = 'Filter Workspaces'; + placeholder = 'Select a project (type to search)'; -export class ProjectQuickPick { - private quickPick: vscode.QuickPick; - private fetchTimerKey: NodeJS.Timeout | undefined; + constructor(private organizationName: string) {} - constructor(private organizationName: string) { - this.quickPick = vscode.window.createQuickPick(); - this.quickPick.title = 'Filter Workspaces'; - this.quickPick.placeholder = 'Select a project (type to search)'; - this.quickPick.onDidChangeValue(this.onDidChangeValue, this); - } + private async createProjectItems(organization: string, search?: string): Promise { + const projects = await apiClient.listProjects({ + params: { + organization_name: organization, + }, + // Include query parameter only if search argument is passed + ...(search && { + queries: { + q: search, + }, + }), + }); - private onDidChangeValue() { - clearTimeout(this.fetchTimerKey); - // Only starts fetching projects after a user stopped typing for 300ms - this.fetchTimerKey = setTimeout(() => this.fetchProjects.apply(this), 300); + return projects.data.map((project) => new ProjectItem(project)); } - private async fetchProjects() { - // TODO?: To further improve performance, we could consider throttling this function + async fetchItems(query?: string): Promise { const resetProjectItem = new ResetProjectItem(); const picks: vscode.QuickPickItem[] = [resetProjectItem, { label: '', kind: vscode.QuickPickItemKind.Separator }]; - try { - this.quickPick.busy = true; - this.quickPick.show(); - picks.push(...(await createProjectItems(this.organizationName, this.quickPick.value))); + try { + picks.push(...(await this.createProjectItems(this.organizationName, query))); } catch (error) { let message = 'Failed to fetch projects'; if (error instanceof Error) { @@ -81,22 +69,8 @@ export class ProjectQuickPick { picks.push({ label: `$(error) Error: ${message}`, alwaysShow: true }); console.error(error); - } finally { - this.quickPick.items = picks; - this.quickPick.busy = false; } - } - - async pick() { - await this.fetchProjects(); - - const project = await new Promise((c) => { - this.quickPick.onDidAccept(() => c(this.quickPick.selectedItems[0])); - this.quickPick.onDidHide(() => c(undefined)); - this.quickPick.show(); - }); - this.quickPick.hide(); - return project; + return picks; } } diff --git a/src/providers/tfc/workspaceProvider.ts b/src/providers/tfc/workspaceProvider.ts index 45f472f3b2..d971095133 100644 --- a/src/providers/tfc/workspaceProvider.ts +++ b/src/providers/tfc/workspaceProvider.ts @@ -5,15 +5,16 @@ import * as vscode from 'vscode'; import axios from 'axios'; +import { z } from 'zod'; import { RunTreeDataProvider } from './runProvider'; import { apiClient } from '../../terraformCloud'; import { TerraformCloudAuthenticationProvider } from '../authenticationProvider'; -import { ProjectQuickPick, ResetProjectItem } from './workspaceFilters'; +import { ProjectsAPIResource, ResetProjectItem } from './workspaceFilters'; import { GetRunStatusIcon, RelativeTimeFormat } from './helpers'; import { WorkspaceAttributes } from '../../terraformCloud/workspace'; import { RunAttributes } from '../../terraformCloud/run'; -import { z } from 'zod'; +import { APIQuickPick } from './uiHelpers'; export class WorkspaceTreeDataProvider implements vscode.TreeDataProvider, vscode.Disposable { private readonly didChangeTreeData = new vscode.EventEmitter(); @@ -47,7 +48,8 @@ export class WorkspaceTreeDataProvider implements vscode.TreeDataProvider { // TODO! only run this if user is logged in const organization = this.ctx.workspaceState.get('terraform.cloud.organization', ''); - const projectQuickPick = new ProjectQuickPick(organization); + const projectAPIResource = new ProjectsAPIResource(organization); + const projectQuickPick = new APIQuickPick(projectAPIResource); const project = await projectQuickPick.pick(); if (project === undefined || project instanceof ResetProjectItem) { diff --git a/src/terraformCloud/filter.ts b/src/terraformCloud/filter.ts index 3bf639d38b..1d110d1efd 100644 --- a/src/terraformCloud/filter.ts +++ b/src/terraformCloud/filter.ts @@ -26,3 +26,12 @@ export const workspaceIncludeParams = makeParameters([ .optional(), }, ]); + +export const searchQueryParams = makeParameters([ + { + name: 'q', + type: 'Query', + description: ' A search query string.', + schema: z.string().optional(), + }, +]); diff --git a/src/terraformCloud/index.ts b/src/terraformCloud/index.ts index 32a66fc849..c233c868d9 100644 --- a/src/terraformCloud/index.ts +++ b/src/terraformCloud/index.ts @@ -13,6 +13,9 @@ import { projectEndpoints } from './project'; import { runEndpoints } from './run'; import { workspaceEndpoints } from './workspace'; +// TODO: replace with production URL +export const TerraformCloudHost = 'app.staging.terraform.io'; + // TODO: Replace with production URL before going live export const baseUrl = 'https://app.staging.terraform.io/api/v2'; diff --git a/src/terraformCloud/organization.ts b/src/terraformCloud/organization.ts index 306ef33fc1..4a2625c62a 100644 --- a/src/terraformCloud/organization.ts +++ b/src/terraformCloud/organization.ts @@ -6,17 +6,20 @@ import { makeApi } from '@zodios/core'; import { z } from 'zod'; import { paginationMeta, paginationParams } from './pagination'; +import { searchQueryParams } from './filter'; + +const organization = z.object({ + id: z.string(), + attributes: z.object({ + 'external-id': z.string(), + name: z.string(), + }), +}); + +export type Organization = z.infer; const organizations = z.object({ - data: z.array( - z.object({ - id: z.string(), - attributes: z.object({ - 'external-id': z.string(), - name: z.string(), - }), - }), - ), + data: z.array(organization), meta: z .object({ pagination: paginationMeta.optional(), @@ -49,7 +52,7 @@ export const organizationEndpoints = makeApi([ alias: 'listOrganizations', description: 'List organizations of the current user', response: organizations, - parameters: paginationParams, + parameters: [...paginationParams, ...searchQueryParams], }, { method: 'get', diff --git a/src/terraformCloud/project.ts b/src/terraformCloud/project.ts index 7782ece242..b2c009a1ba 100644 --- a/src/terraformCloud/project.ts +++ b/src/terraformCloud/project.ts @@ -3,9 +3,10 @@ * SPDX-License-Identifier: MPL-2.0 */ -import { makeApi, makeParameters } from '@zodios/core'; +import { makeApi } from '@zodios/core'; import { z } from 'zod'; import { paginationMeta, paginationParams } from './pagination'; +import { searchQueryParams } from './filter'; const project = z.object({ id: z.string(), @@ -23,15 +24,6 @@ const projects = z.object({ }), }); -const searchQueryParams = makeParameters([ - { - name: 'q', - type: 'Query', - description: ' A search query string. This query searches projects by name. This search is case-insensitive.', - schema: z.string().optional(), - }, -]); - export const projectEndpoints = makeApi([ { method: 'get',