Skip to content

Commit

Permalink
Add API based filtering for organizations (#1472)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
dbanck committed Jul 11, 2023
1 parent e307c17 commit 868b67a
Show file tree
Hide file tree
Showing 10 changed files with 238 additions and 101 deletions.
54 changes: 29 additions & 25 deletions src/features/terraformCloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
5 changes: 1 addition & 4 deletions src/providers/authenticationProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions src/providers/tfc/organizationPicker.ts
Original file line number Diff line number Diff line change
@@ -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<OrganizationItem[]> {
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<vscode.QuickPickItem[]> {
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;
}
}
64 changes: 64 additions & 0 deletions src/providers/tfc/uiHelpers.ts
Original file line number Diff line number Diff line change
@@ -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<vscode.QuickPickItem[]>;
}

export class APIQuickPick {
private quickPick: vscode.QuickPick<vscode.QuickPickItem>;
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<vscode.QuickPickItem | undefined>((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();
}
}
72 changes: 23 additions & 49 deletions src/providers/tfc/workspaceFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -29,48 +30,35 @@ class ProjectItem implements vscode.QuickPickItem {
}
}

async function createProjectItems(organization: string, search?: string): Promise<ProjectItem[]> {
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<vscode.QuickPickItem>;
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<ProjectItem[]> {
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<vscode.QuickPickItem[]> {
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) {
Expand All @@ -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<vscode.QuickPickItem | undefined>((c) => {
this.quickPick.onDidAccept(() => c(this.quickPick.selectedItems[0]));
this.quickPick.onDidHide(() => c(undefined));
this.quickPick.show();
});
this.quickPick.hide();

return project;
return picks;
}
}
8 changes: 5 additions & 3 deletions src/providers/tfc/workspaceProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WorkspaceTreeItem>, vscode.Disposable {
private readonly didChangeTreeData = new vscode.EventEmitter<void | WorkspaceTreeItem>();
Expand Down Expand Up @@ -47,7 +48,8 @@ export class WorkspaceTreeDataProvider implements vscode.TreeDataProvider<Worksp
async filterByProject(): Promise<void> {
// 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) {
Expand Down
9 changes: 9 additions & 0 deletions src/terraformCloud/filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
]);
3 changes: 3 additions & 0 deletions src/terraformCloud/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Loading

0 comments on commit 868b67a

Please sign in to comment.