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

Implement workspace filtering by project #1455

Merged
merged 4 commits into from
Jun 5, 2023
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
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
Comment on lines +91 to +93
Copy link
Contributor

@jpogran jpogran Jun 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need this to get the project name to display in the view. If the user selected a project, then we know the name both to filter and to add to the workspace, so wouldn't need to call out twice to get projects.

So, if user specifies project to filter, then use that project name to build the WorkspaceTreeItems. If not, call out and get a list of projects.

We can make this a followup ticket, so this is not stopping merging now.

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