Skip to content

Commit

Permalink
Implement workspace filtering by project (#1455)
Browse files Browse the repository at this point in the history
* Contribute new filter icon and command

* Add search query parameter to projects endpoint

* Implement project quick pick

* Improve error messages
  • Loading branch information
dbanck authored and jpogran committed Jul 11, 2023
1 parent 6704270 commit 845afc5
Show file tree
Hide file tree
Showing 4 changed files with 153 additions and 3 deletions.
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,11 @@
"command": "terraform.cloud.run.viewInBrowser",
"title": "View Run",
"icon": "$(globe)"
},
{
"command": "terraform.cloud.workspaces.filterByProject",
"title": "Filter by Project",
"icon": "$(filter)"
}
],
"menus": {
Expand Down Expand Up @@ -536,6 +541,11 @@
"when": "view == terraform.providers",
"group": "navigation"
},
{
"command": "terraform.cloud.workspaces.filterByProject",
"when": "view == terraform.cloud.workspaces",
"group": "navigation"
},
{
"command": "terraform.cloud.workspaces.refresh",
"when": "view == terraform.cloud.workspaces",
Expand Down
102 changes: 102 additions & 0 deletions src/providers/tfc/workspaceFilters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/

import * as vscode from 'vscode';
import { apiClient } from '../../terraformCloud';
import { Project } from '../../terraformCloud/project';

export class ResetProjectItem implements vscode.QuickPickItem {
get label() {
return '$(clear-all) Clear project filter. Show all workspaces';
}
get description() {
return '';
}
get alwaysShow() {
return true;
}
}

class ProjectItem implements vscode.QuickPickItem {
constructor(protected project: Project) {}
get label() {
return this.project.attributes.name;
}
get description() {
return this.project.id;
}
}

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 ProjectQuickPick {
private quickPick: vscode.QuickPick<vscode.QuickPickItem>;
private fetchTimerKey: NodeJS.Timeout | undefined;

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 onDidChangeValue() {
clearTimeout(this.fetchTimerKey);
// Only starts fetching projects after a user stopped typing for 300ms
this.fetchTimerKey = setTimeout(() => this.fetchProjects.apply(this), 300);
}

private async fetchProjects() {
// TODO?: To further improve performance, we could consider throttling this function
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)));
} catch (error) {
let message = 'Failed to fetch projects';
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);
} 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;
}
}
29 changes: 28 additions & 1 deletion src/providers/tfc/workspaceProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,46 @@
*/

import * as vscode from 'vscode';
import axios from 'axios';

import { RunTreeDataProvider } from './runProvider';
import { apiClient } from '../../terraformCloud';
import { TerraformCloudAuthenticationProvider } from '../authenticationProvider';
import axios from 'axios';
import { ProjectQuickPick, ResetProjectItem } from './workspaceFilters';

export class WorkspaceTreeDataProvider implements vscode.TreeDataProvider<WorkspaceTreeItem>, vscode.Disposable {
private readonly didChangeTreeData = new vscode.EventEmitter<void | WorkspaceTreeItem>();
public readonly onDidChangeTreeData = this.didChangeTreeData.event;
private projectFilter: string | undefined;

constructor(private ctx: vscode.ExtensionContext, private runDataProvider: RunTreeDataProvider) {
this.ctx.subscriptions.push(
vscode.commands.registerCommand('terraform.cloud.workspaces.refresh', (workspaceItem: WorkspaceTreeItem) => {
this.refresh();
this.runDataProvider.refresh(workspaceItem);
}),
vscode.commands.registerCommand('terraform.cloud.workspaces.filterByProject', () => this.filterByProject()),
);
}

refresh(): void {
this.didChangeTreeData.fire();
}

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 project = await projectQuickPick.pick();

if (project === undefined || project instanceof ResetProjectItem) {
this.projectFilter = undefined;
} else {
this.projectFilter = project.description;
}
this.refresh();
}

getTreeItem(element: WorkspaceTreeItem): vscode.TreeItem | Thenable<vscode.TreeItem> {
return element;
}
Expand Down Expand Up @@ -62,8 +80,17 @@ export class WorkspaceTreeDataProvider implements vscode.TreeDataProvider<Worksp
params: {
organization_name: organization,
},
// Include query parameter only if project filter is set
...(this.projectFilter && {
queries: {
'filter[project][id]': this.projectFilter,
},
}),
});

// TODO? we could skip this request if a project filter is set,
// but with the addition of more filters, we could still get
// projects from different workspaces
const projectResponse = await apiClient.listProjects({
params: {
organization_name: organization,
Expand Down
15 changes: 13 additions & 2 deletions src/terraformCloud/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* SPDX-License-Identifier: MPL-2.0
*/

import { makeApi } from '@zodios/core';
import { makeApi, makeParameters } from '@zodios/core';
import { z } from 'zod';
import { paginationMeta, paginationParams } from './pagination';

Expand All @@ -14,21 +14,32 @@ const project = z.object({
}),
});

export type Project = z.infer<typeof project>;

const projects = z.object({
data: z.array(project),
meta: z.object({
pagination: paginationMeta,
}),
});

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',
path: '/organizations/:organization_name/projects',
alias: 'listProjects',
description: 'List projects in the organization',
response: projects,
parameters: paginationParams,
parameters: [...paginationParams, ...searchQueryParams],
},
{
method: 'get',
Expand Down

0 comments on commit 845afc5

Please sign in to comment.